Changelog¶
0.1.0 (unreleased)¶
First release. Kokua starts from AIMU's examples/personal-assistant/ and restructures it into an
installable, modular application: a small transport-agnostic core with capability pushed into plugins.
Because there is no earlier release, this section describes what 0.1.0 is rather than what changed.
The pre-release development history is in the git log.
Requires Python 3.11+ and AIMU 0.38.0 or newer. Apache-2.0.
Package and entry points¶
src-layoutkokuapackage with two console scripts:kokuaruns the selected front end (defaultcli),kokua-webis a convenience forkokua --frontend web. Both route throughkokua.cli, so both run the AIMU preflight described under Diagnostics.- Plugin system (
kokua.plugins). Front ends and toolsets are discovered through thekokua.frontendsandkokua.toolsetsentry-point groups. The built-incli/webfront ends and the built-in toolsets register exactly as a third party's package would, so if the built-in path and the plugin path ever diverge, the plugin path is the broken one. Inspect with--list-frontendsand--list-toolsets. Discovery is unconditional: installing a distribution that registers an entry point is the consent, and there is no switch to withhold it. Nor is any registration treated as untrusted: a toolset that fails to import, or whosebuildraises, fails startup naming itself, whichever route it arrived by. Kokua ships no third-party code, so it carries no special handling for code it does not ship. toolsets/holds toolsets and nothing else. The machinery a toolset is built against (theToolsetandSettingtypes, name resolution, and theLiveState/ToolsetContextabuilddraws on) iskokua.registry; assembling every provider into one namespace, validating an agent, and assembling its prompt iskokua.core.agents. So a reader looking for a capability finds a file with its name, and nothing else to sort past.- No startup warning for a registered name no agent declares. Telling a name the user provisioned
from one that merely ships 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), and it is the case that lost a signal here; see
Add MCP services. The distinct warning for a
config.tomlsection whose owning toolset no agent declares is unaffected. - The stable public import surface is
kokua.plugins,kokua.config,kokua.core,kokua.channels.web, andkokua.images. Everything else is internal and may move. - The
webextra's two dependencies are capped below their next major:starlette>=1.0,<2anduvicorn>=0.30,<1. Nothing else in a resolved install bounds either one, sincemcp,sse-starlette, andfastmcp-slimall depend on a barestarlette, so these are the only bounds there are. Left open, the first install after a Starlette 2.0 resolves a major nobody has run the web front end against, and the breakage lands on a fresh clone rather than on a deliberate upgrade. uvicorn is capped for the same reason and not a weaker one: Kokua calls it in two places, but it is the half that implements the WebSocket handshake the UI lives on. The floors carry less information than the caps do, naming the lineuv.lockresolves and CI installs rather than a tested claim about anything older.
Conversations and turns¶
- System prompts in the export, and
kokua export --turn N. The conversation export now shows each agent's system prompt the first time it appears and again whenever it changes, read from the recorded request payloads (never the payloads themselves), in every route: the command, the web download, andexport_conversation.kokua export <id> --turn Nwrites one turn alone: the user's message, then every model call it made grouped by run with the request exactly as sent, the response, and how each loop ended, to<id>.turn-N.md. A payload that is missing reads_not captured_; a turn out of range exits 2 naming the valid range. - Conversation commands at the prompt:
/new,/conversations,/switch <id>. The terminal used to have no way to start a conversation or reach one it was not already in, which made every earlier conversation unreachable from the CLI and left restarting the process resuming the most recent one. The three commands are dispatched inAssistant._serve_channelbeside/stopand/diag, not inchannels/cli.py, and call the samenew_conversation/select_conversationthe web UI's sidebar buttons do, so every channel carrying typed text has them and there is one meaning of a switch. A conversation is named by the leading fragment of its id that/conversationsprints, the same fragmentkokua exportaccepts; an ambiguous one is refused with the full ids to choose between, rather than opening the wrong conversation. Deleting, renaming, and pinning stay web-only: none is needed to keep a terminal user from being stuck. Switching still never cancels a running turn, and since the terminal cannot mute one the way the page does, the reply says so and says that/stopnow reaches the conversation you moved to. A switch made by the core repaints a front end that draws a whole conversation at once, through the newChannelUI.show_historyandChannelUI.show_working, so the commands work typed into the web composer too. Those two frames and the notice between them are ordered on purpose: a page replaces its whole transcript on a history frame and treats an ordinary message as the end of a turn, so a notice on either side of that gap is lost or leaves a live turn reading as idle. The three words are reserved from a workflow's command at startup, exactly as/stopand/diagalready were. - Listing conversations no longer reads their transcripts. The sidebar, task ownership, and the
startup pointer only ever asked about a conversation's title, timestamp, and message count, but
ConversationBook.sessions()answered that by fetching every stored session in full: one whole-file JSON parse per conversation, 4,790 ms on a 56.8 MB real store just to draw a list of titles. The newConversationBook.summaries()calls AIMU'sSessionStore.list_summaries()instead, whichTinyDBSessionStoreanswers from its own table without building aSessionat all, andlist(),sessions_for_task(), andmost_recent_or_new()all moved onto it.sessions()survives for the one caller that genuinely needs message text, the agent's cross-conversation search; thelist_conversationstool's message count is now the raw stored count (including tool results and the loop's own injected turns) rather than the filtered, human-readable one, so its wording says "stored messages" to keep the two from being confused. Needsaimu>=0.29.0; see "Diagnostics and error reporting" below. - Per-conversation agents. Each conversation owns its AIMU
SkillAgentand model client, built lazily and held in a bounded LRU registry (agent_cache_cap, default 8). Memory and documents stay shared across conversations. - Concurrent conversations. A turn keeps running when you switch away: it streams only into the
conversation you are viewing, persists to its own conversation, and raises a completion alert card
instead of streaming. Switching does not cancel it; only deleting a conversation does, and only its
own turn. The invariants that make this safe are documented at the top of
core/turns.py. - Switching into a running turn shows the turn. A turn's messages are not in the store until it ends, so its output is recorded as it is produced -- the user bubble, reasoning, tool calls with their results, sub-agent cards, images, and the answer so far -- and replayed on switch-in, after which the rest streams into the bubble replay left open. The record is dropped once the turn reaches the store, so nothing is shown twice. Unattended scheduled turns record the same way, so switching into a running task shows the sub-agent work it has done.
- Conversations are titled by the model. The first user message, truncated, is the title the
moment a conversation has one; a background call then asks the model the conversation runs on for a
real one (
core/titles.py: a fresh context-free client, no tools, no reasoning) and the sidebar updates again when it lands. Best-effort throughout: an endpoint that is down, a model string naming nothing, or an answer that cleans up to nothing all leave the truncated title in place, silently. The write is guarded, so a conversation deleted while the model was writing is not resurrected (and anything that changes the title in the meantime wins), and it takes that conversation's own turn slot, since the store saves whole sessions. Shutdown cancels a title still in flight rather than waiting on an endpoint. Scheduled tasks keep the task name their conversations are minted with.[assistant] generate_titles(defaulttrue) turns it off, leaving the truncated title; it is hot, so the change applies to the next conversation with no restart, and it is the one entry inCORE_RUNTIME_SETTINGS, since no capability owns it. Which model writes the title is not configurable: the conversation already answers that. - A hot setting applies from inside a turn.
update_configon a hot key reachedSettingsApplier.apply, which held the turn gate exclusively, from a tool that was itself running inside a turn holding that conversation's reader: the writer waited for a reader count that included the turn awaiting it, so the call never returned. Because the gate is writer-preferring, it also queued every other conversation's next turn behind it, making one tool call stop the whole assistant until the turn was cancelled. The applier now writes a single key without any hold, which is safe because that write is synchronous and reaches one scalar, and keeps the exclusive hold for a whole payload, where several keys really can be read half applied. The tests missed it because every one of them stubbed the hot-apply callable, so the hold was never taken. - A conversation can also be renamed on purpose, three ways, all ending at the same guarded write.
A sidebar row's pencil edits its title in place (Enter and blur commit, Escape abandons), sending the
title the page was showing along with the new one, so a row a generated title has already moved under
loses to what is stored and is repainted rather than silently overwriting it. The editor carries a ✨ that asks the
model instead, and unlike the automatic title that one reads the whole conversation, condensed to
keep both ends (the opening says what it was for, the tail says where it went), because a conversation
renamed later has usually gone somewhere its first message did not predict. It ignores
generate_titles, which governs the title nobody asked for. Andrename_conversationgives the assistant the same job. That last one is where turn invariant 1 becomes visible: a tool holds its turn's gate slot and the write needs the same one, so the tool queues the write behind the turn and says so in its reply, since a read later in that turn still shows the old name. /stopcancels an in-flight reply and keeps the partial turn so the conversation continues (the web UI has a Stop button for the same). Built on AIMU'saio.RunHandle; reactive turns run as background tasks, so the channel keeps reading mid-turn -- which is also what lets a web approval reply reach the waiting tool call.- A turn records what it cost.
core/metrics.py'sTurnMetricsaccumulates each model call's count, model, seconds, and (when a provider reports them) tokens, stored undersession.metadata["usage"][str(user_index)]beside the model and effort already recorded there. The sink (core.metrics.record_event) is assigned to the conversation's client once, durably, rather than being passed torun(); what is turn-scoped is aContextVarthe sink reads at emit time, so the client-wide assignment is inert outside a turn and needs no teardown after one. This is what makes a planned turn count too, since a workflow drives the conversation's own agent. Recorded on every exit branch of a turn: success, cancellation, a connection error, a generic error, with the wall-clock figure measured at the moment of recording, so a turn that raised still reports what it cost up to the point it stopped. - A turn records every model call's request and every loop's ending.
core/requests.py'sTurnRequests, fed by the samerecord_eventsink as the cost record, stores each call's endpoint and a reference to the exact request and response (files under$KOKUA_HOME/data/payloads/) and how each agent loop ended, undersession.metadata["requests"][str(user_index)]. Recorded on every exit branch, unattended runs included, and dropped or kept with the turn when a conversation is branched or truncated.[assistant].capture_requests(defaulttrue, hot) turns the payload files off; endings are recorded either way.CORE_RUNTIME_SETTINGSnow holds two entries. - The web UI shows what the model saw. A
{ }button per model call on that round's own output, which opens a collapsible right panel (it narrows the conversation, and overlays it below 900px; Escape, the close button, or the same button closes it). Its summary is drawn without a fetch; the panel shows the round's two halves, the request (fetched once and rendered a level at a time) and the response the model returned (reasoning, requested tool calls, text), with long strings cut behind "show all", image data labeled, and no inline request tree above two million characters. Each half links/payloads/<sha256>?download=<name>(accepted only for^[A-Za-z0-9._-]{1,100}\Z, served as an attachment). Each loop'sended: ...wording (as the terminal and export word it, rounds counted from 1) appears in the panel of its last round, a wrap-up or error tinting that round's button, with the old inline line kept only for an ending with no round to ride; the user bubble carries aturn Nbadge. No marker rows or ending lines are drawn in the log, and the terminal still prints every ending. A tool round's button ends on the last tool or sub-agent card it asked for, live as on reload, though its record reaches the page before that card does: each call record carriestool_call_count(how many tool calls its response requested), and only a round that asked for tools hands its button on. - Those records reach the channel live.
ChannelUI.show_request/show_endingand the optionalRichChannel.send_request/send_endingcarry arequestframe per completed entry-agent call and anendingframe per loop; a sub-agent's appear asrequestandendingentries in its own card (SubagentReporter.append). That includes a worker built bycompose_subagent, which hands the run that called it to the spawn as the worker's parent (an injectedToolContext, invisible to the model), the way AIMU's spawn tool does when the loop calls it. A channel without the frames shows no round button and gets one plain line for awrap_uporerrorending only, such as[assistant] ended: tool-use limit of 10 rounds reached; answered on a forced wrap-up call. Kokua's terminal channel definessend_turnandsend_endingitself and prints every ending and the[turn N]line; no other channel gets the turn line. Every surface counts rounds from 1. - Round buttons, turn badges, and tool character counts wait for a hover. A round button's tooltip reads
turn X, round Y, and the button, the turn badge, and a tool card's character count are revealed when their card is hovered or focused (a warning-tinted button stays visible, since endings now live in the panel). On every row the right edge reads info, buttons, then the timestamp, in one column. - Turns are numbered once, and replay shows the round buttons.
core/conversations.py'sturn_numbersgives each turn its ordinal, skipping loop nudges, agents' messages, and messages typed mid-turn, and the live[turn N]line (ChannelUI.show_turn; printed by the terminal channel only), theturn_savedframe's newturnfield, the historyuseritem'sturn, and the export's## Turn Nheadings all read it. A reloaded conversation replays each entry-agent round'srequestitem right after that round's assistant message and the loop'sendingat the turn's end, as the live frames arrived; the export renders each ending, a sub-agent card's included, as_[ended: answered at round 2 of 10]_or_[ended: tool-use limit of 10 rounds reached; answered on a forced wrap-up call]_. - Delegated and reviewed work counts toward that same total, with one named exception. A spawned
sub-agent and a workflow critic each build their own client, outside the family a turn's own scoped
sink reaches, so both were invisible to
TurnMetricsand a heavily delegating turn read as cheap. Both now receivecore.metrics.record_eventexplicitly:core/agents.py's spawn tool passes it asmake_async_subagent_tool(events=...), andworkflows/critics.py'sreviewer_agentpasses it as bothaio.Agent(events=...)andclient.events = record_event, since its typed verdict is fetched with a directclient.chat(..., schema=Verdict)call outside anyrun(), where the agent's owneventsoverride does not apply.record_eventis a module-level constant, not aLiveStatefield, since a field would be shared across every conversation's concurrent turns; it reads the turn actually running off a contextvar when an event fires, so a tool or agent built once at composition time reports into whichever turn asked for it. Recorded model calls attribute by the event'sagentfield, which is what makesTurnMetrics.record'sby_agentbreakdown non-empty for a turn that delegated or reviewed. The verdict call itself stays uncounted regardless: AIMU's structured (schema=) path emits no turn event on any client, so wiringclient.eventscloses the gap for every other direct client call but not this one. A/planturn's recorded cost is short by exactly one call per review round, with no qualifier able to say so. - The per-agent cost breakdown names the entry agent by its configured name. The entry agent's own
event sink is now wired beside its client's, so its calls carry its name (
assistantby default, whatever[assistant].agentsays otherwise) and a call no run claimed is labelledunattributed. kokua exportwrites a conversation as Markdown.transcript_export.render_markdownrenders a saved conversation's header, each turn's user message, reasoning, tool calls with their arguments and results, sub-agent cards, and what it cost, into one Markdown document a person can read, diff, or paste into a review. Pure and dependency-free (nochannels/orfrontends/import), so both front ends and the CLI can call it. Two rules keep it honest: a figure nobody reported prints as "not reported" rather than an invented zero, and a tool payload pastDEFAULT_MAX_PAYLOAD_CHARS(4000 characters) is cut with a note saying how much was removed rather than shown as if it were complete (--fulllifts the cap). A sub-agent's tool response spilled to a payload file (see "Payloads" below) is a third case the cap-and-note rule alone would miss: its stored preview is alreadyRESPONSE_PREVIEW_CHARS, the same value as the default cap, so it would read as complete either way. It is therefore always noted, naming the real size and the reference, and--fullreads the payload file back and shows the whole thing rather than the preview, falling back to the preview with an explicit note if the file is gone.kokua export [id-or-prefix] [-o path|-] [--full]reads the session store directly and writes the file: noAssistant, no model client, no agent, so it works with the model server down. Fidelity is part of the file's name (<id>.mdplain,<id>.full.mdfor--full, both fromtranscript_export.export_filename, which the tool and the CLI share so they cannot drift): the two hold different amounts of one conversation and read identically, so a single name for both let a plain export replace a full one in place with nothing in the result to say what was lost. Re-exporting at one fidelity still overwrites, so a conversation has at most two export files. A read that races the daemon's own persist reports the store as busy rather than parsing a torn file. See Export a conversation.- A message sent while a turn is running joins that turn. It used to queue behind the turn on the
per-conversation gate, so a correction arrived after the work it was meant to redirect had already
been paid for.
core/messaging.pygives each turn a message bus,Assistant._offer_messageroutes a plain message into the one running on the conversation being viewed, and AIMU's loop drains it at the turn's next model call. The bus is append-only with a cursor per reader, not a queue, because the message goes to the entry agent and to every spawned worker: a redirection that only reaches the supervisor redirects nothing, and a shared queue would let whichever reader drained first consume a message the others never saw. Five run shapes can receive a message mid-turn, and they are the five a turn is made of: a plain turn, a/planturn (where every entry-agent run shares the one cursor, since a cursor belongs to a turn and not to a run), every worker spawned throughbuild_agent_specs, a workertoolsets/capabilities.pycomposes per call (which was the one shape this missed untilmessaginglanded; see that toolset's entry under Toolsets), and a scheduled firing, which a user who switched into its conversation can redirect like any other. The one run that deliberately cannot receive a message mid-turn is an independent reviewer (workflows/critics.py), which is context-free by design. Three fates, and the message is never lost between them. It runs as its own turn, or joins the running one, or is accepted by a turn that ends before reading it and then runs as a follow-up turn.offerandcloseare both synchronous and asyncio is single-threaded, so the serve loop cannot see a bus as open in the same tick the turn'sfinallyshuts it; invariant 9 at the top ofcore/turns.pystates the whole guarantee, the stranding and double-run bugs it rules out, and the one window it does not cover./stopis the deliberate exception: someone who cancelled the turn is not asking for one more, so the stop notice says the last message was not delivered rather than running it. Delivery buys a fresh round budget; it does not buy an approval. AIMU resets the turn'smax_iterationsstretch when it drains a message, because a human saying something is evidence the run is not spinning, which is what the cap exists to catch. The[security.auto_approval]budget is deliberately untouched, since more user text does not make a gated call safer; what the message does reach there is the reviewer's copy of the request, amended so a redirected turn's calls are judged against what the user now wants. Only yours is. A message one of the turn's own agents sends amends nothing, for the reason given undermessagingbelow. What does not reach a running turn this way: anything the assistant answers without running a turn (/stop,/diag, the conversation commands, a reply to an approval prompt or a plan review), a workflow command, which starts a turn of its own as it always has, and a message carrying an image, which has no defined place inside a loop. Needsaimu>=0.33.0; see "Diagnostics and error reporting" below. - A turn records which of its messages were sent mid-turn, so a reload does not replay one turn as
two. A message you hand to a running turn is committed as an ordinary
usermessage, indistinguishable in the transcript from one that started a turn, and position cannot tell them apart either. (One an agent sends carries a provenance tag instead; seemessagingbelow. The record covers both, because a round that delivered yours and an agent's together is one message that is both and so stays untagged.)TurnRunnerresolves the positions off the message list after the run (a compaction between rounds can move them) and stores them inmetadata["messages"], keyed by the host turn like every other per-turn map, so a branch or a truncation filters them with the rest.replay_itemsemits a mid-turn message as its own"inbox"item with nomessage_index, which is what withholds the turn controls from it: the index that would delete from "here" points into the middle of the host turn, so offering the control there would cut a turn in half. A conversation stored before this was recorded has no entry and replays the old way.kokua exportwrites the same message as a labeled line of the user's own words, uncapped, rather than the italic note a loop marker gets. The same record is whatturn_endreads, so branching and truncation agree with the replay about where a turn ends. Without it a scan for the nextusermessage stops at the mid-turn message, and branching a turn carrying one would write a fork missing both the redirection and the answer it produced, which is normally the whole reason for branching there. A mid-turn index is refused as a turn start for the same reason, so the guard that backstops a stale index cannot approve a cut inside a turn.mid_turndefaults to empty, so a transcript stored before any of this behaves exactly as it did. A/planturn records whatever its execution left behind. On the shipped[planning]defaults (result_reviewandshow_reasoningboth off) execution keeps the executor's own messages and rewrites only the prompt, so a message delivered while the executor was working is stored and recorded exactly as a plain turn's is. Turn either flag on and execution commits one user/assistant pair in place of everything the executor appended, so a message delivered there is live and in the catch-up record but not in the stored messages. A message delivered while the plan was still being drafted is in neither on either path, because planning scratch is rolled back. -
Change, and the one that costs a reader an afternoon if it is missing: a new toolset arrives in package metadata rather than in source, so
uv syncis required after pulling this. A toolset is registered throughpyproject.toml'skokua.toolsetsentry-point table, and an editable install's entry points are written into itsdist-infoat install time. So an existing checkout that pulls this and runs the suite without re-syncing sees every agent declaring the new capability refused, at the registry rather than at the thing that is actually stale:ToolsetError: agent 'researcher' declares unknown toolset 'messaging', listing twenty-two toolsets, whilesrc/kokua/toolsets/messaging.pysits right there in the tree. The failure is loud and its cure is not visible from it, which is the whole reason this entry exists.uv sync --all-extrasfixes it. This is a fact about any toolset, not about this one; it has simply not come up before, because every toolset the project shipped arrived with the install that first created the environment. -
Change, for anyone running a pre-release checkout: "steering" is renamed to messaging throughout, with no legacy path on either side of the AIMU boundary. Steering was one case of messaging all along, so the special case stops naming the general mechanism. AIMU's half is the 0.34.0 floor below (
SteeringbecomesInbox,run(steering=)becomesrun(inbox=), the"steering"spec key andStreamingContentType.STEERINGlikewise), and it breaks any third-partySteeringimplementation and any exhaustivematch chunk.phase. Kokua's half iscore/steering.pytocore/messaging.py,SteeringMailboxtoMessageBus,SteeringMessagetoMessage,send_steering/steering_takentosend_message_frame/message_taken, the web frame and card kindsteeringtomessage, and the CSS class.bubble.user.steeredto.bubble.user.mid-turn. One of those is a persisted key, and it is the only entry with a data cost:session.metadata["steering"]is nowsession.metadata["messages"], and there is deliberately no dual-read. A store written by a pre-rename checkout that holds such a record replays that turn the way it did before the record existed, as two turns rather than one, which is the defect the record was added to prevent. The rename is affordable only because that record was empty everywhere it was checked: the feature merged days earlier and no stored conversation carried one. A graceful migration was considered and rejected, since a dual-read is a second way of saying the same thing that never comes out again. The first steered turn in a store would have converted this into a real data migration, so anyone holding one should branch or delete the affected conversation rather than expect a reader for the old key. - Branch a conversation at a turn. Every turn in the web UI carries a branch control, on the
message that opened it and beside the delete-from-here control:
it forks a new conversation holding everything through that turn and switches to it, leaving the
original untouched in the sidebar. The fork carries the turn's recorded sub-agent cards, models,
and costs, is titled "Branch of
", and never inherits a scheduled task's ownership, so branching a task's run does not enter it into that task's retention. A terminal /branchis not part of this (see #13). - Duplicate a whole conversation. Every row in the web UI's sidebar carries a duplicate control,
beside export and delete: it copies that conversation, with its recorded sub-agent cards, models, and
costs, into a new one titled "Copy of
". Unlike a branch it does not move the view, so a row you are not reading can be copied without leaving the one you are; the copy appears in the sidebar and nothing else changes. Like a branch it never inherits a scheduled task's ownership. There is no terminal command or agent tool for it. - Delete a conversation's turns from a turn onward. Every user turn in the web UI carries a delete-from-here control: it removes that turn and every turn after it from the saved conversation, in place, leaving the earlier turns and the conversation itself untouched. The deleted turns leave the model's context too, along with their recorded cards, traces, and costs. Deleting from the first turn empties the conversation and returns it to "New conversation" rather than leaving it named after a turn that is gone. A conversation with a turn still running is refused rather than queued behind it, and says so. There is no undo, and no terminal command or agent tool for it (see #13 and #14).
Front ends¶
cli: the terminal, via AIMU'sCLIChannel./attach <path>stages a local image onto the next message;/think <level>sets the reasoning effort for the messages that follow (off,low,medium,high, ordefault), and a bare/thinkreports the current one.web(behind thewebextra): a Starlette + uvicorn WebSocket server with a streaming browser UI, bound to127.0.0.1:8000by default. The page is three files:index.htmlfor the markup,app.css, andapp.js, served from the same static-asset allowlist as the vendored libraries.- A flat transcript, not a chat. One left-aligned column: the user's turn is marked with a
>rather than filled, and machine events are dim monochrome one-line rows set apart by a kind word and the smaller type they are set in. Every block takes the same indent and the same measure, prose and machine event alike, so left and right edges line up down the page and the hover fill, the wrap point, and the caption column are one width whatever kind of block you point at. The measure is wider than prose alone would want, which is the price of the alignment: a tool call needs room for its arguments, and a narrower cap would put a card's metric a long way from the call it describes. Expanded, a block's body is set off by a rule down its left, so an open block reads as open from the margin rather than only from its header's triangle, and its content is bounded: on an indent alone a disclosed body ran flush into the next block's header. Nested blocks set off one level in (a tool's output, a sub-agent card's own thinking), so depth reads as depth without drawing a second border inside the first, and nothing has to toggle the rule, since a collapsed body is not drawn at all. Tables and code blocks run wider than that measure, since they are scanned rather than read. Pointing at a block fills it in the same colour a hovered row in the sidebar takes, so the block under the pointer is marked and the caption and turn controls that appear on it (below) read as belonging to that block rather than to the turn around it. A foldable fills its header line only: an expanded sub-agent card runs pages tall, and filling the card would draw one slab over a page of content and light it up from anywhere inside it, including from a nested block that fills on its own. The approval prompt and the centered system notices keep the backgrounds they have. - Colour means one thing. Twelve semantic tokens, of which the warm pair marks only a decision waiting on you: a tool approval or a plan review. Tool calls, sub-agent cards, and proactive messages are not warm. The disclosure triangle is the only glyph on the page; every block names its kind in words.
- Conversations are listed in a sidebar, auto-titled from the first message, each row showing its
relative age, with new / select / rename / rename-with-AI / delete (deleting the active one switches
to the most recently updated remaining conversation). The header strip names the conversation being viewed. The list
sits under a foldable Conversations title carrying the count of rows it shows, which is the rows
themselves rather than every conversation the server sent: a task's own firings are listed under that
task, so a count read off the frame would say 3 beside a section showing 1. The title row is the same
furniture the tasks section wears (one
.section-*rule set, onewireFoldableSectionhelper over two storage keys), and the two folds are independent and remembered per browser. The sidebar collapses to an icon rail and drag-resizes between 180 and 480px; the divider is keyboard-operable. Width, collapsed state, and theme are per-browser preferences applied before first paint, so there is no flash on load. - Each row also carries an export button, beside delete, that renders the conversation with the
same
transcript_export.render_markdownthe CLI uses and downloads it, without switching the row into view or reloading the page. There is noGET /export/{id}route: the front end builds itsAssistantfresh per WebSocket connection, so a plain HTTP handler would have no live session store to read. An"export"control on the same socket every other sidebar action uses does the read and write, and the reply reuses the existing/download/{name}route (already serving generated PDFs) rather than opening a second one. See Export a conversation. - The composer is one bordered box with a text area that grows to eight rows: Enter sends, Shift+Enter inserts a newline (an IME's Enter does neither). Send is replaced by Stop for the duration of a turn rather than sitting beside a permanently disabled button, and switching conversations mid-turn updates it to match the conversation being viewed.
- A bubble sent into a running turn is marked, not stamped. The page draws a bubble when you press
Enter and only learns later which of its three fates the message met, so the server reports that
fate on a frame:
turn_savedfor a message that became a turn, and a newinboxframe for one that joined the turn already running. A mid-turn bubble keeps the user row's marker and measure, gives up the gap that separates one turn from the next, and deliberately carries no branch and no delete-from-here control: it has no turn of its own, and the index that would delete from "here" points into the middle of the turn it joined. The mark is withdrawn if the message turns out to have become a turn after all (accepted, never read, re-run as a follow-up), since the mark and the controls standing together would say it both did and did not become a turn. A sub-agent card gets an entry of its own kind for the same message, filed apart from theloopmarker beside it, because a card that credited the agent loop with what a person said would name the wrong author. On reload the message replays as a collapsed row of the user's words rather than a marked bubble, which is a deliberate divergencereplay_items's own docstring argues; the one thing both views agree on, and the one that matters, is that no turn control is offered there. - The server now says which message became which turn, instead of the page guessing.
app.jskept a positional queue of bubbles awaiting a save, which forced it to predict server behavior before sending: it had to decide for itself which composer text would be answered as a command and run no turn, and keep that prediction in step with the serve loop's dispatch. A wrong guess in one direction was destructive rather than untidy, since an entry nothing consumed stranded at the head and the next turn's save stamped an older bubble with a newer turn's index, so clicking delete-from-here on one message took that message and the one before it. Every composer message now carries atokenthe page minted, echoed back on whichever frame reports its fate, and the queue is a map keyed on it. Two consequences worth knowing. An unclaimed entry is now inert rather than poisonous, so the page parses no commands it does not own. And every composer message goes as aninputframe, where only a message carrying images or a reasoning effort used to: a token has nowhere to ride on a bare string, and the server reads a frame's text exactly as it read a bare one. The page's own control replies (Stop, an approval's Allow/Deny, a plan review's verdict) are still bare strings, since they answer a turn rather than opening one and draw no bubble. This closes theTODO.mditem that described the defect. - An inline working indicator says a turn is running. A dim row pinned to the foot of the
transcript, a spinner over the seconds since the turn began, with the turn's output growing above
it. It goes up the moment you send and comes down when the turn ends. Switching into a conversation
whose turn is still running raises it too, counting on from that turn's real age rather than
restarting at zero:
ChannelUI.show_workingcarries a duration (Nonefor "nothing running") rather than a flag beside one, so "idle for twelve seconds" cannot be expressed. It replaces a static chip in the header, which said only that something was happening and said it a long way from where it was happening. Pinning is deliberate and was measured rather than assumed: an indicator that the first rendered content displaced was on screen for 23ms against a local endpoint, whose first token lands about that fast, so what it answers has to be "is a turn running" (true until the turn ends) rather than "has anything arrived yet". - Alerts sit over the page, not in the transcript. Everything raised outside the conversation on
screen is a card in a fixed layer: a scheduled task's report, an MCP authorization an in-flight
connect is waiting on, a background turn's completion, and a gated tool a backgrounded turn was
denied. Before this they were messages, pushed into whichever conversation the user happened to be
reading, which is how a task's "open the 'Digest' conversation to review" could land in the middle
of an unrelated one (or in 'Digest' itself). Each card carries the time the thing happened, one way
to act on it, and a dismiss control; Escape takes the newest. The action is a pointer rather than a
decision: Open switches to the conversation the card names, and Authorize is an anchor built from
the frame's own
urlfield, only forhttp(s), never by hunting for a URL in the text, since that URL comes from a remote server's metadata. Nothing expires, and the layer takes no pointer events of its own, so an unattended task can never block the composer. A later card supersedes an earlier one with the samegroup(a task, a conversation, a conversation and tool pair), which keeps a task firing all night from greeting you with one card per firing; a card whose conversation has since been pruned keeps its text and drops its Open control. The terminal has no such layer, soChannelUI.alertprints the sentence there instead, which is why an alert's text always carries its link in words. - A Think picker beside the Plan toggle sets the reasoning effort for the messages you send after
it. Sticky like Plan and, like Plan, per request rather than a setting: it rides the message as an
inputframe field, writes nothing toconfig.toml, and resets to the configured default on reload. It goes inert while Plan is on, because a planned turn runs its own agents at their declared efforts. - Replies render as GitHub-flavored markdown while they stream, via vendored
marked+DOMPurify(bundled, served locally, no CDN). The rendered HTML is sanitized, so model and tool output cannot inject scripts or markup, and links open withrel="noopener". The page reparses the whole accumulated answer on a 50ms timer rather than committing each finished block as it lands, because a newline is not a block boundary in CommonMark: a lone newline continues the paragraph, and a newline inside a fence, list, or table sits inside an open block, so a committed prefix could be wrong in a way no later token could repair. Reparsing converges on the final render by construction. LaTeX math is typeset with vendored KaTeX after sanitization, withtrust:falseand amaxExpandcap (a malformed expression is left as source text rather than breaking the row), and only once the turn completes: KaTeX is the expensive half of the render and half-typed TeX is noise, so mid-stream the expression stands as the source the model wrote. - A turn renders in the order it was produced. An answer the model wrote before calling a tool
stays above the card for that call: the block closes the answer bubble, and the tokens that follow
open a new one below it. A turn that says something, calls a tool, and then says more therefore
reads top to bottom in all three renderings -- live streaming, a background turn's catch-up replay,
and a reloaded transcript, where a stored assistant message's prose replays ahead of the
tool_callsit carries rather than behind them. - Auxiliary blocks fold, and the folded line carries the call. Thinking, tool calls, verbose-trace phases, sub-agent cards, drafted plans, and agent-loop continuation markers render collapsed behind a one-line header: the kind word, then the call with its condensed arguments, then a metric (a result size, a line count, a status). Only the arguments ellipsize, so a row is never taller than one line however long they are, and a turn can be read without opening anything. The full untruncated arguments are inside the fold. Direct messages and the interactive approval and plan-review prompts are unaffected.
- A tool card shows what the call returned, not just what it was asked to do. The result renders
in a nested foldable labeled with its size (
output 2,148 chars), filled on first open and clamped at 4000 characters with ashow allcontrol. Always plain text, never markdown, because a tool result is untrusted input. An error, an argument-binding failure, and a denied approval are results too and arrive the same way, so there is no separate failure rendering. All four surfaces that render a tool call carry it -- a live card, a background turn's catch-up replay, a sub-agent card's nested calls, and a reloaded transcript -- and replay rejoins a stored call to its result bytool_call_id, since concurrent dispatch appends results in completion order. - Sub-agent work is visible. A
spawn_subagentcall renders assubagent spawn_subagent(<role>) <status>over an argument line, then the nested thinking, tool calls, and answer the child produced, each individually foldable and built by the same builders and CSS as their top-level counterparts. The child's text streams in as plain text and renders as markdown when the spawn ends, which is the one place the page still waits for a terminal status to render (the assistant's own reply now renders as it streams). The parent's own duplicatespawn_subagenttool block is suppressed. Recording and display are independent, so a background turn's spawns are recorded even though its frames are muted, and switching in later shows the work. A round the worker's own loop injected shows inside the card too, naming which one it was and quoting the prompt, so a worker that hit the round cap reads as an explained outcome rather than an answer that came back thinner for no visible reason. - A sub-agent spawned by a sub-agent is drawn inside its parent's card. A composed worker can
compose another, and every card used to land at the top level, so a three-deep chain read as
unrelated siblings interleaved in the transcript, with nothing saying which worker made which call.
core/subagents.pynow records the spawning card's id asparenton the child's create event, recovered through acurrent_spawncontext variable bracketed byspawned/finishedthe waycurrent_addressis, since AIMU's observer callbacks name a spawn but not its spawner. The page draws the child card in the parent's body at the point in its run where the spawn happened, andkokua exportquotes it there. A card recorded before this change carries noparentand still renders flat. - A spawn card replays where its call was, and stands in for
compose_subagenttoo. Reload used to put every spawn card straight under the user's message, above the reasoning that led to it, and onlyspawn_subagent's tool block gave way to its card, so acompose_subagentcall showed twice, card first live and tool block first on reload. Now a card replaces the call that opened it, live, on reload, and inside a parent's card, paired by the task both carry (transcripts.spawn_task); a call that opened no card, such as a refused composition, keeps its tool block. A composed card readscompose_subagent(<name>)and its argument line carries the capability names the call asked for. - An oversized tool response is recorded as a preview plus a reference, not held whole. A PDF
fetched as text put 7.8 MB into a single recorded tool-call card, and 51.9 MB of one developer's
56.8 MB session file was this one field, re-parsed on every store read and replayed to the browser
on every conversation switch.
core/subagents.pynow caps what it keeps inline in a card atRESPONSE_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). A response over that length is recorded as the firstRESPONSE_PREVIEW_CHARScharacters, aresponse_ref(a/payloads/<sha256>reference, see "Payloads" below), andresponse_bytes(the full length); the rest is written once topayloads_path. The cap applies where the card is recorded, not where it is replayed, so a reload never shows a card shaped differently than the one shown live. The card renders the preview plus a "Show full response" control naming the size in KB or MB; activating it fetches the reference and swaps the full text in for the preview, once, and a fetch that fails (the file was cleared by hand; payloads are never garbage collected, see #15) leaves the preview in place with a short note instead of a control that would only fail again. The row's own collapsed header statesresponse_byteswhen the response was spilled, not the preview's own length, so a 7.8 MB result reads as its real size before the card is even opened rather than as "4,000 chars" until it is expanded twice. - Rows carry a localized datetime caption, revealed on hover (full precision in its tooltip) so
the transcript is not dated line by line. Every kind of block carries it the same way: at the right
edge of the row, on the block's first line, so the captions form one column down the page. On a
foldable it rides the always-visible header, so it shows collapsed or expanded; on a regular bubble
it rides a cluster that also carries the turn's branch and delete controls, which sit to its left
rather than beyond it, so the column holds even on the one block that carries all three. Those two
are the same quiet glyph control a sidebar row carries and take the same hover: the glyph brightens
and lifts onto a chip of the page's own background, destructive in
--danger, declared in one rule with the sidebar's rather than a second one that agrees until someone edits it. That cluster is a flex track beside the content rather than a float or a positioned corner, which is what lets it sit on the first line at all: content wraps before a track, so however long an answer runs it can never end up underneath the caption. Ephemeral chrome (approval prompts) is not stamped, an alert card carries a stamp of its own outside the transcript, and a centered system notice keeps its caption below its text, having no right edge to align to. Messages persisted before timestamps existed render without a caption. - A loop marker names which injection it was, live and on reload. The loop injects two kinds of
turn that say opposite things to the model: a continuation nudge after an empty turn ("keep
working"), or a forced wrap-up with tools disabled at the round cap ("answer from what you have").
Each renders as a dim row naming which one it was (
continuationortool-use limit) and quoting the prompt it carried, distinct from the user's own turn. The marker means an injected round specifically, not any rise in the loop's iteration count, so it appears once per injection and not after an ordinary tool round: an ordinary round is already separated by the tool block between its two halves, at the top level and inside a sub-agent card alike. A kind neither the page nor the Markdown export knows a word for (a third one AIMU might add) is shown by its raw name rather than labelled as a nudge, since a marker that names the wrong injection is the thing naming it was meant to stop. A proactive message keeps its own uppercase label. - No settings window. The page's one settings control is a theme button in the header, cycling
auto / light / dark; it is a per-browser
localStoragepreference applied before first paint and never reaches the server. Everything else runtime-mutable -- the planning toggles, and whatever else a toolset declares hot -- is changed by asking the assistant, whoseupdate_confighot-applies the change for the next turn and persists it toconfig.toml. That keeps one path for every hot setting, including a third party toolset's, instead of a panel that could only show the ones its markup named. (Thesettings/get_settingscontrol frames remain part of the web transport's contract for a front end that wants to render them.) The model and the reasoning effort are not runtime-mutable at all: both are read at startup, from[assistant].model/[assistant].thinkingor an agent's own[agents.<name>]keys. Change them inconfig.tomland restart. - The header says which model is answering. A dim label beside the conversation title, carrying
the entry agent's resolved model string (
provider:model_id, endpoint included, exactly the form you could type back intoconfig.toml), with every agent that declares its own on a line of the label's tooltip. It arrives as amodelsframe once per connection, sent afterAssistant.start()returns, and is never refreshed: a model is read as the agents are built, so nothing in a session can change it. Which is the reason to show it at all, since the bullet above is otherwise where this question stops: a startup-only setting has no panel field, and until now the only surface in a running session that named one was/diagin the chat. Both front ends askbuild.model_recordrather than working it out apiece, so the page's label and/diag's line cannot drift; the record is fields and the words are each reader's own, the way the sidebar words a task's status for a person and the scheduling tools word it for a model. - Sampling parameters are AIMU's, not a Kokua setting. There is no
[generation]section and no generation fields anywhere in Kokua's settings. AIMU 0.16.0 resolves generation kwargs through one precedence chain (the client's fallbacks, then the model card's tuned profile, thenclient.default_generate_kwargs, then the per-call dict), and Kokua writing that third tier from config duplicated the chain while shadowing the card: a model whose card specifies a tunedtemperature/top_p/top_k/min_plost it to whatever that section set.[generation]gets no special handling on the way out: it is now an unrecognized section, so a stale one left in aconfig.tomlfails to load the way any unknown key does, and deleting it is the fix. - The sidebar appears before the assistant has finished starting.
Assistant.createnow reads only local state (the session store, the validated agent registry) and reaches no network; connecting every configured MCP server, building the entry agent, checking the approval gates against the tools that connect contributed, and arming the scheduled tasks moved to a newAssistant.start(), whichrun()awaits so serving a turn always implies a started assistant.start()is idempotent and terminal: a failed start is not retried, since a retry would reconnect a server a first attempt already appended to the live connection list. The web front end sends the conversation list, the active conversation's history, and the current settings between the two calls, then the tasks, then a closingreadyframe, so the page renders what it already knows while the servers are still being asked for their tool lists. Untilreadylands,<body>carries abootingclass that dims the conversation and task lists and disables clicks on their rows, with a "Starting up..." line explaining why, so a row that cannot act yet reads as inert rather than broken. The composer is left alone throughout, since neither typing nor sending ever depended on that half of boot finishing. Measured against a copy of a real$KOKUA_HOME(28 conversations, the same two remote servers declared), the sidebar itself, meaningAssistant.create()plus the conversation list, history, and settings reads it renders, is on screen in about 0.1 seconds (three runs, 0.095-0.103s, roughly 0.063s increate()and 0.040s in those reads), against about 8.2-8.8 seconds measured the same way before this change. Most of that gap belongs to something else: listing conversations from a stored summary rather than parsing each one's whole session file cuts the samecreate()-plus-reads path from 1.499s to about 0.1s on its own, a savings of roughly 1.4 seconds that has nothing to do with when the sidebar appears relative tostart(). What this change is responsible for is the rest of it: the sidebar used to wait behind the whole eight-second-plus boot, remote servers included, and now waits behind none of that remote half. That figure covers the sidebar only, not the whole connection:start()still has the remote servers to connect, so the assistant behind the sidebar is ready later, not at the 0.1-second mark. - A dropped socket reconnects on its own. Restarting Kokua under an open browser used to leave a
page that said "Disconnected." and could only be recovered by reloading. The page now retries with
backoff (500ms doubling to a 10s ceiling, indefinitely) and shows one notice for the whole outage
rather than one per attempt. That notice counts down to its next attempt ("Reconnecting in 7s...")
and carries a Retry now button, so at the 10s ceiling a wait reads as a wait rather than as a
page that has stopped trying, and a reader who knows the server is back does not have to sit it out.
A manual retry deliberately does not reset the backoff, which only a connection that succeeded does,
so clicking repeatedly at a server that is still down cannot drive it back to the base 500ms interval.
Almost none of the work is on the client: the server already resyncs its
whole view on every connection (conversations, history, settings, tasks, then a closing
ready) and thehistoryframe replaces the transcript, so a reconnected page repaints rather than appending a second copy of what it already showed. What a reconnect does not restore is a turn that was in flight when the socket dropped, since the front end builds anAssistantper connection: the page comes back to what was persisted, not to the stream it was watching. Two closes are not retried, and the page tells them apart from a restart by whether anything arrived before the close: a server that answered and refused sends one message and closes without ever reachingready(the one-connection guard's "busy in another tab", or a config error reported in place of a session), and retrying that would thrash a server that is working while burying its explanation under repetitions of itself, so a refused page says to reload and stops.ready, nothistory, is what marks a connection synced, because a config error is now reported from the half of boot that runs after the history has already reached the page: a page still keyed onhistorywould read that refusal as a drop and retry it forever. The same rule catches a server that dies partway through starting for any other reason, which looks identical from the socket's side, so that case also asks for a reload rather than retrying on its own. Nothing having arrived at all is the ordinary restart case, and does retry. - A tab left open across a restart that changed the page reloads itself. A reconnect resyncs the
data but not the script, so after a restart that changed
app.jsan open tab kept drawing the new server's frames with the old code, and a feature you had just restarted for looked broken until you thought to reload. The server now writes a hash of the page's own files intoindex.htmland sends it as the first frame on every connection; a tab whose hash differs saves its composer draft, reloads, and puts the draft back. A restart that left the page alone reloads nothing, and a mismatch the reload cannot fix is tried once rather than in a loop. - Reloading the page replays the prior conversation, reasoning and tool calls included. What was streamed live is what is replayed: nothing in the core decides which frames are worth sending, so a front end that wants to fold or hide a block does that with the block in hand.
- A whitespace-only answer segment renders as nothing. A server that separates reasoning from the answer itself 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. That used to open an empty stamped bubble between the reasoning block and the tool card, reading as a section whose content failed to arrive. Live and on reload, whitespace alone no longer opens a bubble; inside an open one it is still the spacing between words.
- Breaking for a third-party channel:
RichChannel.send_turn_savedtakes a third argument. The signature is nowsend_turn_saved(conversation_id, message_index, *, token=None), andChannelUIpassestoken=on every call, so a channel still implementing the two-argument form raisesTypeErrorthe first time a turn reaches the store. The token is the front end's own id for the message a turn was made of, echoed back so a front end that draws its own bubbles can match the two; a channel that does not draw bubbles can accept and ignore it, which is what addingtoken=Noneto the signature does. It is keyword-only, which is the shape every optional opaque id on this feature's surfaces takes (send_message_frame,ChannelUI.message_takenandturn_saved,MessageBus.offer), so an implementation cannot accept one by position and a later parameter cannot change what a positional argument means.channels/protocol.pydeclares the new shape, and the pairedsend_message_frame(text, *, token=None)is optional like every other rich frame: a channel that does not implement it degrades inChannelUIto no call at all, which is correct for the terminal, where AIMU's base channel already prints the mid-turn words as the run reads them.
Agents and tools¶
- Tool access is three axes, and the config now says so: breaking, with no migration code. What an
agent may call (availability), what is in its context (residency), and what asks before it runs
(approval) used to share one list per agent and two global lists. Availability and approval now have
their own keys, in one
[agents.<name>.tools]subtable per agent with[assistant.tools]as the default for the approval half, and residency hasresident(below). The explanation is Tool access. The old keys fail startup with a message naming their replacement:
| Old | New |
|---|---|
[agents.<name>].tools = [...] |
[agents.<name>.tools].available = [...] |
[security].confirm_tools |
[assistant.tools].confirm, minus any entry that moves to review |
[security.auto_approval].tools |
[assistant.tools].review, and remove those entries from confirm |
The shipped config splits what confirm_tools held: skills.add_skill_script, mcp.add_mcp_server, and
config.update_config stay in confirm, and compute.run_command, compute.execute_python, and
fs_write move to review, where they prompt you exactly as before unless you switch
auto-approval on. A config that sets neither key
keeps the old six-entry gate. --confirm-tools now overrides [assistant.tools].confirm, and naming a
tool the file's review also names is refused. assistant.tools.* joins the default
locked_config_keys for newly scaffolded configs only. An existing config writes the list out in full
without it (the old "security.*" is what covered confirm_tools), so add "assistant.tools.*" to
[security].locked_config_keys by hand, or update_config can rewrite the default policy; the
removed-key message for [security].confirm_tools says so.
- Each agent's tool calls are judged by its own approval policy. confirm and review are one pair:
an agent whose table declares either declares its whole policy (a key it leaves out is empty), and one
that declares neither inherits [assistant.tools]'s pair whole. A declared pair replaces the default and
never merges with it. Policies resolve once, after every agent is wired (core/approval.py), and the
gate is bound to each agent's name: a delegated worker carries its own through AIMU 0.37.0's
"tool_approval" spec key, and a composed worker is bound to composed:<label> and always gets the
default tier. So a worker's fs_write is held to the worker's policy, where it used to be held to one
global list.
review entries prompt you whether or not auto-approval is on, so switching it off never ungates a call;
a dead review entry fails startup even with it off; and never_auto_approve is checked only when it is
on. [security.auto_approval] keeps enabled, reviewers, timeout_seconds, and max_per_turn.
Enabling it with no review entry in any policy is a startup error.
- Residency: an agent loads part of its capability on demand. [agents.<name>.tools].resident names
the toolsets from available whose tools are in the agent's context from its first turn. The rest are
deferred, and the agent gets AIMU 0.37.0's available_tools (every toolset it holds or can load, with
its description and which of its tools ask first) and load_tools(name). Its prompt names the deferred
toolsets in one line, Also available, load before use: ..., and a deferred toolset's guidance arrives
with its tools when it loads, not in the prompt. Unset, every available toolset is resident, which is
how every shipped worker is declared; an entry outside available is a startup error, and
update_config's dry run refuses it too. The shipped entry agent keeps memory, config,
conversations, planning, capabilities, time, and messaging resident and loads documents,
skills, mcp, scheduling, and benchmark on demand. Measured as the JSON of every tool spec it is
sent, its tool block falls from 30,560 bytes (all 37 tools) to 16,244 bytes (nineteen: seventeen
resident plus the two loaders), 46.8% smaller on every round that loads nothing; a turn that needs a
deferred toolset pays one round to load it. available = "all" (below) is the one declaration that
requires a resident list. See
Tool access.
- A load pays for the build, and checks approval, when it happens. A deferred toolset is built when
it is loaded, through the same build_tools call a resident one gets, so a failure surfaces at load:
the model reads Could not load '<name>': <error>, you get an alert, and the toolset stays loadable
for a retry. A deferred MCP server that is not connected refuses to load rather than loading empty. An
approval entry naming a deferred toolset is checked for shape at startup and resolved when the toolset
is built, by whichever path builds it (a model's load, a composed worker, a rebuilt agent's preload);
one that then matches nothing refuses every build of that toolset, failing closed rather than letting
it load ungated. A refused load reads Could not load, and a refused compose_subagent reads
Could not compose, each naming the entry, and either raises an alert. With auto-approval enabled,
[security].never_auto_approve entries for a deferred toolset wait the same way, and a build after which a policy's review reaches the floor is refused.
A conflict the pending entries' text already settles (review = ["mcp"] against the floor's
mcp.add_mcp_server, or one tool reached by both halves of a pair) fails startup instead of every
load.
A skill the entry agent's SkillAgent holds (every skill when it declares skills, otherwise the skills it names) exists from the first turn, so a gate on
it resolves at startup, even when a worker could also load that skill later. The shipped gate covers the same
seven tools it did: skills.add_skill_script and mcp.add_mcp_server resolve when their toolset first
loads.
- A rebuilt agent preloads what its conversation loaded. On restart, when switching among more than
eight conversations evicts an agent, and after a truncation, the rebuilt agent builds the toolsets its
transcript's load_tools calls loaded, in order, rather than replaying the calls (the builder is
synchronous and AIMU's load is async). Only a load whose stored result says it succeeded comes back: a
load the approval gate refused stays refused, rather than returning at the next restart, and a failed
or cancelled one stays loadable. available_tools then lists those as resident rather than
loaded. A preload that fails is logged at WARNING, not alerted, and the toolset stays deferred and
loadable; a preloaded tool whose name the agent already holds is skipped and logged.
- core.available_tools and core.load_tools are gate names. Wherever some agent defers a toolset,
a bare core approval entry covers the two loaders as well as spawn_subagent and activate_skill,
and each can be named on its own.
- [[mcp.server]].description. An optional string: the server's line in available_tools,
list_capabilities, and --list-toolsets, which is what an agent deferring the server decides by.
Unset, it reads Tools from the MCP server at <url>.; a non-string is a startup error.
- Agent names may not contain : or [. The gate reserves them for composed:<label> and for the
default tier's own label, so an agent named either way would collide with them. Refused at startup.
- kokua agents, and a startup summary. kokua agents prints each declared agent's availability,
residency, and confirm / review lists, marking which came from [assistant.tools], reading the
config alone and building nothing, so it works with the model server down. Startup logs one line per
agent to kokua.log (for example assistant: 12 available, 7 resident, 3 confirm, 3 review (from [assistant.tools])), because a declared
policy replacing the default silently dropping its other entries is the one foot-gun the replace-whole
rule leaves, and this is where it shows. The same lines go to stderr only when the run logs at DEBUG
(--debug, or [logging].level = "DEBUG"), since kokua agents shows them in full on demand.
- --debug logs at DEBUG for one run, overriding [logging].level, and prints the startup summary.
- The approval prompt and the auto-approval card name the agent asking ("Allow coder to run ...",
"approval and
auto_approval frames carry "agent"), since agents now hold different policies and a bare tool name no longer says whose call it is.
- Breaking for a third-party channel: RichChannel.send_approval_request and send_auto_approval take
an agent argument. The signatures are now send_approval_request(name, arguments, *, agent=None)
and send_auto_approval(..., agent=None), and ChannelUI always passes agent= (None when unknown),
so a channel still implementing the older form raises TypeError the first time a gated call asks. A
channel that does not show who is asking can accept and ignore it, which is what adding agent=None
to the signature does. It is keyword-only for the reason send_turn_saved's token
is.
- The AIMU floor is 0.38.0, for a calculate that evaluates arithmetic and nothing else (it was an
escapable eval, and the shipped policy leaves it ungated) and for the note a spawn now puts in front
of a worker cut off at its round cap. Before it the floor was 0.37.0, for the spawn spec's
"tool_approval" key and for ToolGroup, which residency rides. See the floor history under
"Diagnostics and error reporting" below.
- A delegating agent hears when its worker ran out of rounds. A worker's forced wrap-up answer
read like a finished one, and the assistant learned it was partial only if the worker said so. AIMU
0.38.0's wrap-up prompt asks the worker to name what it did not finish, and its spawn tool leads a
capped worker's result with a note naming the cap, so the assistant can say which part is missing
instead of relaying partial work as the whole answer.
- available = "all": every installed toolset, still a declaration. An agent's
[agents.<name>.tools].available takes the string "all" in place of a list. It expands, wherever
availability is read (core/agents.py's available_for), to every registered toolset in trust order:
Kokua's own, then third-party plugins, then skills, then MCP servers, registration order kept within
each, so where toolsets built together offer one tool name a remote server's never shadows one Kokua
ships. It leaves out entry_point_only toolsets (skills) on any agent but the entry agent, never
covers the core tools (spawn_subagent is still earned by delegates_to), and requires a resident
list, each entry within the expansion; an entry nothing installs, or skills on a worker, fails startup
saying which. ["all"], "All", and "all" in any casing inside a list fail startup with the fix, all
is a reserved toolset name, and update_config writes the string (with [agents.*] unlocked) and refuses
the same near misses. Upgrading: an existing skill, plugin, or MCP server named all now fails
startup, since the name is reserved; rename it. kokua agents prints available: all (N): ... and the startup summary
name: all (N) available, .... The shipped config keeps every agent on a list, with a commented
# available = "all" line in [agents.assistant.tools]. See
Tool access.
- A shadowed resident tool raises an alert at startup. build_tools keeps the first of two
same-named tools among toolsets built together and used to say nothing of the other. Once every startup
build is done, each declared agent's resident toolsets are checked, and each hidden tool raises
{agent}: {tool} from {shadowed!r} is shadowed by {kept!r} (the earlier resident toolset keeps the
name). "Earlier" is the agent's declared order for a list and trust order for "all". The shared
activate_skill every skill carries is not counted, since a second skill offering it hides nothing.
Startup builds the entry agent and every worker reachable from it by delegates_to, so an agent is skipped,
and logged at INFO rather than reported as clean, when any of its resident toolsets was built by no
agent. A deferred toolset loaded later keeps whichever tool arrived first, and AIMU's load result names
the skipped tool and its holder (a group only when the holder was loaded; a resident holder reads
a tool given to this agent directly).
- A skill written mid-session reaches workers. After each author_skill, add_skill_script, or
update_skill, the registry's skill toolsets are rescanned from disk (every other entry kept as startup
registered it) and every live agent's spawn_subagent is rebuilt, so the skill reaches an
available = "all" worker on its next spawn and compose_subagent on its next call. The entry agent
still gets it immediately through its SkillAgent. A worker declaring a list gains no new skill, but a
skill it already names reaches its next spawn as now written, new scripts included. A gate naming the
skill covers those new scripts: every build re-matches each confirm, review, and
[security].never_auto_approve entry naming its toolset, adding names and never removing one, and a
script write reports each skill's tools as a build before any delegate is rebuilt, so
review = ["weekly-digest"] holds back a script added mid-session for every agent, the entry agent
included. A refresh that fails for any reason is reported in the tool's result, after the write,
rather than raised. MCP servers added at
runtime still wait for a restart: a new server is a new trust path, a remote host's tools arriving after
a person approved only its URL, where a skill's prose and its approved scripts are not. author_skill
and add_skill_script both rebuild the skill tools workers use, retiring the previous skills MCP server
rather than closing it, since a worker spawned before may still be calling it; update_skill changes no
tools and so retires nothing.
- author_skill refuses a name already taken, before writing anything. A toolset's name, an MCP
server's name on file in config.toml (including one add_mcp_server wrote this session, which the
running registry has not seen), core, and all are refused with a sentence the model can act on.
Before, a skill named web was written and then stopped the next start as a registry collision. A
collision the check cannot see (a skill directory made by hand) comes back with the tool's result.
- Composition guidance gives two reasons to compose. CAPABILITIES_GUIDANCE and list_capabilities
now say to compose a sub-agent when a task needs a capability you do not hold, or when its tool output
would flood your context. On an available = "all" agent only the second applies.
- An individual skill is a name in the same namespace as everything else. A
[agents.*]table lists"citation-check"beside"web"and"stocks"without saying which kind each is, and--list-toolsetsshows every skill on disk under askill:group. Two paths deliver one declaration, split on who authors: the entry agent stays an AIMUSkillAgent, and its catalogue is scoped withSkillManager(include=...)to the skills it declares; a worker, which is a plain agent with no skill machinery, gets a declared skill's script tools andactivate_skillthrough the registry like any other toolset, and the skill's description reaches its prompt as that toolset's guidance. Declaring theskillsauthoring toolset opts an agent out of scoping, because narrowing an author's catalogue would hide the skill it just wrote and makeadd_skill_script's "callable in the same turn" promise false. A skill whose name collides with a toolset or an MCP server is a startup error, like any other collision, and a skill on disk that no table names is simply available to an authoring entry agent through its catalogue anyway. -
A skill is editable, so learning a procedure wrong is recoverable. The
skillstoolset hands an agent three tools, not two:author_skillwrites a skill,add_skill_scriptattaches a runnable script (and replaces one in place when called with the same filename), andupdate_skillrevises an existing skill's description or body, passing only the part that changes. The third closes a loop that was open:author_skillrefuses to clobber, so before it a skill's prose was write-once, and an assistant that had learned a procedure wrong could fix its code forever and never a word of its text, with authoring a second skill under a different name as the only route to a better version. It carries no agent (editing a skill's text changes none of its tools, so nothing reloads) and it is deliberately not in[security].confirm_tools, on the same line that putsadd_skill_scriptthere: a gate is for a call that reaches past the model, which a script does and prose does not, so gating the tool that edits instructions while leaving ungated the one that writes them would buy a prompt and no boundary. Needsaimu>=0.32.0, which also stoppedadd_skill_scriptfrom rewriting a skill's wholeSKILL.mdto attach a script: that rewrite dropped every frontmatter key it did not re-emit, so three of the four skillskokua skills installships would have shed theirlicenseandcompatibilitylines the first time an agent touched them. -
Every agent is declared whole in
config.toml. One[agents.<name>]table per agent, carrying adescription(the label a delegator sees), asystem_message, atoolslist of toolset names, and adelegates_tolist.[assistant].agentnames the entry agent, the one you talk to and the root of the delegation graph.assistant,researcher, andcodership as live tables inconfig.example.toml, which is whatkokua config initwrites, and are edited, deleted, or added to like any other setting. Nothing about an agent is defaulted in code, so Kokua refuses to start without a config file, with one that defines no agents, or with one whose[assistant].agentnames no table, naming a remedy that works from that state -- a config file that exists but declares no agents is told to add an[agents.<name>]table by hand or overwrite it withkokua config init --force, since plainkokua config initrefuses a file that is already there -- rather than running something that looks alive and cannot work. - One namespace of named toolsets. A toolset is one named capability an agent can declare: a name,
a description, a
build(ctx)returning tool callables, and optionalguidance. Every provider lands in one namespace -- AIMU's built-in tool groups plus its two stores and skills, Kokua's owncapabilities/config/conversations/mcp/scheduling, each installed plugin toolset, and each configured MCP server by its (now required)name-- so atoolslist says"stocks", never"mcp:stocks", and a capability can change provider without touching an agent. The cost is paid at startup: two providers claiming one name is aConfigErrornaming both.kokua --list-toolsetsprints the whole registry grouped by provider, and is the discovery command for the namespace. - A capability is declared, never defaulted. There is no code path that adds a tool an agent did not
name, and no flag that can disagree with a declaration. Not even the clock:
timeis a toolset that every agent wanting one declares. The state a toolset draws on is a lazy property on oneLiveState, so two agents declaring one toolset share one store rather than opening two, and the memory and document stores are opened because some agent declared them and not otherwise. An unknown name raises rather than being dropped, since a dropped name is a declaration silently overruled. The one exception is a sub-agent composed bycompose_subagent(below), and it is entered by declaration too: only an agent whose own table namescapabilitiescan compose one, and what it composes is one task's sub-agent rather than an agent the config describes. spawn_subagent(agent_type, task)is typed, and a non-emptydelegates_tois the whole switch for it: that agent gets a delegate offering exactly the agents it names, each running on its own declared model (or the default) with the tools its own table declares. Delegation nesting is Kokua's rather than AIMU'smax_depth, so an agent you delegate to that delegates in turn gets its own menu of its own targets; the graph must be acyclic and a cycle fails startup printing the cycle as a path. Independent spawns in one turn run concurrently ([assistant] concurrent_tools, default on). A worker's gated-tool call is routed to the user for approval and is never run unattended.- Capability discovery (
capabilitiestoolset). An agent can read the toolset registry at runtime withlist_capabilitiesand, when no declared sub-agent role fits a task, callcompose_subagentto build a sub-agent holding exactly the capabilities that task needs. The composed sub-agent is constructed per call, runs one task, and is discarded; its tools still route through[security].confirm_tools.[capabilities].max_depth(default 3,0off) bounds how far composition nests: at 3 a chain reaches three sub-agents, and the last of them holds neither tool, since acompose_subagentwith no way to look up capability names is useless to whatever holds it. A declared role is ranked above composing one in the prompt guidance, since its instructions were written for its job where a composed one's are written in the moment. A composed sub-agent now also gets a bus address of its own (inbox=WORKER_SOURCE, the same key a declared worker's spec carries) and reports its cost into the turn's own metrics (events=record_event); both were missing, the second silently and the first exploitably oncemessaging(below) gave an address something to grant.compose_subagentrefuses a capability that builds to no tools (imagewithoutAIMU_IMAGE_MODEL,github_backupwithout its token,planningalways), naming it with its description. It used to compose the worker anyway, without the tool, and each worker then composed another for the same request until the depth cap ran out: one recorded request for an image spent seven workers and fourteen model calls finding out that no image model was configured. - Guidance travels with the capability. Each toolset carries the prompt text that makes the model use
it, appended to any agent holding it, so installing a toolset brings its instructions and removing one
takes them away. An agent's system message is its own opener (falling back to
[assistant].system_message, then the built-in default), then its toolsets' guidance in declared order, then the delegation instructions when it delegates, and finally a "you are a lean supervisor, you MUST delegate" clause only when every toolset it declared is markedcross_cutting-- something an agent holds to manage itself rather than to do domain work.cross_cuttingdecides that one sentence and nothing else: it is not an authorization boundary, and an agent declaringconfigreally does getupdate_config. The boundaries are that[agents.*]is locked by default and thatconfirm_toolsgates by tool name whoever calls it. - The prompt names what cannot be answered from memory. A model that believes it knows an answer
never reaches for a tool, so both halves of the prompt state the trigger as a property of the question
(could the answer have moved since training, could the user check it against a source: current events,
prices, releases, published figures, who holds a role, what a page says today) rather than as the
model's own confidence, which is the signal it is worst at reporting. A delegating agent is told to
delegate those "even when you think you know"; the
webtoolset's guidance tells whoever holds the tools to look it up rather than recall it and to say where the answer came from. The two reach different agents: the shipped[agents.assistant]holds no web tools and gets the first,[agents.researcher]does not delegate and gets the second. - Figures and dates are computed, not written. The
computetoolset's guidance tells whoever holds it to work out every figure that matters withcalculateorexecute_pythonrather than in prose, converting mixed units to one base unit and reporting what it converted between. A lean delegator, which never holdscompute, is told instead that arithmetic (a currency or unit conversion, a percentage, a span between dates) is work to delegate with the raw figures and their units. Thetimetoolset's guidance tells whoever holds the clock that its sense of the date comes from training: readget_current_date_and_timebefore anything depending on the present, and convert timezones withconvert_timerather than by adding an offset. These replace directives a user had been writing into their own agents' openers. --systemoverrides the entry agent's opener for that run, winning outright over its declaredsystem_message(and over the[assistant].system_messagefallback), since the message a person is talking to is a prompt, not the capability[agents.*]is the single source of. A worker's own declared opener is untouched: the flag only ever reaches the agent the user is talking to.- The shipped config, not the code, is what makes the assistant lean.
[agents.assistant]declares the cross-cutting toolsets and no domain tools, delegating web, file, and compute work, which keeps the always-on agent's tool context small; give it"compute"and it runs Python itself. The one structural restriction isskills, marked entry-point-only because a spawned worker is a plain AIMUAgentrather than aSkillAgent, so declaring it elsewhere fails startup instead of producing a worker whose skill tools quietly do nothing. - Startup validates the whole graph before touching anything. An unknown toolset name (with the
valid names listed), a duplicate provider name, a missing or absent entry agent, no agents at all, an
unknown delegation target, a delegation cycle, and
skillson a non-entry agent each fail before the session store is opened or any MCP server connected, so a bad config leaves nothing written and nothing connected. One agent shape backs this:wire_agentbuilds any agent from its declaration, selecting its toolsets once and feeding the same list to both its tools and its prompt. - The assistant can look across your other conversations. The
conversationstoolset, three reads and two tools that write:list_conversations(ids, last-active times, message counts, titles),read_conversation(one transcript as plain text),search_conversations(case-insensitive text across every saved conversation, with snippets),rename_conversation(a title, and nothing else), andexport_conversation(a Markdown file, and no change to any conversation). So "what did we decide about the deployment last week?" is answerable, and context from a past thread can be carried into aspawn_subagenttask. The shipped config gives it to the entry agent and to no worker, and a test pins that: a worker shares no history and has no conversation identity, so the capability would widen a spawn's blast radius for nothing.
Reads come from the last persisted snapshot, never a rebuilt agent -- building one to read it would
allocate a model client, re-expand every stored image, and evict live agents from the LRU cache, and
it would be less correct, since a running turn appends to its message list in place. Two markers
keep the snapshot honest instead: a conversation with an in-flight turn is flagged as having unsaved
messages, and the conversation you are in says so, since its current turn is not persisted yet and
the model already has it in context. A transcript holds only what was said, with reasoning and tool
calls left out and each image as [image], one long message cut on its own so a pasted document
cannot swallow a read, and the whole bounded by a max_chars the model can raise; when it does not
fit, the oldest messages are dropped behind a count, so a read always ends with the most recent
message. Search matches the phrase first and falls back to all-of-these-words within one message,
saying which it used.
- The assistant can analyze how another conversation ran, not just what was said in it.
export_conversation renders one saved conversation to the same Markdown kokua export writes, over
the same render_markdown, and answers with the file's path, line count, and size. That keeps
everything the reading tools drop: the reasoning, every 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. So "which tool call failed in yesterday's flight-price conversation, and what did it
return?" is answerable, which read_conversation could never do at any max_chars. A full
parameter lifts the payload cap, the same one kokua export --full lifts, for when the exact text of
a long tool result is the thing being debugged, and sends the result to its own <id>.full.md so it
cannot be overwritten by a later plain export of the same conversation.
It answers with a path rather than the document, and that is the design rather than a limitation of
it. read_file(path, max_lines, offset) returns 2,000 lines per call, so returning the Markdown
itself would spend the asking conversation's whole context on one run's tool output, in the one
context that cannot afford it, and paging it there is the same bill in instalments. A path can be
handed onward instead: config.example.toml ships [agents.introspector], a delegate declaring fs,
and the tool's answer advises delegating once the file passes DELEGATE_ABOVE_LINES, so a long
transcript is spent against a worker's fresh context and only the findings come back. Paging arrived
with AIMU 0.31.0 (read_file's truncation notice now names the offset that continues the read, which
retired TODO.md item 19), and the introspector is instructed to use it and to say which part of the
run it saw if it stops short. What paging does not fix is an export longer than the introspector's
own context, which no offset makes holdable.
The write is bounded by construction, not by validation: the directory is
AssistantConfig.downloads_path and the filename is the resolved session's key, so no argument the
model passes reaches the filesystem, and exporting the same conversation twice replaces one file
rather than accumulating. What it leaves there is a full transcript in the clear, in the folder
/download/{name} serves unauthenticated, which is the reason to consider naming it in
[security] confirm_tools even though it changes no conversation.
- One worker reads history, because it is the one with a conversation as its subject.
[agents.introspector] declares conversations alongside fs, which makes evaluating a run a single
delegation rather than two: it finds the conversation, exports it, reads the file, and reports against
the criteria it was given, quoting the transcript lines each judgment rests on. The search results,
the transcript, and the file all stay out of the asking conversation's context, and its instructions
hold it to three honesty rules that mirror the export's own: a criterion the transcript cannot settle
is reported unassessable rather than guessed, a read it stopped short of finishing is reported as
covering part of the run, and with no criteria given it names the rubric it chose, since a worker
cannot ask. It declares fs and not fs_write, so it reads every file it can reach and changes none
of them: a conversation is its subject, and evaluating one is no reason to rewrite one.
That is a narrowing of the old rule, not an exception to it. Reading the user's other conversations is
meaningless to an agent with no relationship to any of them, which is why researcher and coder
hold none of it, and test_only_the_introspector_reads_history_among_the_shipped_workers pins both
halves. The reach it does grant is worth stating: conversations lets that worker read (and rename)
any saved conversation, not only its subject, and a transcript is untrusted text, so an injection in
one conversation could steer a worker spawned from another.
Granting the toolset to a second reader exposed a sentence that had been relying on there being
one. read_conversation and export_conversation describe the active conversation as "the
conversation you are in", telling the reader to use its own context for the turn not yet saved. 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 sent to context with nothing in it. make_conversation_tools now takes
is_entry_agent, derived in TOOLSET.build from ctx.agent_name == ctx.config.entry_agent, and
picks DELEGATING_CONVERSATION_NOTE 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. A flag rather than
two toolsets, since everything else the tools do is identical. list_conversations' account of the
(current) marker was fixed in place for the same reason, having 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 its only subject.
- Agent tools are findable, and are only presentation. Every module defining an @aimu.tool is a
toolset module, so grep -rl '@tool' src/kokua/ finds only files under toolsets/. Kokua's own five
(capabilities.py, config.py, conversations.py, mcp.py, scheduling.py) each wrap one
subsystem (capabilities.py wraps the registry itself) and export the TOOLSET for it, indexed in
toolsets/core.py. The subsystem underneath (core/transcripts.py, config/store.py,
mcp/servers.py, scheduling/tasks.py) holds only what agents and front ends both need: it returns
records and raises typed errors, and formats no sentence. Because about half the 30 tools the shipped
entry agent holds come from AIMU and cannot be grepped here at all,
docs/explanation/architecture.md carries
the full inventory with the factory that builds each and the toolset it is declared as, and
tests/core/test_build.py pins it as an exact set: adding a tool to the entry agent fails the suite
until the table is updated in the same commit, and a plugin toolset leaking onto it fails too, naming the
offender.
- Skills: AIMU's skill authoring plus runnable skill scripts, as the skills toolset.
- Memory: a SemanticMemoryStore for facts (memory) and a DocumentStore for documents
(documents), shared by every agent that declares them. Concurrent-turn safety lives inside AIMU's
stores (a re-entrant per-store lock) rather than a Kokua-side wrapper.
Bundled skills¶
Kokua ships three Agent Skills in a repository-level skills/ directory, deliberately outside the
package and therefore outside the wheel: these are content, not Python, and keeping them in src/ would
put them back in the source tree. kokua skills list shows what is bundled with each declared
description; kokua skills install [name...] copies them into the skills folder your config resolves,
honouring [paths].data_dir and $KOKUA_HOME, and leaves an already-installed skill of the same name
alone unless you pass --force, so a local edit survives a reinstall. Both commands run before the AIMU
preflight, since copying files needs no AIMU surface. An installed-from-PyPI Kokua has no copy and the
command says where to get one.
markdown-to-pdfrenders Markdown to a PDF indata/downloads/, which the web front end serves atGET /download/<name>, and reports that link. This was thepdftoolset. Its script declaresfpdf2andmarkdowninline with PEP 723 anduvresolves them per run, so both left Kokua's dependencies entirely. Path traversal in the output name is stripped to a bare filename, and fpdf2's Latin-1 core fonts mean smart quotes, dashes and arrows fold to ASCII (lossy, and expected).email-reportmails a Markdown body to you as HTML with a plain-text fallback, optionally attaching a file already indata/downloads/ordata/images/. This was theemailtoolset, and it keeps both properties that one had by construction: there is no recipient flag at all, so it can only ever mail the configured address; and every attachment resolves before connecting, so a bad name sends nothing rather than an email the caller believes carried the file. On failure it reports only the exception type, because SMTP errors can echo credentials.dice-rollerrolls standard dice notation and prints JSON. This was theexampletoolset, and it stays the worked example: no dependencies, so nouv runneeded, and nothing host-specific to copy around.
A script cannot discover where Kokua serves downloads from or which address it may mail, so
LiveState.script_env() passes the downloads and images folders and the [email] settings into each
script's environment (AIMU 0.14.1 threads them to the subprocess). Deriving them in a script would mean
re-implementing config/paths.py and drifting from it. KOKUA_EMAIL_PASSWORD is deliberately not
passed: a subprocess already inherits it, so copying it would duplicate a secret for nothing.
That map travels by two routes, because a skill script reaches an agent by two. A spawned worker takes
its skill tools from the registry, where LiveState.skill_tools passes the env to
build_skills_server. The entry agent is a SkillAgent, which builds its own skills server and so
cannot be reached that way; wire_agent hands it the same map as SkillAgent(script_env=...), which
needs aimu>=0.20.0. A route that misses it raises nothing: the script runs with the settings simply
absent and reports itself unconfigured, which is why tests/core/test_build.py pins the entry agent's
copy directly.
A skill carrying scripts belongs on an agent that also declares fs and compute, since running the
script is how the skill does its work.
Toolsets¶
All 23 toolsets Kokua ships are one file each under src/kokua/toolsets/, named for the toolset, and
registered in pyproject.toml's kokua.toolsets entry-point table -- the same table a third party's
package writes into. There is no second route, no index in code, and no directory scan: that table is the
index. Adding a toolset is a new file and one line, and tests/toolsets/test_registration.py fails until
both exist and agree, in both directions and on all three names (file stem, entry-point key,
TOOLSET.name). That last one matters because register keys on TOOLSET.name while the entry key feeds
only the provenance label, so a mismatch would otherwise register a toolset under a name nobody wrote.
The directory holds toolsets and nothing else. The machinery is kokua.registry, namespace assembly is
kokua.core.agents, and toolsets/__init__.py exports nothing (Python needs it for the package to be
collected into the wheel and importable by name).
mcp-admin is now mcp, since a toolset's file name is its name. A tools list saying "mcp-admin"
fails startup on the unknown name.
fs reads and the new fs_write writes, because one name could not say both. AIMU 0.31.0 put
write_file and edit_file into builtin.fs, and toolsets/fs.py was list(builtin.fs), so the
upgrade alone would have handed a write to every agent already declaring fs -- a capability arriving by
dependency bump rather than by declaration, which is the one thing an agent's tools list is supposed to
be the whole of. Two names, because Kokua's config has exactly one lever for a capability and with a
single fs there is no way to write down "read a file and do not write one": the shipped
[agents.introspector] declares fs to read a transcript it was asked to evaluate, and the shipped
[agents.coder] declares both. Neither module names a tool. fs is
select(builtin.fs, exclude=builtin.unscoped) and fs_write is
select(builtin.fs, include=builtin.unscoped), over AIMU's own group of tools whose target the model
names, so the two partition one group by reach: a read tool AIMU adds later lands in fs on its own, a
writer lands in fs_write, and tests/toolsets/test_fs.py pins that the halves are a partition rather
than two hand-kept lists. They arrive gated. config.example.toml adds write_file and edit_file
to [security].confirm_tools for the reason run_command is there: coder holds a shell that can write
a file, so an ungated write_file beside it is the same outcome by a shorter route, and the gate would
have been approving the long way round while waving the short one through. A config.toml scaffolded
before this release gates neither; add them by hand, and note that adding them without also declaring
fs_write somewhere fails startup, since a gate naming no real tool is refused. Needs aimu>=0.31.0,
which is the release that added builtin.select and builtin.unscoped along with the writers.
documents gained edit_document, and save_document now refuses to replace a document it has not
seen whole. Both are AIMU 0.31.0's, arriving through make_document_tools, and the pair closes a
destructive round trip that release opened: read_document began returning a 2,000-line window, while
save_document replaces the whole document, so read-then-save on anything longer deleted everything past
the window and answered Saved. A 3,000-line document came back 51 lines long with the truncation marker
written in as content. toolsets/documents.py names edit_document in its guidance rather than leaving
the refusal to do the teaching, because a refusal arrives only after the model has spent a turn deciding,
and it tells the model that read_document windows at all, which nothing else would.
The compute toolset now carries a shell tool, run_command, alongside calculate and
execute_python. It runs a command line through /bin/sh -c and returns the exit code with stdout
and stderr labelled separately; a nonzero exit comes back as output rather than an error, since pytest
exits 1 with the answer on stdout. The timeout is per call, defaulting to 30 seconds and clamped to 600.
The shipped [agents.coder] is the only agent declaring compute, and its system_message already told
it to run "Python, shell, or calculations," which is true for the first time; the toolset module's
identical docstring claim was equally aspirational and is now accurate. It arrives gated:
config.example.toml adds compute.run_command next to compute.execute_python in
[security].confirm_tools, so a
command reaches you for approval before it runs, including one a sub-agent asked for, since a worker's
gated call routes to the parent's gate rather than running unattended. A config.toml scaffolded before
this release still gates execute_python alone; add run_command to it by hand. The reviewer does not
get it. workflows/critics.py names builtin.calculate individually rather than taking the whole
compute group, precisely so a future widening of that group cannot reach an agent with nobody to ask
for approval, and tests/workflows/planning/test_reviewers.py now asserts the absence by name rather
than relying on a set intersection staying empty. Needs aimu>=0.24.0, which is what moved the floor
there: builtin.make_command_tool and run_command's membership in builtin.compute are both new in
that release, and the startup preflight's capability probe moved to make_command_tool, a plain name
lookup, since the capability and its handle are the same object.
[compute] command_env_passthrough names, comma separated, which environment variables a
run_command child may see on top of a fixed allowlist (PATH, HOME, the locale and temp variables,
plus SHELL, TERM, TZ, USER, LOGNAME). Nothing else reaches the child, API keys included, which
is what stops run_command("env") lifting a credential into the model's context on a machine whose
launcher sources a .env; the same default makes gh, ssh, and git push over ssh fail, which is the
policy working as intended, and naming the variable here is how you grant it back. It is a string rather
than a list because a toolset setting is a string, an int, or a bool, and it is cold rather than hot
because the value is baked into the tool's closure when an agent is built; a hot flag doing nothing until
a restart would be worse than a cold one that says so up front. The key is also locked by default in
[security].locked_config_keys, added beside email.to rather than matched by an existing pattern,
since without it the assistant could name one of its own credentials there and read it back out of a
command's environment. Neither execution tool is a sandbox, and run_command is one step less of one
than execute_python: a shell string reaches a credential sitting in a file with no code for anyone
to read first, and process signalling is unconfined either way. run_command also carries no memory
cap, unlike execute_python's 512 MB address-space limit, since that cap would break compilers and
test suites and a shell child's own cap would need preexec_fn, which is neither portable nor
thread-safe. The approval gate is the control.
tests/toolsets/test_compute.py, tests/core/test_build.py, and tests/workflows/planning/test_reviewers.py
cover it.
messaging gives an agent send_message(to, text) and list_agents(), over the per-turn message bus
a mid-turn message already reached. core/messaging.py's bus mints an address for every run that
opens a reader this turn (an exact one per worker, like researcher#1, and the declared name alone for
the entry agent), and until this release the only sender who could use an address was the user's own
typed message, sent to EVERYONE. This toolset lets an agent address one of those runs directly: the
entry agent can redirect a worker already running, and a worker can report back to the parent that
spawned it, by its declared name. list_agents shows the current roster plus everyone, and marks
the caller's own address (you): without it a worker cannot tell itself from a sibling under the same
label, and one did message itself, believing it was asking the run that had written the file it needed
checked, then read its own words coming back as an echo. A worker's drain also marks an agent's message
sent before that worker started ([message from report-writer#1, sent before you started]): its cursor
opens at zero, so it is replayed a sibling's earlier words, and unmarked a finished sibling's request
read as live and drew an answer nothing could receive. The person
the turn is for is not on it, since user is a sender (the address the bus re-runs a redirected message
under), not a selector a tool argument can name. send_message refuses a to that matches nothing on
the roster right now, rather than accepting it and later reporting nothing delivered, since the roster
can answer that without knowing whether any run is still alive (a send-time check needs no liveness
tracking because the roster is append-only). It cannot refuse the opposite case, a worker that has
already finished: the roster does not retire an address, by the same argument core/messaging.py makes
for never inferring a run has ended, so a message to a finished worker is accepted and comes back as an
undelivered report at the end of the turn instead (invariant 10 in core/turns.py) -- read by the user,
never by the sender, whose own run has already ended by the time that report is possible. A receipt
therefore says a message was accepted for the addresses it matched, never that any of them will read
it -- a bare label matching several runs is satisfied by any one of them, so naming both in a receipt
cannot promise both will see it.
The sender's own address cannot be an argument (a tool has nothing in its arguments or its call stack
naming the run calling it), so it comes off a current_address contextvar core/messaging.py sets when
a reader opens. send_message refuses outright when that var is unset, rather than sending under
whatever the surrounding Context happens to hold: a run that never opened a reader (a worker whose
spec omitted an inbox) has no address of its own to claim, and the refusal is what turns that into a
loud failure instead of a silent one sent under whoever spawned it. Keeping the var correct the rest of
the time needed more than the reader's own reassertion on every drain: AIMU dispatches a round's tool
calls sequentially whenever that round calls exactly one (the common case, with no
TaskGroup.create_task and so no fresh Context), so a round that both spawns a worker and sends a
message -- two sequential tool calls, one round -- has no round boundary between them for a drain to
land on, and the spawning run's own later call in that round would still see the worker's leftover
address. core/subagents.py's SubagentReporter (already the display hook every spawn Kokua builds
passes as observer, declared or composed) closes that gap instead: its spawned/finished callbacks
now also clear current_address before a spawned run's own run() starts and restore whatever was
current once it returns, in a finally, bracketing the spawn itself rather than waiting for a round
boundary. compose_subagent (the capabilities toolset) gets the same inbox-of-its-own fix that makes
any of this apply to it at all; see that toolset's own entry below.
Declared on the shipped entry agent and all three shipped workers, since both directions need it:
[agents.assistant].tools and each of [agents.researcher], [agents.coder], [agents.introspector]
name messaging. Deliberately absent from [security].confirm_tools: a gate is for a call that reaches
past the model, and this one writes to an in-process bus every reader is another agent in this same
turn, nothing outside it. One limit is by design rather than an oversight: an independent reviewer
(workflows/critics.py's reviewer_agent) is built with no inbox=, so it inherits whatever address
was ambient rather than None, and the fail-closed check cannot tell it apart from a run that is
entitled to send. Closed today because nothing ever hands it Kokua's messaging toolset: every caller
leaves its tools parameter at the fixed, AIMU-only REVIEWER_TOOLS default, and ReviewerConfig
rejects a config-declared tools or delegates_to by name at parse time, so there is no route from
config.toml to this parameter either. Neither guard lives in core/messaging.py or
core/subagents.py, so a change that gave a reviewer real tools would reopen this without touching
either.
An agent's message is distinguishable from yours wherever it is read, and two of the three places
needed a rule rather than a rendering. It arrives in the identical user shape yours does, so the
turn writes messages.PROVENANCE_AGENT onto it before the store is written
(TurnRunner._tag_agent_messages). is_user_turn then excludes it, which is what keeps a branch or a
truncation from ending a turn at a worker's note, with no stored record needed; replay_items draws it
as its own collapsed row rather than a user bubble, and marks it from: "agent" so kokua export
signs the line Agent (mid-turn) instead of User (mid-turn), which is the one place the export
states who spoke. One qualifier, and it is deliberate: a drain's list becomes one appended message,
so a round that carried your words and an agent's is one message that is both, and tagging it would
hide what you said from every one of those readers. That case stays untagged and rests on the per-turn
message index, which lists a mid-turn message either way. A turn with several deliveries is answered
one delivery at a time (MessageBus.entry_deliveries), so a turn where you spoke in one round and a
worker in another tags only the worker's; where the pairing cannot be made safely, which is a /plan
turn whose planner rounds were rolled back under a delivery, the fallback broadcasts one answer across
every index, but only when there are more deliveries than indices to answer for. With fewer, nothing
is tagged at all rather than broadcasting an answer that could land on an index no delivery produced,
which is what makes "it can only under-tag, never mis-tag" true unconditionally instead of resting on
an unstated premise about delivery counts.
The tag is a key on the stored message, which the model reading the turn live never sees, so a
worker's note still read as bare words inside a recipient's own context, indistinguishable there from
yours. MessageBus's two drains (reader, entry_reader) now render that same distinction into the
words themselves: yours passes through bare, and an agent's is rendered [message from researcher#1]
..., the same form the CLI's own mid-turn marker uses. Done at the drain rather than at send, so the
prefix reaches what AIMU's loop takes as the next round's prompt, and so the stored content of the
appended message it becomes (the words a reader sees are exactly the words the model saw), while
Message.text itself stays bare: close()'s undelivered report, entry_deliveries's tagging
decision, and the sender's own receipt all read that field directly and never see the prefix.
A mixed delivery is not left undefended either, on either side of the bus. PROVENANCE_AGENT
cannot go on it (its own contract is "nothing here is the user's", which would be false), so the
message keeps the messages.PROVENANCE_MIXED tag instead: a second, narrower tag that answers "does
this mix the two" without touching is_user_turn's own answer, which stays exactly what it would be
for a plain user message. core/transcripts.py's replay reads it and marks the item from: "mixed",
and transcript_export.py signs it Mixed (mid-turn) rather than crediting the agent's half to the
user; readable_messages/flatten_transcript drop the whole message from search, the same direction
tag_for_delivery already takes for the stored tag (losing the user's own half of it rather than
risking counting the agent's). The one case that still has neither tag is the fallback's complete
under-tag, reached only when a turn has fewer recorded deliveries than appended mid-turn messages.
The in-text prefix covers it regardless, which is the point worth keeping: an agent's words there
still announce themselves to the model that reads them, so the two mechanisms cover each other rather
than one of them resting on the other's reach. That shape is also not reachable on any path shipped
today, but reachability is the weaker guarantee of the two and is the one a later change can revoke;
see TurnRunner._tag_agent_messages's own guard, added in the same pass, for why it is refused a
broadcast rather than guessed at.
An agent's message never amends what an auto-approval reviewer reads as the turn's request, where
yours does. A reviewer judges one gated call's arguments against that text, so a model that could
append to it would be editing the terms its own calls are judged against, which is an escalation; you
appending to it is the principal exercising your own budget. The whole rule is one sender test in
MessageBus.send, which is why it is pinned directly. What is not ruled out, and is recorded rather
than designed away: a delivery extends the recipient's round budget whoever sent it, because AIMU's
_extend_budget fires on any delivery and the drain's list[str] contract is what keeps AIMU
ignorant of senders. That includes a run's message to itself, since matches answers everyone
before it reads an address and an exact self-address matches too, so a sender is a reader like any
other and no broadcast is needed to reach it. AIMU's own cap on extensions (at most max_iterations
of them) is what bounds it, and the bound is worth stating as the number it is rather than as a flat
ceiling: each extension moves the budget's base to the round the message landed in, so the worst case
is on the order of max_iterations times the cap. messaging is a declared capability, so no agent
holds it by default. core/messaging.py's WORKER_SOURCE and
Agent messaging carry the argument, and
the lever on the record is in send_message (excluding the caller from its own selector), not the
refusal of to=everyone an earlier draft recorded, which a self-address goes around. It closes that
route and not the extension, which two agents messaging each other reach by sends the capability
exists to allow; the extension cap is the bound.
tests/core/test_messaging.py, tests/core/test_subagents.py, tests/toolsets/test_messaging.py,
tests/toolsets/test_capabilities.py, tests/core/test_messages.py, tests/core/test_turns.py,
tests/core/test_transcripts.py, tests/core/test_conversations.py, tests/test_transcript_export.py,
and tests/test_aimu_compat.py (a release-fact pin that AIMU's
loop drains an inbox after a round's own dispatch, not only on a tool-free turn) cover it.
The four below are Kokua's own standalone capabilities, needing nothing but AssistantConfig. They are
the shortest worked examples of the shape: one file, one TOOLSET, one entry-point line.
image:generate_image, offered only whenAIMU_IMAGE_MODELis set (e.g.gemini:nano-bananaor anhf:<repo>diffusers model).aimu_agents: mounts AIMU's prebuiltCodeReviewAgent,ResearchReportAgent, andContentCreationAgentas the toolscode_review,research_report, andcreate_content. It exists mainly as the worked example of wiring an AIMU-built agent into Kokua: everyRunnerexposes.run(task) -> str, so a toolset is the entire bridge and the core gains no new surface. Each call builds a fresh agent on[assistant].model, the same default an agent that declares no model of its own runs on. The caveats are documented in the module: the prebuilts are synchronous, so a nested run gets no sub-agent card, no/stop, and no approval gate on its workers.benchmark: one tool,benchmark_model, measures the model the agent holding it runs on and reports time to first token and output speed in tokens per second, as a median and a range over three timed runs. The two metrics are separated because they have different causes (prompt processing and queueing against decoding), and a discarded warmup run is reported on its own line because a first request can carry a cold model load worth many seconds, which would otherwise read as a slow model. It takes no arguments: the model, the sampling parameters, and the reasoning effort all come fromconfig.toml, so there is nothing to redirect and nothing a per-call approval would protect. Token counts come from the provider's own usage report where there is one (Ollama, Anthropic, and the OpenAI-compatible providers all report it even on a streamed call) and from counting stream chunks where there is not, and the report says which, since a chunk is usually a token but nothing guarantees it. It builds its own client rather than borrowing the agent's, which is what keeps it from racing the turn it runs inside forlast_usage, and it carries an empty system message so the figure describes the model rather than Kokua's own multi-thousand-token prompt. Any agent may hold it, and what it measures is its own holder: the model comes fromToolsetContext.agent_name, so a worker running on a model of its own is timed on that model, and the report names the agent whose model it timed rather than leaving a reader to assume. Resolution goes throughconfig.model_forrather than the live agent because nothing on a model client retains the string it was built from. An in-process model (hf:,llamacpp:) cannot be benchmarked and the tool says so: a second client over one model would load its weights twice, so AIMU refuses to build one.github_backup: one tool,backup_kokua_state, mirrorsconfig.toml,sessions.json, the memory store, saved documents, and authored skills into a private GitHub repository as a git commit, pushing from a working tree underdata/backup. It takes no arguments, which is what lets it run ungated and therefore from a scheduled task; the repository and branch come from[github_backup]and the token from$GITHUB_BACKUP_TOKEN, which never reaches a command line or.git/config. A public repository is refused, an unchanged state makes no commit, and a diverged remote is reported rather than force-pushed. Three things keep the reported outcome honest across runs: repointingrepoat a second repository is refused whiledata/backupstill tracks the first (Kokua will not verify one repository and push to another), a commit an earlier push failed to deliver is pushed rather than reported as an existing backup, and one lock serialises the working tree so two concurrent turns cannot push a half-copied memory store as a success. The toolset offers no tool untilrepois set. Logs, downloads, and images are excluded, and an in-tree.gitignoreexcludes anything further. Restore is manual: see Back up to GitHub.
Nothing a toolset contributes reaches an agent until that agent's tools list names it, and nothing
warns you about a name no agent named: a toolset that ships and is never declared costs nothing to leave
alone. The case that does cost something is a configured MCP server, which connects regardless; see
Add MCP services.
Proactive work: scheduled tasks¶
- Durable, agent-managed tasks that fire an unprompted turn when due, declared as
[scheduling.task.<name>]tables inconfig.tomland re-armed at startup. A task's name is its identity: it is required, unique, the table key, and the handle every scheduling tool and the sidebar use in place of an internal id. A table can be hand-written or hand-edited, comments and all, the same way an[[mcp.server]]entry can: the comment lines directly above a task's header are that task's, so they stay put when it is renamed and go with it when it is cancelled, never sliding onto its neighbour. Cancelling and renaming also cover the shapes a hand edit can write and the app never does: a task written as an inline table, and one task whose keys are split across several fragments, both move or go whole rather than in part. A malformed table is rejected at startup, naming the table rather than becoming a task that silently never fires. Schedules are one-shot, interval, daily, or weekly (no cron dependency). - Managed by the assistant through
schedule_task,list_scheduled_tasks,get_scheduled_task,update_scheduled_task,cancel_scheduled_task,disable_scheduled_task/enable_scheduled_task(pause without losing the task),run_scheduled_task(run one now, reproducing a real firing, so a task can be dry-run before it is due), andstop_scheduled_task(end a run that is happening now).[scheduling.task.*]is refused byupdate_config, even though the assistant writes it: a task write has to be paired with the scheduler (un)arming that accompanies it, which a bare config write would skip, so the scheduling tools are the only path in. - A fired one-shot is retired in place, not deleted. Its table stays, with
enabled = falseand afired_atstamp, so the run stays on the record and re-running it is a one-character edit.cancel_scheduled_taskstill removes the table outright. - A run in flight can be stopped, from the task row in the web sidebar, by asking in chat, or with
/stopon a channel where a firing runs in the conversation being viewed. Stopping ends that run only: the task stays on its schedule and fires again as usual, so a task that should not come back is still adisable. The stopped run keeps whatever it produced in its own conversation, with the stop recorded against that turn the way a failure is (and treated like one by retention, so a stopped run is evicted before a report). A firing is never stopped from inside itself, which would cut its own turn off mid-tool-call. This is also why a firing runs in a child task: cancelling the scheduler's job would have disarmed the schedule as a side effect. update_scheduled_taskedits any subset of a task's fields in place, keeping itscreated_atand retention cap, and re-deriving the schedule fields you omit, so changing a weekly task's time keeps its day. It re-arms only when the schedule actually changes, so editing a prompt never restarts an interval countdown, and it rejects an invalid schedule, a past one-shot, or a name another task holds without writing anything.new_namerenames a task: the rename moves its table inconfig.tomland re-points the conversations it has already run, so its history follows it under the new name.- Every firing runs in its own conversation, stamped with the task's name, and a per-task
max_conversationssays how many of them survive:1means each run replaces the one before it,0keeps every run forever, and a task that names no cap follows[scheduling] max_task_conversations(default 3), read at fire time so a change reaches the next firing without a restart. Pruning happens after every firing, successful or not, evicting runs that hold no report before ones that do, so a task that keeps failing cannot grow past its cap and a cap of1still keeps the last good report rather than the failure that followed it. Lowering a cap deletes nothing until the task next fires, so an edit is not destructive and a disabled task keeps everything. A run the user already deleted is not an error. A channel with no conversation list (the CLI) has nowhere to put a minted conversation, so there a firing runs in the one being viewed and prunes nothing. - A failing firing is reported and swallowed rather than propagating into the scheduler, which has no handler of its own. Its conversation is still persisted as far as the run got -- at minimum the prompt, since the model client appends the user turn before it sends the request -- with the reason it stopped recorded against that turn and replayed as a notice at the end of it. Without that, a firing that failed left a conversation with nothing in it at all, and the status line explaining why went to whichever conversation the user happened to be viewing.
- A tasks section in the web sidebar, below the conversation list, showing each task's name, schedule, and next firing, with disable/enable, run-now, and delete per row. A task with a run in flight pulses, keeps its buttons visible rather than waiting to be hovered, and offers Stop in place of run-now; the run's own conversation appears under it as it starts, marked running, rather than only once it has finished. It hides itself entirely when there are no tasks, collapses (remembered per browser), and scrolls independently of the conversation list so neither can crowd the other out. Creating and editing tasks stays in chat, where the model turns a natural-language schedule into a validated one.
- The panel's actions and the agent's tools share one implementation (
scheduling.TaskService), so a table write and the scheduler (un)arming that must accompany it can never come apart. They do not share wording: the service returns records, and the panel and the model each phrase their own. The action name arrives from the browser and is allowlisted rather than dispatched on. - Each task's conversations are nested under it in that section and left out of the chat list, so
the chat list holds only conversations you started. A conversation records the task that minted it
(
task_idin its metadata, set to the task's name -- its identity -- so a rename re-points every conversation the old name owned, right after the table itself is renamed). Grouping happens on the page, not in the core: nothing is filtered out ofConversationBook.list(), so the agent's conversation tools still see every conversation. A conversation whose task has been deleted falls back into the chat list rather than becoming unreachable. Each nested row carries its own delete, like a chat row: a task that mints a conversation per firing accumulates them here, and the run being viewed is deletable too. - Breaking, and with no migration. Scheduled tasks used to persist as JSON records in
data/scheduled_tasks.json, keyed by an internal uuid; they now live inconfig.tomlas[scheduling.task.<name>]tables, keyed by name. An existingdata/scheduled_tasks.jsonis ignored and left on disk rather than imported, and a conversation stamped with an old uuidtask_idstops grouping under its task, since nothing inconfig.tomlmatches that id any more. Re-create any tasks you want to keep, or copy them intoconfig.tomlby hand.
Workflows: a pluggable turn strategy¶
- A
Toolsetcan carry aWorkflow, a named turn strategy, instead of (or alongside) tools. Declaring that toolset in an agent'stoolsis what gives the agent the workflow's/-command, resolved from the same toolset registry every other capability is -- a turn strategy is granted the same way a tool is, and no code path grants one a config did not declare. Deep planning, described below, is the first workflow and is no longer a hardcoded core feature. [agents.*].toolsmust nameplanningfor/planto exist, on the web Plan toggle as much as the CLI. An existingconfig.tomlthat predates this release and does not list it gets no startup warning, since nothing warns about an undeclared toolset -- but typing/planstill gets an actionable reply rather than the model answering the literal"/plan <task>"text: the serve loop recognizes any command an installed workflow-bearing toolset offers, even one the entry agent didn't declare, and says which toolset to add. The fix is one line: add"planning"to the entry agent'stools.- A third party ships a workflow exactly the way it ships a toolset, through the
kokua.toolsetsentry-point group -- no separate mechanism for a turn strategy versus a tool. - Two tiers. A workflow builds an
aimu.aio.AsyncRunner, so any of AIMU's ownaimu.aio.workflowsrunners (Chain,Parallel,Router,EvaluatorOptimizer,PlanExecuteEvaluator) works as a base-tier workflow with no adapter: Kokua streams itsrun()into the reply. A base-tier turn is not persisted: the runner appends nothing to the agent's own transcript, so there is no message to anchor the turn to, and the exchange is gone after a reload. That is documented behavior a base-tier workflow author needs to know going in, not a bug to file. The rich tier (a runner that also implementsrun_turn()) gets the channel, the human-decision slot, and control of the transcript instead, which is what lets deep planning persist a planned turn as a plain user/assistant pair. - A toolset can own a whole
config.tomlsection. Declaringsettings=(Setting(key, kind, default, hot=...), ...)on aToolsetgives it a[<name>]section -- always named after the toolset itself, so the namespace's existing duplicate-name check also keeps two toolsets from claiming one section, and a name colliding with a section Kokua's own core already parses is refused at startup.hot=Trueis what letsupdate_configchange a setting live; a cold one is a startup-only key like any other. Deep planning is the first user:toolsets/planning.pydeclares[planning]'s five keys (plan_review,plan_review_agent,result_review,show_reasoninghot;review_roundscold), and a workflow reads its own section as attributes throughWorkflowContext.settings. - Breaking for anything importing the old fields.
AssistantConfig.plan_review,plan_review_agent,result_review,review_rounds, andshow_reasoningare gone, replaced byconfig.toolset_settings["planning"][...].config.tomlitself is unchanged -- the[planning]section still parses and still means the same thing, so no user action is needed -- but code that read those attributes directly has to change. - A contributed setting's wire key is namespaced (
planning.plan_review) to keep two toolsets from colliding in the one flat settings payload; Kokua's own core settings stay bare. - Startup warns about a configured-but-undeclared toolset. A
config.tomlsection belonging to a toolset no agent'stoolsnames is now its own warning, distinct from the provisioned-toolset warning below: the section still parses and seeds its declared defaults, but nothing reads them and any command the toolset offers does not exist.
Deep planning and adversarial review¶
- Planning is per request, not a global mode: the web UI's Plan toggle beside the message box (a
sticky per-request switch), or
/plan <task>in either front end. A planned turn first drafts an explicit plan -- which tools, skills, and MCP services to use, what to search for, where to author a skill or connect a server -- and then executes it. Planning is scratch work kept out of the saved conversation, which stores your actual request and the answer. plan_reviewpauses a planned turn for Approve / Edit / Reject; off runs the plan autonomously.- Adversarial plan and result review (both off by default): an independent, context-free reviewer
agent -- a fresh client seeing only the request plus the plan or answer -- critiques the plan and/or
the final result.
plan_review_agentre-plans on rejection up toreview_rounds, surfacing leftover concerns.result_reviewchecks the answer before it is shown and revises on rejection; because a result cannot be vetted and streamed at once, the executor's thinking and tool calls stream live while the final answer is withheld until it passes, then a clean transcript is committed. - Reviewers are tool-using and grounded. Each runs a bounded tool-calling assessment over a curated
verification toolset and then extracts its typed verdict in a follow-up structured call, whose
approvedfield is narrowed to a real boolean as it arrives: that path builds the verdict from a model's JSON without validating types, so a reviewer replying"false"would otherwise hand every caller a truthy string and a rejected plan would read as an approved one. The toolset is web lookup,calculate, and the current date and time; it deliberately excludes the user's memory and documents, skill authoring, MCP mutation, and both execution tools,execute_pythonandrun_command-- a reviewer cannot be approval-gated, since an autonomous critic has nobody to ask mid-review, so it is given nothing that would need a gate. Both prompts warn that the reviewer's own knowledge may be stale, that disagreement with memory is not evidence of fabrication, and that a suspected inaccuracy must be verified with tools before flagging. The result reviewer is additionally shown an Evidence section -- the tool results the agent actually retrieved -- so it judges against real sources. - Each critic's model, effort, sampling, and standard are yours to set, in
[reviewers.plan]and[reviewers.result](see Configuration). An undeclared table leaves planning's own shipped prompt in force, so this is an override rather than a default you must restate. It matters because a reviewer rejecting your plan is making a judgement you could not previously read, let alone change, and because a critic is the one place where a second, cheaper or stronger model is obviously the right call. - Verbose trace ("Show all reasoning"), off by default: turns a planned turn into a labeled, live trace -- planner, each plan reviewer, executor, each result reviewer, and every revision -- showing every intermediate plan and result version. It overrides result review's hide-until-vetted gate; only the final approved answer is committed. The whole raw trace is recorded per turn and replayed on reload, so a reloaded verbose turn shows the same output it did live rather than a summary.
- In non-verbose turns the reviewers appear as their own cards that update in place from "reviewing..." to approved or rejected with the issues, so the otherwise-silent reviewer pauses are visible. Verdicts are recorded per turn and replayed in order on reload.
Configuration¶
config.tomlis the single source of settings, and the app writes it -- there is no parallel store. Layering is built-in defaults < the TOML file < command-line flags. Writes are comment-preserving (viatomlkit, since stdlibtomllibcannot write TOML), so hand-written comments and layout survive an app write; a fresh file is seeded from the shipped example.kokua config initscaffolds$KOKUA_HOME/config.tomlfromconfig.example.toml, where every key sits at its built-in default with a line of description. The long form, with what each key accepts, which keys apply live, and who may write each one, is docs/reference/configuration.md. The example file stays short on purpose:read_confighands the assistant the scaffolded file, so its comments occupy the model's context on every configuration question.- Strict parsing: an unknown key or non-table section fails fast with a
ConfigErrornaming the key, so typos and removed keys surface immediately instead of being silently ignored. [agents.*]describes every agent, and replaces the per-role vocabulary this line of development went through. Nothing has been released, so there is nothing to migrate from, but a config or a script written against an earlier commit of this development line hits the following, and a config key in the left column fails with a message naming its replacement rather than a generic unknown-key error:
| Was | Now |
|---|---|
the whole [tools] section (groups, and --tools overriding it) |
each agent lists what it holds in [agents.<name>].tools |
[subagents.roles.<name>] |
[agents.<name>] |
a role's groups / tool_packs / mcp_servers |
one flat tools list over the single namespace |
[subagents].concurrent |
[assistant].concurrent_tools |
[assistant].memory (and --memory / --no-memory) |
declare the memory and documents toolsets on an agent |
an implicit time group on every agent |
"time" in the tools list of each agent that wants a clock |
an optional [[mcp.server]].name |
required: it is how the server enters the namespace |
[assistant].agent is the new key naming the entry agent. The --tools, --memory and --no-memory
flags are gone rather than deprecated, since a flag that could contradict a declaration is exactly what
the declarative model rules out; argparse rejects them as unrecognized. --list-tool-packs became
--list-toolsets and widened: it prints the entire registry grouped by provider, not just the installed
plugins, and reads the config file first because the registry depends on it. The plugin contract renamed
with the concept: ToolPack is Toolset (with build(ctx) in place of build(config)), the
entry-point group kokua.tools is kokua.toolsets, and src/kokua/toolpacks/ is src/kokua/toolsets/.
- [reviewers.<name>] declares a reviewer whole, the way [agents.<name>] declares an agent. A
reviewer is one context-free model call whose answer code consumes, and the table carries the five
fields that describe how a model is asked (description, system_message, model, thinking,
generation) and none of the three that give an agent reach. tools, delegates_to, and
max_iterations are refused by name with the reason, rather than accepted and ignored: a reviewer's
tools are fixed in code by whatever consumes it and this table has no key to change them (the
approval reviewer is asked with tools off; a plan critic gets the curated verification toolset in
workflows/critics.py), nothing delegates to it, and it runs no tool loop (not so for a plan critic,
which loops up to a fixed 6 rounds: see #4). The approval reviewer's
own tool-less call is also why it cannot recurse into itself. model, thinking, and
generation resolve against the [assistant] tiers exactly as an agent's do; system_message is
the one field with no [assistant] tier, because what an undeclared standard means is the consumer's
to decide (deep planning substitutes its own shipped prompt, the approval gate refuses to start).
Three names are consumed today: plan and result are deep planning's two critics (see Deep
planning and adversarial review) and approval is the
auto-approval gate's (see Security). The whole section is locked against
update_config, like [agents.*]. Every declared [reviewers.<name>].model is validated at
startup, the same way and by the same check as [agents.<name>].model: a name AIMU's catalogue
does not know fails there, naming the table, rather than surfacing later as "the reviewer could not
be reached" on the first gated call or plan review it is asked to do. Checked for every declared
reviewer, not only the ones [security.auto_approval].reviewers names, and whether or not that gate
is on, since a bad [reviewers.plan] breaks /plan either way. An undeclared model inherits
[assistant].model and is not re-checked here.
- One runtime-settings table. config/table.py's SettingsTable -- built at startup from
CORE_RUNTIME_SETTINGS plus every installed toolset's own hot Settings -- is the single
declaration of what can change without a restart, driving the TOML schema, the incoming-payload
sanitizer, the hot-apply set, the live-apply loop, and the persist path at once. Adding one of
Kokua's own is one CORE_RUNTIME_SETTINGS entry; adding a toolset's is one Setting on the toolset
and nothing else. Both are enforced by tests. CORE_RUNTIME_SETTINGS ships empty: every runtime
setting Kokua has turned out to belong to a capability rather than to the core, so every one of them
is that capability's own declaration.
- The assistant can inspect and repair its own configuration: read_config / update_config.
update_config validates and coerces the value, applies hot-appliable keys immediately and persists
only after a successful apply (so a flag that cannot be applied is not saved), and reports "restart
required" for everything else. Which of the two happened is in every result, because most of what the
assistant is asked to change -- the model and the reasoning effort among them -- is startup-only, and
a saved-but-not-yet-applied change reported as done is a change the user thinks they have. A rejected
key says where to put it instead: an unknown [section].key names the section that does declare
that key ([assistant.generation].thinking is answered with [assistant].thinking) and lists what
the section it was given accepts, so the assistant can correct itself from the error alone. An
[agents.<name>] table gets both hints as well, read off the one set of entries that serves every
agent, and a hint pointing into an agent's table says [agents.<name>] rather than the wildcard the
schema is keyed by, because a model follows a hint literally and the wildcard written back would
create an agent named *. The tool refuses that section outright as well, along with every other
agent name a section header could not carry as written (an empty one, a spaced one): those are quoted
on the way into the file, so the table written would not be the section the tool just reported. The
rule is TOML's bare-key character set, so report-writer and stock_trader still write. A
config.toml that is not valid TOML is answered the same way, as a
refusal naming the file and the syntax fault: both parsers the tool sits over (tomlkit for the write
itself, tomllib for the re-read an agent write is checked against) raise a ConfigError on a syntax
error, so a stray bracket from a hand-edit made while Kokua runs is something the assistant can report
rather than an exception out of the tool call. A model
string gets a stronger check than the schema's str: [assistant].model is startup-only, so nothing
applies it live and a name that does not resolve would be saved and surface as a Kokua that will not
start. It is refused at write time by building a throwaway client -- the same call startup makes, so
it also catches a provider whose extra is not installed. A
list of locked patterns, [security].locked_config_keys, ships covering [security] confirm_tools,
[email] to, [paths] data_dir, and the whole [agents.*] and [reviewers.*] sections, matched by
section prefix since agent and reviewer names cannot be enumerated ahead of time. Each is refused by
the tool by default, and changeable only by hand-editing the list itself: update_config is a tool
the assistant holds, so a writable agent table would let it widen its own reach. update_config is
also in the default confirm_tools list, as config.update_config, so each write it is allowed
goes through the approval prompt.
- The update_config write policy is now yours to set. [security].locked_config_keys holds the
patterns the assistant may not write, defaulting to what was previously hardcoded. The key is itself
always locked, so the assistant cannot unlock itself in one call. A pattern that could never match
anything fails startup rather than reading as a lock you do not have. Two checks decide that: the
shape (no dot, whitespace at either end, an empty segment, a * sharing a segment with other
characters, or a * anywhere but the last one), and then the vocabulary, read off the sections and
keys this install really has, which is what refuses agnets.*, Agents.*, and
security.confirm_tool. A name only you can invent is deliberately not checked: agents.<name> and
scheduling.task.<name> are sections you create, so locking one before it exists stays legal.
Removing agents.* genuinely
unlocks agent tables: update_config can now resolve an agent's keys, and dry-runs validate_agents
before saving so a write that would break the next startup is refused. That dry run reads the file
rather than the running session, the agent tables, the [assistant].agent naming the entry agent, and
the [[mcp.server]] entries alike, so two writes that are each fine alone cannot combine into a config
the next startup rejects, and a write naming a server add_mcp_server connected this session is
accepted rather than refused as an unknown toolset: the entry is already on file, so the next startup
registers the name even though the running registry never learned it.
read_config opens with the policy in force, and a refusal names the pattern that matched.
- A default model, with per-agent overrides. [assistant].model is the model every agent runs on;
an agent that names its own [agents.<name>].model runs on that instead. Resolution is per agent and
never inherited down the delegation graph, so a delegator that pins a model does not drag its workers
onto it -- a worker declaring nothing runs on the default like any other undeclared agent. A declared
model that AIMU cannot resolve fails startup naming the table it came from, rather than surfacing
later as a failed spawn. Both are read once, at startup: no live client is ever rebound to another
model, which is why the model is not a runtime setting. [assistant].thinking follows the same
shape for reasoning effort (below).
[assistant].model is optional, and leaving it out is supported rather than merely tolerated: AIMU
resolves a default from $AIMU_LANGUAGE_MODEL, or a probe of the local servers when that is unset,
which is what lets one config.toml be shared across machines serving different models.
AssistantConfig.default_model performs that resolution once per process and model_for falls back
to it, so model_for is total: it answers with a string whatever the file says, and nothing
downstream has to reconstruct the default from somewhere lossier. See the endpoint entry below for
what "somewhere lossier" cost.
- A default reasoning effort, with per-agent overrides. [assistant].thinking is the effort every
agent runs at; an agent that names its own [agents.<name>].thinking runs at that instead. The values
are AIMU's own four: unset sends nothing (each model keeps its own behavior), false asks the model
not to reason, true reasons at the model's default effort, and "low" / "medium" / "high" request
a level. Startup-only and not a runtime setting, for the same reason the model is. An unrecognized level
fails startup naming the table, since AIMU would otherwise raise on it only once a request was built,
and "xhigh" is a plausible typo (it is Qwen's own effort ceiling).
Resolution tests "is it declared" rather than "is it truthy", which the model can afford and this
cannot: thinking = false is a declaration that has to be able to override a "high" default. And
unlike the model, the resolved value is written into every sub-agent spec rather than only a declared
one -- AIMU falls a missing spec model back to the spawn tool's own, but a missing spec thinking is
simply None, so an undeclared worker would skip the default rather than inherit it. The /plan
workflow's independent reviewers run at the [assistant] effort too, being no agent's table.
Two caveats it is worth knowing: a level is advisory on a model whose card declares no effort-level
support (AIMU warns once and reasoning is still on), and false additionally selects a card's
instruct-mode sampling profile where it declares one -- only the Qwen 3.5/3.6/3.8 cards today. A
generation parameter set below still applies over that profile; it is only a parameter nobody set that
the profile switch decides outright.
Needs aimu>=0.17.0, which added the agent_types spec key the per-worker half rides on. An AIMU that
predates it ignores an unknown spec key silently, so a per-worker effort would simply not apply with
nothing raised; that release also closes a spec's keys to a published SUBAGENT_SPEC_KEYS, which is
what the startup preflight now probes, since the key itself is a dict entry no probe can see.
A turn may also ask for its own effort, which beats both tiers for that one turn: the web composer's
Think picker and the CLI's /think. The request rides ChannelMessage.metadata, so it is a property of
the message rather than a setting anything stores, and it reaches the entry agent's own run alone: a
spawned worker keeps its table's effort, and a workflow turn ignores the request rather than applying it
to agents the user did not choose for. What the turn actually ran at is what metadata.thinking records.
- A default tool-loop cap, with per-agent overrides. [assistant].max_iterations is the most model
calls any agent's turn may make; an agent that names its own [agents.<name>].max_iterations runs
under that instead. Both default to 10, which is also AIMU's own default. The count is model calls
rather than tool calls: the initial turn, every tool-follow-up turn, and any continuation nudge all
count against it, and AIMU's forced wrap-up (one call with tools disabled, run after the cap so the
agent still answers from what it has gathered) does not. That is why hitting the cap reads as a thinner
answer rather than a truncation error, and it is exactly the case a dial was wanted for: a search-heavy
worker is what feels it, since a run spending every round on tool calls is the one still
mid-investigation when the cap lands.
Resolved per agent and never inherited down the delegation graph, like model and thinking: raising
a delegator's cap buys it more rounds of delegating, not more rounds inside each worker it spawns. Both
spawn tools and compose_subagent's are built with the global default rather than the delegator's
resolved cap, which is what makes that hold rather than merely describes it.
Rejected at parse time at both tiers unless it is an integer of 1 or more: 0 is a loop that makes no
model call at all, and bool is an int subclass, so an unguarded max_iterations = true would have
been read as a cap of 1 rather than rejected. Startup-only and not a runtime setting, for the same
reason [assistant].model is: nothing re-caps a live agent's loop.
The per-agent tier needs aimu>=0.28.0, which added the "max_iterations" sub-agent spec key it is
written into; the global tier rides a factory argument AIMU has had since 0.12.0. Unlike earlier spec
keys, an older AIMU does not swallow this one in silence: its key set was already closed and is checked
when a spawn tool is built, so a checkout predating 0.28.0 fails at startup either way, and what the
preflight adds is a message naming the fix instead of a raw ValueError. See
the configuration reference and
the turn loop.
- A default tier of generation parameters, with per-key overrides. [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,
so naming only temperature still inherits the default's context_length rather than replacing the
whole table. AssistantConfig.generation_for(name) is the single resolution, per agent and never
inherited down the delegation graph, and it is empty by default -- the normal case, since a key this
never sets stays absent from the request and a model card's own tuned sampling profile is what answers
instead. The result reaches three places: the entry agent's client (build_model_client), each
spawned worker's spec (the resolved value, like thinking, so an undeclared worker still inherits
the default rather than skipping it), and the two planning reviewers, which get the [assistant] tier
only, being no agent's own table. Startup-only and not a runtime setting, like the model and the effort, and
[assistant.generation] is the first dotted sub-table config/file.py's section loader handles, which
is what lets a schema entry per parameter serve it instead of the flat-key loop reading it as one
[assistant] key holding a table.
context_length has a second job, and it applies on every backend. A spawned worker now trims its
own messages before each model turn to fit the window declared for it, so a long delegation bounds its
own growth instead of failing once the window fills. core.agents.compaction_for_window builds the
trimmer from the resolved context_length and build_agent_specs writes it into each worker's spec,
which means a worker declaring its own window compacts to that one and an undeclared worker to the
global one, never to its delegator's. Three quarters of the window is left for messages, since the
same window has to hold that worker's system message, its tool block, and the reply, and
trim_messages counts only the messages; a fraction rather than a subtraction of those three, because
two of them cannot be measured before the turn that needs them and a subtraction of guesses goes
negative on a small window, which would stop compacting exactly where a window fills soonest.
Declaring no context_length leaves it off: nothing else states a window (no client reports the one it
is talking to), and guessing one would mean rewriting a worker's messages against a number nobody
wrote down. What a worker with no declared window does instead is AIMU 0.31.0's doing rather than
Kokua's: a spawn that runs out of context now returns a tool result naming the sub-agent's window as
the one that filled, where it used to hand the parent a message about "the conversation" that the
parent read as its own and stopped delegating over. The conversation you are in is never trimmed,
whatever you declare: it is yours to read and Kokua stores it, so dropping from it would delete history
still on screen, silently and for the rest of that conversation's life. A worker's messages are built
per spawn and discarded with it, which is what makes an automatic rewrite of them safe at all, and
tests/core/test_delegation.py pins the asymmetry. Needs aimu>=0.31.0, which added the compaction
factory argument and the matching "compaction" entry in SUBAGENT_SPEC_KEYS; it is also the surface
the startup preflight's capability probe now grips. A stored conversation,
a scheduled task's included, is never compacted: the user can still open and read it.
Two things worth knowing about the parameters themselves: max_tokens caps generated tokens while
context_length sizes the whole window prompt and output share, so a 32768 window with a 4096 cap
leaves roughly 28k for the system prompt, the tool block, and history -- and AIMU's own weakest tier
sets max_tokens = 1024 on Anthropic, the OpenAI-compatible family, and llama.cpp, which is low for a
turn carrying tool results. A parameter a backend cannot take is dropped 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. That warning is written to the rotating file log
(data/logs/kokua.log under your Kokua home) and nowhere else -- not the chat, not the terminal, and
not /diag, which reports what you declared rather than what the backend accepted -- so the log is
where to look when a parameter you set has no effect.
Needs aimu>=0.18.0 for the per-agent half, which added the generate_kwargs key to the agent_types
spec; an AIMU that predates it ignores the unknown key silently, so 0.18.0 replaces the 0.17.0 floor
above rather than sitting alongside it. This is a breaking change.
That AIMU bump also changes how four Ollama models sample, whether or not you set anything here.
It renames the portable repetition_penalty into Ollama's own repeat_penalty, so the
repetition_penalty = 1.0 that the qwen3.8:27b, qwen3.6:35b, qwen3.6:27b, and qwen3.5:9b cards
have always carried now reaches the wire instead of being discarded by the SDK, and Ollama's own server
default of 1.1 no longer applies: the repetition penalty is effectively off for those four. It is what
each card asks for, but it is a real change in their output. The example config's ollama:qwen3:8b is
not one of them -- that card declares no sampling profile at all, so nothing about it changes.
- /diag names the models, the reasoning effort, and the generation parameters in play: the entry
agent's first, then each agent that declares its own. None is a runtime setting and all are read only
at startup, so this is where a running session says what they are. With nothing declared, the model line
reports what AIMU resolved onto the live client, and the thinking and generation lines are omitted
rather than reading "unset" on every /diag; an agent that overrides the default generation tier shows
only the keys it declares, not the merged result, since what a table declares is what a reader checks
against the file.
- A stored conversation says which model produced its output, and how hard it thought. Each turn
records the model that answered it under metadata.model.<user_index> and its reasoning effort under
metadata.thinking.<user_index>, and each sub-agent card carries its own worker's pair, so a
conversation that spans a config edit -- or a turn whose workers ran on different models at different
efforts -- stays readable from the JSON alone. An effort of None is left out (it is the common case);
false is recorded, being a declaration rather than the absence of one. It is metadata, never a message key: AIMU strips only its own
inert keys before a request, and Ollama and OpenAI-compatible providers forward anything else
verbatim.
- Remote and custom model endpoints. [assistant] model accepts AIMU's extended
provider:model_id[@base_url][;flags] form, so Kokua can target a remote OpenAI-compatible server or
a model id not in AIMU's catalog, e.g.
model = "llamaserver:qwen3-8b.gguf@http://gpu-box:8080/v1". A worker's [agents.<name>].model takes
the same suffixes, which is worth stating because the two are checked by different code and had drifted:
a worker's model was parsed with a resolver reading only provider:model_id, so pinning a worker to the
endpoint the assistant itself runs on failed at startup.
Needs aimu>=0.20.0, which is what moved the floor there: earlier AIMU resolved a spawned agent's
model string with that same narrow resolver, so a documented endpoint on [assistant] model killed
every delegation (Provider 'ollama' has no model id 'qwen3.8:27b@http://gpu-box:11434') while the
entry agent ran on it happily, and a fix would still have dropped the endpoint had it only widened the
parse: no resolved model enum can carry one, so the sub-agent would have gone on talking to the provider
default.
- The endpoint reaches every sub-agent, including when the default comes from the environment. The
paragraph above ends on the reason a widened parse would not have been enough, and Kokua had the other
half of exactly that bug. With [assistant].model unset and the default arriving through
$AIMU_LANGUAGE_MODEL (ollama:qwen3.8:27b@http://gpu-box:11434), five places recovered the default by
reading it back off an already-built client, as config.model or agent.model_client.model. A client
reports a resolved Model enum, which names a catalogued id and carries nothing else, so the @base_url
was gone before any of them saw it: spawn_subagent's delegate, a nested delegate one level down,
compose_subagent, the aimu_agents prebuilt orchestrators, and both /plan reviewers were each built
against the provider default while the entry agent talked to the override. Where a local server happened
to be running it produced answers from the wrong model with nothing reported anywhere; where none was, it
surfaced as All connection attempts failed from a tool call inside a turn that was otherwise working.
All five now ask config.default_model, and none of them may consult a live client. Needs aimu>=0.21.0,
which exports resolve_default_text_model: AIMU's public default resolver was the enum-returning twin,
which by its own docstring cannot represent an endpoint, and the string resolver its documentation told
callers to use was importable only from aimu.models._internal.
Two things fell out of the fix. build.model_label no longer takes a client, so /diag and the stored
per-turn record show ollama:qwen3.8:27b@http://gpu-box:11434 rather than OllamaModel.QWEN_3_8_27B --
the string you wrote, not the enum it became. And the test suite pins a default of its own
(tests/conftest.py's pin_default_model), because resolving through AIMU reads a .env found by
walking up from the working directory and otherwise probes a local server over HTTP; a developer's real
~/devel/.env did leak into a run before that fixture existed.
- All state under one directory you own: $KOKUA_HOME, default ~/.kokua, replacing the example's
reliance on aimu.paths.output. data/ holds content only -- conversations, memory, documents,
images/, downloads/, logs/. Images and downloads live in their own folders so the binary files
never disturb the DocumentStore, which scans the documents folder as text. Scheduled tasks are the
one declared exception: they live in config.toml itself, as [scheduling.task.<name>] tables, not
under data/.
MCP¶
- Remote MCP servers from a startup
--mcp <url>(repeatable) or[[mcp.server]]tables inconfig.toml, plusadd_mcp_server/remove_mcp_serverat runtime. Servers connect before the first agent is built so config-declared servers reach the agents that name them, and a runtime add or remove rebuilds each live agent'sspawn_subagentso its workers pick the server up or drop it immediately. - Configured servers connect concurrently at boot, and still attach in
config.toml's order. A boot with two remote servers used to pay the sum of their handshakes; the handshakes now overlap, whileconnections(and so which server wins a duplicate tool name) stays in declaration order regardless of which one answers first. Each server's tool fetch still happens inside that same ordered attach pass (onelist_tools()round trip per server), so it remains serial; only the handshake itself was made concurrent. A lock serializes every OAuth-mode connect, not only an interactive authorization: fastmcp decides insideconnectwhether a stored token is reusable, so the lock cannot tell in advance which kind it is about to hold, and two OAuth servers with valid cached tokens still do not overlap each other. When the held call really is an interactive authorization, its callback listener binds one pinned port, so two servers both needing a fresh authorization at the same moment would race to bind it, and the loser's bind failure would read as that server being unreachable rather than the port collision it actually is; the wait there is however long the person takes to approve it, not network time, so a second server's link is not posted until the first server's flow completes. A bearer-token connect and an unauthenticated probe never touch the lock and still overlap fully, so the common shape of one bearer server plus one OAuth server keeps essentially the whole win. - Each configured server is a toolset, named by its
name.nameis required: it is how the server enters the one namespace an agent declares against, so a server nothing can name reaches nothing. A runtimeadd_mcp_server, and the startup--mcp <url>flag, both derive a name from the server's host and disambiguate it (shared logic) against every other name already claimed -- on file foradd_mcp_server, across the flag's own URLs for--mcp-- so neither can produce a config, or a set of servers in one run, the registry's collision check would reject. That derived name reaches no agent in the session that added it, since servers are not re-read mid-session; after a restart it reaches an agent declaringavailable = "all", and any other only once a human puts it in an[agents.*]table, which is locked by default: neither can grant itself the capability. A bearer-token server added at runtime is not written to the file, soadd_mcp_server's result now says nothing can use it and its token is not saved, and names the[[mcp.server]]entry withtoken_envthat would keep it. - Per-server bearer tokens via environment variables. Each
[[mcp.server]]takes a requiredurl, a requiredname, and an optionaltoken_envnaming the variable holding that server's token, read at startup so the secret stays out ofconfig.toml. Atoken_envwhose variable is unset logs a warning and connects tokenless rather than aborting startup. - OAuth:
add_mcp_serverstarts the OAuth flow for a server that signals its auth requirement with a non-standard response (a 400 plus "missing Authorization header", not only a 401/403). When the flow cannot complete because the server lacks dynamic client registration, it returns an actionable "provide a bearer token and add the server again" message instead of a rawOAuthRegistrationError, and its docstring tells the assistant to relay that, ask for a token, and retry withbearer_token. - The OAuth callback is placeable through
[mcp].oauth_callback_host/oauth_callback_port. FastMCP's defaults (loopback, a fresh random port per process) assume the browser runs on the machine the client does. When it does not, the provider redirects the approved browser to its own loopback, Kokua's listener never hears it, and the connection fails only when the flow times out minutes later, with nothing anywhere naming the cause. Pinning the port makes an SSH forward possible while keeping the loopback redirect URI that OAuth providers accept; setting the host suits a provider that takes a non-loopback one. Pinning also fixes a single-machine case, since the client registration is cached across restarts while a random port is not, so a re-authorization in a later process could present a redirect URI the provider had on file under the old port. The authorization message now names the callback address it will use, because a redirect landing on the wrong machine is otherwise invisible. - Boot logs the tools each server contributed, by name. This is the only record of a remote server's
tool list:
config.tomldoes not know it,--list-toolsetsruns before any connection so it can only name the server, andadd_mcp_serverreports tool names on a runtime add but a boot reconnect reported nothing. Without it, an agent that answered from its own knowledge rather than calling a server's tool gave you no way to tell whether the tool it needed was absent or merely unused. A server that connects and contributes nothing is named as such, since it otherwise looks identical to a healthy one. - The auth-challenge log line says what it is. Every non-bearer server rediscovers its auth mode through one rejected unauthenticated request on every boot, because no auth mode is persisted (which keeps a stored mode from going stale against a server that changed its mind). That step used to log "requires authorization; starting OAuth flow", which reads like the user is about to be prompted, when a cached token makes it silent -- misleading enough to send a reader hunting a token-persistence bug that was not there. It now reports a rejected unauthenticated request and an attempt with stored credentials. An authorization link still reaches the conversation only when no usable token exists, so the absence of one is the signal that token reuse is working, and a test pins that the challenge path itself posts nothing.
- No startup warning for a registered name no agent declares. There was one, covering a configured
MCP server as well as an installed third-party toolset, and it was dropped: telling a name the user
provisioned from one that merely ships needed a provenance rule spanning the whole namespace, and a
toolset nobody declares costs nothing to leave unnamed. The MCP case is the one that lost something
real, since a configured server connects, spends its token on the handshake, and is then reachable by
nobody;
docs/how-to/add-mcp-services.mdsays so plainly and says what to check instead. The distinct warning for aconfig.tomlsection whose owning toolset no agent declares is unaffected.
Images¶
- Attach images and the assistant reads them (needs a vision-capable model); it can generate them when
AIMU_IMAGE_MODELis set. In the web UI, attach via the composer's paperclip or by pasting, with thumbnails previewed before send and images rendered inline live and on reload; in the CLI,/attach. - Uploaded and generated images are stored content-addressed under
data/images/and served atGET /images/<name>. A conversation keeps only a short/images/<name>reference, so the session store stays small; the bytes are re-inlined as base64 only when a turn is sent to the model, since a localhost URL is not fetchable by the provider.
Payloads¶
payloads.pyis the same content-addressed shape asimages.py, for oversized text instead of image bytes. A payload is written underdata/payloads/named by the sha256 of its UTF-8 bytes (so identical text is stored once), served atGET /payloads/<sha256>, and referenced from session metadata as/payloads/<sha256>rather than held inline. The one caller today is the sub-agent reporter (see "An oversized tool response..." under Conversations and turns, above); other oversized text a future feature wants to keep out ofsessions.jsoncan reuse it the same way.scripts/migrate_subagent_payloads.pyis a one-off, deletable migration for asessions.jsonwritten before the cap above existed: it walks every stored sub-agent tool response, spills anything overRESPONSE_PREVIEW_CHARSto a payload file, and rewrites the entry to the same shape the live recorder now writes.--dry-runreports what would change without touching disk; a real run backs upsessions.jsonbefore its first write and is idempotent, so re-running it (including after an interruption) changes nothing further. A response the recorder itself could not encode (a lone surrogate from decoded binary content) is left inline exactly as the live recorder leaves it, counted in the report rather than raised, so one unreadable entry cannot abort a migration of the rest of a user's history.--prune-orphansis a separate pass, run only on request and never in a dry run, that deletes payload files no session references, and refuses a custom sessions-file argument since it always deletes from the one configured payloads directory. A real run (including--prune-orphans) requires--confirm-kokua-stopped: the script has no reliable way to detect a running Kokua from outside it, and a live instance writing to the same files at the same time can silently lose data that would not show up in the backup either, in two distinct ways, both named in the script's own docstring and--help. The reclaimed-bytes figure in the report is the actual JSON-encoded size delta (measured with the sameensure_ascii=TrueTinyDB's storage writes with), not a character count, since a character removed from a response could have cost up to six bytes on disk as a\uXXXXescape. The defaultsessions.jsonand payloads directory are resolved by loadingconfig.tomlthe waykokuaitself does, so a configured[paths] data_diris honored rather than silently scanning the built-in default. Asessions_fileargument that is not the configured file needs--payloads-dirtoo for a real (non-dry-run) run, or it is refused: writingresponse_refvalues under the default payloads directory while migrating a different file would point every migrated card at blobs the app reading its own configured file would never find.
Security¶
Kokua can author and run Python and shell scripts as real subprocesses with your user privileges and no sandbox, and can connect to remote MCP servers and run whatever tools they expose. Real capability is the point of a personal assistant, but it means a prompt-injected or mistaken model can run arbitrary code on your machine. Run it only with a model, inputs, and MCP servers you trust. The CLI prints a notice on startup.
- Security policy.
SECURITY.mdstates the reporting channel (GitHub private vulnerability reporting), which barriers a report is about (the approval gate,[security] locked_config_keys, agent capability boundaries, the web front end's download and image routes, the[email].torecipient lock, and secret disclosure), and which behavior is the product working as documented (a model you configured running code you approved). - Tool approval. Configured risky tools require confirmation before each call -- terminal
y/N, web Allow/Deny -- built on AIMU'sToolApprovalgate. The default set isskills.add_skill_script,mcp.add_mcp_server,compute.execute_python,compute.run_command,fs_write, andconfig.update_config; adjust with[security] confirm_toolsor--confirm-tools(empty disables). Proactive and backgrounded turns auto-deny gated tools regardless, so a full-access tool is never run unattended, and a prompt only ever appears for the conversation you are currently viewing. A backgrounded turn's auto-deny raises an alert card naming the tool and linking the conversation: the deny itself is unchanged, but a refusal recorded only in a transcript the user is not reading told them nothing about their own turn losing a capability by their switching away. A proactive firing denies silently still, since it reports itself when it ends and a task calling a gated tool every firing would otherwise raise a card every firing. Parking a backgrounded call for the user to answer later, instead of denying it, is #7. The reply is routed through the single channel reader, so it is safe alongside/stop. Approval and plan review share one lock-guarded pending slot, so two concurrent requests cannot overwrite each other. - A gate names its toolset, and one that would gate nothing fails startup. A
[security] confirm_toolsentry is<toolset>for every tool a capability provides,<toolset>.*for the same thing said with a wildcard, or<toolset>.<tool>for one;*anywhere else is refused. A reservedcoreprefix holds the two tools no toolset provides,spawn_subagentandactivate_skill, and a skill's scripts sit under the skill, which is also its toolset name. What the prefix buys is a gate that can hold back a whole capability in one line, and an error that can point at the right one; the gate itself still matches a bare tool name at call time, resolved from these entries once every agent is wired. An entry that gates nothing holds nothing back, and the symptom is a prompt that never comes, which nobody notices, so every such entry is named at startup with its near misses: a bare tool name with no prefix (the error gives the form to write), a real tool filed under the wrong toolset, a toolset no agent declares, and a toolset that provides no tools are all refused. The vocabulary is every tool the config builds, not just the entry agent's:compute.execute_pythonresolves because[agents.coder]declarescompute. A tool that arrives later (from a runtimeadd_mcp_server, or built only bycompose_subagentout of a toolset no agent names) cannot be listed ahead of time, and the error says so.coreis refused as a toolset name at registration, so the reservation cannot be shadowed by a plugin. - A model reviewer may answer an approval prompt in your place (
[security.auto_approval], off by default). Enable it, name the gated tools it may answer for, and name one or more[reviewers.<name>]tables, and a gated call is shown to a fresh, context-free reviewer just before the prompt it would otherwise raise. The reviewer answers three questions (is this call in scope for what the user asked, is it reversible, does the text it was shown look like it is trying to instruct the reviewer) and writes one sentence;core/auto_approval.py'sdecidecomputes the outcome from those answers, so the policy is code rather than a model's verdict. It is convenience, not a boundary, and one property is what keeps that honest: a review can only turn a prompt into an approval, never widen what you could have approved yourself, because there is no deny verdict anywhere in it. Nine paths fail closed onto the ordinary prompt (no turn budget open, the budget spent, a packet field over 2,000 characters, a field forging the<untrusted>fence, a timeout, a refusal, an unreachable reviewer, an answer of the wrong shape or the wrong types, and a catch-all that logs its traceback), and an escalation carries the reviewer's sentence so a person deciding gets its read for free. The shipped eligible list namescompute.run_command,compute.execute_python, andfs_write. Both compute tools are there deliberately: a shell already reaches unrestricted Python by a shorter route, so excludingexecute_python, which runs in a subprocess under a timeout with an import allowlist, restricted builtins, and no view of the process environment, would have withheld nothing and only routed the same capability through the weaker of the two. Both outcomes are reported as a card naming the tool, the arguments, the model, and the reason, since an auto-approval nobody saw is a decision made on the user's behalf in silence. Both are logged as well, atINFO, because a card is a channel frame and not part of the saved transcript: after a reload the log underlogs_pathis the only thing that distinguishes a gated call a reviewer waved through from one the user approved. An unattended turn is excluded twice over: it auto-denies before a reviewer is consulted, and it opens no per-turn budget, so a call reaching the reviewer anyway would fail closed.timeout_seconds(default 10) andmax_per_turn(default 5, spent when a review is attempted) bound what it can cost. What it does not do is written down as plainly as what it does, in docs/explanation/auto-approval.md: no sandbox under it, no shell parsing in front of it, an argument-scoped allowlist would remove most prompts with no model at all, and the reviewer's own token cost is not counted (AIMU's structured path returns before a turn event is emitted, so no metrics sink can see it). [security].never_auto_approve: tools no reviewer may ever approve. Ships asconfig.update_config,skills.add_skill_script, andmcp.add_mcp_server, and naming one of them in[security.auto_approval].toolsis a startup error. These three change what may act later rather than acting once, so the arguments a review was about stop constraining anything the moment the write lands: a wavedupdate_configcan widen this very list, a wavedadd_skill_scriptwrites a script that becomes a tool, and a wavedadd_mcp_serveradds a whole source of tools. Reviewing a call like that answers a question about one action when what it grants is a capability. Emptying the list is a hand-edit you may make, like every control in[security].[reviewers.*]is locked by default. That lock pattern sits besideagents.*in[security].locked_config_keysfor the same reason:update_configis a tool the assistant holds, and a reviewer's prompt and model decide how the assistant's own work is judged, including whether a gated call reaches you at all.- The reviewer toolset needs no gate. An autonomous critic cannot pause to ask you mid-review, so
rather than exempting it from the gate the reviewer is given nothing the gate exists to cover: web
lookup,
calculate, and the clock. A test pins this against the shippedconfirm_toolsdefault, so adding a name there fails the suite until the reviewer's toolset is re-checked. - Injection crosses conversations. The assistant can read every saved conversation, and a transcript
is untrusted text, so an injection that lands in one conversation can influence what the assistant
does in another. The three read tools are ungated by default, since they only read and gating them
would make an unattended scheduled run that reads history fail silently; add
conversations.read_conversationandconversations.search_conversationsto[security] confirm_toolsto change that.export_conversationis ungated for a different reason: it changes no conversation, and the reason to gate it is what it leaves behind rather than what it reads, a whole transcript in the clear underdata/downloads/.
Diagnostics and error reporting¶
- An AIMU too old to run Kokua fails with an instruction, not a traceback. The
aimu>=0.31.0requirement covers a normal install, but a development checkout installs the sibling../aimueditable and that checkout can sit on an older commit.kokua.aimu_compatpreflights both the version floor and one capability probe -- the version string of an editable install says what its branch claims, not what its code contains -- and names both fixes: update the sibling, oruv sync --no-sourcesto take AIMU from PyPI instead. The probe tracks the newest surface Kokua leans on and takes that surface's shape, which is why it has moved several times and has been a set-membership check, a plain name lookup (theresolve_default_text_modelexport, the shape to hope for: the capability is the exported name), and a signature check --SkillManager(include=...), thenSkillAgent(script_env=...), thenaio.WebChannel(stream_thinking=...), the rename that carried the default flip Kokua relies on for reasoning and tool calls to reach a front end at all. A plain name lookup onaimu.tools.builtin.make_command_tool, the factory behind[compute] command_env_passthroughwhere the capability and its handle are the same object, was the surface until 0.25.0, when it gave way to a signature check again:make_async_subagent_tool(events=...), the parameter that lets a spawned sub-agent's model turns reach the sink the delegating turn opened. AIMU 0.27.0 moved it to a plain name lookup once more, onaimu.aio.ModelRefusalError, and was the first floor whose reason and whose probe were different capabilities: the floor moved for 0.26.0's fix to AIMU's forced wrap-up (it no longer appends the wrap-up prompt on top of an un-dispatched tool call, which Anthropic rejected outright and which made search-heavy sub-agents fail rather than answer), and that fix had no handle a probe could honestly grip: a private method on a private class is exactly what a later refactor renames, and a probe pointed at one becomes a wall in front of a newer, working AIMU. 0.28.0 was the floor until 0.29.0, and for once reason and probe were the same capability again:StreamingContentType.CONTINUINGis the phase a streamed driver yields before a round the loop injected on its own rather than one the model asked for, the same chunk a loop marker reads to say which injection it was (see "A loop marker names which injection it was" under Front ends, above). The probe was a membership check again, the second time (SUBAGENT_SPEC_KEYSwas the first), readingStreamingContentType.__members__rather than a barein, becauseinon an enum compares values on Python 3.12 and raisesTypeErroron 3.11, where the capability here is a member's name, not its value. 0.28.0's other capability, the"max_iterations"entry inSUBAGENT_SPEC_KEYSbehind a per-agent tool-loop cap, was the floor's job instead: that key set is closed and checked when a spawn tool is built, so an AIMU predating 0.28.0 raisedValueErrornaming it at startup with or without a probe, and the one slot went to the capability that would otherwise fail silently. 0.29.0 was the floor until 0.30.0, and the probe returned to a plain name lookup, the third time (resolve_default_text_model, thenModelRefusalError, then this):aimu.sessions.SessionStore.list_summaries, a session store's own answer to "every stored conversation's title, timestamp, and message count, without its messages", which is whatConversationBook.summaries()calls so the sidebar, task ownership, and the startup pointer stop paying one whole-file parse per stored conversation just to draw a list of titles (see "Conversations and turns" below). The one wrinkle on the usual name lookup: the symbol lives onSessionStore, not at module scope, so the probe resolves the class first and looks the method up there. What it cannot see is whether a concrete store still bothers to override the default: the ABC's ownlist_summariesis correct but slow,TinyDBSessionStore's override is what makes it fast, and a name lookup on the ABC is satisfied by either, so a store that stopped overriding it would still pass while paying the old cost in silence;StreamingContentType.CONTINUINGbecame the floor's job then, the same way every earlier probe surface became the floor's job once a newer one took the slot. 0.30.0 was the floor until 0.31.0, the first one moved by a rename, and its probe was a name lookup for the fourth time:aimu.tools.builtin.get_web_content, which replacesget_webpage. The name is not the point. The old tool never asked what it had downloaded, handingresponse.textto an HTML stripper, and sincerequestsdecodes.textwitherrors="replace", a PDF behind a URL became megabytes of replacement characters that the stripper passed through almost whole and into the model's context. Kokua hands that group out unchanged (toolsets/web.pyislist(builtin.web), andworkflows/critics.pymounts the same group for the reviewer), so an older sibling poisons a context with nothing raised anywhere. Group membership, not the bare name, is what Kokua depends on, and the stricter check was declined the wayrun_command's was at 0.24.0 and with even less to gain: the rename moved the function and the group's entry for it in one upstream commit, so no checkout exists where the name resolves and the group still holds the old tool.list_summariesjoined the floor's job then, and the behavior behind that name (a checkout could exportget_web_contentand classify nothing) was what it left there in turn. 0.31.0 was the floor until 0.32.0, and it is the one release where the probe passed over a newer handle on purpose. The probe is a membership check onSUBAGENT_SPEC_KEYSfor"compaction"-- the second time it has gripped that same set for a different member, which restates the point that set keeps making: a published set's presence proves nothing and only its contents date a checkout.builtin.selectlanded earlier in the release and would have been the obvious grip, a plain name lookup on a functiontoolsets/fs.pycalls directly. It was passed over becausesave_document's read-before-replace guard landed two commits later with no handle at all (a digest set private to onemake_document_toolscall), while theread_documentwindowing that makes an unguarded save destructive landed earlier: a sibling parked in between passes aselectprobe and still cuts a 3,000-line document to 51 lines."compaction"is in the release's last functional commit, so gripping it dates a checkout toselect,unscoped, the writers inbuiltin.fs,edit_document, and the guard together -- the reverse of the trade 0.20.0'sendpoint_kwargshad to accept. What it leaves to the floor is the contents ofbuiltin.unscoped: the wholefs/fs_writesplit rests on that group holding both writers, and asking it needs a membership check over a list of callables matching on__name__, declined for the third time and asserted intests/test_aimu_compat.pyinstead.get_web_contentjoins the floor's job. 0.32.0 was the floor until 0.33.0, for two capabilities that are both about authoring skills, and it is the counterpart to the release before it: two handles again, and that time the newest one was right. The probe was a plain name lookup onaimu.skills.make_skill_update_tool, the factorytoolsets/skills.pycalls to hand an agentupdate_skill, without which a skill's prose is write-once (author_skillrefuses to clobber,add_skill_scriptwrites scripts alone, so the assistant could fix a skill's code forever and never a word of its text). The other capability is the one a user feels and it landed earlier in the same branch:add_skill_scriptno longer attaches a script by rewriting the wholeSKILL.md, a rewrite that 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. A skill fromkokua skills installshed itslicenseandcompatibilitylines the first time an agent attached a script to it, and again on every later fix, with nothing raised anywhere. It has its own handle inwrite_skill_script, and the probe passed over it because the later name dated a checkout to both. What that left to the floor was the behavior behind the fix: a name lookup never asks what a function does, and Kokua calls neither function itself, sotests/test_aimu_compat.pyauthors a skill with optional frontmatter in a temp directory, attaches a script, and reads the file back."compaction"joined the floor's job. 0.33.0 was the floor until 0.34.0, foraio.SkillAgent.run'ssteeringparameter: 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. The probe was a signature check, the fifth time (SkillManager(include=...),SkillAgent(script_env=...),WebChannel(stream_thinking=...),make_async_subagent_tool(events=...)), and it gripped the subclass rather than the base class on purpose:aio.Agent.run(steering=...)landed in the first of the release's steering commits, so gripping it would have dated a checkout to one drain site on one driver and nothing past it, whileaio.SkillAgent.runcould not delegate tosuper().run()and so repeatedAgent.run's whole parameter list by hand -- a parameter added to the base method reached the subclass only when someone remembered to copy it across, andsteeringwas not remembered until a whole-branch review caught it, aftercompactionandscript_envhad each needed the same hand-copy before it. Kokua's entry agent is anaio.SkillAgent, so gripping the subclass dated a checkout to every steering commit in the release and every fix after it, where anAgent-shaped probe would have passed on an AIMU where Kokua's own first steered turn raisedTypeError. 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.Inboxjoins the probe's job. Today the floor is 0.38.0:calculateevaluates an allowlisted syntax tree rather than an escapableeval, and a spawn leads a worker's result with a note when its round cap ended it. Neither offers a symbol for the startup check to look for, so the check stays where it was and the floor alone covers both. Before it the floor was 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; its absence raises loudly, so the startup check stays where it was. Residency rides the same release'sToolGroup, and the check skips it for the same reason: the core imports it at module scope, so an AIMU without it fails at import, after the check and before any conversation. Before it the floor was 0.36.0: every run event carriesrun_idandparent_run_id, which the request records behind "what the model saw" are keyed by, and the startup check is a signature check onaimu.events.RunEventfor itsrun_idfield. Before that it was 0.34.0, the first one a rename alone moved: AIMU renamed its mid-turn-message seam fromSteeringtoInbox, with no legacy path, and taught it which agent is opening each reader.Inbox.reader(agent=...)is called once per run, at its start, andagentis 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 hasSteeringwhere this one hasInbox, so the two names cannot both resolve the way an old and a new export usually can side by side. This renamed only the names Kokua uses to talk to AIMU (everyagent.run(steering=...)call carrying the entry agent's own source becomesagent.run(inbox=...), the"steering"spec key becomes"inbox",StreamingContentType.STEERINGbecomesStreamingContentType.INBOX) and widened Kokua's three reader factories to accept theagentlabel AIMU now passes positionally, since_BaseToolLoop.__init__rehearses that exact call and raisesTypeErrorwhen it cannot be made. Kokua's own vocabulary for the seam is a later change. What this probe cannot see, and what the floor covers alone, is larger than usual here: whether the loop actually passes theagentargument on both surfaces and both drivers, whether theINBOXstreaming phase is emitted whereSTEERINGused to be, whether the spawn path honors the"inbox"spec key, and whether 0.33.0's host-boundary guards survived the rename.aio.SkillAgent.run'ssteeringparameter joins the floor's job, and the parameter it grips today is spelledinbox. It covers one surface at a time by design; every earlier release's capabilities are the floor's job, andtests/test_aimu_compat.pypins the floor againstpyproject.toml's specifier so the two halves of that one decision cannot drift. It pins the floor against the prose too: the README, the docs index, this changelog, the architecture page, and CI's own comments each tell a reader which AIMU to have, none of them is reachable from the specifier, and every one had fallen behind it, by as much as seven releases in CI's case. What is held to the current floor is the present tense ("AIMU X or newer", "Today the floor is X"), never the history beside it, which is correct as written and would otherwise have to be rewritten on every bump. - A failed model request reports its actual cause.
kokua.core.errors.describe_errorwalks the exception's__cause__chain to the root, so an unreachable local model server is diagnosable from the chat itself ("The request couldn't reach the model server: ModelConnectionError: Connection error. (caused by ... Connection refused)"). Reactive and proactive turns both surface the detail. - A refused request reads as refused, not as a failure. A model's safety classifiers declining a
request is not a broken thing to go looking for, so it does not share the generic failure wording.
Anthropic returns a refusal as HTTP 200 with
stop_reason: "refusal"and no content, which AIMU raises asModelRefusalError(without that, an agent loop cannot tell it from a degenerate turn and the continuation nudge burns the run's iterations getting refused again).core/turns.pybranches on it at all three of the sites that already special-cased an unreachable server: the interactive turn, the scheduled task's report, and the reason recorded on the run itself. The message leads with the provider's own classifier category, since that is the part that tells a user which rephrasing might work, and both the category and its explanation are routinely absent, so neither reaches the text as a literal "None". Logged atINFO, not with a stack trace: the model was reached and answered in the time it took, so there is no fault to file against Kokua. - Model resolution failures surface cleanly. With no
modelset, AIMU resolvesAIMU_LANGUAGE_MODEL, else the first already-running local model (Ollama, then a local OpenAI-compatible server), and never a cloud model. When nothing resolves, or the model string is invalid,Assistant.start()raises aModelClientErrorcarrying AIMU's actionable message: the CLI prints it and exits non-zero, the web UI shows it in the chat. Because agents are built lazily, the web UI's new / select / delete controls can hit the same error, and report it in the chat rather than tearing down the WebSocket. Any failure between taking the web front end's single-connection guard and finishing with it releases that guard, so a later connection is not wrongly refused as "busy in another tab" -- a diagnosis that would name the one thing that was not wrong. - An
[agents.*]table that no longer resolves stops the web front end at startup. The web front end builds its assistant per connection, so an unknown toolset name (a toolset renamed, or moved out to a skill that is not installed) used to surface only as a WebSocket that closed: the server came up, served the page, and refused every connection, leaving the browser able to say nothing but "Disconnected."build_appnow validates the agent tables while building the app, sokokua --frontend webandkokua-webboth print the offending name and exit 2, which is what the CLI front end already did. AConfigErrorreaching a connection anyway -- the registry is rebuilt per connection, so a skill deleted from disk under a running server is one -- is shown in the chat, like a model-client failure. - Hang observability.
/diagreports the in-flight turn, elapsed time, whether the turn gate is held, and dumps a wedged turn's async stack. It is handled in the serve loop without taking the gate, so it answers even when a hung turn holds it. Diagnostic logs go to a rotatingdata/logs/kokua.log(5 × 2 MB) with turn-lifecycle lines, level set by[logging] level.faulthandleris enabled, sokill -USR1 <pid>dumps all thread stacks. - The MCP SDK's own errors are logged where they can be read. The
mcpSDK logger has no handler of its own and propagates, so everylogger.exceptionit raised fell through tologging.lastResort, which writes to stderr at WARNING+ with no timestamp, no logger name, and no server URL. Reconnecting several servers at boot runs their OAuth flows one at a time behind a single callback port, so a failed client registration on one server printed a bare traceback to the terminal directly beneath a different server's authorization URL, reading as that flow having failed when it had in fact succeeded.logging_setupnow attaches the rotating file handler tomcpalongsidekokuaandaimu, which suppresseslastResort, and nothing is lost from the console:kokua.mcp.serversalready logs the same failure to that file with the server URL and the full traceback.fastmcpis deliberately left alone, since it configures its own stderr handler at import, setspropagate = False, and removes whatever handlers are already on the logger. - A slow control cannot wedge the web UI.
frontends/web.py'spumpis two tasks over one queue: one reads the WebSocket, one applies frames in arrival order. Reading has to continue regardless of how long a control takes, because a disconnect is a read and that reader is the only task positioned to notice one. Applied inline, as they were, a control waiting on the turn gate stopped the reading with it, and the result was worse than slow: the disconnect behind it was never seen, so the single-connection guard was never released, the reload that would have recovered the page was refused as "busy in another tab", and/stopand/diagwere unreachable for the same reason, since both arrive through that same loop. 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.deletetook the gate's exclusive hold, which drains every in-flight turn, so deleting one conversation waited out an unrelated conversation's turn. It takes that conversation's owngate.turninstead, bounded by thecancel_turnthat precedes it. Whichever half of the pump finishes first ends the connection, in both directions: an error while applying ends the reader too, which would otherwise queue frames into a drain nobody reads. - A reload reconnects on the first try. The reloaded page used to connect while the connection it
replaced was still tearing down (closing each remote MCP session is a network round trip), and the
one-connection guard, still held, refused it as "busy in another tab"; a second reload worked only
because it landed later. The guard now tells a closing holder from a live one: a connection arriving
during a teardown waits for it, bounded by
TEARDOWN_WAIT_SECONDSinfrontends/web.py, and one that outwaits that bound is told the previous connection is still closing. A second tab open beside a live one is refused at once, as before.
Known limitations¶
- A gated tool call inside a sub-agent prompts at the top level, not inside its card (#20).
- A tool result travels whole and only the DOM clamps it, so the web
historyframe grows by every tool result in the conversation and is re-sent on every conversation switch, not only on reload. A conversation with dozens of large results (PDF extractions, email fetches, big MCP responses) makes that frame correspondingly large (#19). - A message can bind to the wrong conversation if you switch immediately after sending. A reactive turn binds to a conversation when the serve loop dequeues its message, while the web front end applies new / select / delete in a task of its own, so a control applied in that sub-millisecond window sends the reply to the conversation switched to. Not reachable by hand; surfaced deterministically by the Playwright suite, which waits for the turn to be observably running before switching (#3).
- A slow control still delays the controls behind it,
/stopincluded. The web front end applies frames in one ordered task, because selecting a conversation and then sending a message has to bind the message to the conversation just selected, and ordering is what buys that. So a control that waits on the turn gate holds up whatever follows it, and/diag's claim to answer past a held gate does not extend to that case: it arrives behind the control. This is a delay rather than a wedge, though. Reloading the page always works, and the reload tears the blocked handler down with the connection (#18).
Internals and development¶
src/kokua/is grouped by subsystem --core/,config/,workflows/,mcp/,scheduling/,channels/,frontends/,toolsets/-- andtests/mirrors it exactly.Assistantis a composition root and the serve loop; it delegates toConversationBook(store + agent cache + active pointer),TurnRunner,HumanGate,SettingsApplier, andChannelUI.ChannelUIis the one adapter that probes each optional channel capability once and gives every rich frame a documented fallback, so there is noisinstance(channel, WebChannel)incore/orworkflows/.channels/protocol.pydeclares the rich surface for documentation and typing.- Why Kokua exists, and the six principles that serve it, are in docs/explanation/design-principles.md: the project's purpose is that people can learn how agentic systems work by reading, running, and extending a real one, and each principle carries the code that backs it. The architecture narrative is in docs/explanation/architecture.md.
- Task-oriented guides for the three ways to add capability are in
docs/how-to/: set up a toolset (the namespace,
declaring an agent, writing a toolset), add a skill, and
add an MCP service. All three converge on the same rule: a capability
is declared, never defaulted, and nothing reaches an agent until an
[agents.*]table names it, composing a worker for one task included, since that takes declaringcapabilities. - A how-to for installing a third-party toolset, docs/how-to/install-a-third-party-toolset.md, walks the plugin seam from the outside: installing the package, declaring it on an agent, giving it its own config section, and gating its expensive tool, with jobme as the worked example throughout.
- A "How agents work" catalogue teaches the mechanisms an agentic system is made of: each page
explains one in general terms first, then a transcript captured from a real run against Kokua, then
the code that produced it. Seven of the section's fourteen planned pages exist:
Get it running,
The turn loop,
Tool calling,
Capability is declared,
Context and memory,
Delegation, and
Agent messaging; the
section index names the rest. The fourteenth was not on
the original thirteen: the messaging page opens on the who-can-reach-whom matrix, because a correct,
general bus whose execution model permits only one direction teaches something about multi-agent
systems that a working feature would not have, and no page beside it can show that.
tests/test_docs.py::test_catalogue_pages_follow_the_templateholds every mechanism page to the same five##sections in the same order, so a page that drops or reorders one fails the suite instead of drifting unnoticed, andtest_every_catalogue_page_is_listed_in_the_indexholds the section index to the directory it indexes in both directions, since that index is the one file all fourteen pages have to touch. - Documentation site at saxman.info/kokua, built from
docs/with mkdocs-material and published by.github/workflows/docs.ymlon every push to main. The build is strict: a broken internal link, or a page missing frommkdocs.yml'snav, fails CI.tests/test_docs.pycovers what a site build cannot, checking that every link out ofdocs/names a path that exists. - A mock-only default test suite. No model, no network, no keys. This is why the model client is
injectable and the builders are free functions. Client-side page JS is
covered by an opt-in Playwright suite (
pytest -m e2e) driving the realindex.htmlin headless Chromium; it skips rather than errors when the browser or thewebextra is absent, and it does not gate the default suite. - CI lints, runs the suite on Python 3.11-3.13, and separately builds the sdist and wheel, checks their
metadata, and installs the wheel into a clean environment to run both console scripts -- an editable
install imports straight from
src/, so it cannot catch a missing package-data entry or a console script pointing at a moved function.