kanban
v0.1.15File-backed kanban board — tickets, threaded comments, agent assignment, the kanban:change and kanban:comment trigger types, and an injectable console board with agent profiles for planning and review.
Install
iii trigger compose::add worker=kanban@0.1.15Run in your project with the iii engine running. Exact versions are immutable and digest-pinned.
skill doc
skills/tickets/kanban-tickets.md
how-toWorking kanban tickets
The kanban worker owns a file-backed board. Every ticket mutation is a
registered function, so you work a ticket with ordinary function calls and
learn about new comments through a trigger instead of polling.
The board file is , resolved from the kanban
configuration entry; kanban::config::info prints the absolute path, the
column list, the priorities and the key prefix. Tickets are soft-deleted when
a human removes one from the ticket screen: the row stays in that file
with deleted_at set, so a person can undo it.
Address a ticket by uuid or by key
Every ticket carries an internal id (uuid) and a human key (KAN-7).
Both work everywhere a ticket is addressed and the key is case-insensitive,
so kanban::ticket::get { "id": "kan-7" } and { "id": " are the
same call. Prefer the key in prose and in trigger configs; it is the form a
human reads.
The working loop
Find work —
kanban::board::get {}for the whole board (every column with its tickets), orkanban::ticket::list { "status": "todo" }. Read the column ids from the response or fromkanban::config::info; they are configurable, and the default board isbacklog,todo,in_progress,in_review,done.Both reads return ticket summaries (no description, comments or activity), so they cost roughly a hundred tokens per ticket — and the
donecolumn grows forever. Keep them small:statusespicks the columns,limitcaps each one,updated_sincekeeps only what changed after a timestamp, andcompact: trueshrinks each row to key, title, status, priority, assignee and comment_count (about a third of the size).ticket_countstays board-wide, so you always know how much the filters hid.kanban::board::get { "statuses": ["todo", "in_progress", "in_review"], "compact": true } kanban::ticket::list { "status": "done", "updated_since": "2026-09-10T00:00:00.000Z", "compact": true }Read the board once per planning pass. A wake already carries its ticket, so reading the board on every wake only inflates your context.
Claim it — assign yourself and move it:
kanban::ticket::update { "id": "KAN-7", "assignee": "", "status": "in_progress", "actor": " " } Report —
kanban::comment::create { "ticket_id": "KAN-7", "body": "…", "author": "" } Hand off — move it to the column the work deserves:
kanban::ticket::move { "id": "KAN-7", "status": "in_review", "actor": "when it needs a check," } "status": "done"when it is finished and verified. Never delete it — see Finishing a ticket below.
kanban::ticket::update also takes title, description, priority and
labels; priority is one of the configured values (low, medium,
high, urgent by default).
Attribute yourself. On a write actor defaults to the literal user,
and on a comment so does author. Pass your own agent-profile id in both:
the activity timeline then reads honestly, and — the part that matters — the
comment filters below key on exactly that string.
Which profile id
kanban::agent::list {} returns the assignable agent profiles
({ id, name, logo, color, model, description }) resolved through
iii-directory. A ticket stores the profile id (for example
senior-software-engineer), never the display name.
Feedback loop: wake on comments
Register a binding on the kanban:comment trigger type; it fires once per
comment created on one ticket.
| config field | meaning |
|---|---|
ticket_id |
Required — uuid or human key. A binding without it never fires. |
author |
Fire only for comments by exactly this author. |
exclude_author |
Fire for every author except this one. |
root_only |
Ignore replies; fire only for top-level comments. |
ticket |
full (default) delivers the whole ticket, comments and activity inline; summary delivers only the board-card fields. Agents that re-read the ticket on a wake should pass summary. |
The payload carries the ticket plus the new comment — the whole ticket by
default, or its summary when the binding says "ticket": "summary" (the
comment field is complete either way):
{
"event": "comment.created",
"ticket_id": "8ec5c3fc-21bd-4142-b503-31bdb9e1ce70",
"ticket_key": "KAN-7",
"ticket": { "…": "the ticket with all comments and activity" },
"comment": {
"id": "5241fadd-cca7-43a3-966b-5e0c21acd2b0",
"parent_id": null,
"body": "…",
"author": "user",
"created_at": "2026-09-10T13:04:29.656Z"
},
"author": "user",
"at": "2026-09-10T13:04:29.656Z"
}Keep your own comments from waking you
Author your comments with your profile id, and exclude that same id on the binding:
engine::register_trigger {
"trigger_type": "kanban:comment",
"config": {
"ticket_id": "KAN-7",
"exclude_author": "senior-software-engineer",
"ticket": "summary"
},
"once": false,
"lifecycle": { "max_fires": 20, "expires_in_ms": 3600000 }
}kanban::comment::create {
"ticket_id": "KAN-7",
"body": "Pushed the fix; ready for review.",
"author": "senior-software-engineer"
}The two strings must match exactly. The binding then fires for every comment
whose author is not that id — a human's reply, another agent's note —
and stays silent on your own posts, so answering the ticket cannot re-enter
your own handler.
Two details of that registration:
- A wake (no
function_id) defaults to firing once.once: falsemakes it standing; give it alifecycleso a forgotten binding cannot run forever. Omitfunction_idto be woken in your session, or name a function to have the event call it instead — that second shape runs token-free but its result is discarded and it cannot reach you. - Teardown is
harness::triggers::unregister;harness::triggers::listaudits what is armed.
Avoid a wake that can never fire
A registration response, and a row in engine::registered-triggers::list, mean the
engine took the binding — not that the kanban worker will ever emit to it.
The worker delivers only to bindings in its own in-process subscriber map, so a
binding it is missing never fires and reports nothing at all. Build the loop so
that cannot leave you parked:
- Never let the wake be your only way back. Pair it with a check-in you own: a
cronbinding that reads the same filtered list. It goes through the board file rather than the worker's emit path, so it cannot fall silent with it.
engine::register_trigger {
"trigger_type": "cron",
"config": { "expression": "0 * * * *" },
"once": false,
"label": "KAN-7-check-in"
}On each fire, read what you missed and move the watermark forward:
kanban::comment::list {
"ticket_id": "KAN-7",
"exclude_author": "senior-software-engineer",
"since": "2026-09-10T13:04:29.656Z"
}Arm a binding for a hand-off, not for the ages. Bound its life so it cannot outlive its usefulness:
{ "max_fires": 20, "expires_in_ms": 3600000 }stops it after a fixed window and wakes you with an expiry notice instead of leaving you parked. Re-arm it at your next hand-off if the work is still open; a bareonce: falsewith no bound is the shape that fails silently.The worker's copy of your binding dies with the worker process while the engine's row survives, so re-arm a long-running loop when you pick the ticket back up — or let a
croncheck-in carry it so nothing depends on that copy.If the trigger type itself looks missing, that is the namespace, not the type:
engine::triggers::info { "id": "kanban:comment" }alone reports it that way because these types live in the worker's namespace. Read the namespace offengine::triggers::listand pass it along.
Tighter and looser variants
- Only the human.
"author": "user"fires only for comments the console UI writes, and is the strictest loop guard available. - Whole ticket. Drop
exclude_authorand you are woken by every comment on that ticket, your own included — do that only when your handler never comments back on the same ticket. - Top-level only. Add
"root_only": trueto ignore thread replies.
kanban::comment::list accepts the same filters, which is how you catch
up after a wake without re-reading everything:
kanban::comment::list {
"ticket_id": "KAN-7",
"exclude_author": "senior-software-engineer",
"since": "2026-09-10T13:04:29.656Z"
}Do not reach for kanban:change when you need author filtering
kanban:change is the board-wide trigger: config { ticket_id?, events?, ticket? },
where events is any of ticket.created, ticket.updated, ticket.moved,
ticket.deleted, ticket.restored, comment.created, and ticket takes the
same full / summary choice. It carries the ticket and is the right
primitive for keeping a board view in sync or for a done-watch.
It filters by ticket and event name only — there is no author and no
exclude_author. A binding of
{ "ticket_id": "KAN-7", "events": ["comment.created"] } fires for your own
comments too, which is exactly the loop the kanban:comment filters exist to
prevent.
Reading the history
kanban::activity::list { "ticket_id": "KAN-7" }— field changes, lane moves, deletion and comment events, oldest first. Narrow it withtypesandsince.kanban::ticket::get { "id": "KAN-7" }— the ticket with itscommentsandactivityinline.- Replies are one level deep: passing the id of a reply as
parent_idattaches your comment to the same root thread rather than nesting deeper.
Finishing a ticket: review or done — never delete
An agent never deletes a ticket. Deleting is the human's call, made from
the ticket screen in the console. Leave kanban::ticket::delete and
kanban::ticket::restore alone: a finished ticket is moved, not removed.
Finish by moving it to the column the work deserves:
- Needs review →
kanban::ticket::move { "id": "KAN-7", "status": "in_review", "actor": "Use this whenever someone else should check the result before it counts: anything user-visible, anything that changes shared state or someone else's work, and any task whose ticket says a review is expected." } - Does not need review →
kanban::ticket::move { "id": "KAN-7", "status": "done", "actor": "Reserve" } donefor work you actually finished and verified — a build you ran, a test that passed, a file you read back. Never markdoneon the strength of having merely written something.
If you are unsure which applies, pick in_review and say why in a comment;
that is the safe direction, because a reviewer can always move it on.
After in_review, the reviewer either moves the ticket to done or sends it
back to in_progress with a comment saying what is missing — which is
exactly when the comment feedback loop above earns its keep. Read the column
ids from kanban::config::info if the board has been reconfigured.
Checklist
- Address tickets by
KAN-nor by uuid; both work every time. - Pass
actoron writes andauthoron comments — your agent-profile id. - Read column ids and priorities from the board instead of assuming them.
- Finish by moving the ticket:
in_reviewwhen it needs a check,donewhen it is finished and verified. Never delete a ticket — only a human does. - For a feedback loop, register
kanban:commentwithexclude_author: "," "ticket": "summary"andonce: false, and never let the handler comment back on the same ticket unconditionally. - Read the board once per planning pass, narrowed (
statuses,compact), never on a wake; catch up withupdated_since. - Catch up with
kanban::comment::listusing the same filters. - Never make a wake your only way back: pair it with a
croncheck-in readingkanban::comment::list, and bound the binding's life (max_fires+expires_in_ms) so a binding the worker never took cannot park you.