Architecture4 of 10
Events and cards — being listened to is not being drawn
Everything the engine wants to put on a screen goes through one primitive: core/events.py. It is a
registry of per-session asyncio.Queues, and it is deliberately tiny.
core.events
event_queues: {session_id -> asyncio.Queue}
_cardless: {session_id, ...} # registered, but paints no fire-and-forget card
register(sid, *, draws_cards=True) -> Queue
unregister(sid, queue=None)
has_listener(sid) -> would a frame reach anyone?
draws_cards(sid) -> would a card emitted here be painted for somebody to answer?
emit_emotion(sid, emotion, run_id="")
emit_task(sid, kind, **data)
_put is a silent no-op when no queue is registered. That is a documented property of the
primitive, not an oversight — but it is also why the two questions below have to exist.
The frames
Every frame carries a type. There are two.
emotion drives the Live2D face in the browser and the pixel-art face in the terminal.
task drives everything else, tagged further by kind:
working · step · artifact · files_changed · need_input · reminder · report_ready ·
subagent_spawned / subagent_step / subagent_done · work_started · work_done · task_list ·
peek · recalled_image.
recalled_image carries only the keepsake's id, never its bytes, because this queue also carries
approval cards and a browser is not the only consumer.
Frames emitted from inside an agentic run also carry run_id — the origin field. A client
deciding whether a frame belongs to the turn it is awaiting or to a detached background job routes on
this key. Routing on "is a turn running right now?" is how a long job's rows once landed inside a new
turn's reply and split it in half. emit_task strips a falsy run_id before it goes on the wire, so
"key present ⇒ real id" holds and an empty id can never match another empty id.
How a surface registers
| Surface | Registration |
|---|---|
| Browser | GET /api/events/{session_id} → register(sid) — draws_cards defaults to True |
| Terminal | cli/session.py → register(sid, draws_cards=on_event is not None) |
| Discord | discord/bridge.py → register(sid, draws_cards=False) |
Registering is not enough. cli/events_bridge.py exists because a queue nobody reads grows for the
life of the process, and a card in an undrained queue is a turn asleep for the whole of its window.
Both the terminal and the Discord bridge run an EventBridge that drains and dispatches.
unregister takes the endpoint's own queue, so an overlapping SSE reconnect cannot have the old
connection's teardown delete the new connection's queue — which would silently kill every emotion and
task frame until the next reconnect.
The two questions
This is the distinction the page is named for.
has_listener(sid) — would a frame emitted here reach anyone?
This is the question a blocking card must ask. With no queue registered the emit is a silent
no-op, so the wait runs its whole window and ends in a timeout the user reads as a refusal they were
never asked for. interaction._reachable guards it inside the primitive, so a new entry point cannot
forget; loop's gate asker raises rather than returning (False, False), which lands on the audit
trail as approver="error" instead of as the user's own no.
Every surface that registers a queue also wires something that can answer a blocking card, in its
own idiom — a panel in the browser, a prompt in the terminal, a button in Discord. So for blocking
cards, has_listener is the whole question.
draws_cards(sid) — would a card emitted here be painted for somebody to answer?
This is the question a fire-and-forget card must ask, and it is strictly narrower:
def draws_cards(session_id):
return has_listener(session_id) and session_id not in _cardless
The defect it closes is real and was live: a surface that consumes frames and paints no input box had
her announce, out loud, that she had put a text box on screen — into a room with no screen. Discord
is exactly that surface. So is kotoba --once, and so is kotoba setup.
Three call sites ask it, and all three change what the model is told, never whether the frame is emitted:
interaction.open_input_cardreturns whether the card is painted, not whether the frame lands.interaction.open_link_card, the same.make_reportdecides from it whether to say the report opened.
register restates draws_cards unconditionally on every call, so a drawing surface that inherits a
session id previously marked cardless is not left refusing cards it would happily paint.
Blocking cards, and the five endings
core/interaction.py holds the human-in-the-loop side. A card is a need_input task frame plus a
per-request Future; POST /api/session/{id}/input resolves it. The futures are keyed
{session_id: {request_id: Future}} in card-open order, because one session can hold several at once
— a work-mode helper shares its parent's session id — so this must never collapse to a single slot.
An approval has five endings, and the whole design turns on the fact that a (approved, always)
tuple can name only one of them:
| Ending | approver_for writes | The terminal row says |
|---|---|---|
APPROVED | user | — |
DECLINED | user | you said no — it never ran |
UNANSWERED | expired | no answer on the card — it never ran |
UNREACHABLE | error | there was no way to ask you — it never ran |
DISMISSED | dismissed | the card went away when you spoke — it never ran |
Every sentence about an ending derives from that one word — refusal_note (what the model is told),
refusal_row (the terminal row), approver_for (the audit trail), verdict_of (the reading of the
out-param). The trail's job is answering "who let this run on my machine", and writing an expired
card down as approved=0, approver='user' is the trail asserting the user saw it and refused it.
That was measured on a typed df -h that expired unread at 181 seconds.
Human wait is charged to a run scope, never to the session
(interaction.interactive_scope), or unrelated cards on the same session extend a background job's
compute ceiling.
For what the gate actually decides, see The approval gate.
