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.0–1.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, status ∈ pending / 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. |