Events
Every session.run() returns an async iterator over a closed discriminated union of typed events. The same union covers the whole loop — provider stream, tool dispatch, permission gates, compaction, skills, subagents, and the terminal result.
#Event shape
The exported Event type is a tagged union — every member carries a literal type string you can narrow on. There is no per-event envelope: events do not carry a top-level session_id, timestamp, or sequence number. Identity instead lives on specific events that need it:
SystemEventis the first event of every run and carriessession_idand a freshrun_id(per call tosession.run()). Capture them once at the top of the stream and tag your own log lines.SubagentEventwraps a child stream and carriesparent_session_idplussubagent_run_idso you can route child events into their own UI panel.- Tool events (
tool_call_start,tool_call_end, and entries insidepermission_request.requests) all carrytool_use_id— the stable correlation key from the model’stool_useblock.
Ordering is the contract, not timestamps. Events appear in the order they happen on the loop’s single yielding path; parallel tool batches interleave events in completion order, demultiplexed by tool_use_id.
import type { Message, ModelId, PermissionMode, StopReason, Usage } from "@skawld/agent-sdk"; export type Event = | SystemEvent | AssistantEvent | UserEvent | PartialAssistantEvent | ToolCallStartEvent | ToolCallEndEvent | PermissionRequestEvent | UsageEvent | CompactionEvent | ResultEvent | ErrorEvent | SkillsLoadedEvent | SkillInvokedEvent | SkillCompletedEvent | SubagentEvent;
Each event also has a matching type guard exported alongside it — isAssistantEvent, isToolCallStartEvent, isPermissionRequestEvent, and so on. Use them when a switch on event.type is inconvenient.
#Event reference
The full set of event types, in roughly the order they appear during a run. Field names and types are taken verbatim from src/core/events.ts.
#system
The first event of every session.run() — emitted before any side effects. Carries the resolved tool list (sorted), the permission mode, the working directory, and identity. The tool list reflects whatever registry the model will actually see, including any per-session override (subagents see their filtered view here).
interface SystemEvent { type: "system"; subtype: "init"; session_id: string; run_id: string; model: ModelId; tools: string[]; permission_mode: PermissionMode; // "default" | "acceptEdits" | "yolo" cwd: string; }
#skills_loaded
Emitted at most once per session, immediately after the first SystemEvent and only when the agent has at least one skill on disk. Lists every skill with its frontmatter for UI rendering. See Skills.
interface SkillsLoadedEvent { type: "skills_loaded"; skills: Array<{ name: string; description: string; when_to_use?: string; argument_hint?: string; }>; }
#user
A user-role Message was appended to the session. Fires twice per turn: once for the prompt that started the run (with the environment block + any images attached) and again for the synthetic message that aggregates tool_result blocks after each round of tool calls.
interface UserEvent { type: "user"; message: Message; // role: "user" }
#partial_assistant
Token-level deltas from the provider stream. Disabled by default — you must construct the Agent with includePartialMessages: true to see them. The complete AssistantEvent still fires at the end of the turn either way; partial_assistant is purely an incremental render feed.
Three delta shapes are emitted, distinguished by the inner kind:
text— incremental text appended to the current text block. Concatenating everytextdelta between two consecutive non-text events reconstructs that block.thinking— incremental thinking text. Only appears when the model is configured to emit extended thinking.tool_use_input— incremental JSON for a tool call’sinputargument, keyed bytool_use_id. Concatenatejson_deltachunks per id and parse at the end; the loop does the same and surfaces the parsed object intool_call_start.
interface PartialAssistantEvent { type: "partial_assistant"; delta: | { kind: "text"; text: string } | { kind: "thinking"; text: string } | { kind: "tool_use_input"; tool_use_id: string; json_delta: string }; }
Token-level deltas exist for streaming UIs, not for parsing. The loop
keeps its own assembly buffers and emits the final tool inputs in
tool_call_start after validating JSON — you should treat
partial_assistant as render-only.
#assistant
The fully-assembled assistant message for the just-finished turn, emitted once per turn after the provider stream closes. Contains every text, thinking, and tool_use block in their final order, plus the turn’s stop_reason. When stop_reason is anything other than tool_use, this is the last turn of the run.
interface AssistantEvent { type: "assistant"; message: Message; // role: "assistant", content: ContentBlock[] stop_reason: StopReason; // "end_turn" | "tool_use" | "max_tokens" | "stop_sequence" | "refusal" | "error" }
#usage
Per-turn token accounting, emitted immediately after each assistant event. usage is the just-finished turn; cumulative sums every turn in the current run.
interface UsageEvent { type: "usage"; usage: Usage; cumulative: Usage; } interface Usage { input_tokens: number; output_tokens: number; cache_read_tokens?: number; cache_creation_tokens?: number; }
#permission_request
Emitted at the start of a tool batch when one or more calls resolved to ask after rules and the mode default. The event fires before any tool_call_start for the batch and before canUseTool is invoked — a UI can render the queue of pending prompts while the callback is still pending. Calls that resolved cleanly to allow or deny do not appear in requests.
interface PermissionRequestEvent { type: "permission_request"; requests: Array<{ tool_use_id: string; // matches the upcoming tool_call_start tool_name: string; input: Record<string, unknown>; summary: string; // tool.summarize(input) }>; }
Use tool_use_id to correlate each request to the canUseTool callback invocation and to the eventual tool_call_start / tool_call_end pair. See Permissions.
#tool_call_start
Fires once per tool call, after permission resolution and before the tool’s execute() runs. The input on this event is the effective input — i.e. the validated input, replaced by updatedInput if canUseTool rewrote it. tool_name is the resolved tool’s name, or the raw block name when the tool was unknown.
interface ToolCallStartEvent { type: "tool_call_start"; tool_use_id: string; tool_name: string; input: Record<string, unknown>; // post-permission, post-rewrite }
#skill_invoked
Fires immediately before tool_call_start when the call is the Skill tool with a resolvable skill argument. Always paired with a skill_completed after the tool ends. The name has any leading / stripped. args is present only when the call passed a string args field.
interface SkillInvokedEvent { type: "skill_invoked"; name: string; args?: string; model_override?: ModelId; allowed_tools?: string[]; }
#tool_call_end
Fires once per tool call after execute() resolves (or throws). is_error is true when the tool returned a tool result with is_error: true, threw a non-abort exception, was denied by permissions, hit a resolution error, or the input was malformed. duration_ms is wall time from tool_call_start to here.
interface ToolCallEndEvent { type: "tool_call_end"; tool_use_id: string; tool_name: string; is_error: boolean; duration_ms: number; }
On abort during a parallel tool batch, the scheduler emits a synthetic
tool_call_end with is_error: true and
duration_ms: 0 for every started tool_use_id
that never reached its real end. Consumers tracking in-flight tools
never leak entries.
#skill_completed
Fires immediately after tool_call_end for a Skill invocation. is_error matches the underlying tool result.
interface SkillCompletedEvent { type: "skill_completed"; name: string; is_error: boolean; }
#compaction
Emitted at the top of a turn when the loop summarized older history to fit context, or after a recovered ContextLengthError forced an in-turn compaction. tokens_after is always 0 at emission time — no local tokenizer is available, so the accurate post-compaction count shows up in the very next UsageEvent.usage.input_tokens reported by the provider. See Compaction.
interface CompactionEvent { type: "compaction"; messages_before: number; messages_after: number; tokens_before: number; tokens_after: number; // always 0; see next UsageEvent strategy: string; }
#error
Emitted when an unrecoverable error escapes the loop — provider failures past the retry budget, turn-limit exhaustion (TurnLimitError), or any other thrown error that is not AbortError. Always followed by a result event with subtype: "error"; the run is over.
interface ErrorEvent { type: "error"; error: { name: string; message: string; retryable: boolean; cause?: unknown; }; }
Aborts do not produce an error event. Calling
session.abort() terminates the run with a single
result event whose subtype is
"aborted".
#result
The terminal event of every run. Exactly one is yielded per session.run(), after which the iterator closes. subtype distinguishes the three terminal states:
"success"— the model stopped calling tools (anystop_reasonother thantool_use).final_textholds the first text block of the final assistant message when present."aborted"— the run was cancelled viasession.abort()or the caller’sopts.signal.stop_reasonis"error"."error"— an unrecoverable error was emitted immediately before this event, or the turn cap was exceeded.stop_reasonis"error".
interface ResultEvent { type: "result"; subtype: "success" | "aborted" | "error"; stop_reason: StopReason; total_usage: Usage; duration_ms: number; // wall time from start of run to here final_text?: string; // only on subtype: "success" }
#Order in one turn
The loop is a single async generator, so events arrive in a strict sequence — with the one exception that events from concurrent tools in the same parallel batch interleave by completion order.
system — once, at the very start of
session.run().skills_loaded — once per session, only if the agent has skills on disk.
user — the prompt + env block (+ skill listing on turn 1 of a fresh session).
compaction (conditional) — if the projected token usage already exceeds the 80% threshold before the first turn.
For each turn, in order:
partial_assistant (zero or more) — only when
includePartialMessages: true.assistant — assembled message with
stop_reason.usage — this turn’s tokens + running total.
If
stop_reason !== "tool_use", jump to the terminal result.permission_request (conditional) — one event for the entire batch, listing every
ask-bound call.For each tool call (parallel-safe batches concurrent, others serial): skill_invoked (if Skill) → tool_call_start → tool-emitted events → tool_call_end → skill_completed (if Skill).
user — synthetic message wrapping the batch’s
tool_resultblocks.Next turn begins. compaction may be emitted at the top of the new turn (either threshold-based or recovered from a
ContextLengthError).
error (conditional, terminal lead-in) — when an unrecoverable error or the turn cap fires.
result — exactly one, then the iterator closes.
Parallel batches interleave. Inside one parallel batch
every concurrent tool produces its own tool_call_start →
events → tool_call_end sequence, but those sequences are
merged in arrival order. Always demultiplex tool-scoped events by
tool_use_id rather than assuming a start/end pair is
adjacent.
#Subagent events
When the model uses the Subagent tool, the runner spawns a child Session and iterates its event stream synchronously. Every child event is wrapped in a SubagentEvent envelope and pushed onto the parent’s stream. The events are never flattened — narrow on type === "subagent_event" first, then read event.event to get the underlying child event.
interface SubagentEvent { type: "subagent_event"; parent_session_id: string; subagent_run_id: string; // unique per Subagent tool call subagent_type: string; // "researcher" | "_default" | ... display_name: string; // "Researcher" or "Agent #N" event: Event; // the child Session's original event }
Nested spawns nest the envelope — if a subagent itself launches another subagent, the parent receives a subagent_event whose event is again a subagent_event. Recurse on event.type === "subagent_event" if you want to render an arbitrarily-deep tree.
The first child event is always the child’s own SystemEvent — read its session_id and run_id to bind a UI panel to the child. The last child event is always a ResultEvent; the runner uses its subtype to set the aborted / errored flags on the tool result returned to the parent. See Subagents.
#Common consumer patterns
A typical streaming UI keeps three pieces of state: the current assistant text being built up, a map of in-flight tool calls keyed by tool_use_id, and the terminal result. Switch on event.type and let TypeScript narrow each branch.
import { Session, type Event } from "@skawld/agent-sdk"; interface ToolState { name: string; input: Record<string, unknown>; status: "pending" | "running" | "ok" | "error"; duration_ms?: number; } async function render(session: Session, prompt: string) { let assistantText = ""; const tools = new Map<string, ToolState>(); for await (const event of session.run(prompt)) { switch (event.type) { case "system": console.log("run", event.run_id, "model", event.model); break; case "partial_assistant": if (event.delta.kind === "text") assistantText += event.delta.text; break; case "assistant": // Final text for the turn (overwrites any partial accumulation). for (const block of event.message.content) { if (block.type === "text") assistantText = block.text; } break; case "permission_request": for (const r of event.requests) { tools.set(r.tool_use_id, { name: r.tool_name, input: r.input, status: "pending", }); } break; case "tool_call_start": tools.set(event.tool_use_id, { name: event.tool_name, input: event.input, status: "running", }); break; case "tool_call_end": { const t = tools.get(event.tool_use_id); if (t) { t.status = event.is_error ? "error" : "ok"; t.duration_ms = event.duration_ms; } break; } case "subagent_event": // Route child events into a nested panel keyed by event.subagent_run_id. break; case "error": console.error(event.error.name, event.error.message); break; case "result": return { subtype: event.subtype, text: event.final_text ?? assistantText, usage: event.total_usage, duration_ms: event.duration_ms, }; } } }
A few patterns worth calling out:
- The
permission_requestevent lets you mark a tool aspendingin the UI beforetool_call_startfires — useful when the callback awaits a slow user confirmation. Thetool_use_idis the same across both events. - On
result, preferfinal_textwhen the subtype is"success"— it’s the loop’s authoritative extraction. Fall back to your own accumulator only for aborted/errored runs wherefinal_textis absent. - Iterate to completion.
session.run()always ends with aresultevent; breaking out of the loop early leaves tool execution and provider sockets in flight. Usesession.abort()instead.
N turns of one run share a single run_id; consecutive session.run() calls
on the same session get fresh run_ids but the same session_id. The 80%
compaction threshold means long sessions will accumulate compaction events
between turns — surface them in dev tooling, ignore them in product UIs.