Skip to content

Architecture

Kokua wraps AIMU primitives into a single-user, always-on personal assistant. The design goal is a small core with capability pushed into plugins, and behind that goal is the reason the project exists: people should be able to learn how an agentic system works by reading, running, and extending a real one. See why Kokua exists and the six principles that serve it.

This page is the reading path for the first of those three. It goes in dependency order rather than alphabetically, so each section can assume the one above it.

The AIMU pieces Kokua is built from are its personal-assistant primitives: a Channel transport, a Scheduler, and a SkillAgent that authors its own skills. Kokua adds persistence, configuration, multiple conversations, human-in-the-loop gates, and the plugin system around them. Capability questions -- which providers, which tools, which models support vision -- are answered in AIMU's docs, not here.

Repository layout

src/kokua/
  cli.py               argparse surface, CLI-over-TOML merge, `config init`, main()
  plugins.py           entry-point discovery for front ends and toolsets
  images.py            the on-disk image store and the /images/<name> reference
  payloads.py          the on-disk store for oversized tool responses and the /payloads/<name> reference
  logging_setup.py     rotating file log + a SIGUSR1 thread-stack dump
  transcript_export.py render_markdown: a saved conversation as Markdown a person can read and judge,
                        imports no channel and no front end, so the CLI export works without `web`
  config.example.toml  every key, one line each (long form: docs/reference/configuration.md)
  web_static/          the single-page web UI plus vendored marked/DOMPurify/KaTeX

  core/          the transport-agnostic runtime
    assistant.py         composition root + serve loop; delegates everything below
    conversations.py     ConversationBook: store + agent cache + active pointer, and id resolution
    transcripts.py       reading a stored conversation as text (flatten, truncate, search) or as full
                          replay items (replay_items), shared by the web channel's history replay and
                          the Markdown export
    turns.py             TurnRunner: reactive and proactive turns. Concurrency invariants live here.
    messaging.py         MessageBus: what the user types into a turn that is already running, and
                          what one of that turn's own agents sends another; the roster of addresses
                          it mints per run, and the two sources that decide who has read it
    interaction.py       HumanGate: tool approval and a workflow's own decision, as lock-guarded single slots
    auto_approval.py     the optional model reviewer that may answer an approval prompt in the user's
                          place, and the code that decides whether it did (explanation/auto-approval.md)
    settings_runtime.py  SettingsApplier: read, apply live, persist
    commands: /stop, /diag, and the three conversation commands are parsed inline in
              assistant._serve_channel; a workflow's own command (e.g. /plan) dispatches through
              self._workflows, built from the toolset registry
    diagnostics.py       the /diag report
    conversation_commands.py  what /new, /conversations, and /switch print
    build.py             free functions that assemble a model client and wire one declared agent
    agents.py            assembles the registry from every provider; resolves, validates, and
                         prompts one declared agent, and builds its delegation tool
    subagents.py         SubagentReporter: sub-agent activity as display frames + recorded events
    agent_registry.py    per-conversation agent cache with LRU eviction and pinning
    turn_gate.py         writer-preferring readers-writer gate
    turn_registry.py     in-flight turn bookkeeping
    messages.py          transcript helpers: text extraction, the placeholder title, image compaction
    titles.py            the generated conversation title: one context-free model call
    errors.py            describe_error: root-cause extraction for user-facing messages
    metrics.py           TurnMetrics + record_event: what a turn cost, accumulated from AIMU's
                          ModelTurnFinished events into calls/tokens/seconds

  config/        settings: the schema, the file, the writers
    schema.py      AssistantConfig, AgentConfig, MCPServerConfig, the default prompt
    paths.py       the three locations that must resolve before config.toml can be read
    file.py        TOML discovery, parsing, schema validation
    store.py       comment-preserving tomlkit writes, the write policy, and apply_setting
    table.py       SettingsTable, built from CORE_RUNTIME_SETTINGS plus every toolset's declared hot
                   settings: the one declaration of what is changeable at runtime
    settings_sources.py  joins a toolset's declared settings into the table; the one module under
                   config/ that imports upward, so the rest of the layer stays at the bottom

  workflows/     protocol.py (Workflow, WorkflowContext, WorkflowResult, is_rich), critics.py (the
                 shared context-free reviewer), planning/ (runner.py's PlanningWorkflow, prompts.py,
                 critics.py's thin wrappers over the shared reviewer)
  mcp/           servers.py (connect, attach, reconnect, runtime add/remove), auth.py (ChatOAuth)
  scheduling/    recurrence.py (pure schedule math), tasks.py (TaskService, over the `[scheduling.task.*]`
                 tables in config.toml)
  channels/      ui.py (ChannelUI), protocol.py (RichChannel), cli.py, web.py
  frontends/     cli.py, web.py -- registered as plugins, exactly like a third party's
  registry/      the machinery a toolset is built against, and no toolsets
    registry.py    the Toolset and Setting dataclasses, `register`, `select`, `build_tools`
    context.py     LiveState (process-wide shared state) and the per-agent ToolsetContext
  toolsets/      the named capabilities themselves, and nothing else: one file per toolset, each file
                 named for the toolset it declares, each exporting a module-level TOOLSET, and each
                 listed in pyproject.toml's kokua.toolsets entry-point table, which is the only index
    audio.py, compute.py, documents.py, fs.py, memory.py, misc.py, skills.py, speech.py, time.py,
                   transcription.py, web.py -- wrappers over AIMU's tool groups and stores
    capabilities.py, config.py, conversations.py, mcp.py, scheduling.py -- one Kokua subsystem's logic
                   each, as agent tools (capabilities wraps the registry itself)
    planning.py    a Workflow instead of tools: the only toolset that contributes no tool at all
    aimu_agents.py, benchmark.py, github_backup.py, image.py -- Kokua's own, needing only the config

tests/ mirrors this layout.

The core

Assistant (core/assistant.py) is the composition root and the serve loop, and little else. It owns:

  • ConversationBook -- the session store, the per-conversation agent cache, and which conversation is being viewed. These move together on a switch, which is why they are one object.
  • TurnRunner -- reactive turns (the user sent something) and proactive turns (a scheduled task fired). The ten concurrency invariants are documented at the top of that module.
  • HumanGate -- tool approval and a workflow's own decision, each a lock-guarded single-slot request the serve loop resolves with the user's next message. With [security.auto_approval] on, a gated call named there is reviewed by a declared model reviewer just before that prompt, and only there.
  • SettingsApplier -- reading, applying, and persisting the runtime-mutable settings.
  • ChannelUI -- the only view of the outside world.

Non-obvious control flow: the serve loop runs each reactive turn as a background aio.RunHandle, so the channel keeps reading during a turn. That is what lets a /stop cancel an in-flight reply, and what lets a web approval reply be routed back to the waiting tool call. Switching conversations does not cancel a running turn: each conversation owns its own agent and client, so a backgrounded turn persists to its own conversation, streams muted, and raises an alert card when it finishes. Only delete_conversation cancels, and only the deleted conversation's own turn.

The approval gate has one optional layer above it, off by default: core/auto_approval.py. A declared reviewer answers three questions and writes one sentence about a gated call, and decide() computes the outcome from those answers, so the policy is code rather than a model's verdict. It sits where it does for a reason worth knowing before changing this module: both auto-denials (a proactive turn, and a turn the user switched away from) run first, so a review happens exactly where a human would otherwise have been asked and nowhere else, and the per-turn budget is a context variable a reactive turn opens and an unattended one does not, so an unattended turn fails closed a second way. A review can only turn a prompt into an approval: there is no deny verdict, and every way a review can fall short arrives at the same prompt. It is a convenience layer rather than a boundary, and Auto-approval is the argument for it, including what it does not do.

The three conversation commands (/new, /conversations, /switch <id>) are dispatched in that same loop, beside /stop and /diag, and go through the same new_conversation / select_conversation the web UI's sidebar buttons call. They are core commands rather than terminal ones on purpose: a conversation is a core concept, a channel has no route to the book that owns one, and the alternative would have been a second implementation living in channels/cli.py with its own idea of what a switch means. Two consequences follow from putting them here. They sit above the pending-answer check, so typing /new while an approval prompt is on screen leaves that question behind (HumanGate.abandon_all) exactly as clicking New does. And because a switch made by the core is one a front end did not initiate, the core repaints: ChannelUI.push_conversations refreshes a sidebar and ChannelUI.show_history replaces what a page is displaying, with ChannelUI.show_working behind it because that repaint clears the page's "a turn is already running here" indicator, all three no-ops on a channel that prints as it goes. The notice itself is sent between the history frame and the working one: a page wipes its transcript on the first and treats an ordinary message as the end of a turn, so either edge of that gap loses something. What the terminal gets instead is that sentence and nothing else, because muting is the one part of a background turn it cannot do: ChannelUI.mutes_background_turns is false there, so the reply to /new says the turn you left keeps printing here, that /stop now reaches the conversation you moved to, and that a tool call needing approval is denied meanwhile. The wording of all three replies lives in core/conversation_commands.py, for the reason core/diagnostics.py holds the /diag report.

A turn's spawned sub-agents surface the same way. core/subagents.py's SubagentReporter implements AIMU's SubagentObserver protocol; Assistant owns one instance per connection (Assistant.create runs once per WebSocket connection), and build.py threads it into every per-conversation agent's spawn_subagent as the observer= argument -- including across the rebuild a runtime MCP server add/remove triggers, so a worker keeps reporting after its tools change. Displaying and recording are deliberately separate. TurnRunner sets a subagent_events ContextVar collector for the duration of a turn, installed and reset alongside streaming_conversation under the same invariant (see the module's concurrency invariants): the reporter appends each spawn's frames to whichever list is current, and the turn records that list, synchronously, as the first action of whichever exit branch runs -- success, cancellation, connection error, or generic error. On the cancelled and error branches, which still await one more status send afterward (the "(stopped)" notice, or the failure message), recording first means a second cancellation racing that send cannot propagate past the completed record call and drop the turn's events. ConversationBook.record_subagent_events persists the result into session.metadata["subagent"][str(user_index)], the same map a rich-tier workflow's reviewer verdict cards write into (a shared merge helper extends rather than overwrites, since one turn can produce both). A background turn's cards are muted like the rest of what it streams, but its spawns are still recorded, so switching into that conversation later shows the work.

Two deviations from a literal live replay are deliberate. Reasoning and generated text are coalesced into one entry per block when recorded -- matching how the page already concatenates consecutive chunks of one kind when it displays them -- but streamed chunk by chunk when shown live, which keeps the stored JSON proportional to text length rather than to token count. A block is closed by anything recorded after it, so a multi-round spawn gets one answer entry per round for free: the round's own tool call sits in between. And a turn's spawn cards replay grouped right after its user bubble on reload, not at the exact point mid-turn where they appeared live. Separately, a gated tool call inside a sub-agent (e.g. execute_python) still prompts at the top level, not inside its card: the approval gate is forwarded to the parent's existing prompt, not rendered into the spawn's own card (#20).

What a turn cost

core/metrics.py's TurnMetrics accumulates what a turn's model calls cost: how many, which models, how many seconds of model time, and (when a provider reports them) input and output tokens. It is itself an AIMU event sink, one callable reading ModelTurnFinished and ignoring every other event, so an AIMU upgrade that adds a new event member cannot turn this sink into an outage.

The wiring follows subagent_events's own split between a durable seam and a per-turn scope, on purpose: core.metrics.record_event is a module-level forwarder with no turn state of its own, so it is safe to assign to agent.model_client.events (AIMU's own words are "the durable, always-shared setting") permanently, unconditionally, with nothing to detach afterward. What is opened and closed around a turn is the current_metrics ContextVar: TurnRunner opens a fresh TurnMetrics, sets it on that ContextVar for the turn's duration alongside subagent_events, and resets it in the same finally. Concurrent turns on different conversations therefore never share an accumulator, by the same ContextVar-per-execution-context mechanism subagent_events relies on. The client-side sink itself never needs that dance: it sits on the client permanently, inert whenever the ContextVar it reads is unset, so no failure path can strand it there by failing to take it back off.

Because the sink lives on the client rather than being threaded through agent.run(), a planned turn's model calls count for free: PlanRunner drives ctx.agent, the conversation's own agent, so its calls land on the same client the reactive path just instrumented. An unattended (scheduled) turn opens its own TurnMetrics and its own current_metrics scope in _unattended_body, since it runs in a child task with no counterpart to the reactive path's outer finally.

TurnRunner._record_provenance turns the accumulator into a stored record at the moment it is called, not earlier: it takes the sink and the turn's start time, calls TurnMetrics.record(wall_seconds=...) itself, and hands the result to ConversationBook.record_turn_provenance as usage, stored under session.metadata["usage"][str(user_index)]. Every exit branch of a turn (success, cancellation, a connection error, a generic error) calls it with the same two arguments, which is what makes the wall-clock figure honest on the paths a reader most wants to examine: a turn that raised still cost what it cost up to the point it stopped. record() returns None when the turn made no model call, and record_turn_provenance then writes nothing, so a turn that never reached the model is not misrecorded as a free one.

A spawned sub-agent and a workflow critic each build their own client, which puts both outside the family agent.model_client.events reaches: without their own wiring, delegated and reviewed work would be invisible to TurnMetrics and a heavily delegating turn would read as cheap. Both take record_event directly rather than a LiveState field or a threaded parameter, for the same reason the top-level wiring does: record_event is a module-level constant with no turn state of its own, so a tool or agent built once, at composition time, reports into whichever turn is actually running when an event fires, with nothing to plumb back to the caller. core.agents._spawn_tool and make_delegation_tool pass it as make_async_subagent_tool(events=record_event) (needs aimu>=0.25.0, below the current floor; see The model every agent runs on for what the probe checks today), and workflows.critics.reviewer_agent passes it as both aio.Agent(events=record_event) and client.events = record_event, unconditionally rather than through a parameter a caller could forget to pass; review and stream_review need no change, since both build their agent through reviewer_agent. The client-level assignment is what covers any direct call a caller makes on the client rather than through run(), since the agent's own events override only applies inside a run() call; finalize_verdict's client.chat(..., schema=Verdict) is exactly such a call. It does not, however, close the gap for that specific call: AIMU's structured (schema=) path returns before a turn event is ever emitted, on any client, so no sink, however wired, can observe it. A /plan turn's recorded cost is therefore short by exactly one model call per review round, with nothing able to flag the gap, since the missing call never enters the count a partial-report qualifier could point at. Every call that is recorded attributes by the event's agent field, so a delegated or reviewed turn's TurnMetrics.record() carries a non-empty by_agent breakdown where an undelegated turn's does not (test_omits_by_agent_when_only_the_entry_agent_ran pins the undelegated case).

A message sent while a turn is running

A turn is long: three model calls is seconds at best and minutes on a local model. Until AIMU 0.33.0 gave run() a steering parameter (one of the floor's past capabilities, below), a message typed inside that window queued behind the turn on the gate, so a correction arrived after the work it was meant to redirect had already been paid for. A message typed now reaches the turn that is already running, at its next model call. AIMU 0.34.0 renamed that parameter to inbox and taught it which agent is opening each reader, which is what the floor's own current paragraph, below, is about; the parameter Kokua passes changed name, not what it does.

core/messaging.py holds one MessageBus per turn, opened by TurnRunner before the turn's first await and published on the turn tracker's entry for that conversation, which is what lets the serve loop route by conversation id alone. Assistant._offer_message is the whole of the routing decision, and it sits below every other branch in the serve loop, so nothing those branches could have answered changes meaning: /stop still cancels, the conversation commands still switch, a pending approval or plan verdict still consumes the reply, and a workflow command still starts a workflow turn of its own. It also declines in four shapes: there is no turn in flight on this conversation (or its handle has already finished, or it has not published its bus yet, which a burst the channel delivers in one loop step makes reachable); the bus has closed; the message carries an image, which has no defined place inside a loop; or the text is blank, since AIMU discards whitespace at the drain and accepting it would mean an empty follow-up turn.

A cursor per reader, not a queue. The message goes to the entry agent and to every worker a declared agent spawned, because a redirection the user meant for the work is useless if it only reaches the supervisor. A shared queue would let whichever reader drained first consume a message the others never saw, so the bus is append-only and each run opens its own cursor over it. Two sources decide which cursor: ENTRY_SOURCE opens the bus's entry cursor and goes to every run of the entry agent, while WORKER_SOURCE opens an independent one and rides the spec of every worker Kokua spawns, whether build_agent_specs wrote that spec for a declared agent or compose_subagent built it per call. The distinction is load-bearing rather than tidy, because close() measures what is left over from the entry cursor's position. Hand the entry agent a worker-shaped source and that position never moves, so every message the turn actually delivered would also run again as a turn of its own. A worker having read a message is not the conversation having read it, which is the same fact from the other side: a message only a worker consumed still comes back from close().

Three fates, not two. A message runs as its own turn, or joins the running one, or is accepted into a turn that ends before reading it and then runs as a follow-up turn carrying the first message's front end id. The third exists because acceptance and delivery are not the same moment: the window after the loop's last drain is real, and close() hands back what was never read so TurnRunner._resubmit_messages can run it. offer and close are both synchronous, and asyncio is single-threaded, so the serve loop cannot see a bus as open in the same tick the turn's finally shuts it. That is invariant 9 in full, including the one window it does not cover and the reason closing that window would cost more than it buys. A stop is the deliberate exception: someone who cancelled the turn is not asking for one more, so the cancelled branch peeks at what was undelivered and says so in its notice instead of running it. close() hands back two lists rather than one, because who sent a message decides what its failure to arrive deserves: the user's own words can become the next turn, while an agent's note to another run is reported instead, since re-running it as a user turn would put words in the user's mouth. Telling the two apart needs more than a cursor position, which is why the bus records which messages a reader actually took: every cursor advances past every message whether its filter matched or not, so a message addressed to a run that never drained it looks read from every position on the bus. That is invariant 10, including the limit of liveness-free addressing it rests on (a bare label naming several runs is satisfied by any one of them, so a report means "nothing took this" and never "this run did not read it") and the gaps it leaves.

An address is minted at reader-open, and the roster dies with the turn. The same bus carries a message one of the turn's own agents addressed to another (toolsets/messaging.py's send_message and list_agents), which needs something the user's broadcast never did: a name per run. MessageBus mints one in each reader factory, because that is the only moment a run is individuated (the entry agent's declared name with no ordinal, since exactly one runs per turn; label#ordinal for a worker, with AIMU's subagent- decoration stripped so the address reads the way [agents.<name>] does), and matches is the pure predicate a drain filters itself with: everyone, one exact address, or a bare label reaching every run under it. The roster is append-only and turn-scoped, and both halves are load-bearing. Nothing in AIMU's Inbox protocol signals that a run has ended, and inferring it from reader-open order is the identity-by-ordering that protocol's own docstring warns against, so an address names a run that opened a reader, never one still open. That is what makes a send to a finished worker an accept followed by an undelivered report rather than a refusal, while a send to an address that never existed this turn is refused outright, since the roster can answer that much without liveness. Who can reach whom is decided by the execution model rather than by the bus, and Agent messaging is where that argument lives (a parent is blocked for as long as its children run, so sibling-to-sibling is the only direction this adds between two runs already going, and an orchestrator's own reach is a broadcast ahead of a spawn); nothing here repeats it.

Liveness resets, safety does not. AIMU's loop drains the bus once per round, at all three ways a round can end, and a delivered message moves the round budget's base so the run gets a fresh max_iterations stretch counted from there: a human saying something is the evidence that the run is not spinning, which is what the cap exists to catch. (AIMU caps the extensions, because a bus whose cursor never advanced would otherwise lift the bound entirely.) An agent's message is not that evidence and gets the same extension, including a run's message to itself, which matches allows because a sender is a reader like any other. The extension cap is what keeps that bounded, at a number worth knowing rather than assuming: at most max_iterations extensions, each moving the base rather than adding a round, so the worst case is on the order of max_iterations times the cap. Agent messaging carries the argument, and the lever on the record there closes a run messaging itself rather than the extension: two agents messaging each other reach it by sends the capability exists to allow. The cap above is the bound, not the lever. The auto-approval budget is left alone on purpose. It bounds how many gated calls run without a prompt, and more user text does not make a gated call safer. What the message does reach there is ReviewContext.request, amended so a reviewer judges a redirected turn's calls against what the user now wants rather than against instructions already replaced. That amendment rides the bus rather than the contextvar beside it, because an offer arrives on the serve loop's task while current_review_context is set inside the turn's own and is therefore invisible from where the offer lands.

Five run shapes can receive a message mid-turn, and the differences between them follow from their shapes rather than from different rules. A plain reactive turn is the simple case. Every entry-agent run inside a /plan turn shares the one entry cursor, since a cursor belongs to a turn and not to a run, so a message one of those runs delivered is not re-submitted by the one after it. A spawned worker reads through its spec, which build_agent_specs writes unconditionally: a stated exception to "a capability is declared, never defaulted", because the value is not a per-worker setting but the one process-wide source that resolves whichever turn is running when a reader is opened, and a worker with no turn around it gets a drain that returns nothing. A worker toolsets/capabilities.py's compose_subagent builds per call reaches the same place by a different route, and is the one run shape whose source was added for two reasons rather than one: that tool builds its own spawn tool instead of going through build_agent_specs, and for a while passed no inbox (nor events, the same omission one subsystem over), so a composed worker could not receive a message mid-turn. It writes both now, and delivery is only half of why inbox is unconditional there: a run that opens no reader mints no address, which leaves current_address holding whatever its caller set, so send_message would have attributed a composed worker's message to the agent that composed it. capabilities and messaging are both in the shipped default [agents.assistant.tools].available, so that was reachable on a default install rather than hypothetical. A scheduled firing opens its bus in _run_unattended rather than in the body that reads it, because the body runs in a child task that copies the context at creation, and reaches close() outside the gate hold, because a follow-up turn takes a hold of its own (invariant 1). What deliberately cannot receive a message mid-turn is an independent reviewer (workflows/critics.py), whose whole value is that it reads the request and nothing else: it passes no inbox at all, so a redirection cannot reach the agent judging the work.

Which of a turn's messages were sent mid-turn is recorded, because nothing in the transcript shows it. A message the user handed to a running turn is appended as an ordinary user message, indistinguishable from one that started a turn, so resolve_message_indices reads the positions back off the message list after the run (a compaction between rounds can move them) and record_turn_provenance stores them under metadata["messages"]. A message one of the turn's agents sent is not indistinguishable: the turn writes messages.PROVENANCE_AGENT onto it before _persist snapshots the turn (TurnRunner._tag_agent_messages), so a worker cannot reach the stored transcript wearing the user's role, and is_user_turn excludes it with no record needed. The tag alone is metadata the model itself never reads, so the two drains that hand AIMU's loop a message's text (MessageBus.reader, MessageBus.entry_reader) also render an agent's words attributed to its sender ([message from researcher#1] ...) and the user's own bare: the same distinction one level below the tag, protecting the model that acts on a message rather than the reader that looks at the record afterwards. A worker's drain adds , sent before you started inside that bracket for an agent's message that predates the worker, since its cursor opens at zero and a replayed request from a sibling that has since finished would otherwise read as live. One qualifier, and it is the reason the record still carries both kinds: a drain's list becomes one appended message, so a round that delivered the user's words and an agent's is one message that is both, and PROVENANCE_AGENT cannot go on it, since that tag's own contract ("nothing here is the user's") would be false. is_user_turn reads this message exactly as it would an untagged one, and the per-turn index is what answers for it there (MessageBus.tag_for_delivery); the same holds for the one other case PROVENANCE_AGENT never reaches, where a mid-turn delivery cannot be paired with the message it became and the turn answers for the whole turn at once rather than guess (TurnRunner._tag_agent_messages). Neither is left undefended on the model's side (the words an agent contributed were already rendered attributed inside its own context by the drain, before AIMU ever joined them into the message) or, where the mixing can actually be told apart, on the transcript's: a mixed delivery carries messages.PROVENANCE_MIXED instead, a narrower tag is_user_turn does not look for, which is what lets replay_items mark it from: "mixed" and the Markdown export sign it Mixed (mid-turn) rather than crediting an agent's half to the user. Only the fallback's own complete under-tag, reached when a turn has fewer recorded deliveries than appended messages, carries neither tag; that shape is not reachable on any path shipped today, and the guard that refuses to guess at it lives beside the broadcast in TurnRunner._tag_agent_messages.

That record is what keeps a reload from replaying one such turn as two: the "inbox" item replay_items emits carries no message_index, so a renderer that stamps a turn's controls on the first item carrying one has nothing to stamp it with, which is exactly right, since the index that would truncate "here" points into the middle of the host turn. A transcript stored before turns recorded this has no entry and replays the old way, which is the best a reader can do with a record nobody wrote.

What a /plan turn records is decided by how it executed, and on the shipped configuration it records the same as a plain turn. With [planning]'s result_review and show_reasoning both off, which is how they ship, execution goes through _execute_streaming: that path keeps the executor's own messages and rewrites only the prompt it ran them under, so a message delivered while the executor was working sits in the stored transcript and in metadata["messages"] like any other, and survives a reload. Turn either flag on and execution goes through _execute_with_review instead, which replaces everything the executor appended with a single user/assistant pair: a message delivered there reaches the model, shows live, reaches the catch-up record a switch-in replays, and is gone on reload, where the turn reads as the request and its answer alone. The planner's own rounds behave that second way whichever path follows them, because _make_plan rolls its scratch back, so a message delivered while the plan was still being drafted is never in the stored transcript either. The resolve call is made on the workflow branch regardless of which of those happened, so the two branches cannot drift.

Each channel then says it in its own vocabulary. The terminal prints the words as the run reads them, from AIMU's base channel, which is all a channel with no bubble to go back and mark can usefully do. The web page marks the bubble it already drew and gives it none of a turn's own controls, and a sub-agent card gets an entry of its own kind, filed apart from the loop marker beside it because a card that credited the agent loop with what a person said would be reporting the wrong author. kokua export writes it as a labeled line rather than the italic machine note a loop marker gets, and uncapped, for the same reason.

Agents and delegation

Every agent is declared whole in config.toml, as one [agents.<name>] table carrying a description, a system_message, a [agents.<name>.tools] subtable (an available list of toolset names, plus optionally a resident subset and its own confirm and review), and a delegates_to list. Nothing about an agent's availability is defaulted in code; its approval policy is the one thing that can be inherited, whole, from [assistant.tools]. [assistant].agent names the entry agent: the one Kokua constructs directly, one per conversation. Every other agent exists only as a spawn target, built when a delegator's spawn_subagent is constructed.

There is one agent shape, not two. build.wire_agent builds any agent from its declaration (select its toolsets, build the resident ones' tools, hand it the deferred ones as loadable groups, attach the approval gate, attach its delegate if it has targets), so a conversation's agent cannot differ from a sibling's by accident, and there is no mode to branch on.

  • A declaration is the only way capability is granted. A built-in group, an installed plugin toolset, and a connected MCP server all reach nothing until some agent's available names them (or an agent declares available = "all"), and nothing warns about a name nothing references (the one startup warning is for a toolset whose own config section was written while no agent declares it, whose settings nobody then reads). Startup used to, for names the user had provisioned specifically to be reachable, but telling those from the ones that merely ship meant a provenance rule over the whole namespace, and a toolset nobody declares costs nothing to leave unnamed. A configured MCP server does cost something (a handshake, and a held credential), which is the case that lost a signal here; see Add MCP services.
  • A composed sub-agent is the one exception, and it is entered by declaration. compose_subagent (toolsets/capabilities.py) resolves a sub-agent's tools from names the model picked out of the registry rather than from a table, and runs one task on it through AIMU's subagent machinery instead of wire_agent. Only an agent whose own available names capabilities holds that tool; it may not be handed capabilities itself, since how far composition nests is [capabilities].max_depth's decision (default 3, 0 off) and not the model's; and its tools still go through the approval gate, bound to the label composed:<label> and always under the default [assistant.tools] pair. It is built per call and discarded with the call, so what widens is one task's reach, never a persistent agent's.
  • Delegation nesting is Kokua's, not AIMU's. AIMU's max_depth gives every level the same worker menu, which cannot express a graph where each agent has its own targets, so build_agent_specs recurses over delegates_to and calls AIMU with max_depth=1 at every level. validate_agents proving the graph acyclic is what makes that recursion terminate, which is why a cycle is a startup error and not a style complaint.
  • A runtime MCP change is applied by rebuilding the delegate. A worker's tools are snapshotted when spawn_subagent is built, so add_mcp_server fans rebuild_delegation_tool across every live agent rather than appending to any agent.tools.

At least one agent is therefore required, and Assistant.create refuses a config with none, or one whose [assistant].agent names a table that does not exist.

What the shipped config declares is a lean entry agent: kokua config init gives [agents.assistant] the cross-cutting toolsets (memory, documents, skills, config, mcp, scheduling, conversations, planning, capabilities, the clock) and no domain toolset, delegating web work to researcher, filesystem and compute work to coder, and evaluating a past conversation to introspector (the delegate export_conversation is meant to be paired with; see Analyzing another conversation). There is deliberately no catch-all role alongside them: a task neither specialist covers is what compose_subagent is for, and a generalist declared next to it would claim the same slot more cheaply and win. That keeps the always-on agent's tool context small, and its resident list keeps it smaller: only the toolsets it uses on most turns are in its tools from the start, and the rest it loads when a turn needs them (see the inventory). The prompt tells it so: assemble_system_message adds the "you are a lean supervisor, you MUST delegate" clause only when every toolset the agent declared, resident or deferred, is cross_cutting. But that is a property of the config, not a law of the code. Give [agents.assistant] a available = [..., "compute"] and it gets execute_python, loses the lean clause, and Kokua neither objects nor cares. The one structural restriction is entry_point_only: skills works solely on the entry agent, because a spawned worker is a plain AIMU Agent rather than a SkillAgent, so skill injection would have nothing to hook.

How an agent's tools resolve

Six steps. The first two run once at startup, before Kokua opens or connects to anything; the next three run once per agent, whenever one is built; the sixth runs once, after every agent is wired, and again for a single toolset whenever a deferred one is built. Steps 1 to 3 read an agent's available list, which is the availability axis of Tool access; step 4 is its residency axis, and step 6 its approval axis:

  1. Build the namespace. agents.build_registry(config) collects toolsets from four sources, each with a provider label: MCP server (one per [[mcp.server]], named by its required name), skill (one per skill on disk, so a skill name sits in the same namespace as everything else), built-in toolset, and plugin. The last two are both the kokua.toolsets entry-point group, split by which distribution registered them: Kokua's own toolsets, wrappers over AIMU's groups included (fs, compute, web), take the built-in label, and a third party's take the plugin label, so --list-toolsets can group them separately. That label is the only difference between the two, and every toolset keeps the build its author wrote: nothing is wrapped, so a toolset that fails to import or to build takes startup down naming itself, whichever route it arrived by. Nothing gates any of this either. Registration is unconditional because installing a distribution that registers an entry point is the consent, and because the switch that used to exist could not do what it claimed: resolve_config imports every entry point before the file is parsed (see Configuration), so withholding the names afterwards ran the same code and only turned a working available declaration into an unknown-toolset error. registry.register then rejects a name two providers claim, and refuses two names outright, core and all, which the approval vocabulary and available = "all" have already spent. The four labels are mostly presentation, but two questions are answered from them: agents.is_skill tells a skill's entry from any other kind, and agents.TRUST_ORDER ranks them (built-in toolset, plugin, skill, MCP server) for available = "all". Skills are the one source re-read after startup: each skill write calls build.refresh_skills, which swaps in agents.with_skill_toolsets(registry, ...), every skill entry rescanned from disk and every other entry kept as startup registered it, then rebuilds every live agent's spawn_subagent. That is why a skill reaches an "all" worker on its next spawn while a server add_mcp_server connected waits for a restart. A Toolset is a frozen dataclass: name, description, build, guidance, cross_cutting, entry_point_only, workflow, settings.
  2. Validate. agents.validate_agents runs before the session store is opened or any server connected, so an unknown toolset name, a missing entry agent, an unknown delegation target, a cycle, skills on a worker, or an "all" agent's resident entry outside the expansion fails with nothing written and nothing connected.
  3. Select. The names come from agents.available_for(config, registry, agent), the one reader of an agent's availability: its list as written, or for available = "all" every registered toolset sorted by TRUST_ORDER (registration order kept within a provider), minus entry_point_only toolsets on any agent but the entry agent. The config holds "all" as the sentinel ALL_TOOLSETS, which raises if iterated, so a reader that forgot to expand it fails loudly instead of walking the letters of "all". registry.select(names, registry, agent=, entry_point=) returns those toolsets in that order, deduplicated, raising on an unknown name (with the available ones listed) or an entry_point_only toolset declared elsewhere. It never drops: dropping is what the previous per-role lists did, and a typo silently produced a smaller toolset. For the entry agent, skill names are held back first (agents.without_skill_names), since its SkillAgent already advertises every skill.
  4. Split by residency. agents.split_by_residency divides the selection into what the agent holds from its first turn (agents.resident_for: its resident list, else all of its availability) and what it loads on demand, both in availability order. build.tool_groups_for turns the split into AIMU ToolGroups, handed to the agent's constructor: a resident group has no build and exists so available_tools shows the whole picture, and a deferred group's build is build.build_deferred, which is build_tools over that one toolset, so a toolset arriving late goes through exactly the call a resident one does. A deferred group also carries the toolset's guidance, which AIMU returns as the load's result, and an Asks first note from agents.approval_note. Any deferred group gives the agent AIMU's available_tools and load_tools. A deferred MCP server that is not connected refuses to build, and a failing build alerts the user through LiveState.alert_soon before AIMU turns the exception into the model's Could not load result; the group stays loadable. A delegated worker gets the same groups through its spawn spec's "tool_groups" key.
  5. Build. registry.build_tools(toolsets, ctx) calls each Toolset.build(ctx) and concatenates, deduplicating by __name__ and keeping the first, so availability order (trust order, for "all") decides a collision among toolsets built together. A deferred toolset loaded later is settled by AIMU instead, which keeps the tool the agent already holds and names the one it skipped in the load result (with its holder: a group's name when the holder was loaded, a tool given to this agent directly when it is resident). A resident collision is reported: once every startup build is done, agents.shadowed_tools reads LiveState.tools_by_toolset and Assistant.start raises one alert per hidden tool. Startup builds the entry agent and every worker reachable from it by delegates_to, so an agent is skipped (and logged at INFO) when any of its resident toolsets was built by no agent. ctx is a ToolsetContext: the one process-wide LiveState, this agent's live object (None for a spawned worker), and its agent_name, which unlike the object is known at every construction site and is how a toolset scopes itself to its own holder (benchmark asks the config what agent_name runs on). At wiring this runs over the resident toolsets only; a deferred one reaches the same function later, through its group. build must create only closures, never process state: every shared singleton (the memory store, the document store, the SkillManager, the TaskService) is a lazy property on LiveState, so two agents declaring one toolset share one rather than constructing two, and the two stores are opened only because some agent declared them (and, for a deferred toolset, loaded it). The SkillManager and the TaskService are the exceptions to that last half, and not by accident: every agent is a SkillAgent and so takes the manager regardless, and tasks.arm_all() has to fire a persisted scheduled task whether or not any agent can talk about scheduling.
  6. Resolve approval. core/approval.py's resolve_approval_policies runs from Assistant.start after every agent is wired, because a gate entry is written as <toolset>.<tool> and the tool names exist only once the toolsets have built them. It resolves one ApprovalPolicy per declared agent (its own confirm and review, or [assistant.tools]'s pair inherited whole) plus the default tier itself, and rejects any entry that gates nothing. An entry naming a deferred toolset nobody has built yet cannot be resolved then, so after its shape is checked it waits in the policy's pending_confirm or pending_review. build_tools calls LiveState.toolset_built for every toolset it builds, after recording the tool names and before returning any tool, and Assistant._on_toolset_built resolves the waiting entries there (ApprovalPolicies.with_built, and, with auto-approval enabled, AutoApproval.with_built for [security].never_auto_approve entries waiting the same way). Each policy keeps the entries it was written with, and the hook matches every one naming the built toolset, resolved or waiting, adding names and never removing one, because a toolset's tools can grow after startup: refresh_skills reports each skill's tools to the same hook after a script is written, before it rebuilds any delegate, so review = ["weekly-digest"] covers a script added mid-session. Because the hook sits in the one build every path shares, it holds whoever builds the toolset: a model's load_tools, a composed worker, or a rebuilt agent's preload. An entry that still matches nothing, or (with auto-approval enabled) a review list that now reaches the floor, raises there and refuses the build, every time, so a misnamed gate fails closed. A conflict between pending entries that their text alone settles (a whole toolset on one side, one of its tools or the same toolset on the other, or one tool named on both) is refused at startup instead, by agents.certain_overlap from the same two checks, _refuse_overlap and _refuse_floor_reach; the build-time check remains for an overlap only the built names reveal. Both objects are swapped under one lock, since a deferred build runs in a worker thread. Whether an entry waits is decided in one place, agents.built_toolsets: every toolset a build_tools call has recorded, plus every skill the entry agent's SkillAgent holds (every skill when it declares skills, otherwise the skills it names). AIMU builds that skills server itself, so no build ever records it, yet its tools exist from the first turn; counting it as built is what keeps a gate on such a skill from waiting forever, whichever agents could also load it later. (Whether an agent carries the loaders at all is a separate question, agents.loadable_toolsets, which follows wiring in leaving the entry agent's skills out of its deferred set.) HumanGate.approve(agent, name, arguments) then looks the calling agent's policy up at call time. The gate is bound to each agent's name through LiveState.gate_for: the entry agent gets its own, a delegated worker gets its own through the spawn spec's "tool_approval" key (AIMU 0.37.0), and a composed worker is bound to composed:<label> and always judged by the default tier. : and [ are therefore reserved in agent names.

The prompt is assembled from the same split, in agents.assemble_system_message. For the entry agent, a --system flag wins outright over its declared opener; a worker's declared opener is never touched by the flag. Absent an override, it is the agent's own system_message (falling back to [assistant].system_message, then the built-in default), then each resident toolset's guidance in availability order, then one line naming the deferred toolsets (Also available, load before use: ...) if there are any, then DELEGATION_GUIDANCE if delegates_to is non-empty, then LEAN_DELEGATION_GUIDANCE if every selected toolset, resident or deferred, is cross_cutting. A deferred toolset's guidance is not in the prompt: it rides its group and reaches the model as the result of loading it. Guidance travelling with the capability is the point: installing a toolset brings the instructions that make the model use it, and removing one takes them away, with no prompt constant to keep in step by hand.

Two of those sentences exist for one failure, a model answering a question it should have looked up, and they are split across the two halves because the two halves reach different agents. DELEGATION_GUIDANCE tells a delegating agent that a request counts as specialized whenever its answer could have moved since training or the user could check it against a source, and to delegate it "even when you think you know"; the web toolset's own guidance tells whoever holds the tools to look it up rather than recall it. An agent that delegates but holds no web tools gets only the first, a worker holding web gets only the second, and the shipped [agents.assistant] and [agents.researcher] are exactly that pair. Both state the trigger as a property of the question (could it have changed, could the user check it) rather than as the model's own confidence, which is the signal a model is worst at reporting: LEAN_DELEGATION_GUIDANCE already named the activity ("web research"), and naming an activity only helps once the model has decided the question needs the web. wire_agent selects and splits once and passes the same halves to both the message and the tools, so the two cannot resolve different toolsets for the same names.

Arithmetic is split the same way, for the same failure in another form: a figure worked out in the reply reads exactly like a computed one. The compute toolset's guidance tells whoever holds the tools to calculate rather than estimate in prose; LEAN_DELEGATION_GUIDANCE tells a lean delegator that its arithmetic is delegated work, because "running code" does not reach a model writing a conversion into its reply. That sentence lives in the lean clause rather than in DELEGATION_GUIDANCE because only there is the agent certain to hold no compute, which is not cross_cutting, so the two can never contradict each other in one prompt. The time toolset's guidance covers the clock: a model's sense of today's date comes from training, so it is told to read the clock and to convert timezones with convert_time.

A rebuilt agent preloads what its conversation loaded. The agent builder make_agent_builder hands wire_agent a preloaded list, read by build.loaded_toolsets_in from the stored transcript's load_tools calls in the order each name first loaded successfully, so an agent rebuilt on restart, after LRU eviction from AgentRegistry (more than eight live conversations), or after a truncation holds what the model had loaded. Only a call whose stored result (joined by tool_call_id, as replay joins one) opens with AIMU's Loaded '<name>': counts. AIMU stores a call whether or not it ran, so reading calls alone would give back, on the next rebuild, a load the approval gate refused, and a denied load has to stay denied; a failed load, and a call left without a result by a cancelled turn, are left out the same way. tests/test_aimu_compat.py pins that text against a real load, since Kokua now depends on it. Replaying the calls through the agent is not possible here: AIMU's load is async and the builder is not. So wire_agent builds each still-deferred name through build_deferred, which is the same connected-server check and the same approval hook a load gets, appends the tools to the agent's own list, and constructs the agent with those toolsets' groups marked resident; the prompt is rewritten so their guidance is in it and the names line lists only what is still loadable. The visible cost is that available_tools lists a preloaded toolset as resident rather than loaded. A preload that fails is logged at WARNING, not alerted (a rebuild happens without anybody asking, and an alert per conversation switch would be noise), and the agent is constructed again with that toolset left deferred and loadable, so the model can retry and read the real error. A preloaded tool whose name the agent already holds is skipped and logged, as AIMU skips one on a model-driven load.

What a skill script sees

A skill's scripts run as subprocesses, and a script cannot discover where Kokua serves downloads from or which address it is allowed to mail. LiveState.script_env() is the one place those facts are turned into environment variables (KOKUA_DOWNLOADS_DIR, KOKUA_IMAGES_DIR, and the [email] settings); deriving them inside a script would mean re-implementing config/paths.py and drifting from it. KOKUA_EMAIL_PASSWORD is deliberately not among them, since a subprocess already inherits it from this process and copying it would duplicate a secret for nothing.

That map has to travel by two separate routes, which is the part worth knowing before changing either. A spawned worker is a plain Agent, so its skill tools come from the registry, and LiveState.skill_tools passes the env to build_skills_server when it builds that server. The entry agent is a SkillAgent, which builds its own skills server (on first run, and again on reload_skills), so nothing outside it can reach that call; wire_agent hands the same map to SkillAgent(script_env=...) instead. A route that forgets raises nothing anywhere: the script simply runs with the settings missing and reports itself unconfigured, which is what email-report did on the entry agent until the second route was wired. tests/core/test_build.py pins it, because nothing else would notice.

The shipped entry agent's inventory

All 37 tools the shipped [agents.assistant] table resolves to, and where each comes from. This is what config.example.toml declares, not a fixed list: a different available list produces a different set. Fourteen of the 37 come from AIMU, more than a third, and so are not greppable in this repository (more once skills are installed, since AIMU injects a tool per skill script on top of this set), which is why this table exists rather than a naming convention alone. The table declares a resident list too, so only part of the inventory is in the agent's tools from its first turn; the rest arrives when the model loads it:

Tools Built by Declared as Shipped
store_memory, search_memories, list_memories AIMU make_memory_tools memory resident
read_config, update_config toolsets/config.py config resident
list_conversations, read_conversation, search_conversations, rename_conversation, export_conversation toolsets/conversations.py conversations resident
list_capabilities, compose_subagent toolsets/capabilities.py capabilities resident
get_current_date_and_time, convert_time AIMU builtin.time time resident
send_message, list_agents toolsets/messaging.py messaging resident
spawn_subagent AIMU make_async_subagent_tool implied by a non-empty delegates_to always held
save_document, read_document, edit_document, list_documents, search_documents AIMU make_document_tools documents deferred
author_skill, update_skill, add_skill_script AIMU make_skill_authoring_tool / make_skill_update_tool / make_skill_script_tool, wrapped by toolsets/skills.py to refresh the registry after each write and refuse a taken name skills (entry agent only) deferred
add_mcp_server, remove_mcp_server toolsets/mcp.py mcp deferred
schedule_task, list_scheduled_tasks, get_scheduled_task, update_scheduled_task, cancel_scheduled_task, enable_scheduled_task, disable_scheduled_task, run_scheduled_task, stop_scheduled_task toolsets/scheduling.py scheduling deferred
benchmark_model toolsets/benchmark.py benchmark deferred

planning is resident too and contributes no tools, only its /plan workflow and its guidance. Holding anything deferred adds AIMU's available_tools and load_tools, which no toolset provides (a gate names them core.available_tools and core.load_tools). So the agent starts each conversation with seventeen of the 37 plus those two loaders. Measured as the JSON of every tool spec it is sent, that block is 16,244 bytes, against 30,560 bytes for all 37 tools resident: 46.8% smaller, on every round of every turn that loads nothing.

Two conventions keep this honest. Every Kokua-side agent tool lives under toolsets/ and nowhere else, so grep -rl '@tool' src/kokua/ finds only files in that one directory. And test_entry_agent_toolset_is_exactly_the_documented_inventory in tests/core/test_build.py asserts the built agent's tool names as an exact set mirroring this table's resident rows plus the two loaders, with every deferred tool absent, and test_loading_every_deferred_toolset_completes_the_documented_inventory loads each deferred toolset and asserts the whole table. So adding a tool to the entry agent fails the suite until the table is updated, a plugin toolset leaking onto it fails too, and so does a toolset quietly changing sides. Documentation alone would have rotted; the test is what makes the table trustworthy.

The pattern for a new Kokua capability is two modules, split by who reads the output. The logic goes in the owning subsystem (core/, config/, mcp/, scheduling/) and returns data or raises a typed error; it holds only what agents and front ends both need, and formats nothing. The agent tools go in toolsets/<name>.py, one file named for the toolset: a make_*_tools(...) factory closing over the live state, the docstrings that steer the model, every sentence it reads, and a TOOLSET whose build pulls that state off the context, plus one line in pyproject.toml's kokua.toolsets table, which is the only registration there is. A capability needing nothing but AssistantConfig skips the first module and is just the toolset. Either way it reaches an agent only when a [agents.*] table names it.

The split earns its keep where the two readers diverge. A scheduled task's next firing is a status to TaskService, "~3600s" to the model, and "in 1h" in the sidebar; when the service returned one sentence, the sidebar was showing prose written to steer a model.

Reading across conversations

toolsets/conversations.py defines list_conversations, read_conversation, and search_conversations over core/transcripts.py (flattening, truncation, search) and ConversationBook.resolve (an id or a unique prefix). It also defines two tools that are not plain reads: rename_conversation, covered under Renaming a conversation below, and export_conversation, covered under Analyzing another conversation. Two decisions in it are worth knowing before changing them.

The rule is about conversation identity, not about workers, and the difference took a second worker to become visible. Reading "the user's other conversations" means nothing to an agent with no relationship to any of them, and granting it would widen a spawn's blast radius for no gain, so researcher and coder hold none of it and the entry agent reads first and puts the text into the spawn's task. But an agent spawned to evaluate a conversation has a conversation as its subject, and for it history is the whole job: the shipped introspector declares conversations for that reason, and test_only_the_introspector_reads_history_among_the_shipped_workers pins both halves, since the failure worth catching is the toolset spreading to a worker with no such subject.

Granting it to a second kind of reader is what exposed a sentence that had been quietly relying on there being one. read_conversation and export_conversation append a note when the conversation being read is the active one, and it said: this is the conversation you are in, so use your own context for anything said in this turn. Both clauses are false for a worker, which is in no conversation and holds no transcript, and the request that trips it (evaluate the conversation the user is sitting in) is the likeliest one there is, so a worker would have been told to consult context with nothing in it. make_conversation_tools therefore takes is_entry_agent, which TOOLSET.build derives from ctx.agent_name == ctx.config.entry_agent, and picks between that note and DELEGATING_CONVERSATION_NOTE, which says what is true of a worker instead: this is the conversation that delegated to you, and the turn that spawned you is not saved yet, so what you can read stops just before it. It is a flag rather than two toolsets because everything else the tools do is identical, and a flag rather than a docstring per agent because a tool schema should stay a literal a reader can find. The same reasoning fixed list_conversations' description of the (current) marker in place instead: it had told the reader not to read that conversation back, which is right for the agent holding it in context and would have talked a worker out of the only conversation it was spawned for.

Every read goes through the store, never ConversationBook.agent_for. Building an agent to read it would allocate a model client, re-expand every stored image, mutate the LRU registry (so reading twenty conversations would evict the live agent of one with a running turn), and can raise ModelClientError for reasons unrelated to reading. It is also less correct: a running turn appends to agent.model_client.messages in place, so a reader in another task can observe a half-written turn, while the store holds a snapshot written once at the end of a turn. The two markers close the gap the snapshot opens -- turn_running flags unsaved messages, and the active conversation is flagged as the one whose current turn the model should read out of its own context. A test constructs a book with no registry bound, so any accidental agent_for path fails rather than passing quietly.

ConversationBook.summaries() is the single place the store's bulk-metadata read and the updated_at ordering live: one call to list_summaries(), not one get() per stored conversation. list() projects it into the shape the sidebar wants, and most_recent_or_new (and cli.py's _export, for kokua export latest) takes its head and fetches only that one winning conversation's messages with a single get. That is what makes a switch, a sidebar repaint, or an export independent of how many other conversations are stored: none of them costs more than the one conversation actually being shown. sessions() is the one method still paying for every conversation's messages, because it is the one question that genuinely needs all of them: search_conversations has to read what was actually said in each conversation to match a query against it, ids belong on matching_ids/the store's list_keys instead.

Analyzing another conversation

The three reading tools answer "what was said". export_conversation answers a different question, "how did that run go", and the difference is everything read_conversation deliberately drops: the reasoning, each tool call with its arguments and its result, sub-agent activity, each turn's model and reasoning effort, what it cost, and why a turn stopped when it stopped short. That is not a second renderer. transcript_export.render_markdown already produced exactly this document for kokua export and for the sidebar's download button, over the same replay_items the web channel replays a reload from, so the tool is a resolve, a render, and a write.

It returns a path, not the document. That is the whole design, and the reason survived the limit that first forced it. AIMU's read_file(path, max_lines, offset) gained the offset in 0.31.0, so a long export is now readable past its first window; what has not changed is that every page a model turns is paid for out of the context it is turning them in. A tool that returned the Markdown itself would put a run's entire tool output into the context of the conversation asking about it, which is the one context that cannot afford it, and paging it there is the same bill in instalments. Handing back a path lets the answer go to a sub-agent instead, so the transcript is spent against a fresh context and only the findings come back. config.example.toml ships [agents.introspector] as that worker, and test_the_shipped_introspector_can_both_export_a_conversation_and_read_the_export pins the pairing, since either half alone leaves the delegation a dead end: the export is the only route to the reasoning and the tool calls, and read_file is the only thing that turns a path into something to evaluate.

Because that worker holds conversations itself, the whole job is one delegation rather than two. The entry agent does not have to find and export the conversation before handing it over: "evaluate the conversation about the flight prices against these criteria" is a single spawn, and the search results, the transcript, and the file all stay out of the asking conversation's context. The entry agent keeps its own export_conversation for the other request, the one where you want the file rather than an answer.

Three consequences to know before changing it. The answer reports the file's line count and size and advises delegating past DELEGATE_ABOVE_LINES, because a model cannot see how big a file is before reading it, and the advice is a sentence rather than a refusal: the tool does not know what the model has to delegate to. An export larger than one window is read in pages, since read_file's truncation notice names the offset that continues it, and the introspector's own instructions tell it to page through the rest and to say which part of the run it saw if it stops early, which converts a silent partial answer into a stated one. And the write is bounded by construction rather than by validation: the directory comes from AssistantConfig.downloads_path and the filename from the resolved session's key, so no argument the model passes reaches the filesystem, which is why exporting the same conversation twice replaces one file instead of accumulating.

Titling a conversation

A conversation gets its title twice. ConversationBook.persist derives the placeholder the moment the first user message lands (messages.derive_title: that message, truncated to 40 characters) and returns that it did, which is what makes TurnRunner._persist push the conversation list. It then asks Assistant._spawn_title for the real one, and that runs as a background task: core/titles.py puts the opening message to a fresh, context-free, tool-less client on the same model the conversation runs on, and ConversationBook.retitle swaps the placeholder for what comes back, followed by a second sidebar push. A conversation a scheduled task minted is pre-titled with the task's name, so it derives nothing and is never retitled; on a channel with no conversation list a firing runs in the viewed conversation instead, and that one is titled like any other first turn.

Three things about that shape are deliberate. It is in the background because the reply is what the user is waiting for and a title is worth neither a round-trip on the end of their first turn nor an error they have to read: every failure path -- an endpoint that is down, a model string naming nothing, an answer that sanitizes to nothing -- returns None, and the placeholder stands. It is guarded on the way in: retitle checks that the conversation is still in the store, so one deleted while the model was writing is not resurrected by a get that answers a missing key with a fresh empty session, and it checks that the title it was built to replace is still there, so a rename that landed first wins over a title in flight. And it takes that conversation's own turn slot for the write, like delete and for the same bounded reason, because the store saves whole sessions: an unsynchronized read-modify-write would revert whatever the next turn persisted between the read and the write. Shutdown cancels a title still in flight rather than awaiting it, for the reason invariant 7 gives about the store closing under a task nobody is watching.

[assistant] generate_titles (default true) turns the second half off, leaving the placeholder as the title. It is one of two entries in CORE_RUNTIME_SETTINGS (the other is [assistant].capture_requests), and it is there rather than in a toolset because no capability owns it: Assistant._spawn_title is the only reader. It is hot rather than startup-only because that read happens per conversation, so a change genuinely applies without a restart, which is the test the model itself fails. There is no setting for which model writes the title, because the conversation already answers that.

Renaming a conversation

A title can also be replaced on purpose, three ways, all of them ending at the same ConversationBook.retitle the generated title uses.

By hand. The sidebar row's pencil swaps its title for an input (Enter and blur commit, Escape abandons) and sends a rename control carrying the new title and the one the page was showing. Assistant.rename_conversation clamps to 40 characters, refuses an empty title rather than clearing the one there, and passes the old title as retitle's replacing guard: a row a generated title has already moved under loses to what is stored, and the page is told so and repainted. The page defers its own re-render while an edit is open, because until the input is committed the typed title exists nowhere else.

By asking the model. The ✨ inside that same editor sends a retitle control, and Assistant.regenerate_title spawns titles.summarize_conversation_title in the background exactly as the automatic title is spawned. It differs from that one in what it reads and in who may ask. It reads the whole conversation rather than the opening message (titles.condense_transcript: the first entry always, the newest entries filling a 4000-character budget, an omission marker between), because a conversation renamed later has usually gone somewhere its first message did not predict, and re-reading only that message would hand back the title already showing. And it ignores [assistant] generate_titles, which governs the title nobody asked for. The control returns as soon as the call starts, never awaiting it: the web front end applies controls in arrival order on one task, so awaiting a local model here would queue every control behind it, /stop included. The sidebar is pushed however the call ends, including on a decline, because the row shows "naming" until a conversation list arrives and nothing else would ever clear it.

That ✨ lives in the editor rather than beside the pencil for a measured reason, and the number is worth keeping. A sidebar row is 240px by default, its hover controls are invisible but still take clicks, and each one added eats the title: three controls left the title 130px, four leave it 93px, and five left it 56px, which is about six characters before the ellipsis. At five, the row's own centre point was the duplicate button, so clicking a row to switch conversations copied it instead. Grouping the two ways of naming under one control is what keeps the row a row. The same arithmetic is why a sixth control here would need a menu rather than a place in the line.

By the assistant. rename_conversation is a tool, and it is the one place in this repository where turn invariant 1 shows up as a visible design constraint rather than a rule to obey. A tool runs inside its turn, that turn holds its conversation's turn slot for its whole length, and retitle takes the same slot, so awaiting the write from inside the tool would wait on a lock the waiting task itself holds. The tool therefore hands the write to Assistant.schedule_rename, which queues it as a background task the way a title is queued; the gate's per-conversation lock is a plain asyncio.Lock with no owning task, so the queued write simply waits the turn out. Two consequences are stated rather than hidden. The tool's own reply says the rename lands when the turn finishes, since a read_conversation later in the same turn still shows the old title and a model told nothing would "correct" itself in a second call. And the queued write passes replacing=None: the only title it could read when queued is the one from before that turn's own persist, so comparing against it would refuse every rename asked for on a conversation's first turn. The existence check and the gate still apply.

Branching a conversation

ConversationBook.branch(conversation_id, user_index) forks a conversation at one of its turns: it writes a new session holding a copy of the parent's transcript through the end of that turn, titles it Branch of <the parent>, and activates it through the same _activate path create uses, so a branch whose agent fails to build reverts the pointer rather than stranding the view. The parent is not touched, and the two are ordinary independent conversations afterwards.

Three things decide the shape. The cut is a turn boundary, found by turn_end: the next message the user sent to start a turn. Three kinds of user-role message sit inside a turn instead, and turn_end skips all of them: the nudges the agent loop injects between tool-calling iterations, a message one of the turn's own agents sent to another run on it, which carries messages.PROVENANCE_AGENT, and a message the user typed while the turn was running, which carries no tag because it genuinely is user input. The first two carry a provenance tag messages.is_user_turn recognises; the third has no tell at all, so turn_end takes the recorded indices (metadata["messages"], flattened by mid_turn_indices) as an argument; a transcript stored before that record existed passes none and behaves exactly as it always did. Stopping at a mid-turn message would copy a branch that loses both the redirection and the answer it produced, which is normally the reason for branching there, and a mid-turn index is refused as a turn start for the same reason. That is the only cut a transcript survives, because anywhere else can fall between an assistant message holding tool_calls and the tool messages answering them, which a provider rejects on the branch's next request rather than at the fork, where it could still be reported. The per-turn metadata maps (subagent, trace, model, thinking, failure, usage, messages) are filtered, not remapped, because a prefix copy leaves every index where it was: the branch replays its inherited turns exactly as the parent does, cards and costs included. And task_id is not inherited, because a branch of a scheduled run is the user's conversation rather than another run of that task; inheriting it would nest the branch under the task and enter it into that task's retention pruning.

Branching reads the store, never agent_for, for the reason the conversation tools do, and takes no turn-gate hold: it reads a snapshot of the parent and writes a brand-new key, so there is nothing for another turn's persist to collide with. A turn in flight on the parent is invisible to it, which is correct, since the cut is always behind that turn. Switching into the branch backgrounds a turn running in the parent rather than cancelling it, exactly as select does.

The web page names a turn by the position of its user message, which replay_items already stamps on replayed items and which the turn_saved frame supplies for a turn that just finished live. That frame is published by TurnRunner._persist, after the store write and only when ConversationBook.branchable says the stored transcript really has a user turn there, so the page is never offered a branch point the store cannot serve.

The control itself sits on the bubble that opens the turn, beside the delete-from-here control, and stampTurnControls places the pair together so they cannot drift apart. Anchoring both on the turn's first bubble rather than on its answer is what lets the page name a turn without inference: that bubble is the one message_index refers to, so nothing has to associate an answer back to the user message above it, and a turn whose output is a sub-agent card, an image, or nothing at all needs no special case. It also keeps the newest turn branchable, which anchoring on the following user message would not: there is no message after the newest turn, and the answer you just read is the one most likely to be forked. The label says "including this exchange" because a control on your own message otherwise reads as an offer to re-ask it, which would cut before the message rather than after its answer.

Truncating a conversation

ConversationBook.truncate(conversation_id, user_index) is branching's mirror image: a branch keeps messages[:turn_end(...)] in a new conversation, and a truncation keeps messages[:user_index] in this one, deleting the named turn and every turn after it. The conversation keeps its id and its place in the sidebar, so the view does not move.

Four things decide the shape, and three of them are branching's answers read backwards. The cut is a turn boundary, validated through the same turn_end, because everything before a message the user actually sent is complete turns: any other cut can leave the transcript ending between an assistant message holding tool_calls and the tool messages answering them, which a provider rejects on the conversation's next request rather than at the click. The per-turn metadata maps are filtered, not remapped, since a prefix cut leaves every surviving index where it was. The title is dropped when no user turn survives, which makes an emptied conversation indistinguishable from a fresh one instead of leaving it named after a turn that is gone (the system message survives such a cut, so "emptied" is about user turns rather than about an empty list). And the conversation's cached agent is dropped, not discarded: AgentRegistry.drop_agent forgets the agent and keeps the per-conversation lock, because the truncation is holding that lock while it writes and replacing it would let a queued turn and a later one serialize against different objects. The agent rebuilds from the shortened store, which is how the deleted turns leave the model's context as well as the display.

One consequence is recorded rather than guarded. A branch stores branched_from, the {conversation_id, message_index} it was forked at, and truncating the parent can delete the index a child points to. Nothing reads the key today, and the user is allowed to tidy a parent without it silently breaking a child, so a later change that reads it (sidebar nesting is the obvious one) has to tolerate a fork point that no longer resolves.

Concurrency splits into two halves that answer different questions. The write is held under the conversation's own gate.turn slot, like delete and retitle, because the store saves whole sessions and a read-modify-write racing a turn's persist would revert one of them. A conversation with a turn actually in flight is then refused, in Assistant.truncate_conversation, and the refusal is worth reading in two parts. What the hold already covers: a turn holds the same slot across its own persist, so the cut can never be interleaved with a turn's save, and the tempting rationale for the refusal (a turn's save would undo the cut) is false. What the refusal adds: a bounded wait (the web front end applies controls in arrival order on a single task, so a control parked on the gate leaves every control behind it, /stop included, queued until it returns; the socket is read on a separate task, so that is a delay rather than a wedge), a user who is not surprised by a deletion applying minutes later and silently taking a turn that arrived in between, and one piece of correctness the hold misses. TurnRunner.reactive fetches its agent before taking the gate, so a turn queued behind a truncation would run to completion on the agent the truncation dropped and have its answer discarded by persist, which re-fetches the agent and snapshots the rebuilt one. Refusing keeps such a turn from starting behind a cut; issue #1 carries the deeper fix, which belongs to the turn runner. The refusal is asked twice, once by Assistant.truncate_conversation before it awaits anything and once inside the book's hold through a turn_running predicate that method injects, so a turn already in flight cannot slip through the window between the check and the gate. It is injected rather than looked up because the book holds the gate while the assistant owns the turn tracker, and core's conversation layer must not reach upward for it.

Duplicating a conversation

ConversationBook.duplicate(conversation_id) copies a whole conversation into a new one. It is the third member of the branch/truncate family and the one that asks about a conversation rather than a turn, which is what removes most of the shape the other two need: no index, no turn_end, and no metadata filtering, because nothing is cut. It keeps the copy discipline they share (one level per message, a deep copy of the per-turn maps), titles the result Copy of <the original>, records copied_from, and leaves task_id behind for the same reason a branch does.

Two things distinguish it. It does not activate what it wrote, so it is the one conversation-minting method with no _activate and therefore no pointer to revert: no agent is built, so nothing here can fail on a model client. And it is reached from a sidebar row rather than from a turn, which is why not switching is the right default: the row you copy is often not the conversation you are reading, and moving the view onto the copy would take you out of it. What tells you it worked is the new row, not a jump.

The web control follows from that. duplicate is handled in frontends/web.py beside export and the settings and task controls, in the group that answers without the sidebar-and-history refresh the switching controls get: it pushes a fresh conversation list, because the copy has to appear, and skips the history replay, because the page is still displaying what it was already displaying.

Plugins

Two entry-point groups: kokua.frontends (a FrontEnd with run(config, args)) and kokua.toolsets (a Toolset with build(ctx)). The built-in cli/web front ends and every toolset Kokua ships are registered in Kokua's own pyproject.toml exactly as a third party would register theirs; plugins.py discovers them at runtime, and kokua.plugins re-exports Toolset and ToolsetContext as the public surface a third party imports. Add a transport or new tools as a plugin, not by editing the core; see toolsets/image.py.

A third party's toolset is distinguished from one Kokua ships by exactly one thing: its provider label in --list-toolsets, which comes from which distribution registered the entry point. Nothing branches on it. Both arrive through the same group, both keep the build their author wrote, and either one failing to import or to build stops startup naming itself.

A toolset is also how a whole agent arrives. Every AIMU Runner exposes .run(task) -> str, so mounting one needs no core surface at all: build() returns a callable that runs it. toolsets/aimu_agents.py does this for AIMU's three prebuilt orchestrators and is the reference for wiring your own. It builds its agent inside the tool call rather than in build(), because build() runs once per agent and constructing a sync ModelClient is what loads weights on an in-process provider -- and because a cached orchestrator's messages would be shared across concurrent calls.

Configuration

Precedence is CLI flag > TOML config file > built-in default. config/schema.py holds AssistantConfig (a plain dataclass, with leaf paths derived from data_dir). cli.resolve_config builds the settings table first (config.settings_sources.build_settings_table(), over the installed toolsets), parses the TOML file against that table (config/file.py's load, which needs it to know which sections are runtime-settable and which of a toolset's remaining keys are merely cold), merges the result under the CLI flags into the constructed AssistantConfig, and only then seeds every declared setting's default onto it (settings_sources.seed_toolset_defaults) for whatever the file left unset. Building the table imports every installed toolset, on every run, because a config file naming an installed toolset's section has to stay parseable. That is also why nothing gates registration: a switch withholding those names from the registry afterwards would have executed exactly the same code, which is what retired the old load_plugins key. Flag defaults are the None sentinel, so an unspecified flag defers to the file.

The file itself is required: config/file.py::load raises rather than returning no overrides when it is missing. Agents live only in [agents.*] and the assistant cannot function without at least one, so there is no useful unconfigured state to degrade to. Assistant.create enforces the companion rule and refuses a config that defines zero agents, or whose [assistant].agent names none of them. Individual keys keep their built-in defaults.

Keys the old per-role vocabulary used are not silently ignored. [tools], [subagents], [assistant].memory, and a per-agent groups / tool_packs / mcp_servers each raise a targeted ConfigError naming the replacement, checked ahead of the schema so an old file gets that message rather than a generic unknown-key one.

Which model an agent runs on, and how hard it thinks

[assistant].model is the default every agent runs on; an agent naming its own [agents.<name>].model runs on that instead. AssistantConfig.model_for(name) is the single resolution, and it is per agent and never inherited down the delegation graph: a delegator that pins a model does not drag its workers onto it, so a worker declaring nothing runs on the same default every other undeclared agent does. That is also why make_delegation_tool builds the delegate with the default rather than the delegator's own model, and build_agent_specs sets a spec's model key only for a worker that declared one -- AIMU reads a missing key as "the model the spawn tool was built with". validate_agents resolves every declared model string at startup (offline, no client and no key), so a typo names its table instead of surfacing later as a failed spawn.

The model every agent runs on

[assistant].model is optional, and leaving it out is a supported way to run: AIMU resolves a default from AIMU_LANGUAGE_MODEL, or from a probe of the local servers when that is unset, which is what lets one config.toml be shared across machines that serve different models. AssistantConfig.default_model is where that resolution happens, once per process, and model_for falls back to it, so model_for is total: it answers with a string whatever the file says.

That totality is the point, not a convenience. The alternative is to hand None to AIMU at client construction and ask the built client afterwards what it became, and a client cannot answer: client.model is a resolved Model enum, which names a catalogued id and carries nothing else. A default may be an extended string -- ollama:qwen3.8:27b@http://gpu-box:11434 (see model) -- and the @base_url is gone by the time anything reads it back off a client. Kokua did read it back, in five places, and the result was that every spawned sub-agent, every composed worker, and both planning reviewers were rebuilt against the provider default while the entry agent talked to the override. On a machine with a local server running it produced answers, from the wrong model, with nothing reported anywhere; with no local server it surfaced as All connection attempts failed from a tool call whose parent turn was working fine.

So the rule is: anything needing the default asks the config, never a live client. default_model resolves with include_hf_cache=False, matching what aio.client asks for, because the aio surface cannot construct an in-process hf: model from a string. The answer is cached on the config, since the no-env-var path probes over HTTP and compose_subagent asks on every call. build.model_label renders that same string for /diag and the stored record, which is why a session with nothing declared now reports ollama:qwen3.8:27b@http://gpu-box:11434 rather than OllamaModel.QWEN_3_8_27B.

Both are read once, at startup. No live client is rebound to another model, which is why the model is not a RuntimeSetting: a hot setting is one the change reaches in the same session, and this one cannot. It is an ordinary cold key, so update_config writes it and reports that a restart is needed.

Both front ends name it, since a running session otherwise has no surface that does. /diag reports the entry agent's model plus every override as a line of prose, and the web page shows the entry agent's in its header with the whole record in the label's tooltip. Neither works the answer out for itself: build.model_record is the one answer to "which models is this session running", returning the entry agent's name, its resolved label (build.model_label, which renders model_for's string, and is also what the stored record uses), and each agent whose own table declares one. A record rather than a sentence, because the two readers want different words for it, which is the same split the sidebar makes for a task's status.

The page is told once per connection, by a models frame sent after Assistant.start() returns. After, not before, for a sharper reason than the task list has: resolving the label asks AIMU what is locally available when no table names one, and that raises when the answer is nothing. start() has just built a client on that same answer, so by then it is cached, and a failure to resolve has already been reported as the message it is. Once per connection is enough because the answer is startup-only: nothing in a session can change it, so the page paints it and never refreshes it, and a reconnect repaints it with the rest of the resynced view.

A stored conversation records what produced it, in session.metadata rather than on any message dict: metadata.model.<user_index> is the model that answered that turn, and each sub-agent card carries its own worker's model (SubagentReporter resolves it, since AIMU's observer callbacks do not carry one). Metadata, deliberately -- AIMU strips only its own inert keys before a request, and Ollama and OpenAI-compatible providers forward any other message-dict key verbatim.

Reasoning effort follows the same shape, with three differences. [assistant].thinking is the default and [agents.<name>].thinking the override, resolved by AssistantConfig.thinking_for(name), read once at startup, reported by /diag, and recorded per turn under metadata.thinking.<user_index> and on each spawn card. The values are AIMU's own four: absent emits nothing, false asks the model not to reason, true reasons at the model's default effort, and "low" / "medium" / "high" request a level. What differs:

  1. Resolution tests is None, not truthiness. thinking = false is a declaration ("do not reason"), so an or the way model_for uses one would let a "high" default swallow it. Every guard on the value follows suit -- in thinking_for, build_agent_specs, record_turn_provenance, SubagentReporter.spawned, and _thinking_line.
  2. build_agent_specs writes the resolved value into every spec, not just a declared one. This is the reverse of model: AIMU reads a missing spec model as the spawn tool's own model, which is the default, but a missing spec thinking as None, because a spawn tool has no thinking tier. Left to AIMU, an undeclared worker would skip the default rather than inherit it.
  3. It reaches the reviewers. critics.reviewer_agent takes a thinking, and the four planning wrappers thread config.thinking to it. A reviewer is not an [agents.*] agent, so the [assistant] tier is the only one it has -- exactly as it is for the model it already ran on.
  4. A turn can ask for its own. The web composer's Think picker and the CLI's /think put a level on ChannelMessage.metadata["thinking"]; config.file.thinking_request normalizes it (the softer sibling of the _thinking validator: unrecognized degrades to the configured effort rather than raising), and TurnRunner.reactive passes it to agent.run(..., thinking=...) as a per-run override. A per-run argument rather than a write to agent.thinking, because the planning workflow drives the same agent object, so the field would leak the request into a planned turn's planner and executor phases. The same resolved value goes to _record_provenance, which is why that method takes the effort as a required argument now instead of reading the config: with a per-turn request the two can disagree, and the record has to say what the turn did, not what it would have done. A workflow turn resolves to the config regardless, so a request that reaches one is neither applied nor recorded.

One consequence worth knowing: false also selects a model card's instruct-mode sampling profile where the card declares one (select_profile in AIMU). Only the Qwen 3.5/3.6/3.8 cards do today; every other model has a single profile and is unaffected. Any key [assistant.generation] or an agent's own [agents.<name>.generation] sets still applies over that profile -- it is a tier above whichever profile select_profile picked, not a replacement for the mechanism -- so it is only a parameter nobody set that the profile switch decides outright.

The per-agent half needs aimu>=0.17.0, which added the "thinking" key to the agent_types spec. An AIMU that predates it ignores an unknown spec key in silence, so a per-worker effort would simply not apply with nothing raised -- and a dict key is invisible to both a name lookup and a signature check. The same release closes a spec's keys to a published set (SUBAGENT_SPEC_KEYS), which is what the startup probe moved to at the time: a symbol, and the set the depended-on key belongs to. The probe has moved on twice since (see below); the floor is what still covers this release. See kokua.aimu_compat.

Generation parameters

[assistant.generation] sets temperature, top_p, top_k, min_p, presence_penalty, repetition_penalty, max_tokens, and context_length for every agent; an agent's own [agents.<name>.generation] overrides it per key, not as a whole-table replacement, so an agent naming only temperature still inherits the default's context_length. AssistantConfig.generation_for(name) is the single resolution -- {**self.generation, **(agent.generation if agent else {})} -- and, like model_for and thinking_for, it is per agent and never inherited down the delegation graph. It returns an empty dict when nothing is declared anywhere, which is the normal case: a key a config never mentions stays absent from the request, so a model card's own tuned sampling profile survives untouched.

Three places apply the result, all of them startup-only reads:

  1. Every client the factory builds. core.build.build_model_client serves the entry agent and each per-conversation client alike, and right after construction it sets client.default_generate_kwargs = config.generation_for(agent_name) -- but only if generation:, so a config that declares nothing leaves the attribute as AIMU built it and the model card's own profile is what select_profile returns. Writing an empty dict would be writing this tier, and this tier sits above the card. An injected client (tests, make_agent_builder) is left alone: these parameters live on the client, so one built elsewhere already carries whatever its own factory chose.
  2. Each spawned worker's spec. build_agent_specs writes the resolved value into specs[name]["generate_kwargs"], not just a declared one, for the same reason thinking does: AIMU reads a spec without the key as "no generation parameters", so an undeclared worker would skip the default rather than inherit it. Omitted entirely when nothing resolves, since an empty dict is itself a written tier that would still sit above the card's profile.
  3. The two planning reviewers. workflows/planning/runner.py threads config.generation (not generation_for, since a reviewer is no agent's own table) to both the plan-reviewer and the result-reviewer, exactly as it already threads config.thinking. [assistant.generation] is the only tier a reviewer can have.

The per-agent half needs aimu>=0.18.0, which added the generate_kwargs key to the agent_types spec. That key was the probe's surface for a release: 0.17.0 published SUBAGENT_SPEC_KEYS itself, so the set's existence no longer proved this capability, and the probe checked generate_kwargs's membership in it instead -- a membership check, the third shape it has taken after a name lookup and a signature check. AIMU 0.20.0 then carried two capabilities Kokua depends on. The first is a sub-agent honouring a provider:model@base_url string (see model), a behavioural fix inside a private function with no symbol, parameter, or set member to grip; while it was the newest surface the probe gripped endpoint_kwargs, the mapping the fix routes through, and said in its own docstring what that left uncovered. The second arrived later in the same release with a handle of its own: SkillAgent(script_env=...), the parameter that carries the [email] settings and the downloads folder into a skill script the entry agent runs (see What a skill script sees above). That was a signature check, the shape this probe has taken twice.

AIMU 0.21.0 raised the floor for the resolve_default_text_model export that default_model calls, and while that was the newest surface the probe gripped that name. It is the cleanest case this preflight has had: the capability is the exported symbol, so a plain name lookup asks exactly the question that matters, where the shapes before it each had to settle for the nearest available handle.

AIMU 0.23.0 renamed the channel flags show_thinking / show_tools to stream_thinking / stream_tools and flipped both defaults to True. Kokua deleted its own display settings in the same change and constructs both channels bare, so that default is what puts reasoning and tool calls in front of a user at all. The probe moved to a signature check on aio.WebChannel.__init__ for stream_thinking: the parameter is not itself the capability (the default value is), but the rename and the flip shipped together, so the new name dates a checkout past both. Against an older AIMU the bare construction still works and streams neither phase, and since Kokua no longer reads self.show_thinking anywhere there is not even an AttributeError to notice, which is the failure mode this preflight exists for.

The floor was aimu>=0.27.0 until 0.28.0, and it was the first floor whose reason and whose probe were different capabilities from different releases. That split is worth following, because it is the shape of every future case where a bug fix rather than a feature moves the floor.

The floor moved for 0.26.0: AIMU's tool loop no longer strands an un-dispatched tool call before its forced wrap-up. Exhausting max_iterations on a turn that had requested tools used to leave those calls unanswered and then append the wrap-up's user prompt directly on top of them, which Anthropic rejects with messages.N: `tool_use` ids were found without `tool_result` blocks immediately after. Kokua felt it as sub-agents failing rather than answering, and search-heavy ones most of all, since a run that spends every round calling tools is the one still holding a pending call when the cap lands. There is no handle on that fix a probe could honestly grip: _settle_pending_tools is a private method on a private class, exactly the kind of internal a later refactor would rename, and a probe pointed at it would turn this preflight into a wall in front of a newer, working AIMU. That is the trap the 0.20.0 paragraph above describes, so the floor carries this one alone.

The probe therefore gripped 0.27.0's ModelRefusalError instead, a plain name lookup on aimu.aio and only the second time this probe had had that shape (0.21.0's resolve_default_text_model was the first). The capability is the exported name. Anthropic returns a refusal as HTTP 200 with stop_reason: "refusal" and no content, so an AIMU that does not raise for it returns an empty string, which an agent loop cannot tell from a degenerate turn: the continuation nudge fires and the run spends its iterations being refused again. core/turns.py branches on the class at three sites so a declined request reads as declined rather than as a generic failure, and an AIMU without the name fails at import instead of degrading quietly. 0.27.0's other half is the floor's job for the same reason as 0.26.0's: every provider now reports how a turn ended, so TruncatedTurnError fires outside Ollama for the first time and client.last_stop_reason carries the provider's own word for it, but that is an attribute on a live client rather than a module symbol and Kokua reads it nowhere directly.

make_async_subagent_tool's events parameter was the surface while 0.25.0 was the floor, and it is the floor's responsibility now, as every capability older than the current probe becomes. What that probe could not see, and what the floor now covers: whether a spawned worker's own spawn tool forwards events on to a grandchild it delegates to in turn, so a recursive delegation could go uncounted one level down. The probe covers one surface at a time; the version floor covers every earlier release's. (That capability first shipped tagged 0.24.0, but the number collided with a different, unrelated 0.24.0 that AIMU's own main released first; the branch carrying events rebased past it and renumbered to 0.25.0, so a real, released 0.24.0 correctly failed that probe rather than exposing a gap in it.)

The floor was aimu>=0.28.0 until 0.29.0, and unlike 0.27.0 it was a floor whose reason and whose probe were the same capability again. It moved for AIMU's CONTINUING chunk, the phase a streamed driver yields for a round the loop injected itself (a continuation nudge, or the forced wrap-up at the round cap) rather than one the model asked for. No other seam could carry it: Kokua constructs nothing differently against an older AIMU, so channels/web.py and core/subagents.py simply never see the phase, and nothing raises, which is the silent-degradation shape this preflight exists to catch.

The probe is a membership check on StreamingContentType.CONTINUING, the third shape it has taken and the second time membership has answered (0.18.0's SUBAGENT_SPEC_KEYS was the first). StreamingContentType itself predates this floor by a long way, so its mere presence proves nothing; only whether it carries this member dates a checkout, the same argument SUBAGENT_SPEC_KEYS made one container kind over. It reads __members__ rather than testing membership directly, because in on an enum compares values on Python 3.12 and raises TypeError for a plain string on 3.11, which Kokua still supports, and the capability here is a member's name, not its value.

What the probe leaves to the floor: whether both streamed drivers emit the chunk, and whether both injection kinds (a continuation nudge and a forced wrap-up) do. A checkout carrying the member but wired to only one driver, or emitting it for only one injection kind, still passes, the same shape of gap 0.25.0's events parameter left one level down, where only the first hop being wired was everything that probe could ask.

0.28.0 brought a second capability Kokua depends on, and it is worth reading for what a probe cannot claim. The "max_iterations" entry AIMU added to SUBAGENT_SPEC_KEYS is the spec key core/agents.py writes for an agent declaring its own tool-loop cap; without it a per-agent cap has nowhere to go, since AIMU's own cap was one value shared by a whole spawn tool. That set is closed and validated at factory-call time, which for Kokua is wire_agent building a conversation's agent, so an AIMU predating 0.28.0 raises ValueError naming the key at startup already: no silence to convert into noise, and no mid-session failure to pull forward. A probe there would buy the wording rather than the timing, which is why the one slot goes to the phase above, which has no such escape hatch, and this key is left to the floor. The global tier needed none of it: the factory argument behind [assistant].max_iterations has existed since 0.12.0.

0.29.0 moved the floor for SessionStore.list_summaries, a store's own answer to "every stored conversation's title, timestamp, and message count, without its messages". Asking that through ConversationBook.sessions() cost one whole-file JSON parse per stored conversation, 4,790 ms on a 56.8 MB developer store to draw a list of titles; summaries() now calls the store method, and the sidebar, task ownership, and the startup pointer all read through it. The probe gripped it as a plain name lookup, structurally a half-step deeper than its predecessors since the symbol is a method on SessionStore rather than a name at module scope. What that could not see, and what the floor covers now: the method has a default implementation on the ABC (read everything, keep the metadata, drop the messages), correct anywhere and slow where a full read is expensive, so a name lookup cannot tell TinyDBSessionStore's override from a plain inheritance of the default.

The floor is now aimu>=0.38.0, for two capabilities. calculate evaluates an allowlisted syntax tree instead of eval with an empty builtins dict, which any expression could climb out of to reach os, and the shipped approval policy leaves compute.calculate ungated on the premise that it runs no code of the model's. And a spawn leads a worker's result with a note when its round cap ended it, which is how the delegating agent learns the answer is partial. Neither has a symbol, parameter, or set member to probe, so the probe stays on RunEvent.run_id and the floor covers both, pinned by behavior in tests/test_aimu_compat.py.

Before it, the floor was aimu>=0.37.0: a spawned worker's spec carries a "tool_approval" key, which is what lets each agent's tool calls be judged by its own approval policy. The probe did not move: that key set is closed, so an older AIMU raises ValueError naming the key when the factory is called, and the one probe slot stays on a capability whose absence is silent. Residency rides the same release's aimu.tools.ToolGroup (and the available_tools / load_tools an agent gets from it), and it needs no probe for the same kind of reason: core/build.py imports ToolGroup at module scope, so a checkout without it fails with an ImportError the moment a front end or kokua agents imports the core, after the preflight and before any conversation exists. Loud, which is the 0.28.0 reasoning again. Before it, the floor was aimu>=0.36.0: every run event carries run_id and parent_run_id, which Kokua's request records (core/requests.py) are keyed by, and the probe is a signature check on the dataclass aimu.events.RunEvent for its run_id field (a dataclass field is a constructor parameter). Before that, the floor was aimu>=0.34.0, the first one a rename alone moved: AIMU renamed its mid-turn-message seam from Steering to Inbox, with no legacy path, and taught it which agent is opening each reader. Inbox.reader(agent=...) is called once per run, at its start, and agent is that run's own name, offered so a host can route a message to the one run it was meant for rather than to every run's drain; AIMU attaches no meaning to the string beyond passing it back. The probe is a plain name lookup, the sixth time (resolve_default_text_model, ModelRefusalError, SessionStore.list_summaries, builtin.get_web_content, aimu.skills.make_skill_update_tool), and the first time a rename rather than an addition has given it an exact question: a checkout predating 0.34.0 has Steering where this one has Inbox, so the two names cannot both resolve the way an old and a new export usually can side by side.

This moved only the names Kokua uses to talk to AIMU: every agent.run(steering=...) call carrying the entry agent's own source became agent.run(inbox=...), the "steering" spec key became "inbox", and StreamingContentType.STEERING became StreamingContentType.INBOX. Kokua's three reader factories in core/messaging.py widened to accept the agent label AIMU now passes positionally, because _BaseToolLoop.__init__ rehearses that exact call and raises TypeError when it cannot be made; that constructor sits on the path of every driver and every spawned worker, so the rename could not wait for Kokua's own vocabulary for the seam to follow in a later change.

What this probe cannot see, and what the floor covers alone, is larger than usual here: whether the loop actually passes the agent argument on both surfaces and both drivers, whether the INBOX streaming phase is emitted where STEERING used to be, whether the spawn path honors the "inbox" spec key, and whether 0.33.0's host-boundary guards survived the rename rather than being lost in it. None of those is a name, so no name lookup reaches them.

The floor was aimu>=0.33.0 until then, for aio.SkillAgent.run's steering parameter: a run already in progress could be handed a user message without waiting for it to finish, which is what let a message typed mid-turn reach the turn running now instead of queuing behind it. Kokua's entry agent is an aio.SkillAgent, so this was the exact method a mid-turn message reached.

The probe gripped the subclass, not aio.Agent.run, which landed the same parameter in the first of the release's steering commits: gripping that handle would have dated a checkout to one drain site on one driver and nothing past it. aio.SkillAgent.run could not delegate to super().run() (it prepares, sets skills up, then calls the post-prepare helpers that do the actual work), so it repeated Agent.run's whole parameter list by hand, and a parameter added to the base method reached the subclass only when someone remembered to copy it across. steering was not remembered until a whole-branch review caught it, after compaction and script_env had each needed the same hand-copy before it, so the base class's signature was not evidence about the subclass's. The shape was a signature check, the fifth time (SkillManager(include=...), SkillAgent(script_env=...), WebChannel(stream_thinking=...), make_async_subagent_tool(events=...)).

What it left to the floor turned out larger than usual: whether the spawn path honored a "steering" spec key, whether both drivers drained all three branches a steered turn could take, whether the three stream consumers rendered the phase, whether the budget reset on a drained mailbox was bounded, and whether a misbehaving host source was caught rather than trusted. None of those was a signature, so no signature check reached them; the branch review that caught the missed SkillAgent copy raised the last three as findings in their own right, and the 0.34.0 rename above is exactly the kind of change that gap was written to warn about.

The floor was aimu>=0.32.0 until then, and two capabilities in that release were Kokua's, both about skill authoring. make_skill_update_tool is the factory toolsets/skills.py calls to hand an agent update_skill, so a skill whose instructions turned out wrong can be fixed in place. Before it a skill's prose was write-once: author_skill refuses to clobber and add_skill_script writes scripts alone, so the assistant could revise a skill's code forever and never a word of its text, and the only route to a better version was a second skill under a different name. The other capability is the one a user feels: add_skill_script no longer attaches a script by rewriting the whole SKILL.md. That rewrite passed back the description and body it had just read, so it could not change the prose and existed only to drop the frontmatter keys it did not re-emit, which meant a skill from kokua skills install shed its license and compatibility lines the first time an agent attached a script to it, and again on every later fix, with nothing raised anywhere.

The probe gripped make_skill_update_tool, a plain name lookup, and that time the newest handle was the right one: the script-write fix landed earlier in the same branch and has a handle of its own in write_skill_script, so gripping the later name dated a checkout to both. What it left to the floor was the behavior behind that fix, since no name lookup asks what a function does and Kokua calls neither function itself; tests/test_aimu_compat.py authors a skill with optional frontmatter in a temp directory and reads it back instead.

0.31.0 was the floor until 0.32.0, and three capabilities in it are Kokua's. builtin.select and builtin.unscoped are what toolsets/fs.py and toolsets/fs_write.py partition one group with, after AIMU put write_file and edit_file into builtin.fs: handing that group out unchanged would have granted a write to every agent already declaring fs, on an upgrade, with no config change and nothing reported. edit_document and a read-before-replace guard on save_document close the other silent one, since read_document now returns a window and replacing a whole document from a windowed read deleted everything past the window (a 3,000-line document came back 51 lines long). And compaction is a spec key core/agents.py writes per worker from a declared context_length, so a long delegation trims its own messages rather than dying in its window.

The probe gripped the third of those, SUBAGENT_SPEC_KEYS's "compaction", and that choice was the one time this preflight has passed over a newer handle on purpose. select landed earlier in the release and is a plain name lookup for a function Kokua calls directly, but the save_document guard landed two commits after it with no handle at all, while the windowing that makes an unguarded save destructive landed before it: a checkout in between would have passed a select probe and still truncated a document. compaction is in the release's last functional commit, so gripping it dates a checkout to all three. What it leaves to the floor is the contents of builtin.unscoped, since asking whether the group holds both writers needs a membership check over a list of callables, the shape declined twice before and asserted in tests/test_aimu_compat.py instead.

The floor was aimu>=0.30.0 until then, and that one was the first moved by a rename. get_webpage became get_web_content, and the point is not the name: the old tool never asked what it had fetched. It handed response.text to an HTML stripper, and requests decodes .text with errors="replace", so a PDF behind a URL arrived as megabytes of replacement characters that a tag stripper passes through almost whole. Four such results, 6.28 MB of them, are in this developer's own stored transcripts, each having entered a model's context whole. The new tool classifies the response by Content-Type with the %PDF- magic bytes as tiebreaker, returns Markdown (a PDF under one ## Page N heading per page), and caps what it returns, downloads, and extracts. Kokua needs the floor because it hands that group out unchanged: toolsets/web.py is list(builtin.web), and workflows/critics.py mounts the same group for the reviewer, so on an older AIMU both quietly poison a context.

That probe was a name lookup on builtin.get_web_content, the fourth time that shape has answered. What Kokua actually hands an agent is the group, so the stricter question is whether web contains it, and that check was declined for the reason run_command's was at 0.24.0, this time without even that release's brief window: the rename moved the function and the group's entry for it in a single upstream commit, so no checkout exists where the name resolves and the group still holds the old tool. A membership check over a list of callables, matching on __name__, would be a new probe shape bought to close a window that does not exist; tests/test_aimu_compat.py asserts the group membership instead, as a fact about the release rather than as the preflight's shape. What the probe leaves to the floor is the behavior behind the name: a checkout could export get_web_content and classify nothing, and only fetching a PDF would prove otherwise.

Two application facts worth knowing beyond the parameters themselves. max_tokens and context_length are different knobs that share one window: max_tokens caps generated tokens, context_length sizes the whole window the prompt and the output share, so context_length = 32768 with max_tokens = 4096 leaves roughly 28k for the system prompt, the tool block, and history. And a parameter a backend cannot take is dropped by AIMU with a warning naming the remedy -- Ollama's SDK has no min_p, the Anthropic API has no penalties, and only Ollama's native API sizes the context window per request, so context_length is a no-op with a warning everywhere else. That warning reaches one place: the rotating file log (logs_path/kokua.log, i.e. data/logs/kokua.log under $KOKUA_HOME), since logging_setup attaches a file handler and nothing else. It is not surfaced in the chat, in the terminal, or in /diag, which reports what the config declares, not what survived to the wire. A user whose parameter never applies finds out by reading that log.

[assistant.generation] is also the first sub-table config/file.py's _sections handles: tomllib nests a dotted TOML header like [assistant.generation] inside assistant, so a flat key loop over [assistant] would read it as one key holding a table rather than as its own section. _sections re-enters it as assistant.generation, which is what lets one schema entry per parameter serve it and what makes an unknown key inside it report [assistant.generation].<key> rather than a generic [assistant] type complaint. Read only at startup, like the model and the reasoning effort: update_config can write the default tier (a cold key, applying on the next start) but not an agent's own [agents.<name>.generation], which is locked the same way the rest of [agents.*] is by default.

config.toml is the single source of settings and the app writes it. config/store.py does comment-preserving writes via tomlkit (stdlib tomllib cannot write). Two writers: the add_mcp_server/remove_mcp_server tools, and the assistant's own update_config. update_config refuses whatever [security].locked_config_keys matches, via config/store.py's locked_by, with ("security", "locked_config_keys") itself locked by axiom regardless of what the list says. See the configuration reference for the shipped patterns and what removing each one permits. update_config applies hot-appliable keys live.

Which settings are hot is not a list maintained by hand in several places: it is config/table.py's SettingsTable, built once at startup from CORE_RUNTIME_SETTINGS plus every toolset's own hot Settings, and every consumer -- the schema, the incoming-payload sanitizer, the live-apply loop, and the persist path -- loops over that one instance. CORE_RUNTIME_SETTINGS holds two entries, [assistant].generate_titles and [assistant].capture_requests; every other hot setting a run holds arrives from a toolset, so the table's contents depend on which toolsets are installed. The sanitizer predates the removal of the web settings window and outlived it: update_config is now its only caller, so "panel" in the surrounding code names a surface that no longer exists.

SettingsApplier writes a hot value two ways, and which gate hold each takes is decided by reach, the rule core/turn_gate.py states. apply takes a whole payload and holds the gate exclusively, so no turn reads a set half applied: several keys land together, and a turn seeing one toolset's new flag beside its old one is the tear that hold excludes. apply_one writes a single key and takes no hold at all, which is not a shortcut. Its one caller is update_config, a tool, and a tool runs inside its turn, which holds that conversation's reader for its whole length; reaching for the exclusive hold from there deadlocked outright, since the writer waits for a reader count that includes the turn awaiting the call, and being writer-preferring it queued every other conversation's next turn behind it too. One key needs no exclusion: the write loop is synchronous with no await in it, so a reader sees the values from before the call or from after it and never a mixture. This was the same misjudgement ConversationBook.delete once made with the same cause, which is why the gate's module docstring leads with it: reaching for the writer when a single slot, or none, would do is how an unbounded wait gets built out of bounded parts.

State

Everything lives under ~/.kokua (override with KOKUA_HOME). config.toml sits at the root; data/ holds only content: sessions.json, skills/, memory/, documents/, downloads/, images/, payloads/. Scheduled tasks are the one declared, user-editable exception to that split: they live in config.toml as [scheduling.task.<name>] tables, not under data/.

Images

images.py owns the on-disk store and the /images/<name> reference. Input images (web upload/paste, CLI /attach) and generated images live under images_path; the web server serves them at /images/<name>. Non-obvious: AIMU inlines a base64 data URL into stored message content, but a persisted session must stay small and a localhost URL is not fetchable by the provider, so core/messages.py rewrites data URLs to references on persist and re-inlines them before each agent.restore.

Payloads

payloads.py solves the same problem images.py does, for oversized text instead of image bytes: content-addressed filenames under payloads_path (the sha256 of the UTF-8 text, so identical content is stored once), a /payloads/<sha256> reference stored in session metadata in place of the bytes, and a route (frontends/web.py) that serves the file when asked. Two parallel modules rather than one shared abstraction, since the generic part is about twenty lines and the rest of each is specific to its own payload kind.

The one caller today is core/subagents.py. A sub-agent's tool result can be megabytes (a PDF fetched as text is the case that forced this), and recording it inline put 51.9 MB of one developer's 56.8 MB session file into a field that every store read re-parsed and every conversation switch replayed to the browser. SubagentReporter now caps what a recorded tool-call card holds inline at subagents.RESPONSE_PREVIEW_CHARS (4,000 characters, a module constant rather than a config key: it is not a security control and not a capability an agent declares, so it has no natural home in config.toml). A response at or under that length is recorded exactly as before. A longer one is recorded as the first RESPONSE_PREVIEW_CHARS characters plus response_ref (the payload's /payloads/<sha256> reference) and response_bytes (the full length), with the rest of the text written once to payloads_path via payloads.save_text. The cap is applied where the card is recorded, not where it is replayed, so the frame sent live and the entry a later send_history replays are the same dict; capping only on replay would make a card change shape when the user switched away and back, which is worse than the size problem it would be fixing.

payloads.save_text can fail two independent ways, both of which SubagentReporter treats as a reason to skip the spill rather than let the turn fail. Encoding with strict UTF-8 raises UnicodeEncodeError on a Python str carrying lone surrogates, the shape upstream code leaves behind when it decoded bytes with errors="surrogateescape" (a binary file fetched as text is a realistic source). Separately, the write itself can fail on its own terms (a full disk, a permissions problem, a payloads_path that cannot be created), raising OSError; before this cap existed, the tool-calling path never touched disk at all, so this callback did not previously depend on a write succeeding. Either way, recording a spawn's activity sits on a live turn's path, so an exception there ends the turn, while falling back to the unbounded, pre-cap inline shape only leaves one oversized card.

An entry carrying response_ref renders in app.js exactly like any other, plus one control below the preview: "Show full response" with response_bytes formatted as KB or MB. Activating it fetches the reference and swaps the fetched text in for the preview, once; a second activation is a no-op because the control is gone by then. The fetched text is exactly as untrusted as the preview already was (someone else's tool result), so it lands the same way the preview does, as a text node rather than markup. A fetch can fail, since payloads are never garbage collected but a user can clear the folder by hand; that leaves the preview in place and turns the control into a short disabled note instead of a button that would only fail again.

MCP

All servers come from [[mcp.server]] at startup (mcp.reconnect_mcp_servers is a single pass over config.mcp_servers, connecting them concurrently and attaching them in declaration order, so a duplicate tool name still resolves the same way every run). Two servers whose connects each take 1.5 seconds finish their handshakes together in about 1.5 seconds rather than the roughly 3 a serial pass would take, but attach_server still awaits each server's client.as_tools() inside that same ordered attach pass, one list_tools() round trip per server, so the tool fetch itself stays serial and only the handshakes overlap. A module-level lock in mcp/servers.py serializes every OAuth-mode connect, not only an interactive authorization: fastmcp decides inside connect whether a stored token is reusable, so the lock cannot tell in advance which kind of connect it is about to hold and wraps the whole call either way. Two OAuth servers therefore never overlap each other, even when both hold valid cached tokens; a bearer-token connect and an unauthenticated probe never touch the lock and still overlap fully, so the common case of one bearer server plus one OAuth server keeps essentially the whole win. The lock exists because an interactive authorization's callback listener binds one pinned port, so two servers each needing a fresh authorization at once would collide on the bind and have that collision misreported as either server being unreachable; when the call it holds really is interactive, the wait is however long the person takes to approve it, not network time, so a second server's own authorization link is not even posted until the first server's flow completes.

Each server is also a toolset, named by its required name, which is how an agent reaches it, and described by its optional description, the line an agent deferring the server reads in available_tools (unset, it says only where the server is). A deferred server that is not connected refuses to load rather than loading empty, since a load is recorded as done and an empty one would stay empty after the server returned (step 4 of How an agent's tools resolve). The runtime add_mcp_server tool appends reconnectable servers there via config/store.py (no secret on disk), so config.toml stays the one source; it writes a name derived from the server's host and disambiguated against the names already on file, so a successful add can never leave behind a config the registry's collision check would reject at the next boot. Nothing can use the server in the session that added it, since the registry is not rebuilt for servers (only skills are re-read mid-session), so the tool can connect a server but cannot grant itself the capability. After a restart the derived name reaches an agent declaring available = "all", and any other agent once a human adds it to that agent's [agents.*] table, which is locked by default. A bearer-token server is not written at all, so a restart drops it. author_skill reads the server names on file (config/store.py's mcp_server_names) as well as the registry, so a skill cannot take a name this session's add_mcp_server wrote and stop the next start on the collision. mcp/auth.py handles OAuth by posting the authorization link into the chat and persisting tokens to disk. It also carries the one piece of that flow a single-machine library gets to assume away: OAuthSettings holds where the provider's redirect lands, because FastMCP's default (loopback, a random port per process) sends the approved browser to its own machine, which is the wrong one whenever Kokua is not where you browse. The two settings behind it are [mcp].oauth_callback_host / oauth_callback_port.

Workflows

A workflow is a named turn strategy carried by a Toolset (its workflow field): declaring that toolset in an agent's available is what gives the agent the workflow's /-command, resolved by toolsets.agents.build_command_map from the entry agent's own declared toolsets -- the same list that resolves its tools, so a turn strategy is granted exactly the way a tool is. A workflow's build returns an aimu.aio.AsyncRunner, AIMU's abstract base for every agent and workflow, so AIMU's own aimu.aio.workflows (Chain, Parallel, Router, EvaluatorOptimizer, PlanExecuteEvaluator) work here with no adapter. workflows.is_rich splits a runner into two tiers by probing for a run_turn method, the way ChannelUI probes an optional channel frame rather than doing an isinstance check: a base-tier runner is driven by TurnRunner itself, which streams run() into the reply and owns catch-up, at the cost that the runner never appends to the agent's own transcript, so a base-tier turn has no message to anchor to and does not survive a reload. A rich-tier runner additionally implements run_turn() and is handed a WorkflowContext carrying the channel, a decide() slot for a human decision (the asker supplies its own reply parser, so no one workflow's vocabulary lives in the core), and control of the agent's transcript. A workflow that also wants to reach the model as a callable tool needs AsyncRunner.as_tool(), a concrete method the base class provides rather than a name Kokua looks up, so it is available only to a runner that actually subclasses aio.AsyncRunner, not one that merely matches its shape by duck-typing run and messages.

toolsets/planning.py is the first workflow, and the only toolset that carries one instead of tools: declaring "planning" in [agents.<name>.tools].available is what gives that agent the /plan command (the web UI's Plan toggle sends the same command, and an agent whose available omits "planning" has neither). Its runner, PlanningWorkflow (workflows/planning/runner.py), is rich tier -- it shows phases and reviewer cards, pauses for a human approve/edit/reject, and rewrites the transcript so a planned turn is saved as a plain user/assistant pair rather than as planner scaffolding -- and subclasses aio.AsyncRunner for the as_tool() reason above, even though nothing currently offers it to the model as a tool. /plan drafts a plan, optionally has an independent reviewer critique it and a human approve it, executes, and optionally reviews the result. There is one pipeline; how much of its work is shown is a Presentation value with two instances, SUMMARY and VERBOSE (the latter selected by the planning toolset's own show_reasoning setting, read through ctx.settings, on a channel that can render phase headers). The reviewer itself is generic and workflow-independent: workflows/critics.py runs a fresh, context-free agent over a curated verification toolset and extracts a typed Verdict; workflows/planning/critics.py supplies only the two prompts (review_plan, review_result) that make it deep planning's own standard rather than a shared one.

Web front end

frontends/web.py is a Starlette + uvicorn WebSocket server (behind the web extra); channels/web.py's WebChannel subclasses AIMU's base WebChannel. The streaming transport (token/thinking/tool/done frames and send()) lives in AIMU's base; Kokua's subclass adds the frames its richer page needs, among them conversations, history, approval, turn_saved, models, and inbox. The UI is a single self-contained web_static/index.html served as package data, plus vendored marked + DOMPurify (GitHub-flavored markdown, sanitized, rendered client-side as the answer streams) and vendored KaTeX, typeset after sanitization with trust:false once the turn completes. The server allowlists these assets: JS/CSS by name, the woff2 fonts under /fonts/.

What a turn recorded about its model calls (core/requests.py) reaches the page as request and ending frames, on replay as items of the same names, and inside a sub-agent's card as request and ending entries, which can arrive after the card's terminal status and are drawn anyway. A request renders as a { } button on the round's own output (the last assistant output before it in the log or card body; a round with none gets a small round-orphan row), and an ending attaches to its run's latest round, tinting the button for a wrap-up or error, with an inline line only as the fallback when no round exists. The button opens the single #round-panel beside the log, whose summary line is drawn from the call record with no fetch. The viewer is size-safe because a request can be megabytes: the payload is fetched when the panel shows that round, from /payloads/<sha256>; the tree is built a level at a time (an unopened subtree is one element); strings are cut at 4,000 characters; image data is labeled; above REQUEST_INLINE_CEILING characters there is no tree, only the download link (?download=<name>, served as an attachment only for a name matching ^[A-Za-z0-9._-]{1,100}\Z). Every payload-derived string reaches the DOM through textContent, since a request is model and user content. turn_saved carries the turn's number, which the user bubble wears as a turn N badge.

The page sends an input frame ({"type": "input", "text", "token", "images"?, "thinking"?}) for every message the composer sends, text-only ones included, and the bare string only for its own control replies: the Stop button's /stop, an approval's Allow/Deny, and a plan review's verdict. It used to be the other way round, a frame only for a message carrying images or an effort, and the token is what changed it (below): a token has nowhere to ride on a plain string, and the commonest message of all is the one that most needs naming. frontends/web.py's _parse_input decodes it and WebChannel.feed_input queues it; both shapes converge on one ChannelMessage in receive, so nothing downstream knows which path a message took, and a frame's text is read exactly as a bare one's, commands and all.

Reading the socket, and applying what arrives

pump is two tasks over one queue: one reads the WebSocket, one applies frames in arrival order. They are split because they fail differently. Applying a frame can take arbitrarily long, since a control that waits on the turn gate waits for in-flight turns to drain, which on a local model is minutes. Reading has to keep happening regardless, because a disconnect is a read and this is the only task positioned to notice one.

Applied inline, as they were, a slow control stopped the reading with it, and the result was worse than slow. The disconnect queued behind the control was never seen, so the one-connection guard in ws_endpoint was never released, the reload that would have recovered the page was refused as "busy in another tab", and killing the process was the only way out of a UI that had merely been asked to wait. The trigger was a conversation delete during a long turn: ConversationBook.delete took the gate's exclusive hold, which drains every in-flight turn, so deleting one conversation waited out an unrelated conversation's turn. It now takes that conversation's own gate.turn instead, which cancel_turn has already asked to stop, so the wait is bounded by that cancellation landing.

One queue and one applying task, rather than a task per frame, because order is part of the contract: selecting a conversation and then sending a message has to bind the message to the conversation just selected. A slow control therefore still delays the frames behind it, /stop included. That is the cost of ordering, and it is now a delay rather than a wedge. Whichever half finishes first ends the connection, in both directions: a disconnect ends the reader, and an unexpected error ends the applier and has to end the reader with it, since a reader left running would queue frames into a drain nobody reads, which is the same wedge with the halves swapped.

The one-connection guard tells a live holder from a closing one, because a reload always meets the second. The page navigating away closes its socket at once, but the connection it held still has to tear down, and closing each remote MCP session is a network round trip, so the reloaded page connects while the guard is still held. Refused, it was told the assistant was "busy in another tab" when no other tab existed, and a second reload appeared to fix that only by landing after the teardown. So pump marks the guard closing as it begins to unwind, and claim_guard makes a connection arriving then wait for the release rather than refusing it. It waits rather than starting alongside, because two assistants would share one session store. The wait is bounded (TEARDOWN_WAIT_SECONDS), and a teardown that outlasts it is refused in its own words, "still closing", rather than blamed on a tab. A guard whose holder is still reading its socket is another tab, and is refused at once as before.

new, select, delete, and branch (which carries id and message_index) all end the same way: each resyncs the view rather than describing what changed, so the applying task never has to reason about a diff. delete takes that same path even when the conversation it removes is not the one on screen, where the resync only refreshes the sidebar; the four share one mechanism regardless of which of them actually changes what the user sees. Among the frames the server pushes, turn_saved ({"conversation_id", "message_index", "token"?, "turn"?}) takes no such resync, because it names one turn that already rendered rather than replacing anything: it exists only to say where that turn now lives in the store, so a bubble already on screen can grow a branch control. The optional token says which bubble, and is absent for any turn the page did not compose, a scheduled firing being the case that reaches it, which is why a page that mints tokens has to read a missing one as "not mine" rather than as the next one waiting.

Which of the messages it drew became a turn

The page draws a bubble the moment you press Enter, before the server has said anything about the message, and the turn controls that bubble eventually carries (branch, and delete-from-here) are addressed by a transcript index only the server knows. So something has to connect the two, and until mid-turn messaging landed the page did that by counting: a queue of bubbles awaiting a save, each turn_saved consuming the oldest. Counting forced the page to predict server behavior before it sent, deciding for itself which composer text would be answered as a command and run no turn at all, and to keep that prediction in step with Assistant._serve_channel's dispatch. Every wrong guess cost a control, and one direction of wrongness was destructive rather than untidy: an entry nothing ever consumed stranded at the head, so the next turn's save took it and stamped an older bubble with a newer turn's index, and clicking delete-from-here on one message then took that message and the one before it.

A message able to join a running turn made counting impossible rather than merely fragile, because a message now has two frames it can be named by in sequence instead of one. So the page mints a token per composer message, the server echoes it on whichever frame reports that message's fate, and app.js keys its pending bubbles on it. Matching is also what makes an unclaimed entry harmless: text answered as a command, or consumed as the reply to a pending approval, is simply never named, and its entry is dropped at the next repaint along with the bubble it points at. Tokens carry a per-load random prefix, because a turn outlives the page that started it: reload while one is running and its save still arrives, and a counter starting over at 1 would let that frame name a bubble this load drew.

Three fates, and the page has to tell them apart from the frames alone. A turn_saved carrying the token means the message became a turn, and the bubble is stamped. An inbox frame carrying it means the message joined the turn already running, and the bubble is marked rather than stamped: it has no turn of its own, so it gets no controls, and the index that would truncate "here" points into the middle of somebody else's turn. The third is both in order: accepted into a turn, never read, re-run as a turn of its own by TurnRunner._resubmit_messages, which carries the same token onto that turn's save. That is why the mark is looked up without being claimed, the one place in app.js the map is read that way, and why the save withdraws the mark when it stamps: the two standing together would say the message both did and did not become a turn.

Two inbox frames arrive for one mid-turn message and only one of them carries a token. The tokened one is WebChannel.send_message_frame, the server saying the message was accepted; the other is mapped from AIMU's own INBOX chunk when a run drains the message, and it names no bubble because AIMU never sees a token (a reader's drain hands it the text alone, which is all the loop takes as a prompt). The untokened frame marks nothing on the composer's side and is what a sub-agent card renders instead.

Reconnecting after a dropped socket

The page opens its socket through a connect() function that reassigns the ws binding on every attempt, so restarting Kokua under an open browser does not leave a page whose only recovery is a reload. ws.onclose retries with backoff, 500ms doubling to a 10s ceiling and then retrying indefinitely, and shows one notice for the whole outage rather than one per attempt.

That one notice is live rather than static, because at a 10s ceiling an unchanging "Reconnecting..." is indistinguishable from a page that has given up. Its label is rewritten in place on a 250ms tick (faster than the second it counts in, so a tick lands near each rounded boundary instead of drifting against the deadline and skipping numbers): a countdown while an attempt is pending, and the bare wording while one is actually open. The seconds are rounded up, since the first wait is 500ms and would otherwise read "in 0s" for its whole life, which says the attempt has already been made. Beside the label sits a Retry now button, and both it and the expiring timer call the same attemptReconnect, so an impatient reader takes exactly the path the backoff would have. What the click does not do is reset the backoff: ws.onopen is what resets it, on a connection that actually succeeded, so a manual attempt that fails falls back onto the schedule it was already on rather than restarting at 500ms and letting a held mouse hammer a server that is genuinely down.

Almost none of the work is on the client, because the server was already written for this: it resyncs its entire view on every connection (_sync_view sends the conversation list and the active conversation's history, followed by the settings, then the models, the tasks, and a closing ready once the assistant has started), and the page's history handler clears the transcript before replaying it. So a reconnected page repaints rather than appending a second copy of what it already showed, and the retry notice goes out with the rest of the old log.

Where that resync sits in the connection's own startup is the reason the sidebar is not empty while you wait. Boot has two halves. Assistant.create reads local state only: it validates the agent registry, opens the session store, adopts the most recently updated conversation, and builds the LiveState and the agent registry, reaching no network at all. Assistant.start() is the remainder, and it is the expensive one: it connects every configured MCP server, builds the entry agent (which resolves a model), resolves each agent's approval policy against the tools that now exist, and arms the scheduled tasks. On a config with two remote servers that is most of startup, and it is dominated by round trips Kokua does not control. So serve_connection sends the conversation list, the history and the settings between the two calls, and the page renders them while the servers are still being asked for their tool lists. run() awaits start() itself, so serving a turn always implies a started assistant; a front end calls it explicitly, as both of Kokua's do, only to choose where its errors get reported. start() is also idempotent and terminal: it records that it ran before its body does anything, so a failed start is not retried, since a retry would reconnect a server that a first attempt already appended to the live connection list.

The page shows that state rather than pretending to be ready: until the ready frame lands it marks itself as starting, dimming the conversation and task rows and disabling clicks on them, so a row that cannot act yet is visibly inert instead of silently ignoring you. The composer is left alone, since neither typing nor sending a message ever depended on the assistant having finished starting.

What a reconnect does not restore is a turn that was in flight when the socket dropped. build_app constructs an Assistant per connection and serve_connection ties its serve loop to that connection's lifetime, so the page comes back to what the turn persisted, not to the stream it was watching.

Nor does a reconnect reload the page's own code. A resync replaces the data, but the tab keeps the script it loaded, so a restart that changed app.js would leave an open tab drawing the new server's frames with the old one. So the page is told two versions and compares them. _page_version hashes index.html and every served asset; the hash is written into index.html as the page is served (the kokua-page-version meta tag), and it is the first frame on every connection (page, sent before Assistant.create, so a stale tab reloads before the connect sequence it would throw away). On a mismatch the page saves the composer's draft to sessionStorage and reloads, and restores the draft once the new page loads. A hash of the content rather than a per-process id, so a restart that left the page alone does not reload the tab and cost the reader their place. If the same version shows up mismatched a second time, the reload evidently cannot fix it, and the page stays put rather than reloading in a loop.

Two of the ways a socket can close are not worth retrying, and the page tells them apart from a restart by whether anything arrived before the close. A server that is not up yet closes having sent nothing, which is the ordinary restart case and is exactly what the backoff is for. A server that answered and refused sends one message frame and closes without ever reaching ready: the one-connection guard's "busy in another tab", or a config error reported in place of a session. Retrying a refusal would thrash a server that is up and working, and every attempt would append the refusal again, burying the sentence that explains it under repetitions of itself. So a refused page says to reload, and stops.

ready, not the history frame, is what marks a connection synced, precisely because a config error can now arrive after the history: the model is resolved in the half of boot that runs once the transcript is already on screen, so a page keyed on the transcript would treat that refusal as a drop and retry it forever. The honest cost of that choice is the same rule catching a case that is not a refusal at all: a server that dies partway through its own start, for any reason, looks identical from the socket's side to one that refused on purpose, so the page asks you to reload rather than retrying on its own. A drop before any frame arrives is still unambiguous and still retries.

Streaming the answer

token frames accumulate into streamingText, which is the source of truth; the bubble's DOM is a view derived from it. renderStreaming reparses that whole buffer and replaces the bubble's innerHTML, on a 50ms timer coalesced by scheduleStreamRender, so a reader watches structure appear (headings, lists, tables, code) instead of waiting for done.

Reparsing the whole buffer looks wasteful next to committing each finished block as it arrives, and the waste is the point. A newline is not a block boundary in CommonMark, so there is no cheap "everything before here is settled" mark to commit at:

  • With breaks:false, a lone newline continues the paragraph. Committing there splits one <p> in two.
  • A newline inside a fenced code block is inside an open block, so the commit would carry an unclosed fence.
  • A newline after a list item leaves the list open, giving consecutive <ul>s in place of one; the same holds for a table that has only its header row so far.
  • A setext heading (Title over ===) is the sharpest case: the next line decides how the previous one parses, so no prefix ending at that newline is settled at all.

A blank line is a real boundary, but not inside a fence, and a loose list continues across one. Every one of those failures is permanent, because committed HTML is what the reader keeps. Reparsing has no such state to get wrong: it converges on the finalized render by construction, and finalizeStreaming runs the same renderMarkdown over the same buffer, so the last live tick and the final render agree. An unterminated fence needs no special handling for the same reason the spec gives it none, since a fence with no closer runs to the end of the input.

Two things are deliberately not reparsed. typesetMath is deferred to finalizeStreaming, because KaTeX is the expensive half of the render and a half-typed $x^ is noise rather than math, so mid-stream an expression stands as the source the model wrote. And finalizeStreaming cancels the pending timer before it renders: a tick landing afterwards would reparse the buffer into a body that has already been typeset, undoing typesetMath and leaving the answer's math as source text. The caption is not at risk from that tick, because it lives in a sibling of the body a render writes into (see bubbleBody), which is also what lets it sit on the block's first line rather than below the content. The history replay path cancels the timer too, since it drops the bubble reference the tick would have written into.

Saying a turn is under way

A sent message used to leave the transcript unchanged until the first frame of the reply landed. .working-inline fills that gap: a dim row at the foot of the log carrying a spinner and the turn's elapsed seconds, indented into the same column as the thinking and tool rows it sits beneath.

The row is pinned for the turn's whole life, and that was measured rather than assumed. The first version cleared it on the first thing that rendered, on the reasoning that content should replace it. Instrumenting the page (a MutationObserver on #log against a tap on every inbound frame) showed the row living for 23 milliseconds on a local endpoint, whose first token arrives about that fast. The question the row answers is "is a turn running", and that stays true until the turn ends, so that is its life. Its elapsed count is the turn's age, not the age of the current lull, which is what makes it readable as "this is slow" rather than "this just started".

Three details are load-bearing:

  • Every renderer goes through appendToLog, which inserts a top-level block before the row rather than appending after it. That is the whole of the pinning: the row stays last without anything ever moving it. The alternative, re-appending the row after each render, does the same work with a reflow and a flicker per block.
  • lastContentElement() looks past the row. A token frame decides whether to close the open bubble by asking whether anything has landed since the last one, and it reads the log's last element to find out. With the row pinned, that element is always the indicator, so the unguarded check would close the bubble on every token and render the answer one bubble per token.
  • The row is created and destroyed, not shown and hidden. A permanently present element would be the log's last child even when idle, so both points above would need a second condition; and the ticker interval that drives the spinner has to stop when the turn does, which destruction handles for free.

ChannelUI.show_working carries a duration and not a flag: None means nothing is running, and a number is how long the turn has already been going, from Assistant.turn_elapsed. That is what lets a switch into a background turn count on from its real age instead of restarting at zero, and it makes "idle, and it has been running twelve seconds" unsayable rather than merely wrong. The working frame on the wire keeps a boolean, because that is what the page branches on, with elapsed riding along only when there is a turn to time.

The alert layer

Four things happen outside the conversation on screen: a scheduled task finishes, a background turn finishes, an MCP connect needs an authorization, and a backgrounded turn's gated tool call is auto-denied. All four used to arrive as text. Three of them were pushed with Channel.send, and AIMU's WebChannel turns a string sent with no reply_to into a message frame marked proactive, so they were rendered as bubbles in whichever conversation the user happened to be reading. A scheduled task's "open the 'Digest' conversation to review" could therefore land in the middle of an unrelated conversation, or inside 'Digest' itself, where it names the thing already on screen.

They now share one route, ChannelUI.alert, and one frame, notification, which the page draws as a card in a fixed layer over the whole UI. notification is not in _TURN_FRAMES, so it is never muted: being about work the user is not watching is the entire point of it.

Two methods sit on that one frame, and the split is deliberate. ChannelUI.notify keeps its old no-op fallback, because a background turn's completion is worth nothing on a channel that never backgrounds one: the terminal has already printed the reply where the user is. ChannelUI.alert falls back to a plain send instead, because a scheduled task's failure or an authorization link has to land somewhere. That fallback is the reason an alert's text is always self-contained, carrying the URL or naming the conversation in words; conversation_id, url, and group are additions a front end that can draw a control turns into one, not the only copy of the information.

The card is a pointer, never a decision. Open switches to the conversation the alert names; Authorize is an anchor. Approving a tool call from a card was considered and rejected: the card cannot hold execute_python's body or add_skill_script's script, and a truncated argument blob beside an Allow button trains the user to approve unread, which is the one thing the gate exists to prevent.

Three properties of the layer are worth stating, because each answers a failure this shape can have:

  • Non-blocking. The layer takes no pointer events; the cards do. A scheduled task fires whether or not anyone is at the keyboard, so a modal here would let an unattended process seize the composer.
  • Grouped. A card supersedes an earlier card with the same group: the task for a firing's report, the conversation for a completion, the conversation and tool name for a denial. Ungrouped, a task firing every ten minutes overnight greets the user with a card per firing.
  • Anchors are built from the url field, never from the text. The authorization URL comes from a remote MCP server's own metadata, and the page checks its scheme is http(s) before making a link of it. Linkifying the sentence instead would let any text that ever reaches a card become a link.

A card can outlive what it points at, since a task prunes its older runs. The page re-renders the layer on every conversations frame, and a card whose conversation is no longer in that list keeps its text and drops its Open control, rather than offering a switch that would land on nothing.

The tasks section, and the settings frames behind it

Two page surfaces are pure front-end concerns and deliberately absent from RichChannel: the scheduled-tasks section (tasks / get_tasks / task) and the settings frames (settings / get_settings). The core never sends either, so there is no capability for ChannelUI to probe and no fallback to document; Assistant exposes only accessors (list_tasks / task_action, current_settings / apply_settings) and frontends/web.py's pump handles the control frames. A new transport that wants a task list implements its own; it is not a hole in the channel contract.

The shipped page uses only the first pair. There is no settings window: the header's theme button is a per-browser localStorage preference that never reaches the server, and the runtime-mutable settings are changed through update_config, which hot-applies them by the same SettingsApplier path. The settings / get_settings control frames remain part of the web transport's contract for a front end that wants them, and tests/frontends/test_web.py keeps covering the server half.

Exporting a conversation from the sidebar

The sidebar's export button is a third control (export), for the same reason the tasks and settings controls exist outside RichChannel: exporting reads a conversation, it is not part of the assistant loop, and there is no core capability behind it for ChannelUI to degrade. It could not be a plain GET /export/{id} route, because build_app constructs a fresh Assistant per WebSocket connection (see above), so an HTTP handler outside that connection would have no live session store to read. The control instead resolves the conversation through Assistant.resolve_conversation (a thin public accessor over ConversationBook.resolve, added alongside list_conversations and the rest rather than reaching into _book from frontends/web.py), renders it with transcript_export.render_markdown, and writes the result under config.downloads_path. The reply reuses the existing /download/{name} route (already serving generated PDFs and other artifacts) rather than adding a second one: WebChannel.send_download sends a {"type": "download", "name", "url"} frame, and the page turns it into a save by clicking a synthetic anchor carrying the download attribute, never by navigating with location.href. Today the two would behave the same: the route answers with FileResponse(path, filename=name), which Starlette gives a Content-Disposition: attachment header, and Chromium treats that as a download rather than a navigation either way, so the live WebSocket and any running turn survive location.href too as things stand. The anchor's download attribute is what keeps that true independent of the server: if the route ever answered without that header, location.href would load the response in place and drop both, where the anchor still would not. The exported filename is built from today's date, a title slug kept to [a-z0-9] runs, and a short id fragment; the slug is an allowlist rather than an escape, which is what keeps a title holding a / or a .. segment from ever producing a name the download route's own name != Path(name).name guard would refuse to serve.

Stopping a run in flight. A firing is a turn like any other, so stopping one is cancelling a task -- but not the scheduler's task. Scheduler.cancel would reach the job running the firing and would also unregister it, which turns "stop this run" into "silently disarm this schedule". So _run_unattended runs the turn in a child task and registers that in the TurnTracker under the firing's conversation, carrying the task id; Assistant.stop_task_runs cancels every entry for a task, and the scheduler job goes on to re-arm as though the run had finished. Invariant 7 in core/turns.py covers the rest, including how a stop is told apart from a shutdown (both arrive as a cancellation) and why a firing is never stopped from inside itself. Three surfaces reach it and all three go through TaskService.stop: the panel's Stop button, the stop_scheduled_task tool, and -- because the firing is now tracked like any other turn -- /stop on a channel with no conversation list, where a firing runs in the conversation being viewed.

task_action is the seam that keeps the panel honest. Every task mutation pairs a write to the task's [scheduling.task.<name>] table with the scheduler (un)arming that must accompany it, and both the agent's tools and the panel go through the one scheduling.TaskService on LiveState, so the two cannot drift: a front end that edited config.toml's task table directly would leave the in-memory scheduler firing a task the table calls disabled. What they do not share is wording: the service returns records and raises TaskError, and task_action returns nothing, because the panel answers with a refreshed task list rather than a sentence. The action name arrives from the browser, so it is looked up in a table and raises for anything else rather than being dispatched on.

Grouping a task's conversations under it happens on the page, not in the core. new_session stamps metadata["task_id"] on any conversation a firing mints, with the task's name -- its identity -- so a rename has to move that stamp too: update_scheduled_task's new_name path calls ConversationBook.retag_task to re-point every conversation the old name owned, right after the table itself is renamed, which is what lets the sidebar keep a task's history nested under it across a rename. ConversationBook.list() projects task_id, but nothing is filtered there: list_conversations walks summaries() (through list()) and still sees every conversation, task-minted or not. The page nests a conversation under a task when its task_id matches a task currently in the list. Requiring the task to be present is what makes deleting a task return its conversations to the chat list instead of hiding them: keying on task_id alone would leave an orphan unreachable from the sidebar. Because the nesting is client-side, a firing's new conversation appears under its task with no task re-fetch -- TurnRunner pushes the conversation list when a run starts and again when it ends. The reverse is not true: a task the model schedules or cancels mid-chat does not push a tasks frame, which is what the section's refresh button is for.

That same client-side nesting is how the panel knows a task is running at all. Assistant.list_conversations decorates each row with running from the TurnTracker (decorated there rather than in ConversationBook.list, which has no view of turn bookkeeping and needs none), and the page offers Stop on a task when any conversation nested under it is running. Nothing about running state reaches the tasks frame, so the core still never sends one -- which matters because the task's table does not change when a run starts or stops, and a tasks frame is only ever a reply to something the page asked for.

Retention. Every firing mints its own conversation, and a task's max_conversations decides how many survive: 1 replaces the previous run, 0 keeps them all, and a record with no cap of its own inherits [scheduling] max_task_conversations (3), resolved by TaskService at fire time so a settings change reaches the next firing. The prune itself lives in TurnRunner, beside the run it follows: it asks ConversationBook.sessions_for_task for the task's conversations oldest-first (by created_at, so a late turn touching an older run cannot make it look newest), reorders them so runs holding no report come first, and deletes past the cap. It runs after every firing, successful or not, and outside the turn's gate hold, since the delete takes a gate.turn of its own (invariant 1) and the firing's own conversation is a prune candidate, so run inside the hold it would wait on the per-conversation lock the same task already owns. It swallows failures the way the run itself does (invariant 6): a user who deleted a run by hand must not stop the task firing.

That eviction order is what lets the prune run on the failure path at all. Pruning used to be success-only, so a task failing on every firing was never pruned and accumulated conversations without bound, past a cap that was supposed to cover exactly that. Simply running it on both paths is not enough either: at a cap of 1, an oldest-first eviction would drop the last good report in favour of the failure that followed it. So _holds_no_report marks a conversation whose turn recorded a failure, or which has no messages at all, and those go first. Both halves are needed, because the reason is keyed to a turn's user message: a firing that raised before its user turn reached the transcript -- an agent that would not build -- has no turn to key one to, and only its empty transcript says so. That the cap is enforced at a firing rather than at an edit is what makes lowering one non-destructive and leaves a disabled task whole. This is also why the record needs no session_id: nothing reuses a conversation, so a firing writes nothing back to the task's table.

A tool frame carries what the call returned as well as what it was asked to do (response, added to AIMU's base frame), because AIMU yields TOOL_CALLING only once the call has been dispatched. An error, an argument-binding failure, and a denied approval are results too and arrive on that same key, so the page needs no separate failure path and no second frame updating a card in place: the card is complete when it appears. renderTool puts the result in a nested foldable of its own below the arguments, filled on first expand and clamped to OUTPUT_CLAMP characters with a button for the rest, as plain text -- never markdown, since a tool result is untrusted input. Output travels whole and only the DOM clamps, so a history frame grows by every tool result in the conversation and is re-sent on every conversation switch; if that becomes a problem the fix is a server-side cap in replay_items, the path that re-sends.

Replay reaches the same card by a different route. A stored transcript splits a call from its result across an assistant message's tool_calls and a later role: "tool" message, joined by tool_call_id; tool_results_by_call_id builds that map so each replayed call carries its own output. The join has to be by id rather than by position, since concurrent dispatch appends results in completion order. A call with no matching result -- a transcript stored before results were replayed, a turn cut short mid-dispatch -- gets response: None and renders the card exactly as it always did.

The flattener behind all of this, replay_items (with its tool_results_by_call_id and image_refs_of helpers), lives in core/transcripts.py rather than in this channel: send_history below is one caller, and a Markdown export of a conversation is the other, so the module both share is where it has to sit. WebChannel imports it back (locally, inside send_history, to avoid a cycle through core/__init__ -> assistant.py -> this module) and renders its items as display frames; the export renders the same items as prose instead.

Muting a background turn happens per frame, in WebChannel.send_frame, which every live frame passes through. The rule is a property of the frame's type: the _TURN_FRAMES set is turn output (tokens, thinking, tool calls, the message, done, loop markers, images, plan bubbles, phases, sub-agent cards) and is dropped when streaming_conversation names a conversation other than the one being viewed; everything else is channel state (the sidebar, replayed history, settings, alerts, human-decision prompts) and always goes out. Neither half can be decided by task context alone. Hoisting the check out of a streaming loop -- taking it once when a reply starts -- is what let a switch mid-reply append the rest of the old turn's tokens to the conversation the user had just moved to, since the viewed conversation changes during the send that is being gated. And gating purely on the contextvar would drop a background turn's sidebar refresh, because TurnRunner._persist pushes the conversation list from inside that turn's own task.

A loop marker's meaning narrowed with AIMU 0.28.0's CONTINUING chunk: it now names an injected round specifically, one the loop inserted on its own (a continuation nudge after an empty turn, or the forced wrap-up at the round cap), and reason on the frame ("continuation" or "final_answer") says which. Before it, stream_activity guessed the boundary from a rise in StreamChunk.iteration, which labelled the cap with the nudge's own wording and could draw a marker after an ordinary tool round too, since the counter rises there as well and no reload ever reproduced it. A marker now means exactly one thing, live and on reload alike, and SubagentReporter.chunk reads the identical chunk to draw the same boundary inside a sub-agent card.

Whitespace alone does not open an answer bubble. A server that separates reasoning from the answer itself (mlx-lm, llama-server, vLLM) sends the newlines that followed the reasoning as the answer segment's first tokens, so on a turn that reasons and then calls a tool the whole segment can be whitespace: an empty stamped bubble between the reasoning block and the tool card, reading as a section whose content failed to arrive. isBlank in app.js guards the three places a bubble is created from text (a live token frame, and a replayed partial or message item), so such a segment renders as nothing at all. Only the opening is guarded: once a bubble is open the same whitespace is spacing between words the reader can see. The frames themselves are unchanged, so nothing about the transport or the transcript depends on the page's rule here.

Muting a turn is not losing it. Every turn frame is also folded into a per-conversation _CatchUpRecord, which models the page's own append rules (consecutive thinking text collects into one foldable; answer text collects into an open partial bubble that any other block closes, so prose keeps the place it arrived in and the tokens after that block open a new bubble below it) and is appended to the items of the history frame a switch-in sends. That is why a conversation whose turn is still running replays the turn so far and then streams the rest into the same bubble: none of it is in the store until _persist, and a history frame replaces the transcript wholesale. Riding on the existing frame rather than replaying separate frames is deliberate -- a live frame from the running turn can land between two awaits, which would both misorder the catch-up and duplicate that frame. The core owns the record's lifetime (ChannelUI.begin_catch_up / end_catch_up), since only it knows when a turn starts; it ends the record inside _persist, next to the write that supersedes it, so no switch-in can land in a window where both the store and the record would render the same turn.

An unattended turn records the same way, and needs it most: a scheduled task running in its own conversation is never the conversation being viewed, and its spawns' cards are the only display frames it produces at all, so without a record switching into a running task showed an empty transcript and the spawn's later append frames then arrived with no card to update. It opens the record inside its gate hold rather than beside the turn contextvars, because a firing can queue behind a turn already running on that conversation and would otherwise replace a record still standing in for live output.

A failed firing is persisted too. _run_unattended used to reach _persist only where the run returned normally, so a firing that raised left the conversation it minted exactly as minted: zero messages, updated_at still equal to created_at, indistinguishable from a conversation that never ran. Everything the run had done up to the failure lived only on the in-memory agent and went with the next registry eviction. It now holds the error, snapshots the transcript, and re-raises so proactive still logs the traceback and tells the user. The floor of what survives is the prompt, because a model client appends the user turn before it sends the request. The reason goes into metadata["failure"], keyed by user-message index like model and trace, and not into session.messages: the messages are what this conversation's agent rebuilds its context from, so a synthesized assistant turn saying "this failed" would come back to the model as its own prior words. replay_items replays it as a notice item at the end of its turn -- held until the next user message or the end of the transcript, so a conversation the user carried on in keeps the notice inside the turn it describes. An unattended run needs this most, since _report's status line goes to whichever conversation the user was viewing at the time, leaving the run's own conversation with no account of why it holds only half a turn.

metadata["undelivered"] is keyed and flushed the same way, for a record that has nowhere else to live. When a turn ends, whatever one of its own agents sent that no reader took is both said once (TurnRunner._report_undeliverable, to the user, since the sender's run is over) and stored (ConversationBook.record_undelivered, under str(user_index) like failure beside it). The two halves answer differently on an ending that was not asked for, and that asymmetry is deliberate: the record is written from the turn's own finally and so reaches every ending, while the sentence sits after that block and a stopped turn never reaches it, because an await inside a cancellation is the one thing that block cannot hold (invariants 9 and 10). So a /stop leaves the record and no sentence, where before it left neither. Stored because the sentence scrolls away, and because unlike a mid-turn message that was delivered there is no message in session.messages to read it back off: never becoming a message is the whole of what undelivered means, so this record is the only place it exists. replay_items emits it as its own "undelivered" item type rather than folding it into the "inbox" item beside it, because the two answer different questions a reader asks (did this reach anyone, versus did the user or an agent say this), and flushes it at the same point failure is flushed, at the next user message or the end of the transcript, with failure first, which is the order the live turn produces them in. The page draws it with addBubble("notice", ...), naming the sender, the selector, and the text, because no two of the three identify the message. Each text is capped on the way in, with a note saying how much is missing (TurnRunner._capped_message_text, at core/transcripts.py's own per-message number): this is the one durable record here whose words are a model's rather than a person's, written behind nothing but a non-blank check, where a mid-turn message somebody typed is self-limiting.

A mid-turn message replays in one of three states, and the page tells them apart. replay_items decides which from the stored provenance tag and marks the "inbox" item from: "agent", from: "mixed", or nothing at all, and app.js's renderMidTurn turns those into three different rows: a kind word (inbox, agent, mixed) and a class list (.inbox, .inbox.agent, .inbox.mixed). The dimming is the part that carries an argument. .inbox deliberately stays out of app.css's dimmed machine-event group (loop, thinking, tool, subagent, plan), because the plain case is a person's own words and dimming them would read as the opposite; .inbox.agent folds back into that group, because nobody typed it; and .inbox.mixed keeps the undimmed default, since part of that one message really is the user's own words. So the kind word is the only thing separating mixed from the plain case, which is why the end-to-end suite asserts the label, the class, and the computed colour rather than any one of them. A live sub-agent card uses the same renderer for a delivery it received, and recovers only the unambiguous half: the card frame carries no from field (addressing stays out of the channel, principle 1), so cardMessageFrom reads the attribution off the text's own leading [message from ...] marker, and a mixed delivery reads there as the plain case, the same direction MessageBus.tag_for_delivery takes when it cannot tell either.

Planning's reviewer verdicts and a turn's spawned sub-agents share one subagent frame type and one persisted map (metadata["subagent"]); task on the create event is what tells the two apart. replay_items replays that map on reload: a reviewer's verdict right after its user bubble, and a spawn card in place of the tool call that opened it, which is where it opened live (one whose call never reached the transcript lands at the end of its turn). A card and its call are paired by the task string, the one value AIMU's observer and the stored call are guaranteed to share, and the same pairing hides the call's tool frame live (WebChannel.send_frame) and a worker's own spawn call inside its card (SubagentReporter); a spawn call that opened no card, such as a refused composition, keeps its tool block, since it is then the only record of what was asked. transcripts.SPAWN_TOOL_NAMES names both spawn tools. A verbose-traced turn suppresses the reviewer verdict cards (their content is already in the raw trace) but still replays its own spawn cards, since a sub-agent it spawned is not part of that trace -- kept by the create event's id, not by event shape, since a spawn whose text streamed closes with a status-only event indistinguishable from a reviewer's verdict. The page's renderSubagent builds or updates one foldable card per id, filling its body as append frames arrive; a nested reasoning chunk and a nested tool call are reported the same way a top-level one is, with nothing in the core deciding whether they are worth sending. A spawn made from inside another spawn's run (a composed worker composing one more, or a declared worker delegating in turn) carries parent, the spawning card's id, on its create event, and renderSubagent draws it inside that card's body rather than at the top level. AIMU's observer callbacks name a spawn but not its spawner, so SubagentReporter recovers it from a current_spawn context variable it sets in spawned and resets in finished, the same bracket it keeps around current_address.

A card's body is built from the page's own top-level components, not from card-specific markup: addFoldable and renderTool take an optional parent element (opts.parent), so a nested thinking or tool <name> row is literally the same builder (and the same CSS) as its top-level counterpart -- including the nested output foldable, so a spawn's own tool calls show their results too. The generated text is an assistant-styled markdown block. Each nests as its own foldable: thinking and tool calls start collapsed, as they do at the top level, and the answer starts expanded, since it is what a reader opens the card for. The card itself starts collapsed, and a spawn card takes the shape of the spawn_subagent tool block the channel suppressed for it: a monospace subagent spawn_subagent(<role>) <status> header over an argument line built by the same toolArgs helper a tool block's body uses. The arguments are reconstructed on the page from the frame's role and task, which is faithful because Kokua only ever builds AIMU's typed spawn tool. A card opened by compose_subagent reads compose_subagent(<name>) and shows that call's own arguments, capability names included, because its create event carries tool and arguments: the tool declares its call through the spawn_call context variable for the length of the spawn, and spawned reads it. A reviewer card, which no tool call backs, reads review <role> <status> instead, and that kind word is the only at-a-glance difference between the two, which is why an e2e test pins it. Generated text streams in chunk by chunk as plain text and is rendered as markdown when the spawn hits its terminal status, so a replayed card (which also ends with a terminal status) lands on the same DOM. This is the one render the page still defers to a terminal status: the assistant's own reply renders as it streams (see Streaming the answer), and a card would need the same treatment per answer block to match.

Testing

Tests are mock-only. tests/helpers.py provides MockAsyncModelClient; tests/channels.py and tests/fakes.py hold the shared channel and MCP/client doubles; tests/conftest.py redirects KOKUA_HOME to a temp dir so tests never touch real state. The mock fakes tool-call rounds rather than running AIMU's real dispatch, so features that hook dispatch (the tool-approval gate) are tested by calling agent._prepare_run() then agent.model_client._handle_tool_calls([...]) directly.

Client-side page JS is covered by an opt-in end-to-end suite (tests/frontends/test_web_e2e.py, marked e2e, deselected by default): it drives the real index.html in headless Chromium against a live server backed by a mock client. Run it with uv run pytest -m e2e (needs the web extra + uv run playwright install chromium); it is skipped, not errored, when those are absent, and it does not gate the default suite.

See also