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
availablenames them (or an agent declaresavailable = "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 ofwire_agent. Only an agent whose ownavailablenamescapabilitiesholds that tool; it may not be handedcapabilitiesitself, since how far composition nests is[capabilities].max_depth's decision (default 3,0off) and not the model's; and its tools still go through the approval gate, bound to the labelcomposed:<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_depthgives every level the same worker menu, which cannot express a graph where each agent has its own targets, sobuild_agent_specsrecurses overdelegates_toand calls AIMU withmax_depth=1at every level.validate_agentsproving 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_subagentis built, soadd_mcp_serverfansrebuild_delegation_toolacross every live agent rather than appending to anyagent.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:
- 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 requiredname), 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 thekokua.toolsetsentry-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-toolsetscan group them separately. That label is the only difference between the two, and every toolset keeps thebuildits 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_configimports every entry point before the file is parsed (see Configuration), so withholding the names afterwards ran the same code and only turned a workingavailabledeclaration into an unknown-toolset error.registry.registerthen rejects a name two providers claim, and refuses two names outright,coreandall, which the approval vocabulary andavailable = "all"have already spent. The four labels are mostly presentation, but two questions are answered from them:agents.is_skilltells a skill's entry from any other kind, andagents.TRUST_ORDERranks them (built-in toolset, plugin, skill, MCP server) foravailable = "all". Skills are the one source re-read after startup: each skill write callsbuild.refresh_skills, which swaps inagents.with_skill_toolsets(registry, ...), every skill entry rescanned from disk and every other entry kept as startup registered it, then rebuilds every live agent'sspawn_subagent. That is why a skill reaches an"all"worker on its next spawn while a serveradd_mcp_serverconnected waits for a restart. AToolsetis a frozen dataclass:name,description,build,guidance,cross_cutting,entry_point_only,workflow,settings. - Validate.
agents.validate_agentsruns 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,skillson a worker, or an"all"agent'sresidententry outside the expansion fails with nothing written and nothing connected. - Select. The names come from
agents.available_for(config, registry, agent), the one reader of an agent's availability: its list as written, or foravailable = "all"every registered toolset sorted byTRUST_ORDER(registration order kept within a provider), minusentry_point_onlytoolsets on any agent but the entry agent. The config holds"all"as the sentinelALL_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 anentry_point_onlytoolset 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 itsSkillAgentalready advertises every skill. - Split by residency.
agents.split_by_residencydivides the selection into what the agent holds from its first turn (agents.resident_for: itsresidentlist, else all of its availability) and what it loads on demand, both in availability order.build.tool_groups_forturns the split into AIMUToolGroups, handed to the agent's constructor: a resident group has nobuildand exists soavailable_toolsshows the whole picture, and a deferred group'sbuildisbuild.build_deferred, which isbuild_toolsover that one toolset, so a toolset arriving late goes through exactly the call a resident one does. A deferred group also carries the toolset'sguidance, which AIMU returns as the load's result, and anAsks firstnote fromagents.approval_note. Any deferred group gives the agent AIMU'savailable_toolsandload_tools. A deferred MCP server that is not connected refuses to build, and a failing build alerts the user throughLiveState.alert_soonbefore AIMU turns the exception into the model'sCould not loadresult; the group stays loadable. A delegated worker gets the same groups through its spawn spec's"tool_groups"key. - Build.
registry.build_tools(toolsets, ctx)calls eachToolset.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 directlywhen it is resident). A resident collision is reported: once every startup build is done,agents.shadowed_toolsreadsLiveState.tools_by_toolsetandAssistant.startraises one alert per hidden tool. Startup builds the entry agent and every worker reachable from it bydelegates_to, so an agent is skipped (and logged at INFO) when any of its resident toolsets was built by no agent.ctxis aToolsetContext: the one process-wideLiveState, this agent's live object (Nonefor a spawned worker), and itsagent_name, which unlike the object is known at every construction site and is how a toolset scopes itself to its own holder (benchmarkasks the config whatagent_nameruns on). At wiring this runs over the resident toolsets only; a deferred one reaches the same function later, through its group.buildmust create only closures, never process state: every shared singleton (the memory store, the document store, theSkillManager, theTaskService) is a lazy property onLiveState, 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). TheSkillManagerand theTaskServiceare the exceptions to that last half, and not by accident: every agent is aSkillAgentand so takes the manager regardless, andtasks.arm_all()has to fire a persisted scheduled task whether or not any agent can talk about scheduling. - Resolve approval.
core/approval.py'sresolve_approval_policiesruns fromAssistant.startafter 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 oneApprovalPolicyper declared agent (its ownconfirmandreview, 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'spending_confirmorpending_review.build_toolscallsLiveState.toolset_builtfor every toolset it builds, after recording the tool names and before returning any tool, andAssistant._on_toolset_builtresolves the waiting entries there (ApprovalPolicies.with_built, and, with auto-approval enabled,AutoApproval.with_builtfor[security].never_auto_approveentries 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_skillsreports each skill's tools to the same hook after a script is written, before it rebuilds any delegate, soreview = ["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'sload_tools, a composed worker, or a rebuilt agent's preload. An entry that still matches nothing, or (with auto-approval enabled) areviewlist 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, byagents.certain_overlapfrom the same two checks,_refuse_overlapand_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 abuild_toolscall has recorded, plus every skill the entry agent'sSkillAgentholds (every skill when it declaresskills, 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 throughLiveState.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 tocomposed:<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:
- Resolution tests
is None, not truthiness.thinking = falseis a declaration ("do not reason"), so anorthe waymodel_foruses one would let a"high"default swallow it. Every guard on the value follows suit -- inthinking_for,build_agent_specs,record_turn_provenance,SubagentReporter.spawned, and_thinking_line. build_agent_specswrites the resolved value into every spec, not just a declared one. This is the reverse ofmodel: AIMU reads a missing specmodelas the spawn tool's own model, which is the default, but a missing specthinkingasNone, because a spawn tool has no thinking tier. Left to AIMU, an undeclared worker would skip the default rather than inherit it.- It reaches the reviewers.
critics.reviewer_agenttakes athinking, and the four planning wrappers threadconfig.thinkingto 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. - A turn can ask for its own. The web composer's Think picker and the CLI's
/thinkput a level onChannelMessage.metadata["thinking"];config.file.thinking_requestnormalizes it (the softer sibling of the_thinkingvalidator: unrecognized degrades to the configured effort rather than raising), andTurnRunner.reactivepasses it toagent.run(..., thinking=...)as a per-run override. A per-run argument rather than a write toagent.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:
- Every client the factory builds.
core.build.build_model_clientserves the entry agent and each per-conversation client alike, and right after construction it setsclient.default_generate_kwargs = config.generation_for(agent_name)-- but onlyif generation:, so a config that declares nothing leaves the attribute as AIMU built it and the model card's own profile is whatselect_profilereturns. 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. - Each spawned worker's spec.
build_agent_specswrites the resolved value intospecs[name]["generate_kwargs"], not just a declared one, for the same reasonthinkingdoes: 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. - The two planning reviewers.
workflows/planning/runner.pythreadsconfig.generation(notgeneration_for, since a reviewer is no agent's own table) to both the plan-reviewer and the result-reviewer, exactly as it already threadsconfig.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 (
Titleover===) 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. Atokenframe 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
urlfield, never from the text. The authorization URL comes from a remote MCP server's own metadata, and the page checks its scheme ishttp(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¶
- Design principles: why the shape above is the shape.
- Auto-approval: the optional reviewer over the approval gate, and its limits.
- AIMU documentation: the library everything above is built on -- providers and model strings, tools, MCP, memory, sub-agents, and the environment variables Kokua inherits.