Skip to content

Export a conversation

kokua export writes one stored conversation to a Markdown file: what was said, what the model reasoned, every tool call with its arguments and result, and what each turn cost. It exists because watching a run stream by in the browser only works while the run is on screen. The export is the artifact you keep afterwards, to diff against another run, paste into a review, or read once the terminal is gone.

It reads the conversation straight out of the session store and writes the file; nothing else. No model client, no agent, no front end, so it works even with the model server down.

There are three routes to the same content, written to different files: the kokua export command below, a download button in the web UI's sidebar (see From the web UI), and asking the assistant to export one so it can analyze it (see Have the assistant analyze a conversation). All three call the same renderer, so the files read identically; only their names differ (see each route's section below).

The command

kokua export                       # the most recently updated conversation
kokua export abcdef01              # a full id, or a unique leading fragment of one
kokua export abcdef01 -o notes.md  # write somewhere specific
kokua export abcdef01 -o -         # write to stdout instead of a file
kokua export abcdef01 --full       # do not truncate long tool arguments and results
kokua export abcdef01 --turn 3     # one turn, with every request the model was sent

The conversation argument takes an id, a unique leading fragment of one (at least 6 characters, the same rule the assistant's own read_conversation tool follows), or latest (the default). A fragment that matches more than one conversation opens neither: it is reported as matching several conversations, with a count, so you know to type a few more characters rather than go looking for a typo.

Without -o, the file lands in $KOKUA_HOME/data/downloads/<conversation id>.md (by default ~/.kokua/data/downloads/), the same folder generated PDFs and other artifacts use, and the path is printed on success so a script can pick it up. -o - writes the Markdown to stdout instead, for piping into something else. --full lifts the truncation a plain export applies to long tool payloads (see below), and writes <conversation id>.full.md rather than sharing the plain export's name: the two files hold different amounts of the same conversation and read identically, so one name for both meant a later plain export replaced a --full one in place, with nothing in the result to say what had gone. Re-exporting at the same fidelity still overwrites, so a conversation has at most these two files. Take the path from the printed output rather than assembling it.

What a turn's block shows

The file opens with a header: the conversation's title (falling back to its first message, then to "Untitled conversation"), its id, when it was created and last updated, and a running total of model calls, tokens, and time, when any of those were recorded.

Each turn after that is one ## Turn N section: the user's message, the model's reasoning (if the run streamed a thinking block or a verbose trace's reasoning segments), its answer, and, for each tool call, the tool's name, its arguments, and what it returned. A line under the turn heading names the model and reasoning effort it ran at, when either was recorded, and a line beneath that reports what the turn cost. Every tool loop ends with a closing italic line saying which rule ended it, such as _[ended: answered at round 2 of 10]_ or _[ended: tool-use limit of 10 rounds reached; answered on a forced wrap-up call]_. A sub-agent the turn spawned gets its own nested card with the same shape (its own ending line included): its own reasoning, tool calls, and answer. If a turn stopped short (a tool call was denied, a reviewer sent it back for revision, an error cut it off), the reason appears as a quoted note where the turn stopped.

A turn's block also shows the system prompt each agent ran under, as a **System prompt (agent):** heading and a fenced block, but only where it is news: the first time that agent appears in the conversation, and again whenever it differs from that agent's previous one. The entry agent and each sub-agent are tracked separately, so a sub-agent's prompt is never compared against the entry agent's. Prompts come from the recorded request payloads, so a turn whose payload was not captured shows none, and a payload that is readable but holds no system prompt Kokua can find (an in-process provider's raw prompt, say) shows _(system prompt not found in the recorded request; see kokua export --turn N)_ once for that agent instead, and each is capped like any payload unless you pass --full. A turn shows each agent's prompt from its first readable call, so a change between rounds of one turn is not shown. The web download and the assistant's export_conversation file include them too.

One turn, in full

kokua export <conversation> --turn N writes a single turn instead of the whole conversation, to $KOKUA_HOME/data/downloads/<conversation id>.turn-N.md (or wherever -o says; -o - is stdout). It holds the user's message, then every model call the turn made, grouped by run with the entry agent first, each under a ### agent · round R heading naming the run, provider, endpoint, model, and how the call stopped. Under each is the request as recorded, pretty-printed, and the response: text, reasoning, and tool calls. The loop's ending follows. Nothing is truncated, so --full is accepted and changes nothing (a one-line note on stderr says so). A turn with no recorded calls (it predates capture) reads _No model calls were recorded for this turn._ after the user's message.

The request is exactly what was sent. A provider that runs in-process has no wire request, so its entry says no wire request (nearest equivalent; not replayable): the payload shown is the nearest equivalent Kokua could record, not something you can send again. A payload or response that was not captured, or whose file has since been deleted, reads _not captured_. A number outside the conversation's turns exits with status 2 and names the valid range.

This file contains everything the agent saw, including the system prompt, tool results, and any documents or credentials they carried. Treat it as private.

Two honesty rules

A figure nobody reported prints as "not reported", never as an invented zero. Not every model or provider reports token counts, and a stored absence rendered as 0 would be a false claim about what a run cost. The same rule covers delegation: a turn that spawned a sub-agent but whose usage record carries no per-agent breakdown says the delegated cost was not counted, rather than showing the entry agent's own total as if it were the whole turn's.

A truncated payload says so. A tool call's arguments or result is arbitrary text a model or a tool produced, and can be large enough to bury the turn being judged under it. Past kokua.transcript_export.DEFAULT_MAX_PAYLOAD_CHARS (4000 characters), the export cuts a payload and appends a note saying how many characters the full payload held, rather than silently showing an incomplete result as if it were complete. Pass --full to lift the cap and see every payload in full.

What this does not include

A workflow review's typed verdict is not counted, and nothing marks it. A /plan review's tool-calling assessment is counted like any other delegated work, but the call that turns it into a structured verdict goes through AIMU's schema-only path, which returns before any turn event is emitted, on any client. That one call per review round never enters a turn's recorded cost, and because it never enters the count at all, no "not reported" note can point at it the way it does for a missing token figure.

No per-turn or per-model rollup in the header, and no per-spawn token breakdown on a sub-agent's own card. The header totals model calls, tokens, and time across the whole conversation; it does not also break that total down by turn, by model, or by delegation depth, and a sub-agent's card shows its reasoning, tool calls, and answer without a token figure of its own. Both are things a fuller export could add; this one does not, so the total is what you have to work from until a later change adds them.

From the web UI

Hover a conversation in the sidebar and a download arrow (↓) appears beside its delete button. Clicking it does not switch you into that conversation or reload the page: the file is rendered and written on the server, in the same $KOKUA_HOME/data/downloads/ folder the command line writes to, and the browser saves it the way any other file download is saved. The exported filename carries today's date, a slug of the conversation's title, and a short fragment of its id, so several exports land in that folder without overwriting each other or reading as anonymous.

There is no GET /export/{id} route: the web front end builds its assistant fresh per WebSocket connection, so a plain HTTP handler would have no live session store to read from. The button sends an "export" control over the same socket every other sidebar action uses, and the server answers by writing the file and pointing the page at the existing /download/{name} route (the one that already serves generated PDFs and other artifacts) rather than opening a second way to fetch a file.

The download button cannot lift the truncation cap. --full is a command-line flag, and the sidebar's button always exports with DEFAULT_MAX_PAYLOAD_CHARS in effect. A browser-only user is not stuck, though: asking the assistant to export the conversation runs the tool below, which does take the flag. Or use kokua export --full from the command line.

Data at rest. A full transcript, once exported, persists indefinitely under downloads_path and is served by /download/{name} over plain HTTP with no authentication. The path-traversal guards on that route are sound and the default [web] host is loopback-only, but host is a setting a user can change, and a conversation is often the most sensitive thing Kokua holds. Treat an exported file, and the host it is served from, accordingly.

Have the assistant analyze a conversation

The same export is a tool the assistant holds, export_conversation, which is how you get an answer about a run rather than a file about it. Ask in plain words:

Something went wrong in the conversation about the flight prices yesterday. Which tool call failed, and what did it return?

The assistant finds the conversation (list_conversations or search_conversations), exports it, and gets back a path plus how long the file is. The file includes each agent's system prompt (at its first occurrence and whenever it changes, capped like any payload unless full), since the export is the artifact an evaluation reads. What happens next depends on the length, and that is the part worth understanding.

A path, not the transcript. The tool answers with a file path on purpose. read_file returns 2,000 lines per call and names the offset that continues the read, so a long file is reachable whole, in pages, and every page is paid for out of the context doing the paging. A tool that returned the Markdown directly would spend the asking conversation's whole context on one run's tool output in one go, and reading it here page by page is the same bill in instalments. So a long export is meant to be handed to a sub-agent whose fresh context absorbs the file, returning only the findings, and the assistant's guidance tells it to delegate rather than read once the file is past a few hundred lines. A short export it will usually just read itself, which is the right call: a delegation costs a spawn.

Or ask for an evaluation, and skip the export. config.example.toml ships [agents.introspector], a sub-agent whose subject is a conversation and whose job is judging one against criteria you give:

Evaluate yesterday's conversation about the deployment. I care about whether it verified its claims before acting, and whether it delegated work it should have done itself.

That worker holds the conversation tools itself, so the whole job is one delegation: it finds the conversation, exports it, reads the file, and reports per criterion, quoting the transcript lines each judgment rests on. Nothing but its report enters your conversation. Three things it is instructed to be strict about: a criterion the transcript cannot settle is reported as unassessable rather than guessed at, a read it stopped short of finishing is reported as covering only part of the run, and if you give no criteria it evaluates against whether the run reached what you asked for, what it spent, and where it went wrong, saying up front that it chose them.

Ask for full detail when the tool result is the thing you are debugging. The tool takes the same cap --full lifts, so "export it with the full tool output" gets you a file with nothing cut.

What it still cannot do. Paging arrived with AIMU 0.31.0, so a read that stops part way now names the offset that continues it and the introspector is instructed to page through the rest. What paging does not fix is an export longer than the introspector's own context: pages accumulate in the worker's window rather than the asking conversation's, which is the point of the delegation, but a run whose transcript exceeds that window still cannot be held whole. The worker is instructed to say which part of the run it saw rather than answer as if it had read all of it, so you get a stated partial answer instead of a confident one. For a very long run, kokua export plus your own editor is still the more reliable read.

The current conversation is a blind spot, and it says so. The export renders the stored transcript, and the turn you are in right now is not stored until it finishes. Exporting the conversation you are talking in gets you everything up to this turn, and the answer says as much; the same goes for a conversation whose reply is still streaming somewhere else.

This grants a delegate two kinds of reach. [agents.introspector] declares fs, which grants read of any file on the machine and not only of an export, and conversations, which lets it read (and rename) any saved conversation rather than only the one you asked about. The first is the same reach [agents.coder] already ships with; the second matters because a transcript is untrusted text, so an injection sitting in one conversation could steer a worker that was spawned from another. Like every capability here both are lines in your config.toml: delete the agent and its name from [agents.assistant].delegates_to if you would rather not have them, and asking the assistant to export still works (it will read the file itself, or hand you the path).

The store, mid-write

TinyDB (the session store's format) rewrites its whole file on every save, so an export that races the assistant's own write can land on a partial file. Rather than parse whatever arrived, kokua export reports that the store is busy and asks you to try again in a moment: presenting half a conversation as the conversation would be worse than making you wait.

See also

  • transcript_export.py: the renderer this command calls, pure and dependency-free, so both front ends could call it too.
  • Architecture: where the conversation store and the CLI fit in the rest of Kokua.