v0.1.0GitHubnpm
Concepts

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:

  • SystemEvent is the first event of every run and carries session_id and a fresh run_id (per call to session.run()). Capture them once at the top of the stream and tag your own log lines.
  • SubagentEvent wraps a child stream and carries parent_session_id plus subagent_run_id so you can route child events into their own UI panel.
  • Tool events (tool_call_start, tool_call_end, and entries inside permission_request.requests) all carry tool_use_id — the stable correlation key from the model’s tool_use block.

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.

events.ts
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).

SystemEvent.ts
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.

SkillsLoadedEvent.ts
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.

UserEvent.ts
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 every text delta 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’s input argument, keyed by tool_use_id. Concatenate json_delta chunks per id and parse at the end; the loop does the same and surfaces the parsed object in tool_call_start.
PartialAssistantEvent.ts
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.

AssistantEvent.ts
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.

UsageEvent.ts
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.

PermissionRequestEvent.ts
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.

ToolCallStartEvent.ts
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.

SkillInvokedEvent.ts
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.

ToolCallEndEvent.ts
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.

SkillCompletedEvent.ts
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.

CompactionEvent.ts
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.

ErrorEvent.ts
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 (any stop_reason other than tool_use). final_text holds the first text block of the final assistant message when present.
  • "aborted" — the run was cancelled via session.abort() or the caller’s opts.signal. stop_reason is "error".
  • "error" — an unrecoverable error was emitted immediately before this event, or the turn cap was exceeded. stop_reason is "error".
ResultEvent.ts
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.

  1. system — once, at the very start of session.run().

  2. skills_loaded — once per session, only if the agent has skills on disk.

  3. user — the prompt + env block (+ skill listing on turn 1 of a fresh session).

  4. compaction (conditional) — if the projected token usage already exceeds the 80% threshold before the first turn.

  5. For each turn, in order:

    1. partial_assistant (zero or more) — only when includePartialMessages: true.

    2. assistant — assembled message with stop_reason.

    3. usage — this turn’s tokens + running total.

    4. If stop_reason !== "tool_use", jump to the terminal result.

    5. permission_request (conditional) — one event for the entire batch, listing every ask-bound call.

    6. 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).

    7. user — synthetic message wrapping the batch’s tool_result blocks.

    8. Next turn begins. compaction may be emitted at the top of the new turn (either threshold-based or recovered from a ContextLengthError).

  6. error (conditional, terminal lead-in) — when an unrecoverable error or the turn cap fires.

  7. 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.

SubagentEvent.ts
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.

consumer.ts
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_request event lets you mark a tool as pending in the UI before tool_call_start fires — useful when the callback awaits a slow user confirmation. The tool_use_id is the same across both events.
  • On result, prefer final_text when the subtype is "success" — it’s the loop’s authoritative extraction. Fall back to your own accumulator only for aborted/errored runs where final_text is absent.
  • Iterate to completion. session.run() always ends with a result event; breaking out of the loop early leaves tool execution and provider sockets in flight. Use session.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.