Skip to content

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-layout kokua package with two console scripts: kokua runs the selected front end (default cli), kokua-web is a convenience for kokua --frontend web. Both route through kokua.cli, so both run the AIMU preflight described under Diagnostics.
  • Plugin system (kokua.plugins). Front ends and toolsets are discovered through the kokua.frontends and kokua.toolsets entry-point groups. The built-in cli / web front 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-frontends and --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 whose build raises, 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 (the Toolset and Setting types, name resolution, and the LiveState / ToolsetContext a build draws on) is kokua.registry; assembling every provider into one namespace, validating an agent, and assembling its prompt is kokua.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.toml section whose owning toolset no agent declares is unaffected.
  • The stable public import surface is kokua.plugins, kokua.config, kokua.core, kokua.channels.web, and kokua.images. Everything else is internal and may move.
  • The web extra's two dependencies are capped below their next major: starlette>=1.0,<2 and uvicorn>=0.30,<1. Nothing else in a resolved install bounds either one, since mcp, sse-starlette, and fastmcp-slim all depend on a bare starlette, 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 line uv.lock resolves 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, and export_conversation. kokua export <id> --turn N writes 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 in Assistant._serve_channel beside /stop and /diag, not in channels/cli.py, and call the same new_conversation / select_conversation the 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 /conversations prints, the same fragment kokua export accepts; 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 /stop now 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 new ChannelUI.show_history and ChannelUI.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 /stop and /diag already 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 new ConversationBook.summaries() calls AIMU's SessionStore.list_summaries() instead, which TinyDBSessionStore answers from its own table without building a Session at all, and list(), sessions_for_task(), and most_recent_or_new() all moved onto it. sessions() survives for the one caller that genuinely needs message text, the agent's cross-conversation search; the list_conversations tool'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. Needs aimu>=0.29.0; see "Diagnostics and error reporting" below.
  • Per-conversation agents. Each conversation owns its AIMU SkillAgent and 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 (default true) 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 in CORE_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_config on a hot key reached SettingsApplier.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. And rename_conversation gives 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.
  • /stop cancels 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's aio.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's TurnMetrics accumulates each model call's count, model, seconds, and (when a provider reports them) tokens, stored under session.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 to run(); what is turn-scoped is a ContextVar the 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's TurnRequests, fed by the same record_event sink 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, under session.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 (default true, hot) turns the payload files off; endings are recorded either way. CORE_RUNTIME_SETTINGS now 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's ended: ... 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 a turn N badge. 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 carries tool_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_ending and the optional RichChannel.send_request / send_ending carry a request frame per completed entry-agent call and an ending frame per loop; a sub-agent's appear as request and ending entries in its own card (SubagentReporter.append). That includes a worker built by compose_subagent, which hands the run that called it to the spawn as the worker's parent (an injected ToolContext, 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 a wrap_up or error ending only, such as [assistant] ended: tool-use limit of 10 rounds reached; answered on a forced wrap-up call. Kokua's terminal channel defines send_turn and send_ending itself 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's turn_numbers gives 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), the turn_saved frame's new turn field, the history user item's turn, and the export's ## Turn N headings all read it. A reloaded conversation replays each entry-agent round's request item right after that round's assistant message and the loop's ending at 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 TurnMetrics and a heavily delegating turn read as cheap. Both now receive core.metrics.record_event explicitly: core/agents.py's spawn tool passes it as make_async_subagent_tool(events=...), and workflows/critics.py's reviewer_agent passes it as both aio.Agent(events=...) and client.events = record_event, since its typed verdict is fetched with a direct client.chat(..., schema=Verdict) call outside any run(), where the agent's own events override does not apply. record_event is a module-level constant, not a LiveState field, 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's agent field, which is what makes TurnMetrics.record's by_agent breakdown 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 wiring client.events closes the gap for every other direct client call but not this one. A /plan turn'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 (assistant by default, whatever [assistant].agent says otherwise) and a call no run claimed is labelled unattributed.
  • kokua export writes a conversation as Markdown. transcript_export.render_markdown renders 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 (no channels/ or frontends/ 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 past DEFAULT_MAX_PAYLOAD_CHARS (4000 characters) is cut with a note saying how much was removed rather than shown as if it were complete (--full lifts 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 already RESPONSE_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 --full reads 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: no Assistant, no model client, no agent, so it works with the model server down. Fidelity is part of the file's name (<id>.md plain, <id>.full.md for --full, both from transcript_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.py gives each turn a message bus, Assistant._offer_message routes 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 /plan turn (where every entry-agent run shares the one cursor, since a cursor belongs to a turn and not to a run), every worker spawned through build_agent_specs, a worker toolsets/capabilities.py composes per call (which was the one shape this missed until messaging landed; 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. offer and close are both synchronous and asyncio is single-threaded, so the serve loop cannot see a bus as open in the same tick the turn's finally shuts it; invariant 9 at the top of core/turns.py states the whole guarantee, the stranding and double-run bugs it rules out, and the one window it does not cover. /stop is 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's max_iterations stretch 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 under messaging below. 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. Needs aimu>=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 user message, 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; see messaging below. 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.) TurnRunner resolves the positions off the message list after the run (a compaction between rounds can move them) and stores them in metadata["messages"], keyed by the host turn like every other per-turn map, so a branch or a truncation filters them with the rest. replay_items emits a mid-turn message as its own "inbox" item with no message_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 export writes 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 what turn_end reads, so branching and truncation agree with the replay about where a turn ends. Without it a scan for the next user message 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_turn defaults to empty, so a transcript stored before any of this behaves exactly as it did. A /plan turn records whatever its execution left behind. On the shipped [planning] defaults (result_review and show_reasoning both 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 sync is required after pulling this. A toolset is registered through pyproject.toml's kokua.toolsets entry-point table, and an editable install's entry points are written into its dist-info at 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, while src/kokua/toolsets/messaging.py sits 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-extras fixes 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 (Steering becomes Inbox, run(steering=) becomes run(inbox=), the "steering" spec key and StreamingContentType.STEERING likewise), and it breaks any third-party Steering implementation and any exhaustive match chunk.phase. Kokua's half is core/steering.py to core/messaging.py, SteeringMailbox to MessageBus, SteeringMessage to Message, send_steering / steering_taken to send_message_frame / message_taken, the web frame and card kind steering to message, and the CSS class .bubble.user.steered to .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 now session.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 /branch is 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's CLIChannel. /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, or default), and a bare /think reports the current one.
  • web (behind the web extra): a Starlette + uvicorn WebSocket server with a streaming browser UI, bound to 127.0.0.1:8000 by default. The page is three files: index.html for the markup, app.css, and app.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, one wireFoldableSection helper 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_markdown the CLI uses and downloads it, without switching the row into view or reloading the page. There is no GET /export/{id} route: the front end builds its Assistant fresh 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_saved for a message that became a turn, and a new inbox frame 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 the loop marker 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 divergence replay_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.js kept 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 a token the 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 an input frame, 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 the TODO.md item 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_working carries a duration (None for "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 url field, only for http(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 same group (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, so ChannelUI.alert prints 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 input frame field, writes nothing to config.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 with rel="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, with trust:false and a maxExpand cap (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_calls it 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 a show all control. 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 by tool_call_id, since concurrent dispatch appends results in completion order.
  • Sub-agent work is visible. A spawn_subagent call renders as subagent 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 duplicate spawn_subagent tool 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.py now records the spawning card's id as parent on the child's create event, recovered through a current_spawn context variable bracketed by spawned/finished the way current_address is, 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, and kokua export quotes it there. A card recorded before this change carries no parent and still renders flat.
  • A spawn card replays where its call was, and stands in for compose_subagent too. Reload used to put every spawn card straight under the user's message, above the reasoning that led to it, and only spawn_subagent's tool block gave way to its card, so a compose_subagent call 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 reads compose_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.py now caps what it keeps inline in a card at RESPONSE_PREVIEW_CHARS (4,000 characters, a module constant rather than a config key: it is not a security control and not a capability an agent declares). A response over that length is recorded as the first RESPONSE_PREVIEW_CHARS characters, a response_ref (a /payloads/<sha256> reference, see "Payloads" below), and response_bytes (the full length); the rest is written once to payloads_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 states response_bytes when 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 (continuation or tool-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 localStorage preference 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, whose update_config hot-applies the change for the next turn and persists it to config.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. (The settings / get_settings control 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].thinking or an agent's own [agents.<name>] keys. Change them in config.toml and 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 into config.toml), with every agent that declares its own on a line of the label's tooltip. It arrives as a models frame once per connection, sent after Assistant.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 /diag in the chat. Both front ends ask build.model_record rather 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, then client.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 tuned temperature / top_p / top_k / min_p lost 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 a config.toml fails to load the way any unknown key does, and deleting it is the fix.
  • The sidebar appears before the assistant has finished starting. Assistant.create now 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 new Assistant.start(), which run() 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 closing ready frame, so the page renders what it already knows while the servers are still being asked for their tool lists. Until ready lands, <body> carries a booting class 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, meaning Assistant.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 in create() 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 same create()-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 to start(). 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 the history frame 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 an Assistant per 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 reaching ready (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, not history, 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 on history would 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.js an 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 into index.html and 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_saved takes a third argument. The signature is now send_turn_saved(conversation_id, message_index, *, token=None), and ChannelUI passes token= on every call, so a channel still implementing the two-argument form raises TypeError the 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 adding token=None to 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_taken and turn_saved, MessageBus.offer), so an implementation cannot accept one by position and a later parameter cannot change what a positional argument means. channels/protocol.py declares the new shape, and the paired send_message_frame(text, *, token=None) is optional like every other rich frame: a channel that does not implement it degrades in ChannelUI to 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 has resident (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 ...", " auto-approved coder's ..."), on the terminal and on the web card (the 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-toolsets shows every skill on disk under a skill: group. Two paths deliver one declaration, split on who authors: the entry agent stays an AIMU SkillAgent, and its catalogue is scoped with SkillManager(include=...) to the skills it declares; a worker, which is a plain agent with no skill machinery, gets a declared skill's script tools and activate_skill through the registry like any other toolset, and the skill's description reaches its prompt as that toolset's guidance. Declaring the skills authoring toolset opts an agent out of scoping, because narrowing an author's catalogue would hide the skill it just wrote and make add_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 skills toolset hands an agent three tools, not two: author_skill writes a skill, add_skill_script attaches a runnable script (and replaces one in place when called with the same filename), and update_skill revises an existing skill's description or body, passing only the part that changes. The third closes a loop that was open: author_skill refuses 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 puts add_skill_script there: 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. Needs aimu>=0.32.0, which also stopped add_skill_script from rewriting a skill's whole SKILL.md to attach a script: that rewrite dropped every frontmatter key it did not re-emit, so three of the four skills kokua skills install ships would have shed their license and compatibility lines the first time an agent touched them.

  • Every agent is declared whole in config.toml. One [agents.<name>] table per agent, carrying a description (the label a delegator sees), a system_message, a tools list of toolset names, and a delegates_to list. [assistant].agent names the entry agent, the one you talk to and the root of the delegation graph. assistant, researcher, and coder ship as live tables in config.example.toml, which is what kokua config init writes, 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].agent names 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 with kokua config init --force, since plain kokua config init refuses 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 optional guidance. Every provider lands in one namespace -- AIMU's built-in tool groups plus its two stores and skills, Kokua's own capabilities / config / conversations / mcp / scheduling, each installed plugin toolset, and each configured MCP server by its (now required) name -- so a tools list 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 a ConfigError naming both. kokua --list-toolsets prints 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: time is a toolset that every agent wanting one declares. The state a toolset draws on is a lazy property on one LiveState, 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 by compose_subagent (below), and it is entered by declaration too: only an agent whose own table names capabilities can 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-empty delegates_to is 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's max_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 (capabilities toolset). An agent can read the toolset registry at runtime with list_capabilities and, when no declared sub-agent role fits a task, call compose_subagent to 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, 0 off) bounds how far composition nests: at 3 a chain reaches three sub-agents, and the last of them holds neither tool, since a compose_subagent with 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 once messaging (below) gave an address something to grant. compose_subagent refuses a capability that builds to no tools (image without AIMU_IMAGE_MODEL, github_backup without its token, planning always), 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 marked cross_cutting -- something an agent holds to manage itself rather than to do domain work. cross_cutting decides that one sentence and nothing else: it is not an authorization boundary, and an agent declaring config really does get update_config. The boundaries are that [agents.*] is locked by default and that confirm_tools gates 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 web toolset'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 compute toolset's guidance tells whoever holds it to work out every figure that matters with calculate or execute_python rather than in prose, converting mixed units to one base unit and reporting what it converted between. A lean delegator, which never holds compute, 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. The time toolset's guidance tells whoever holds the clock that its sense of the date comes from training: read get_current_date_and_time before anything depending on the present, and convert timezones with convert_time rather than by adding an offset. These replace directives a user had been writing into their own agents' openers.
  • --system overrides the entry agent's opener for that run, winning outright over its declared system_message (and over the [assistant].system_message fallback), 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 is skills, marked entry-point-only because a spawned worker is a plain AIMU Agent rather than a SkillAgent, 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 skills on 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_agent builds 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 conversations toolset, 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), and export_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 a spawn_subagent task. 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-pdf renders Markdown to a PDF in data/downloads/, which the web front end serves at GET /download/<name>, and reports that link. This was the pdf toolset. Its script declares fpdf2 and markdown inline with PEP 723 and uv resolves 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-report mails a Markdown body to you as HTML with a plain-text fallback, optionally attaching a file already in data/downloads/ or data/images/. This was the email toolset, 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-roller rolls standard dice notation and prints JSON. This was the example toolset, and it stays the worked example: no dependencies, so no uv run needed, 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 when AIMU_IMAGE_MODEL is set (e.g. gemini:nano-banana or an hf:<repo> diffusers model).
  • aimu_agents: mounts AIMU's prebuilt CodeReviewAgent, ResearchReportAgent, and ContentCreationAgent as the tools code_review, research_report, and create_content. It exists mainly as the worked example of wiring an AIMU-built agent into Kokua: every Runner exposes .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 from config.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 for last_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 from ToolsetContext.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 through config.model_for rather 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, mirrors config.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 under data/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: repointing repo at a second repository is refused while data/backup still 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 until repo is set. Logs, downloads, and images are excluded, and an in-tree .gitignore excludes 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 in config.toml and 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), and stop_scheduled_task (end a run that is happening now). [scheduling.task.*] is refused by update_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 = false and a fired_at stamp, so the run stays on the record and re-running it is a one-character edit. cancel_scheduled_task still 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 /stop on 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 a disable. 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_task edits any subset of a task's fields in place, keeping its created_at and 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_name renames a task: the rename moves its table in config.toml and 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_conversations says how many of them survive: 1 means each run replaces the one before it, 0 keeps 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 of 1 still 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_id in 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 of ConversationBook.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 in config.toml as [scheduling.task.<name>] tables, keyed by name. An existing data/scheduled_tasks.json is ignored and left on disk rather than imported, and a conversation stamped with an old uuid task_id stops grouping under its task, since nothing in config.toml matches that id any more. Re-create any tasks you want to keep, or copy them into config.toml by hand.

Workflows: a pluggable turn strategy

  • A Toolset can carry a Workflow, a named turn strategy, instead of (or alongside) tools. Declaring that toolset in an agent's tools is 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.*].tools must name planning for /plan to exist, on the web Plan toggle as much as the CLI. An existing config.toml that predates this release and does not list it gets no startup warning, since nothing warns about an undeclared toolset -- but typing /plan still 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's tools.
  • A third party ships a workflow exactly the way it ships a toolset, through the kokua.toolsets entry-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 own aimu.aio.workflows runners (Chain, Parallel, Router, EvaluatorOptimizer, PlanExecuteEvaluator) works as a base-tier workflow with no adapter: Kokua streams its run() 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 implements run_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.toml section. Declaring settings=(Setting(key, kind, default, hot=...), ...) on a Toolset gives 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=True is what lets update_config change a setting live; a cold one is a startup-only key like any other. Deep planning is the first user: toolsets/planning.py declares [planning]'s five keys (plan_review, plan_review_agent, result_review, show_reasoning hot; review_rounds cold), and a workflow reads its own section as attributes through WorkflowContext.settings.
  • Breaking for anything importing the old fields. AssistantConfig.plan_review, plan_review_agent, result_review, review_rounds, and show_reasoning are gone, replaced by config.toolset_settings["planning"][...]. config.toml itself 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.toml section belonging to a toolset no agent's tools names 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_review pauses 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_agent re-plans on rejection up to review_rounds, surfacing leftover concerns. result_review checks 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 approved field 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_python and run_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.toml is 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 (via tomlkit, since stdlib tomllib cannot write TOML), so hand-written comments and layout survive an app write; a fresh file is seeded from the shipped example. kokua config init scaffolds $KOKUA_HOME/config.toml from config.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_config hands 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 ConfigError naming 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 in config.toml, plus add_mcp_server / remove_mcp_server at 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's spawn_subagent so 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, while connections (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 (one list_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 inside connect whether 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. name is required: it is how the server enters the one namespace an agent declares against, so a server nothing can name reaches nothing. A runtime add_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 for add_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 declaring available = "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, so add_mcp_server's result now says nothing can use it and its token is not saved, and names the [[mcp.server]] entry with token_env that would keep it.
  • Per-server bearer tokens via environment variables. Each [[mcp.server]] takes a required url, a required name, and an optional token_env naming the variable holding that server's token, read at startup so the secret stays out of config.toml. A token_env whose variable is unset logs a warning and connects tokenless rather than aborting startup.
  • OAuth: add_mcp_server starts 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 raw OAuthRegistrationError, and its docstring tells the assistant to relay that, ask for a token, and retry with bearer_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.toml does not know it, --list-toolsets runs before any connection so it can only name the server, and add_mcp_server reports 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.md says so plainly and says what to check instead. The distinct warning for a config.toml section 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_MODEL is 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 at GET /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.py is the same content-addressed shape as images.py, for oversized text instead of image bytes. A payload is written under data/payloads/ named by the sha256 of its UTF-8 bytes (so identical text is stored once), served at GET /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 of sessions.json can reuse it the same way.
  • scripts/migrate_subagent_payloads.py is a one-off, deletable migration for a sessions.json written before the cap above existed: it walks every stored sub-agent tool response, spills anything over RESPONSE_PREVIEW_CHARS to a payload file, and rewrites the entry to the same shape the live recorder now writes. --dry-run reports what would change without touching disk; a real run backs up sessions.json before 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-orphans is 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 same ensure_ascii=True TinyDB'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 \uXXXX escape. The default sessions.json and payloads directory are resolved by loading config.toml the way kokua itself does, so a configured [paths] data_dir is honored rather than silently scanning the built-in default. A sessions_file argument that is not the configured file needs --payloads-dir too for a real (non-dry-run) run, or it is refused: writing response_ref values 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.md states 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].to recipient 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's ToolApproval gate. The default set is skills.add_skill_script, mcp.add_mcp_server, compute.execute_python, compute.run_command, fs_write, and config.update_config; adjust with [security] confirm_tools or --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_tools entry 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 reserved core prefix holds the two tools no toolset provides, spawn_subagent and activate_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_python resolves because [agents.coder] declares compute. A tool that arrives later (from a runtime add_mcp_server, or built only by compose_subagent out of a toolset no agent names) cannot be listed ahead of time, and the error says so. core is 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's decide computes 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 names compute.run_command, compute.execute_python, and fs_write. Both compute tools are there deliberately: a shell already reaches unrestricted Python by a shorter route, so excluding execute_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, at INFO, because a card is a channel frame and not part of the saved transcript: after a reload the log under logs_path is 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) and max_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 as config.update_config, skills.add_skill_script, and mcp.add_mcp_server, and naming one of them in [security.auto_approval].tools is 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 waved update_config can widen this very list, a waved add_skill_script writes a script that becomes a tool, and a waved add_mcp_server adds 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 beside agents.* in [security].locked_config_keys for the same reason: update_config is 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 shipped confirm_tools default, 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_conversation and conversations.search_conversations to [security] confirm_tools to change that. export_conversation is 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 under data/downloads/.

Diagnostics and error reporting

  • An AIMU too old to run Kokua fails with an instruction, not a traceback. The aimu>=0.31.0 requirement covers a normal install, but a development checkout installs the sibling ../aimu editable and that checkout can sit on an older commit. kokua.aimu_compat preflights 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, or uv sync --no-sources to 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 (the resolve_default_text_model export, the shape to hope for: the capability is the exported name), and a signature check -- SkillManager(include=...), then SkillAgent(script_env=...), then aio.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 on aimu.tools.builtin.make_command_tool, the factory behind [compute] command_env_passthrough where 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, on aimu.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.CONTINUING is 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_KEYS was the first), reading StreamingContentType.__members__ rather than a bare in, because in on an enum compares values on Python 3.12 and raises TypeError on 3.11, where the capability here is a member's name, not its value. 0.28.0's other capability, the "max_iterations" entry in SUBAGENT_SPEC_KEYS behind 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 raised ValueError naming 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, then ModelRefusalError, 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 what ConversationBook.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 on SessionStore, 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 own list_summaries is 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.CONTINUING became 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 replaces get_webpage. The name is not the point. The old tool never asked what it had downloaded, handing response.text to an HTML stripper, and since requests decodes .text with errors="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.py is list(builtin.web), and workflows/critics.py mounts 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 way run_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_summaries joined the floor's job then, and the behavior behind that name (a checkout could export get_web_content and 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 on SUBAGENT_SPEC_KEYS for "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.select landed earlier in the release and would have been the obvious grip, a plain name lookup on a function toolsets/fs.py calls directly. It was passed over because save_document's read-before-replace guard landed two commits later with no handle at all (a digest set private to one make_document_tools call), while the read_document windowing that makes an unguarded save destructive landed earlier: a sibling parked in between passes a select probe 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 to select, unscoped, the writers in builtin.fs, edit_document, and the guard together -- the reverse of the trade 0.20.0's endpoint_kwargs had to accept. What it leaves to the floor is the contents of builtin.unscoped: the whole fs / fs_write split 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 in tests/test_aimu_compat.py instead. get_web_content joins 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 on aimu.skills.make_skill_update_tool, the factory toolsets/skills.py calls to hand an agent update_skill, without which a skill's prose is write-once (author_skill refuses to clobber, add_skill_script writes 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_script no longer attaches a script by rewriting the whole SKILL.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 from kokua skills install shed its license and compatibility lines the first time an agent attached a script to it, and again on every later fix, with nothing raised anywhere. It has its own handle in write_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, so tests/test_aimu_compat.py authors 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, for aio.SkillAgent.run's steering parameter: a run already in progress could be handed a user message without waiting for it to finish, which is what let a message typed mid-turn reach the turn running now instead of queuing behind it. 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, while aio.SkillAgent.run could not delegate to super().run() and so repeated Agent.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, and steering was not remembered until a whole-branch review caught it, after compaction and script_env had each needed the same hand-copy before it. Kokua's entry agent is an aio.SkillAgent, so gripping the subclass dated a checkout to every steering commit in the release and every fix after it, where an Agent-shaped probe would have passed on an AIMU where Kokua's own first steered turn raised TypeError. 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. Inbox joins the probe's job. Today the floor is 0.38.0: calculate evaluates an allowlisted syntax tree rather than an escapable eval, 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's ToolGroup, 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 carries run_id and parent_run_id, which the request records behind "what the model saw" are keyed by, and the startup check is a signature check on aimu.events.RunEvent for its run_id field. Before that it was 0.34.0, the first one a rename alone moved: AIMU renamed its mid-turn-message seam from Steering to Inbox, with no legacy path, and taught it which agent is opening each reader. Inbox.reader(agent=...) is called once per run, at its start, and agent is that run's own name, offered so a host can route a message to the one run it was meant for rather than to every run's drain; AIMU attaches no meaning to the string beyond passing it back. The probe is a plain name lookup, the sixth time (resolve_default_text_model, ModelRefusalError, SessionStore.list_summaries, builtin.get_web_content, aimu.skills.make_skill_update_tool), and the first time a rename rather than an addition has given it an exact question: a checkout predating 0.34.0 has Steering where this one has Inbox, so the two names cannot both resolve the way an old and a new export usually can side by side. This renamed only the names Kokua uses to talk to AIMU (every agent.run(steering=...) call carrying the entry agent's own source becomes agent.run(inbox=...), the "steering" spec key becomes "inbox", StreamingContentType.STEERING becomes StreamingContentType.INBOX) and widened Kokua's three reader factories to accept the agent label AIMU now passes positionally, since _BaseToolLoop.__init__ rehearses that exact call and raises TypeError when 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 the agent argument on both surfaces and both drivers, whether the INBOX streaming phase is emitted where STEERING used to be, whether the spawn path honors the "inbox" spec key, and whether 0.33.0's host-boundary guards survived the rename. aio.SkillAgent.run's steering parameter joins the floor's job, and the parameter it grips today is spelled inbox. It covers one surface at a time by design; every earlier release's capabilities are the floor's job, and tests/test_aimu_compat.py pins the floor against pyproject.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_error walks 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 as ModelRefusalError (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.py branches 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 at INFO, 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 model set, AIMU resolves AIMU_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 a ModelClientError carrying 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_app now validates the agent tables while building the app, so kokua --frontend web and kokua-web both print the offending name and exit 2, which is what the CLI front end already did. A ConfigError reaching 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. /diag reports 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 rotating data/logs/kokua.log (5 × 2 MB) with turn-lifecycle lines, level set by [logging] level. faulthandler is enabled, so kill -USR1 <pid> dumps all thread stacks.
  • The MCP SDK's own errors are logged where they can be read. The mcp SDK logger has no handler of its own and propagates, so every logger.exception it raised fell through to logging.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_setup now attaches the rotating file handler to mcp alongside kokua and aimu, which suppresses lastResort, and nothing is lost from the console: kokua.mcp.servers already logs the same failure to that file with the server URL and the full traceback. fastmcp is deliberately left alone, since it configures its own stderr handler at import, sets propagate = False, and removes whatever handlers are already on the logger.
  • A slow control cannot wedge the web UI. frontends/web.py's pump is 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 /stop and /diag were 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.delete took the gate's exclusive hold, which drains every in-flight turn, so deleting one conversation waited out an unrelated conversation's turn. It takes that conversation's own gate.turn instead, bounded by the cancel_turn that 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_SECONDS in frontends/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 history frame 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, /stop included. 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/ -- and tests/ mirrors it exactly.
  • Assistant is a composition root and the serve loop; it delegates to ConversationBook (store + agent cache + active pointer), TurnRunner, HumanGate, SettingsApplier, and ChannelUI. ChannelUI is the one adapter that probes each optional channel capability once and gives every rich frame a documented fallback, so there is no isinstance(channel, WebChannel) in core/ or workflows/. channels/protocol.py declares 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 declaring capabilities.
  • 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_template holds 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, and test_every_catalogue_page_is_listed_in_the_index holds 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.yml on every push to main. The build is strict: a broken internal link, or a page missing from mkdocs.yml's nav, fails CI. tests/test_docs.py covers what a site build cannot, checking that every link out of docs/ 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 real index.html in headless Chromium; it skips rather than errors when the browser or the web extra 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.