Skip to content

Event vocabulary

The seam between the harness and any client (the Textual UI is one; a wire client is still possible later). Every event the harness emits — its type string and its fields — is the contract. A headless kin -p run collects the same events into its exit code + report instead of painting them; the Outpost scheduler drives kin -p subprocesses and surfaces their terminal state (done reason, needs_human) through its own job API.

src/kin/harness/events.py is the single source of truth for the type strings and field names; renaming a field breaks src/kin/tui/app.py's on_kin_event dispatch. Treat that file as load-bearing.

Shape

Each event is an Event dataclass with seq, type, and a data dict (plus .get(key, default)). Builder functions in events.py stamp a monotonic seq and return a ready-to-emit event.

@dataclass
class Event:
    seq: int
    type: str
    data: dict[str, Any] = field(default_factory=dict)

    def get(self, key: str, default: Any = None) -> Any:
        return self.data.get(key, default)

Handshake

session and ready open every session (fresh or resumed); the state-echo events below them (mode_changed, model_changed, ultracode_changed, etc.) also fire once at session start to seed the UI with the session's current state. See Sessions, resume & compaction.

session

The session opener. Emitted once, before ready.

Field Type Meaning
session_id str The session id (also shown on the StatusBar)
model str The model id the harness is calling
workspace str The session workdir
posture str The concise auto-mode containment line for the welcome banner (e.g. auto · shell sandboxed (Seatbelt)); empty in strict mode

ready

Emitted after session. The UI treats this as "cold-start done."

mode_changed

Emitted when the active permission mode changes (and once at session start).

Field Type Meaning
mode str The new mode (auto or strict)
error str Non-empty on a refused change (e.g. an unknown mode name)

model_changed

Emitted on /model <id> and once at session start. See Models & providers.

Field Type Meaning
model str The new model id (the configured alias — what the harness sends on the wire; may be "default" for a vLLM-style served-model-name alias)
error str Non-empty on a refused change

model_discovered

The active model id — what the server actually used — surfaced by a /v1/models probe (session start) or by response.model piggybacked off each completed turn. Distinct from model_changed: the latter echoes the configured alias on /model swaps, the former surfaces the resolved name. Drives the top bar's live model slot and the welcome banner's model row when it's still mounted. Both events can co-fire for one /model swap (alias toast + resolved toast).

Field Type Meaning
active str The resolved model id (canonical first served-model-name entry on a vLLM serve, or the actual model on real providers). Sanitized at capture — control chars, Unicode bidi overrides, and zero-width glyphs are stripped (see sanitize_model_id); an empty result is suppressed so the top bar never blanks out.

Display-sink hardening

The active value is server-supplied (untrusted input). The harness sanitizes it at every capture site (_fetch_active_model, _OpenAITurn._run, _AnthropicTurn._run, force_tool_call, count_tokens) AND passes markup=False on the toast so a compromised / malicious server can't phish the visible label via markup-shaped strings. The top bar / banner paths render via rich.text.Text.append(str, style=…) which treats the string as literal text (no markup interpretation). See REFERENCE.md § Display sinks for the pattern.

ultracode_changed

Emitted on /ultracode [on|off] and once at session start when a resumed session restored the flag on. Drives the ⬡ ultracode status-bar badge. See Ultracode mode.

Field Type Meaning
on bool Whether session-wide ultracode auto-orchestration is now on

planning_changed

Emitted when the session's read-only planning freeze toggles. Drives the ⏸ planning status-bar indicator (shown next to the mode badge). Mirrors ultracode_changed as a human-visible state echo, but with a deliberate asymmetry: the model can ENTER planning (dispatching the read-only planner subagent, whose write_plan call auto-enters the freeze on its first write — a capability reduction, so it's non-escalating and safe to self-trigger), while only a human EXITs it (the plan-approval modal or a bare /plan). Never persisted — a transient overlay, not a mode.

Field Type Meaning
on bool Whether the read-only planning freeze is now on

effort_changed

Emitted on /effort [low|medium|high|...] and once at session start when a resumed session restored the level on. Drives the reasoning-effort chip on the TopBar. Mirrors ultracode_changed / planning_changed shape (a state echo of a wire-time sampling knob).

Field Type Meaning
level str The reasoning-effort level now active (low / medium / high / model-specific)
error str Empty on success; non-empty if the level couldn't be applied (the UI surfaces the error in the chip)

settings_mode_changed

Emitted on /settings on|off. Drives the /settings on echo (~800-token in-conversation guide for the model — propose_settings is otherwise denied mid-plan, so this is the persistent human-only opt-in). See Model-writable settings and settings.toml keys § Editing settings from inside kin. Mirrors ultracode_changed / planning_changed as a third human-only session-wide state echo. Survives resume + compaction + clear + rewind.

Field Type Meaning
on bool Whether the session-wide /settings guide is now active

Assistant output

stream_chunk

A streaming text delta from the model. The UI groups these under one AssistantMessage per message_id.

Field Type Meaning
message_id str The assistant message this chunk belongs to
content str The delta (not the full text so far)
source str Optional carrier label

stream_done

Marks the end of streaming for one message_id. The UI finalizes the assistant block.

text

The fully assembled text for a turn segment (assistant / system / user / tool). The legacy "complete text" event the UI prefers for non-streaming paths.

reasoning

A reasoning / thinking chunk. The UI renders a collapsible Reasoning block per message_id.

Field Type Meaning
message_id str The reasoning block id
content str The reasoning chunk (may be empty on deltas)
done bool True on the final delta for this block

thinking

Soft-toggle for the model's "thinking" mode (e.g. Qwen enable_thinking).

Field Type Meaning
active bool Whether thinking is currently active
status str Optional status text

Tools

See Tools for what each tool does; this section only covers the wire shape.

tool_call

A model-issued tool invocation.

Field Type Meaning
id str The tool-call id (used to match the result)
name str The tool name
arguments Any The arguments (parsed dict for typed callers, JSON string on the legacy OpenAI wire)
source str Optional carrier label
batch_id str Present only when this call is part of a parallel batch (len(tool_calls) > 1, non-serial); a shared id the UI groups by. Absent on single / serial calls.
batch_size int Number of calls in the batch (present with batch_id)
batch_index int This call's position in the batch (present with batch_id)

tool_stream

A live-tail chunk for a tool that's still running (shell stdout, build output).

Field Type Meaning
id str The tool-call id this tail belongs to
name str The tool name
message str The new tail text
parent_tool_call_id str For subagent tool calls — the parent's task tool-call id so the UI can nest the stream
spawns_agent bool True when this progress line is the signal that the tool call just became a real subagent spawn (task's foreground branch — including when the model dispatches critic itself from present_plan's adversarial-review option) — the UI flips the already-mounted tool-call row into a live-updating agent-mode timer instead of a one-shot tail line. Defaults False.

tool_call_draft

A throttled progress event while the model is still streaming a tool call's arguments. Carries the tool name (once known) and a cumulative raw-JSON char count so the status bar can flip to drafting <name>… N chars while a long write_file body or shell command is in flight. Never carries argument content itself (a draft may include api_key values, secrets, or shell command bodies); the UI is responsible for a name + length only. Dropped by ForwardingEmit for subagent children (keeps the parent row's label stable). Headless runs deliver tool_call_draft to on_event taps and the journal the same as any other event — they simply don't paint (no contribution to HeadlessResult.text).

Field Type Meaning
name str The tool name once the model has announced a tool_use block; empty until then
chars int Cumulative raw-JSON character count streamed so far

tool_result

A tool's outcome.

Field Type Meaning
id str The tool-call id
name str The tool name
result Any The legacy string form of the result
error str Non-empty on a refused / failed call
parent_tool_call_id str Subagent nesting (see tool_stream)
content_blocks list \| None Structured payload (text / image / notebook cell); omitted for plain-string results
batch_id / batch_size / batch_index str / int / int Parallel-batch grouping (mirrors tool_call); present only on calls from a len > 1 non-serial batch

Blocking events

Blocking events park the turn on an in-process Future keyed by a correlation id; the UI's resolve_* modal workers unblock the turn. The answer encoding lives in REFERENCE.md § "Event contract & IPC". A headless run has no modal to resolve one of these — it resolves the blocking event as a structured needs-human tool error instead of parking forever.

approval_request

A tool call that needs your decision. See Modes & permissions for what ALLOW / ASK / DENY / SANDBOX mean per mode.

Field Type Meaning
id str The correlation id (used by resolve_approval)
tool str The tool name
arguments Any The arguments (rendered in the modal)
scope str Human-readable scope label for an "always allow this session" choice

question

A single- or multi-select (or free-text) question.

Field Type Meaning
id str The correlation id
question str The prompt text
header str Short header
options list[dict] The option list
allow_other bool Allow a free-text "other"
multi_select bool Multi-select

plan_ready

A finished plan presented for approval (present_plan), parking the planning freeze until you choose.

Field Type Meaning
id str The correlation id
plan_path str Path to the saved plan file (under <workdir>/.kin/plans/) — always populated, since write_plan persists unconditionally
content str The plan body
options list[dict] The three action choices — Approve & execute (keep context), Approve, clear & re-inject (reseed just the plan + an execution preamble), Review plan first (adversarial critic). A dismissal (esc) keeps planning

proposal_ready

A model-proposed settings change parked on the artifact-edit ProposalModal (the propose_settings tool — see settings.toml keys § Editing settings from inside kin), parking the turn until you approve or reject the diff. Mirrors plan_ready, but the modal answer is the string "apply" / "reject" (decoded by resolve_proposal), not the int index plan_ready carries.

Field Type Meaning
id str The correlation id
label str The artifact label (e.g. "settings")
path str The file the change targets (~/.kin/settings.toml)
content str The unified diff plus the harness-generated refusal banner naming any human-only keys the model tried to set (deterministic tool text the model's narrative can't suppress)

Status / lifecycle

context_usage

The context-window meter.

Field Type Meaning
usage float Fraction of the window used (0.01.0)
used int Total prompt tokens
max int The window size
cached int Cached tokens (if the carrier reports them)
level str Optional override (warning / critical)
source str The budget side the count is from

todos

The pinned task-panel contents. Full rewrite on every todos tool call. See Tasks vs todos for how this differs from the tasks DAG below.

Field Type Meaning
todos list[dict] The todo items (content, statuspending / in_progress / completed)

Background subagent lifecycle

Three events layer on top of the existing tool_call / tool_result plumbing (which still re-stamps through ForwardingEmit). They drive the agent panel (Ctrl+O) and the dispatcher (seen-set + per-turn cap). All three carry an agent_id so the panel + completion-reminder path can correlate. See Subagents for the task tool and agent profiles these events describe.

None of these events carry the subagent's prose — the model-facing reminder is metadata-only (a bg agent could have fetched web content); the human-facing panel reads it from the registry buffer.

subagent_dispatched

Emitted when a bg subagent is spawned (task(run_in_background=true)). The panel paints the new row immediately.

Field Type Meaning
agent_id str The bg agent id (e.g. agent1)
profile str The profile name (general / researcher / coder / custom)
prompt_preview str First line of the prompt, truncated to 60 chars (for the row)
parent_tool_call_id str The parent's task tool-call id — the UI registers it so the agent's forwarded child tool calls fold into its ambient-strip row (live activity), not the transcript

subagent_progress

Reserved for future streaming progress; emitted today as a no-op placeholder.

Field Type Meaning
agent_id str The bg agent id
phase str The phase label
message str Optional message text

subagent_completed

Emitted on a bg subagent's terminal state. The panel flips the row's status; the model's next depth-0 turn gets a metadata-only reminder.

Field Type Meaning
agent_id str The bg agent id
status str ok / error / killed
duration_s float Wall-clock seconds from spawn to completion
tool_calls int Number of tool calls made by the subagent
prompt_tokens int Prompt tokens the subagent used
completion_tokens int Completion tokens (0 on backends that don't report)

Tasks DAG

See Tasks for the tasks tool this event mirrors.

tasks_changed

Emitted after every tasks tool call. Carries the full post-mutation DAG snapshot — the same authoritative shape the tool returns, so the tool_result and the event agree (and the panel reads the same shape the model sees).

Field Type Meaning
snapshot dict {tasks: [{id, content, status, blockedBy, blocks, ...}, ...]}

Dynamic workflows

Telemetry for a workflow tool run (a model-authored orchestration script fanning out subagents). All four carry the workflow_id (the tool-call id) so a client can correlate a run's started → phase/log/agent → finished arc. The authoritative state lives in Session.workflows (the /workflows polling modal reads it there, mirroring the agent panel); these events are the human-facing transcript breadcrumbs. None carry subagent prose — the only thing re-entering model context is the tool's returned string.

workflow_started

Emitted when a workflow run begins (after approval).

Field Type Meaning
workflow_id str The workflow's tool-call id
goal str The one-line goal shown at approval

workflow_phase

Emitted on each phase(title) call in the script.

Field Type Meaning
workflow_id str The workflow's tool-call id
title str The new phase title

workflow_log

Emitted on each log(message) call in the script.

Field Type Meaning
workflow_id str The workflow's tool-call id
message str The log line

workflow_agent_started

Emitted when a subagent appears in a workflow fan-out (the agent() primitive is called). The renderer learns the agent exists and its row label here; later state changes come via workflow_agent_status.

Field Type Meaning
workflow_id str The workflow's tool-call id
agent_id str The agent's id, minted as <workflow_id>:<n>
label str The row name — the label opt, else a prompt truncation
status str Starts at queued

workflow_agent_status

Emitted on each per-agent transition: queued → running → done / error, plus two extra states — structuring (the brief forced-tool / prompt-JSON call after a schema agent's own loop ends — see structured output) and cached (a resume hit replayed the result from the run-journal instead of dispatching — see resume). The renderer keys on agent_id to update the row.

Field Type Meaning
workflow_id str The workflow's tool-call id
agent_id str The agent whose state changed
status str queued / running / structuring / done / error / cached

workflow_finished

Emitted on the run's terminal state.

Field Type Meaning
workflow_id str The workflow's tool-call id
status str done / error / cancelled
agents_run int Total subagents the run spawned

A workflow tool call mounts a WorkflowCard (anchored on the tool_call(name="workflow") event, which carries the goal and the script — the card pre-parses the script's phase("…") literals into its upcoming-steps trail), and the renderer registers that id as a suppression key: every forwarded child tool_call / tool_result / tool_stream stamped with that parent_tool_call_id is dropped, so the card replaces the subagents' tool-call firehose. The card is driven by workflow_phase (the trail + the status-bar spinner label) + workflow_agent_started / workflow_agent_status (the rows) + workflow_log (the card's one-line narrator; the /workflows panel keeps the full history) and finalized by the workflow's own tool_result (which carries the distilled string). workflow_started / workflow_finished are no-ops in the transcript (the card is the surface).

Terminal

done

Emitted at the end of every turn. In a headless run most non-clean reason values map directly onto the exit-code contract (turn_cap/token_budget → exit 1, an unresolved blocking event → exit 2).

Field Type Meaning
reason str stop (clean) / error / loop_detected / interrupted / turn_cap / token_budget / truncated / retired

Non-clean reasons mount a SystemNote + transient notify; clean / empty reasons stay silent. turn_cap and token_budget are the fleet-safety caps; loop_detected is the doom-loop guard.

interrupted

Emitted when you press Esc mid-turn (after the worker re-raises CancelledError and any in-flight tools clean up). See Rewind & retry for what happens to an interrupted turn's history.

error

A harness-level error (back-end outage that exhausted retries, a config that fails resolution). A headless run's error paths are documented in Headless runs § Exit codes.

Field Type Meaning
message str The error message
recoverable bool Guidance hint, not a retry gate. true for a transient/endpoint-reachability failure (a dead port, a 5xx/429 after retries exhausted); false (the default) for a request rejection (a 4xx — bad api key / model id / base url) the user must fix first. The UI renders different guidance from it ("the endpoint may be unreachable …" vs "the request was rejected …") and offers a focusable retry control either way. Set only at the permanent backend-error emit site (loop.py, re-running _is_transient on the cause); every other error emit site leaves the false default.
redrive bool Retry gate — distinct from recoverable. true ONLY at the single turn-path backend-hard-error emit site (loop.py's permanent-error branch). Drives the UI's RetryNote affordance + the safe re-drive path: Session.pop_for_redrive() pops the failed turn's user message + trailing partial-assistant and rewrites the journal so a re-send lands ONE fresh user turn (no dup). Every other error site leaves the false default — a /compact or workflow-failure emit, or a generic catch in _run_turn, would otherwise re-drive into the prior COMPLETED turn and silently erase user input. Companion to recoverable: the guidance bit tells the UI what to render; the redrive bit tells it whether to enable the retry button.