Events reference
Every AgentEvent variant, when it fires, subscribe vs on<T>, and the runs-never-reject semantics.
On this page
Everything an Agent does is observable through one subscription — this is what the
TUI, the server, and --json mode all consume. AgentEvent is defined in
@arcturn/types (packages/types/src/events.ts) as one discriminated union; this page
documents every variant.
Subscribing
const off = agent.subscribe((event) => {
if (event.type === "toolEnd") {
console.log(event.result.isError ? "✗" : "✓", event.toolCallId);
}
});
// off() to unsubscribeagent.on(type, listener) is sugar over subscribe for one event, with the payload
pre-narrowed — no switch needed:
const off = agent.on("toolEnd", (event) => {
console.log(event.result.isError ? "failed" : "ok");
});Listener exceptions are swallowed by the agent — one bad subscriber can never break a run. Both forms return the same kind of unsubscribe function.
The full event table
type |
Payload | Fires when |
|---|---|---|
runStart |
sessionId, prompt (the Message) |
Once, at the start of every agent.prompt() call. |
turnStart |
turnIndex |
Before each turn's LLM call — turn 0 is the first. |
messageStream |
event (a raw StreamEvent from @arcturn/types) |
Re-emitted verbatim for every token/delta the provider streams, for UIs that render token-by-token. |
messageEnd |
message (AssistantMessage) |
Once a turn's assistant message is complete. |
toolStart |
toolCallId, toolName, input |
Just before a tool call executes (after schema validation and the permission gate pass). |
toolUpdate |
toolCallId, update (ToolUpdate: text?, details?) |
Whenever a running tool calls ctx.onUpdate(...) — zero or more times per call. |
toolEnd |
toolCallId, result (ToolResultMessage) |
Once a tool call finishes, including blocked, denied, or thrown-and-caught calls. |
permissionRequest |
request (PermissionRequest) |
Only when a check reaches the configured requester — never for a check a rule already settled. |
permissionDecision |
decision (PermissionDecision) |
Exactly once per permission check, however it resolved (rule, mode, or requester). |
todoUpdate |
todos (TodoItem[]) |
Whenever the todo tool replaces the list. |
planUpdate |
plan (string) |
Whenever the plan tool records a new plan. |
subagentStart |
agentId, task |
When the subagent tool starts a child agent. |
subagentEvent |
agentId, event (a nested AgentEvent) |
For every event the child agent emits — the child's whole stream, namespaced. |
subagentEnd |
agentId, resultText, isError |
When the child agent's prompt() call settles. |
backgroundTaskStart |
taskId, command |
A bash call with background: true starts. |
backgroundTaskOutput |
taskId, chunk |
Incremental stdout/stderr from a background task. |
backgroundTaskEnd |
taskId, exitCode (number | null) |
A background task exits. |
compactionStart |
— | Compaction begins, automatic or via agent.compact(). |
compactionEnd |
summary, tokensBefore, tokensAfter |
Compaction finishes — summary is "" when nothing was folded. |
turnEnd |
turnIndex, usage (Usage) |
After each turn's tool calls (if any) are done and before the next turn starts, or before runEnd. |
runEnd |
reason ("completed" | "aborted" | "error"), errorMessage? |
Exactly once per prompt() call, always — see below. |
notice |
level ("info" | "warn" | "error"), text |
Non-fatal diagnostics: a stale todo reminder, "nothing to compact", a compaction failure. |
runEnd and the never-rejects guarantee
agent.prompt() resolves when the model stops calling tools, the run is aborted, or a
runtime error occurs — it never rejects on a runtime failure. The outcome is always
reported through a terminal runEnd event instead:
agent.on("runEnd", (event) => {
switch (event.reason) {
case "completed":
break; // normal
case "aborted":
break; // agent.abort() was called, or the external signal fired
case "error":
console.error(event.errorMessage); // a real failure — model, provider, or a thrown hook
break;
}
});This is deliberate: a host built around subscribe should never need a try/catch
around prompt() to find out what happened — the same channel that reported every
toolStart also reports the run's fate. errorMessage is only present when
reason === "error".
Exhaustiveness and narrowing
AgentEvent is a discriminated union on type, so a switch over it is fully checked
by the compiler — there is deliberately no constants object or enum duplicating the
type strings. Two patterns worth using:
import type { AgentEvent } from "@arcturn/types";
// Exhaustiveness: adding a new event type to arcturn breaks this switch at
// compile time until you decide what to do with it.
function handle(event: AgentEvent): void {
switch (event.type) {
// ...every case...
default:
event satisfies never;
}
}import type { AgentEventType } from "@arcturn/types";
// AgentEventType names the union of every event type string, for your own signatures.
function logType(type: AgentEventType): void {
log(type);
}Sub-agent events are nested, not flattened
A subagentEvent wraps the child's entire event stream, including its own
subagentEvents if it delegates further — event.event is itself a full AgentEvent.
A UI that wants a flat timeline needs to recurse:
import type { AgentEvent } from "@arcturn/types";
function flatten(event: AgentEvent, depth = 0): void {
trace(depth.toString(), event.type);
if (event.type === "subagentEvent") flatten(event.event, depth + 1);
}See Advanced: sub-agents for how the child agent producing these events is constructed.