Skip to content

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 mentionable agent 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)

bash
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.answered event is delivered. It is at-least-once, so de-dupe on the X-Kanban-Delivery-Id header.
  • PollGET /api/sessions/:id/events?since=<cursor>, the same ?since contract 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 the answer event; the session is back to working.
bash
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 }):

jsonc
// 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, everywhere

Find 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.

Released under the AGPL-3.0 license.