Transcripts

Every agent run rupu executes writes a transcript: an append-only JSON Lines file with one event per line, recording what was sent, what came back, every tool call and what it did. This page is the schema, for anyone who reads transcripts with their own tools.

Where transcripts live

Each agent run writes one file, named after its run id:

<transcripts-dir>/run_<ULID>.jsonl            # an active transcript
<transcripts-dir>/archive/run_<ULID>.jsonl    # after rupu transcript archive
DirectoryUsed when
<project>/.rupu/transcripts/The directory exists in the project the run started in.
~/.rupu/transcripts/ (or $RUPU_HOME/transcripts/)Otherwise.

File format

One JSON object per line, UTF-8, newline-terminated. Every line has the same envelope:

{"type": "<event>", "data": { … }}

Schema versions

run_start.schema says which writer produced the file. Current rupu writes 2; a transcript with no schema key is version 1.

VersionWhat it records
1 (no key)The conversation as text. Reasoning, if any, is a plain thinking string on assistant_message, with no provider payload.
2Enough to rebuild the exact conversation sent to the provider: the effective system_prompt on run_start, the user_message, each reasoning block as a thinking event with the provider’s byte-exact raw block, the seed the run started from, every compaction, and assistant_block for reply blocks with no event of their own.

Fields and events have been added within version 2 since it shipped: outcome, recovery, assistant_block, customer, codename, cache_write_tokens, purpose, skill_index, skill_load, launch_skills, launch_preload_skills, launch_args, message and injected. A reader should accept a missing optional key and an unknown event type.

Reading rules

Event order

Within one file:

run_start
notice?                     # model_limits
tool_grant                  # the tools offered, and why
notice*                     # tool_unavailable, one per named tool the run can't serve
skill_index?                # the skills bound, when the run has any
(skill_load | notice)*      # preload, continuation and operator loads; skill_not_indexed, skill_missing
seed?                       # a run seeded from earlier history
user_message?
( turn_start
    ( message* injected )*  # what the collectors delivered before this turn's model call
    usage+                  # one per provider call; a compaction call adds one with purpose
    outcome?                # a non-normal reply, ahead of the turn's content
    ( thinking_delta* thinking | assistant_delta* assistant_message | assistant_block )*
    ( tool_call  tool_result  ( skill_load notice* )?  file_edit?  command_run?  ( message | notice )?  tool_audit )*
  turn_end
  recovery*
  ( skill_load | notice )*  compaction? )*
net_flow*                   # interleaved anywhere, as connections close
run_complete

Reasoning, text, tool calls and other blocks appear in the order the model produced them. An outcome follows the turn’s usage and precedes its content; its recovery events follow it, usually after turn_end. A notice can appear wherever the runtime intervened; a hop to a fallback model writes a model_limits notice just before its fell_back recovery, and a note sent back to the model is a user_message. A successful skills.load writes its skill_load right after the call’s tool_result, then any skill_changed or skill_tools_missing notice. Skills put back after a compaction are skill_load lines with via: reattach, alongside any skill_changed, skill_missing or skill_reattach_truncated notice. A session’s compact pseudo-turn (the compaction a running session’s worker does on request) writes the same lines after its compaction usage; it has no compaction event.

Event reference

Fields marked opt are omitted when empty.

run_start

The first line of every agent transcript.

FieldTypeMeaning
run_idstringSame as the file name without .jsonl.
workspace_idstringThe workspace (project) record, ws_….
agentstringAgent name from its frontmatter.
provider / modelstringWhat the run was configured to use. A fallback hop appears later as a notice and a recovery, not here.
started_atRFC 3339Start time, UTC.
modeask | bypass | readonlyThe permission mode: prompt before write/bash tool calls; execute all tools without prompting; deny write/bash and allow read/grep/glob only.
schemainteger, opt2 today; absent on version 1.
system_promptstring, optThe system prompt actually sent, after rupu appended its own sections (coverage catalog, run target, finding guidance).
codenamestring, optThe agent instance’s human codename, e.g. amber-lantern/heron or cobalt-harbor/heron#412; absent on transcripts that predate codenames.
customerstring | null, optThe customer slug. null means recorded as no customer; no key means the transcript predates customers.
launch_skillsstring[], optThe skill entries the launch granted on top of the agent’s (a workflow step’s skills:, rupu run --skill), as written: * stays *. Absent when the launch named none, and on transcripts that predate the field. rupu run --continue of a transcript without launch_args passes them again.
launch_preload_skillsstring[], optThe launch’s preload_skills: / --preload-skill entries, likewise.
launch_argsstring[], optA rupu run’s own arguments, the tokens after run, with the run’s effective values: no prompt, no --continue, and for a continued run the merged command. rupu run --continue restores them, so a continue keeps every original flag and a flag given on it replaces only its own setting. Absent for other runs and on transcripts that predate the field.
{"type":"run_start","data":{"run_id":"run_01JB7Q2M4X8T6V0K3N5R9C1D2E","workspace_id":"ws_01JB7PZ0A1B2C3D4E5F6G7H8J9","agent":"code-reviewer","provider":"anthropic","model":"claude-sonnet-4-6","started_at":"2026-10-07T09:14:03Z","mode":"readonly","schema":2,"codename":"amber-delta/kestrel","customer":null}}

run_complete

The last line of a run that ended. Its absence means the run never finished.

FieldTypeMeaning
run_idstringSame as run_start.
statusok | error | abortedCompleted normally; ended with a provider or internal error; killed or crashed before run_complete.
total_tokensintegerTokens across all turns.
duration_msintegerWall-clock duration.
errorstring, optThe error, when status is error.
outcomeOutcomeRecord, optThe outcome the run failed on, when it ended because the recovery ladder ran out; or, on an aborted run stopped by SIGTERM, the stop: class stopped_by_signal (warning). A workflow run.json’s cause carries the same record when its run failed while its process was stopped by a signal, or runner_died when the reaper finalized a run whose runner had died. A run whose cause is one of these did not fail on its own: whoever continues its parent continues it.

turn_start, turn_end and usage

EventFields
turn_startturn_idx — zero-based turn counter.
turn_endturn_idx; opt tokens_in, tokens_out; opt stop_reason (the provider’s own wire stop value, e.g. end_turn, max_output_tokens); opt response_id; opt stop, a StopRecord: reason (normalized: end_turn, max_tokens, refusal, pause_turn, …), wire (provider name, raw value, extras), opt refusal, opt served_by (the model that answered when a server-side fallback served the turn) — absent on transcripts written before the response-outcomes work; opt discarded: true when the turn’s content was thrown away (a refused, blocked or retried turn) — replay drops it and it never reached the model again.
usageOne per provider call, right after the call returns and before the response’s content events: provider, model (requested; used for pricing), opt served_model (if the provider served another), input_tokens, output_tokens (billable, reasoning tokens folded in — there is no separate reasoning-token field), cached_tokens (prompt-cache reads, part of input_tokens, default 0), opt cache_write_tokens (prompt-cache writes in input_tokens; Anthropic only, omitted when 0), opt purpose: "compaction" for the context-compaction summarizer — real, billed spend that is not an agent turn (turn counters skip it; token and cost totals include it).
{"type":"usage","data":{"provider":"anthropic","model":"claude-sonnet-4-6","input_tokens":18240,"output_tokens":612,"cached_tokens":16384}}
{"type":"turn_end","data":{"turn_idx":3,"tokens_in":18240,"tokens_out":612,"stop_reason":"tool_use"}}

Conversation events

EventFieldsNotes
user_messagecontentThe user turn this run added. Absent on a run that only continues a seed. A note rupu sends back to the model mid-run (after an interruption, or a recovery note) is also a user_message.
assistant_messagecontent; opt thinkingOne complete text block. thinking is the version 1 reasoning string; version 2 writers record reasoning as separate thinking events.
assistant_deltacontentA streamed chunk ahead of its assistant_message. Ignore it unless you’re rendering live.
thinkingopt text, provider, model, rawOne reasoning block, in its real position among the turn’s other blocks. raw is the provider’s byte-exact block, signatures included, kept for replay; never display it. No text means the reasoning was redacted or its display omitted.
thinking_deltacontentA streamed reasoning chunk; the following thinking event has the whole block.
assistant_blockblock; opt abandonedA reply content block with no event of its own, in its position among the turn’s other events: {"type":"fallback","from_model":…,"to_model":…} (an Anthropic server-side fallback boundary) or {"type":"unknown","provider":…,"raw":…} (a block rupu does not model, with the provider’s raw payload). abandoned: true marks a block the API discarded at a mid-reply server-side fallback: never sent back to the model, skipped by replay.
seedmessage_count, sha256; opt source_transcript or messagesThe conversation this run started from (a rupu run --continue, a session’s earlier turns, a workflow resume), stored once. Exactly one of source_transcript (a transcript whose replay rebuilds exactly this seed; chains can be several deep) and messages (the seed inline, reasoning raw intact) is present. sha256 is a hash of the canonical seed JSON, so a reader can verify a reference chain.
compactionseq, summarized_messages, backup_path, messagesContext compaction rewrote the conversation. messages is the whole conversation afterwards, which the following turns were built from. The summarizer call itself is billed under a usage line with purpose: "compaction".

Tool events

EventFieldsNotes
tool_callcall_id, tool, inputThe model asked for a tool. call_id is provider-assigned; input is the JSON it sent.
tool_resultcall_id, output, opt error, duration_ms, opt structuredPairs with its tool_call by call_id. structured is a machine-readable payload some tools add next to the text, e.g. ast_grep’s matches with ranges and metavariable bindings; absent on older transcripts and for tools that add none.
file_editpath, kind (create | modify | delete), diffWritten alongside a write_file / edit_file result, so you can index edits without parsing tool inputs. path is absolute; diff is unified.
command_runargv, cwd, exit_code, stdout_bytes, stderr_bytesWritten alongside a bash result.
action_emittedkind, payload, allowed, applied, opt reasonA standalone workflow action: step’s catalog call: kind is the tool name as the step wrote it (issues.create, scm.prs.comment), payload its rendered with:. applied is true whenever the call reached the connector (whether or not the connector call succeeded) and false only when it was denied before reaching it; allowed is false only when permission was denied; reason explains either. Always followed immediately by a tool_audit for the same call. Old transcripts may carry free-form verbs (log_finding, propose_edit) with applied: false that no current code path can produce.
tool_audittool, declared, granted, blocked, restricted, opt reason, opt decisionThe audit line for one tool call: every call ends in exactly one, whatever the tool (core, coverage/findings, connector, agentiflow) and whatever happened to it. Written by the agent loop right after the call’s tool_result (before run_complete when the operator stops the run at the call’s prompt), and after an action: node’s action_emitted. It is what the control plane’s transcript panel renders a badge from (blocked / not granted). See below.

tool_audit fields:

FieldMeaning
toolThe tool name as the model called it (canonical or a legacy alias, matching its tool_call); for an action: node, the step’s tool.
declaredWhether the step’s actions: names tool. false both when actions: is empty or absent (unrestricted — not a violation) and when it doesn’t name this tool; use restricted to tell those apart.
grantedWhether the run’s grant covered tool before any actions: narrowing. Always true for an action: node.
blockedWhether the call was denied (decision is not allowed).
restrictedWhether the step declared a non-empty actions: allowlist at all.
reason (opt)Why the tool was in the grant, as comma-separated reasons (the tool_grant entry’s), or action for an action: node. Absent when the grant didn’t cover the call, and on older lines.
decision (opt)allowed, denied:readonly, denied:operator (answered no at the prompt), denied:operator_stop, denied:tool (the tool’s own gate refused), or not_granted (the model named a tool the run doesn’t offer). Absent on lines written before every call was audited.
# a write refused by readonly mode
{"type":"tool_audit","data":{"tool":"write_file","declared":false,"granted":true,"blocked":true,"restricted":false,"reason":"declared","decision":"denied:readonly"}}

# an action: step that created an issue
{"type":"action_emitted","data":{"kind":"issues.create","payload":{"title":"null pointer in parser.rs:142","body":"..."},"allowed":true,"applied":true}}

tool_grant

The tools the run offered its model, and why. Written once, right after run_start and its model_limits notice. entries is exactly the model’s tool list.

FieldTypeMeaning
entriesarray{tool, reasons} per offered tool: its canonical name and every reason it is there — declared (named in tools:), declared:<wildcard> (matched *, core.*, scm.*, …), default (the agent has no tools:), ambient:concerns, ambient:engagement, ambient:messaging_default (the messaging tools every run with a message space gets unless its agent, workflow, agentiflow or parent turns them off), ambient:workflow_messaging (a workflow’s messaging: true or { tools: [...] }), origin:flow_lead / origin:flow_unit (an agentiflow lead’s or unit’s coordination tools; a lead’s include dispatch, join and workflows.generate), implied:dispatch (join, added because dispatch is granted).
narrowedarray, optConnector tools the step’s actions: removed.
unavailablearray, opt{tool, missing}: tools named exactly in tools: that this run can’t serve, with the services it lacks. Each also gets a tool_unavailable notice.
skippedarray, opt{tool, missing}: tools a wildcard or the default grant matched that this run can’t serve. Not offered, no notice.

Services a tool can be missing: scm (configured connectors), launcher (dispatch / join, every child kind), findings, engagement, coverage (a concerns: block), message_bus, run_status, catalog, workflow_generator (workflows.generate), netflow.

{"type":"tool_grant","data":{"entries":[{"tool":"read_file","reasons":["declared"]},{"tool":"findings.report","reasons":["ambient:concerns"]}],"unavailable":[{"tool":"board.post","missing":["message_bus"]}]}}

skill_index and skill_load

A run’s skills leave two kinds of line. skill_index is the skills the run bound, written once, right after tool_grant and its notices, and only when the run has a skill: skills is an array with one row per bound skill.

Row fieldTypeMeaning
namestringThe name the skill binds in this run.
layerglobal | projectWhich skills directory it came from.
stampstringThe skill’s stamp at run start: a sha256 over its files’ paths, sizes, mtimes and modes, no contents. A later load compares against it to notice an edit. In a session turn it is the session’s baseline, recorded when rupu session start froze the set (or when session send --skill added the skill), so an edit made since then is noticed in every later turn.
preloadboolThe skill’s body is in the system prompt.
indexedboolThe skill is listed in the model’s skill index. false for a preloaded skill and for one marked disable-model-invocation.
reasonstringWhy the run has it: agent (the agent’s skills: / preloadSkills:), launch (the launch’s own: a workflow step’s keys or rupu run --skill), session (rupu session start --skill / --preload-skill) or operator (a skill rupu session send --skill invoked in an earlier turn; never indexed). A transcript written before the rename says step for launch; readers take the value as written.
shippedbool, optThe skill came from the coordinator of a placed unit, not from the host’s installed layers. Absent when false.

skill_load records that a skill’s content entered the conversation, for every load, whichever path made it:

FieldTypeMeaning
namestringThe skill’s name in this run.
layerglobal | project
content_hashstringsha256 over the skill’s files’ paths, modes and bytes: the version that was loaded. A preload load’s is taken when the run resolves its skills, so it is the version of the body in run_start.system_prompt.
base_dirstringThe skill’s directory, which its relative file references resolve against.
viastringmodel (a skills.load call), preload (at run start), operator (a /name token at the start of the prompt, or rupu session send --skill, put its body in the turn’s message; written at run start), reattach (put back after a compaction) or continuation (carried over from the run this one continues).
{"type":"skill_index","data":{"skills":[{"name":"pdf","layer":"global","stamp":"9f2c…","preload":false,"indexed":true,"reason":"agent"}]}}
{"type":"skill_load","data":{"name":"pdf","layer":"global","content_hash":"4b1e…","base_dir":"/home/me/.rupu/skills/pdf","via":"model"}}

message and injected

A run with a message space records what it sent and received. message is one message, sent or received: sent is written right after the messaging tool’s tool_result; received is written when a collector delivers it, right before the model call that sees it, one event per message, ahead of the injected event that carries it.

FieldTypeMeaning
idstringThe message’s id in the space’s log.jsonl (msg_<ULID>).
directionsent | received
fromstringThe sender’s address (lynx#1, lead, operator).
from_codenamestring, optThe sender’s full codename.
tostring, optThe address it was sent to, as recorded (role:recon, lynx#2, broadcast); absent for an unaddressed post.
kindstringpost, direct, broadcast, directive, retract or operator.
bodystringThe text.
urgentboolAbsent unless true (operator messages).
viatool | injectedThe run called a messaging tool, or a collector delivered it.
deliveryobject, optOn sent: {resolved: [address…], undelivered: [{to, reason}…]} as logged.

injected is context a collector put in front of the model. Every once injection is written, at the point it joined the conversation, so replay re-inserts it. An every_turn injection is written only when its content changed since the turn before, so a standing directive is one line, not one per turn.

FieldTypeMeaning
sourcestringWho it is from (mailbox:lynx#1, broadcast:lynx#1, directive:board, operator, roster, children).
kindstringmessage, directive, operator, observation or status. operator is framed as the operator’s instruction, everything else as data.
cadenceonce | every_turnKept in the conversation, or sent with each request and never kept.
contentstringThe collector’s text, before the framing the model sees (an urgent operator message starts URGENT: ).
message_idsstring[]The messages it delivers; absent when none.
{"type":"message","data":{"id":"msg_01J…","direction":"sent","from":"lynx#1","to":"role:recon","kind":"direct","body":"port 22 is open","via":"tool","delivery":{"resolved":["otter#1"]}}}
{"type":"injected","data":{"source":"mailbox:otter#1","kind":"message","cadence":"once","content":"from lynx#1 at 2026-10-13T…: port 22 is open","message_ids":["msg_01J…"]}}

outcome and recovery

When a reply isn’t a normal one, or the provider returns an error, rupu classifies it and records what it did about it. The classes and the recovery ladder are explained in Recovery & reliability.

outcome has turn_idx and an outcome object (an OutcomeRecord; the same shape as run_complete.outcome):

FieldTypeMeaning
idstringUnique in the run (oc_1, oc_2, …). Recovery events point back to it.
classstringpause_turn, max_tokens, context_window_exceeded, refusal, safety, malformed_tool_call, incomplete, empty_reply, unrecognized_stop, unreported_stop or provider_error. A string, so a class a newer writer adds still parses.
severityinfo | warning | error
titlestringOne line, e.g. refused · cyber.
detailstring, optLonger text: a refusal explanation, the tool call that was cut off, the provider’s error message.
error_classstring, optFor provider_error: rate_limited, overloaded, server, timeout, context_overflow, quota, not_found, policy, auth, permission, invalid_request, too_large or unrecognized.
wireobject, optThe provider’s own stop value or error body, kept verbatim.

recovery is one recovery action taken for an outcome, written after its outcome:

FieldTypeMeaning
outcome_idstringThe outcome it answers.
runginteger0 in place, 1 fallback on the same provider, 2 fallback on another provider, 3 nothing left: the run fails.
actionstringcontinued, retried, compacted, fell_back, served_by_fallback, skipped, asked, parked or failed. asked and parked are reserved for operator-decided recovery and are not written yet. Treat an action you don’t know as other.
attempt / budgetinteger, optWhich rung-0 attempt this is, against the rung-0 budget for this outcome’s turn.
provider / modelstring, optThe fallback, for fell_back, served_by_fallback and skipped.
reasonstring, optWhy: the hint on failed, the build error on skipped.
merge_into_previousbool, optThe next turn’s assistant content joins the previous assistant message (a pause_turn continuation).
continues_outputbool, optThe next turn’s text continues this turn’s answer (a truncation continuation), so the final answer joins across the boundary.
{"type":"outcome","data":{"turn_idx":5,"outcome":{"id":"oc_2","class":"refusal","severity":"error","title":"refused · cyber","wire":{"provider":"anthropic","value":"refusal"}}}}
{"type":"recovery","data":{"outcome_id":"oc_2","rung":1,"action":"fell_back","provider":"anthropic","model":"claude-opus-5"}}

notice

A runtime intervention worth showing that isn’t part of the conversation: a kind and a human-readable message. Expect new kinds; render an unknown one by its message.

KindMeaning
model_limitsThe run’s input and output limits and where each came from, written at start and on a fallback hop.
model_limits_clamped, context_trim, provider_retry, server_side_fallback_disabledContext and provider interventions; see Recovery & reliability.
permission_mode_degradedAsk mode with no operator to ask.
tool_unavailableA tool named in tools: that this run can’t serve; one per tool, right after tool_grant.
skill_not_indexedA granted skill marked disable-model-invocation that isn’t preloaded, so the model can’t load it.
skill_changedA skill loaded at a newer version than the run’s skill_index recorded.
skill_missingA loaded skill no longer installed when it was due to be re-attached, or, at a session turn’s start, a skill the session froze that can’t be given now.
skill_tools_missingA loaded skill’s allowed-tools names tools the run wasn’t granted.
skill_reattach_truncatedLoaded skills that didn’t fit under the re-attach cap after a compaction.
messaging_fullThe run’s message log reached [messaging].log_max_mb, so a send failed; written after that call’s tool_result.
{"type":"notice","data":{"kind":"provider_retry","message":"overloaded; retrying in 2s (attempt 1/3)"}}

net_flow

One outbound network connection the run made: {"flow": <FlowRecord>}, with the host, port, scheme, method and query-stripped path, status and outcome, bytes, timing, and a fidelity that says how much of it was observed. Written as flows are recorded, so these lines can appear anywhere in the file. The record is described on the Netflow page.

gate_requested

Defined (gate_id, prompt, opt decision — approved or rejected —, opt decided_by) but not written by current rupu: workflow approval gates are recorded on the workflow run (run.json, events.jsonl), not in agent transcripts. Readers still parse and render it so older or hand-made files display.

Unknown events

A line whose type a reader doesn’t know is not corruption. rupu reads it as an unknown event that keeps the raw type and data, and writes it back unchanged, so a transcript from a newer rupu survives being read and copied by an older one. rupu itself never writes an unknown event. rupu transcript show prints it as unrecognized event · <type> and the control plane shows an “unrecognized event” block. Your own reader should do the same: skip or pass through, never fail.

What is corruption: a line that isn’t a JSON object, has no type, or has a type that isn’t a string.

Working with transcripts

CommandWhat it does
rupu transcript listProject and global transcripts, newest first, with status and tokens. --limit N (default 50; every row beyond it costs a full read of its transcript), --archived or --all.
rupu transcript show <run>Render one transcript. <run> is a run id, a unique prefix of one, or a codename. --view focused|compact|full picks the density; --pager / --no-pager, --no-color.
rupu transcript archive <run>Move a standalone run’s transcript and its metadata into archive/. --ignore-liveness skips the still-running check.
rupu transcript delete <run> --forceDelete a standalone transcript permanently (--force is required; --ignore-liveness is separate and also required for one that still looks live).
rupu transcript prune --older-than 30dDelete archived standalone transcripts older than the cutoff (30d, 12h, 1w); --dry-run previews.

For your own tooling, the global --format jsonl on show prints the parsed events one per line in the schema above, and --format json wraps them in one transcript_show envelope. Or read the file directly:

# every bash command a run executed, with its exit code
jq -c 'select(.type=="command_run") | [.data.exit_code, (.data.argv|join(" "))]' \
  .rupu/transcripts/run_01JB7Q2M4X8T6V0K3N5R9C1D2E.jsonl

# total spend per model across a run, cache reads included
jq -s 'map(select(.type=="usage").data) | group_by(.model)
       | map({model: .[0].model, input: (map(.input_tokens)|add), output: (map(.output_tokens)|add)})' \
  .rupu/transcripts/run_01JB7Q2M4X8T6V0K3N5R9C1D2E.jsonl

# tool calls that were denied or not granted, with the decision
jq -c 'select(.type=="tool_audit" and .data.blocked) | [.data.tool, .data.decision]' .rupu/transcripts/*.jsonl

Replay

A version 2 transcript is enough to rebuild the exact conversation the provider saw, and rupu does exactly that when it continues a run: rupu run --continue <run-id>, rupu workflow resume continuing an interrupted step, and a session’s next turn. The rules, if you want to rebuild one yourself:

A version 1 transcript rebuilds without reasoning blocks, since it never kept the provider’s payload. Deltas, usage, notice, outcome, tool_grant, tool_audit, skill_index, skill_load, message, net_flow and unknown events don’t affect the conversation. The skills a run loaded are folded from its skill_load events separately, so a continued run knows what to re-attach.