Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Architecture4 of 10

Events and cards — being listened to is not being drawn

About 5 minutes to read

On this page

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

SurfaceRegistration
BrowserGET /api/events/{session_id}register(sid)draws_cards defaults to True
Terminalcli/session.pyregister(sid, draws_cards=on_event is not None)
Discorddiscord/bridge.pyregister(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:

python
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_card returns whether the card is painted, not whether the frame lands.
  • interaction.open_link_card, the same.
  • make_report decides 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:

Endingapprover_for writesThe terminal row says
APPROVEDuser
DECLINEDuseryou said no — it never ran
UNANSWEREDexpiredno answer on the card — it never ran
UNREACHABLEerrorthere was no way to ask you — it never ran
DISMISSEDdismissedthe 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.