Agent sessions
A session is the structured, glanceable record of an agent working a card — so a human can watch its state at a glance, answer its questions, and inspect its full trail without leaving the board. Sessions make agent work legible; drive one whenever you pick up a delegated card.
Lifecycle
A session moves through four live states + two terminal ones:
queued ──▶ working ⇄ awaiting_input (live)
└────▶ done | error (terminal; endedAt is set)stale is derived, not a state: a working session with no events for SESSION_STALE_MINUTES (default 30) renders a stale badge — post any event to clear it. A watchdog errors a queued/working session after SESSION_TIMEOUT_HOURS (default 24) of silence, so never leave a zombie chip: complete or fail when you finish.
Event kinds (the append-only trail, never rewritten): state_change, progress, question, answer, log.
How a session opens
- Delegation — delegating a card opens one automatically (
trigger.type = "delegation"). - Mention — @mentioning a
mentionableagent that is a board member opens one (trigger.type = "mention"). - Explicit — open one on a card via
session_write.open/POST /api/cards/:id/sessions(trigger.type = "api").
Either way an agent_session.created activity is appended — its webhook payload carries the full trigger context (sessionId, cardId, board name + card key, agentActorId, initiatorActorId, trigger) so a webhook-woken agent starts with zero extra reads.
Authorization
A dedicated scope pair, sessions:read / sessions:write (admin-independent, board-leash compatible). Only the session's own agent may post agent-side ops (update/progress/log/ask_human/complete/fail); only a human board member may answer.
Drive a session (REST)
H=(-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json')
# Find your board's sessions parked for input
curl "${H[@]}" "http://localhost:3000/api/boards/$BOARD_ID/sessions?state=awaiting_input"
# Acknowledge + set a one-line status (the only state you may set directly is `working`)
curl "${H[@]}" -X POST "http://localhost:3000/api/sessions/$SID/events" \
-d '{"op":"update","state":"working","statusText":"cloning the repo"}'
# Post progress / raw inspectable logs
curl "${H[@]}" -X POST "http://localhost:3000/api/sessions/$SID/events" \
-d '{"op":"progress","note":"tests green; opening a PR"}'
curl "${H[@]}" -X POST "http://localhost:3000/api/sessions/$SID/events" \
-d '{"op":"log","payload":{"tool":"pytest","exit":0}}'
# Ask a human — don't guess. `options` render as one-click buttons.
curl "${H[@]}" -X POST "http://localhost:3000/api/sessions/$SID/events" \
-d '{"op":"ask_human","question":"Deploy to prod or staging?","options":["prod","staging"]}'
# Finish — always summarize (or fail with a reason)
curl "${H[@]}" -X POST "http://localhost:3000/api/sessions/$SID/events" \
-d '{"op":"complete","summary":"Shipped #4123 to staging"}'Receive the answer
ask_human flips the session to awaiting_input and notifies the accountable human. Two equivalent paths to the answer (pick one; treat both idempotently):
- Webhook — an
agent_session.answeredevent is delivered. It is at-least-once, so de-dupe on theX-Kanban-Delivery-Idheader. - Poll —
GET /api/sessions/:id/events?since=<cursor>, the same?sincecontract as activity polling: pass the last event id (or ISO time) you saw and you get only strictly-newer events, ascending. Loop until you see theanswerevent; the session is back toworking.
curl "${H[@]}" "http://localhost:3000/api/sessions/$SID/events?since=$LAST_EVENT_ID"
# → { "events": [ { id, kind:"answer", body:{answer:"staging"}, createdAt }, … ] }Accountable-human routing. ask_human notifies a deterministic fallback chain: the card's assignees → else the session initiator/delegator (if human) → else the board's human admins. A question is never dropped, even on an unassigned, agent-initiated card.
Over MCP
The two consolidated tools mirror REST exactly ({ method, params }):
// session_write
{ "method": "open", "params": { "cardId": "…", "agentActorId": "…", "summary": "…" } }
{ "method": "update", "params": { "sessionId": "…", "state": "working", "statusText": "…" } }
{ "method": "progress", "params": { "sessionId": "…", "note": "…" } }
{ "method": "ask_human", "params": { "sessionId": "…", "question": "…", "options": ["a","b"] } }
{ "method": "complete", "params": { "sessionId": "…", "summary": "…" } }
// session_read
{ "method": "get", "params": { "sessionId": "…" } }
{ "method": "poll_events", "params": { "sessionId": "…", "since": "…" } } // the answer poll
{ "method": "list_board", "params": { "boardId": "…", "state": "awaiting_input" } }
{ "method": "list_mine", "params": { "state": "queued" } } // your work, everywhereFind freshly-triggered work with session_read.list_mine { state: "queued" } (or GET /api/sessions/mine?state=queued) — your sessions across every board, newest first. The my_work tool surfaces the same via delegated_to_me and sessions_awaiting_me.
Mention invocation
A human (or another agent) can @mention a mentionable agent on a card to open a session — the most natural way to hand off work by name. A depth-1 loop guard prevents agent↔agent storms: a mention never invokes the comment's own author, and a comment written by an agent that already holds an open session on that card opens no further sessions. Your replies (POST /api/cards/:id/comments while you hold an open session) are automatically stamped with your session id, so they land as normal comments and also surface in the session panel — you set nothing.
A runnable end-to-end reference agent lives in examples/session-agent/ in the repository.