Extension events
Events
Lifecycle Overview
Interactive trust-gated startup first emitssession_start and resources_discover for the permitted trust-safe extensions, then resolves project_trust. After authorization, newly loaded extensions receive session_start; resource discovery runs again against the completed set. Existing reporters keep their session and do not receive a second session_start. Noninteractive startup resolves trust before the ordinary session lifecycle.
Startup Events
project_trust
Fired before Atomic decides whether to trust a project with dynamic configs (.atomic, legacy .pi, or .agents/skills). It runs during startup and when session replacement (for example /resume) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI -e extensions participate; project-local extensions are not loaded until after trust is resolved.
project_trust handler must return { trusted: "yes" | "no" | "undecided" }. A user/global or CLI extension that returns "yes" or "no" owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use remember: true to persist a yes/no decision; otherwise it applies only to the current process. Return "undecided" to let later handlers or the built-in trust flow decide. Check ctx.hasUI before prompting. If no handler returns yes/no, normal trust resolution continues: saved trust.json decisions apply first, then defaultProjectTrust controls whether Atomic asks, trusts, or declines by default.
Resource Events
resources_discover
Fired aftersession_start so extensions can contribute additional skill, prompt, and theme paths.
The startup path uses reason: "startup". Reload uses reason: "reload".
Session Events
See Session Format for session storage internals and the SessionManager API.session_start
Fired when a session is started, loaded, or reloaded.session_recovery_restore / session_recovery_replay
Fired once per session when the host recovers work an earlier process left unfinished. The interactive host does this after the transcript is visible. SDK and RPC hosts do it when they callsession.startPendingRecovery() or send start_pending_recovery. Startup binding never fires them.
session_recovery_restore comes first. Restore background work here, such as child agents.
session_recovery_replay follows, but only when delivery cards that were shown before the exit never reached the model. Return a drop decision for each card whose sender or target no longer exists; the card is closed and never offered again. Return defer when you cannot tell yet, for example because the peer’s service is unreachable: the card stays pending without a turn, and the next recovery of the session offers it again. If handlers disagree, drop wins over defer. Cards with no decision are replayed in a visible turn.
Notices from either event go into one message for the user and the agent. That message never starts a turn.
session_info_changed
Fired when the current session display name is set via/name, RPC, or pi.setSessionName().
session_before_switch
Fired before starting a new session (/new) or switching sessions (/resume).
session_shutdown for the old extension instance, reloads and rebinds extensions for the new session, then emits session_start with reason: "new" | "resume" and previousSessionId.
Do cleanup work in session_shutdown, then reestablish any in-memory state in session_start.
session_before_fork
Fired when forking via/fork or cloning via /clone.
session_shutdown for the old extension instance, reloads and rebinds extensions for the new session, then emits session_start with reason: "fork" and previousSessionId.
Do cleanup work in session_shutdown, then reestablish any in-memory state in session_start.
session_before_compact / session_compact / session_compact_failed
Fired by/compact and auto-compaction, including a threshold crossing detected after tool results enter the prospective next-turn context. Atomic prepares the complete active transcript except for the exact newest preserve_recent context-visible messages. Extensions may cancel or provide a complete, non-empty compactedText replacement for that region; they cannot move firstKeptEntryId. The override is persisted verbatim and works without provider credentials. A successful post-tool compaction returns its rebuilt context directly to the already-active Pi loop; it does not start a separate continuation. Cancellation or failure prevents that loop’s follow-up provider request.
session_before_tree / session_tree
Fired on/tree navigation. See Sessions for tree navigation concepts.
session_shutdown
Fired before a started session runtime is torn down. Use this to clean up resources opened fromsession_start or other session-scoped hooks.
fromSignal is set only on reason: "quit". Ctrl+D and /quit leave it unset. Use it to tell a deliberate exit from the host stopping the process, for example to keep externally registered state that a restart can resume from.
Agent Events
before_agent_start
Fired before a run starts from a user prompt or frompi.sendMessage(..., { triggerTurn: true }) while idle. Can inject a message and/or modify the system prompt for the whole run, including requests after tool calls. Messages sent while this hook runs join the starting run rather than starting a second run.
Check event.messageType before reading the starting input. A "user" event has prompt and optional images; a "custom" event has message, the custom message that started the run. Existing handlers that read event.prompt or event.images should first narrow to event.messageType === "user".
systemPromptOptions collections are mutable. Prefer editing sections, selectedTools, or promptGuidelines: Atomic records only changed prompt sections and tool declarations as chronological system messages. Editing selectedTools, or calling pi.setActiveTools() inside the handler, changes the same request’s executable tools as well as their prompt contributions.
Returning systemPrompt, or setting forceSystemPrompt, replaces the complete provider prompt for the run without recording the forced text. The transcript still records structured sections. Models supporting mid-conversation system messages receive patches in place; other models receive the replayed leading prompt. A forced replacement or unsupported tool transition can invalidate the cached prefix.
Inside before_agent_start, event.systemPrompt and ctx.getSystemPrompt() both reflect the chained system prompt as of the current handler. Later before_agent_start handlers can still modify it again.
agent_start / agent_end / agent_before_settle / agent_settled
agent_start begins a low-level run. agent_end fires when that run ends, but Atomic may still retry, compact and retry, or deliver queued follow-ups. agent_before_settle is the final actionable boundary: it can append session entries and request one continuation. agent_settled is final and notification-only; use it when a status integration needs to know Atomic has no automatic continuation left, including a chain of repeated output-cap continuations. Silence during a provider request or between these runs is not settlement.
agent_settled includes aborted: true when the prompt was cancelled, including cancellation during agent_before_settle. Otherwise it is false. Use this field to distinguish cancellation from normal completion; an aborted prompt still emits the final settlement notification.
agent_before_settle handlers are running, valid returned entries are still committed, but requested continuation is suppressed. Work requested from agent_settled is deferred until every settled handler completes, so notification dispatch is non-reentrant. Input sent from an agent_before_settle handler with deliverAs: "steer" or "followUp" queues for the requested continuation instead of starting a second run.
Each provider request is built from the persisted session after every queued handler for the events before it (agent_start, turn_start, message_start, message_end, tool_execution_*) has finished, because a message_end handler may replace the message that gets persisted. Handlers run without blocking the event that triggered them, but a handler that waits for something later in the same run, such as the provider’s response, stalls that run; wait in agent_end or agent_settled instead.
ui_prompt_start / ui_prompt_end
These notification-only events wrap blocking user-facing prompts. Each event hasreason: "ui_prompt" | "project_trust", the prompt kind, and the prompt title when available. Host and status integrations can use the pair to distinguish waiting for the user from active work.
ui_prompt: extension prompts opened throughctx.ui.select(),ctx.ui.confirm(),ctx.ui.input(),ctx.ui.editor(), andctx.ui.custom(). Custom inspection/navigation components can pass{ purpose: "navigation" }to omit their own prompt span. The default remains"prompt". Nested approval calls still emit events; mounting or hiding the workflow graph is not itself an approval.project_trust: interactive startup/resume trust dialogs, trust-hook dialogs, borrowed extension-source authorization, and the built-in/trustselector. Silent saved/default/CLI decisions and noninteractive startup do not emit artificial waits.
turn_start / turn_end
Fired for each turn (one LLM response + tool calls).turn_end runs after the assistant and tool-result messages have been persisted and before the low-level turn_end event. Retry backoff and final-attempt recovery still happen after agent_end, preserving their existing lifecycle and queue ordering; agent_before_settle sees the repaired projection after that work completes. Boundary handlers run in extension load and registration order. Each handler sees prior proposals in event.entries and sees event.context rebuilt from them (contextEntries, contextMessages, llmMessages, pendingMessages, canContinue). Returning entries or continue replaces only that field; omitted fields preserve the current proposal. Allowed draft entry types are custom, custom_message, context_edit, and compaction (summary is the compacted transcript text; firstKeptEntryId: null keeps no preceding entries). The complete proposal is validated before it is appended in list order after all handlers finish; a handler error is reported and later handlers still run. Validation prevents partially applied semantic errors, but persistence is not transactional.
continue: true ensures one next provider request for that boundary invocation. If tool results, steering, or a follow-up already cause that request, they satisfy the decision and no additional request is made; otherwise Atomic makes one context-only request. Error and aborted responses remain hard exits. continue: false never suppresses natural work. Guard continuation conditions: an unconditional continue: true is evaluated again after the next response and can create an endless loop. A custom_message draft contributes a user-role model message but is extension-authored: it does not run human input hooks, slash commands, skills, or prompt templates.
Host integrations that construct TurnEndEvent values must provide messageEntryId, toolResultEntryIds, outcome, entries, continue, and context. ExtensionEvent exhaustive switches must also handle agent_before_settle. ExtensionRunner.emit() excludes actionable turn boundaries; dispatch turn_end and agent_before_settle through emitBoundary(baseEvent, buildContext) so handlers receive chained previews.
message_start / message_update / message_end
Fired for message lifecycle updates.message_startandmessage_endfire for user, assistant, and toolResult messages.message_updatefires for assistant streaming updates.message_endhandlers can return{ message }to replace the finalized message. The replacement must keep the samerole.
tool_execution_start / tool_execution_update / tool_execution_end
Fired for tool execution lifecycle updates. In parallel tool mode:tool_execution_startis emitted in assistant source order during the preflight phasetool_execution_updateevents may interleave across toolstool_execution_endis emitted in tool completion order after each tool is finalized- final
toolResultmessage events are still emitted later in assistant source order
durationMs is the time spent in the tool’s execute(), measured with a monotonic clock. It is absent when the tool did not run.
context
Fired before each LLM call. Modify messages non-destructively. See Session Format for message types. When tool output crosses the buffered compaction threshold, the post-tool compaction preflight finishes before this hook runs for the follow-up call, soevent.messages contains the rebuilt compacted context.
event.messages holds the conversation without system messages. The prompt and tool declarations belong to Atomic and are not part of this hook: when the handler returns a changed list (or edits event.messages in place), Atomic replays the current prompt sections and tool declarations into one leading system message ahead of the returned messages. Filtering, windowing, or slicing from a compaction boundary therefore cannot drop the prompt or the tools. An unchanged list keeps mid-conversation system messages in place, so models that accept them retain their cached prefix. System messages a handler adds are kept after Atomic’s head. To change the prompt or the tool set durably, use before_agent_start or pi.setActiveTools(); to edit system messages for one request, use context_with_system.
context_with_system
Fired before each LLM call, after everycontext handler has run and Atomic has restored the prompt and tool state. event.messages is the full transcript, including the leading system message and any mid-conversation prompt or tool patches (see Session Format). The returned messages are sent as they are: this hook owns the prompt and tool declarations for the request.
getCurrentSystemPrompt() and getCurrentTools() from @bastani/pi-ai. Handlers run in extension load order; a systemPrompt forced from before_agent_start is still projected onto the request afterwards.
before_provider_headers
Fires after outgoing HTTP headers are assembled. Mutateevent.headers to add, override, or remove headers. The event also identifies the provider and model. Atomic’s default User-Agent and provider attribution headers (OpenRouter, NVIDIA NIM, OpenCode) are added after this event, so handlers do not see them; headers you set here or in models.json take precedence over them. For OpenCode models the x-opencode-session header is already present, so a handler can read, replace, or remove it.
When Atomic retries a request with credentials saved by another process after a credential rejection, this event fires again for the retry.
before_provider_request
Fired after the provider-specific payload is built, right before the request is sent. Handlers run in extension load order. Returningundefined keeps the payload unchanged. Returning any other value replaces the payload for later handlers and for the actual request.
Like before_provider_headers, it fires again when Atomic retries a request after a credential rejection.
This hook can rewrite provider-level system instructions or remove them entirely. Those payload-level changes are not reflected by ctx.getSystemPrompt(), which reports Atomic’s system prompt string rather than the final serialized provider payload.
The payload is the full request. Providers that continue from a previous response (OpenAI Codex over WebSocket, Anthropic message threads) may send only the part that is new since the previous request.
after_provider_response
Fired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order.provider_stream_event
Fired for each parsed provider stream event before Atomic normalizes it into assistant message events. Use it to inspect provider-specific fields that Atomic does not keep in the assistant message.event.data is the earliest structured value available to Atomic, not necessarily the original HTTP bytes or SSE frame, and SDK-backed providers expose only the fields their SDK keeps. Treat it as read-only: changing it can change how the response is normalized. The event is notification-only and is not saved to the session. Cache-warming requests do not emit it.
Handlers run in stream order and are awaited, so a slow handler delays the response. Handler errors are reported without changing the provider response. Custom providers registered with streamSimple emit this event only if they call options.onProviderStreamEvent. See debug-provider.ts for an opt-in viewer.
Model Events
model_select
Fired when the model changes via/model command, model cycling (CTRL+P), or session restore.
thinking_level_select
Fired when the thinking level changes. This is notification-only; handler return values are ignored.pi.setThinkingLevel(), model changes, or built-in thinking-level controls change the active thinking level.
Tool Events
tool_call
Fired aftertool_execution_start, before the tool executes. Can block. Use isToolCallEventType to narrow and get typed inputs.
Before tool_call runs, Atomic waits for previously emitted Agent events to finish draining through AgentSession. This means ctx.sessionManager is up to date through the current assistant tool-calling message.
In the default parallel tool execution mode, sibling tool calls from the same assistant message are preflighted sequentially, then executed concurrently. tool_call is not guaranteed to see sibling tool results from that same assistant message in ctx.sessionManager.
Nested calls made inside a tool through ctx.executeTool() run the same hooks and execution events. Their events include parentToolCallId; use that field to distinguish them from model-issued calls. Nested calls do not add standalone transcript messages. See nested tools.
event.input is mutable. Mutate it in place to patch tool arguments before execution.
Behavior guarantees:
- Mutations to
event.inputaffect the actual tool execution - Later
tool_callhandlers see mutations made by earlier handlers - No re-validation is performed after your mutation
- Return values from
tool_callcontrol blocking via{ block: true, reason?: string, terminate?: boolean } terminateonly applies to a blocked call; the agent stops early only when every finalized result in the batch is terminating
Typing custom tool input
Custom tools should export their input type:isToolCallEventType with explicit type parameters:
tool_result
Fired after tool execution finishes and beforetool_execution_end plus the final tool result message events are emitted. Can modify result.
In parallel tool mode, tool_result and tool_execution_end may interleave in tool completion order, while final toolResult message events are still emitted later in assistant source order.
tool_result handlers chain like middleware:
- Handlers run in extension load order
- Each handler sees the latest result after previous handler changes
- Handlers can return partial patches (
content,structuredContent,details,usage, orisError); omitted fields keep their current values, except replacingcontentwithout replacingstructuredContentdrops the structured value
images.autoResize before saving the result to history. If image processing fails, the original image remains in the result.
Use ctx.signal for nested async work inside the handler. This lets Escape cancel model calls, fetch(), and other abort-aware operations started by the extension.
User Bash Events
user_bash
Fired when user executes! or !! commands. Can intercept.
Input Events
input
Fired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so/skill:foo and /template are not yet expanded.
Direct session.steer() and session.followUp() calls also run input handlers before skill/template expansion and queue admission. A handled input is not queued; transformed text and images are queued instead. Their optional third argument sets source, defaulting to interactive; RPC queue commands use rpc.
Processing order:
- Extension commands (
/cmd) checked first - if found, handler runs and input event is skipped inputevent fires - can intercept, transform, or handle- If not handled: skill commands (
/skill:name) expanded to skill content - If not handled: prompt templates (
/template) expanded to template content - Agent processing begins (
before_agent_start, etc.)
continue- pass through unchanged (default if handler returns nothing)transform- modify text/images, then continue to expansionhandled- skip agent entirely (first handler to return this wins)
streamingBehavior-aware routing.
Workflow activity and lifecycle hooks
The host exposes typed workflow observation contracts. A workflow provider must register and publish activity; these APIs alone do not connect the workflow scheduler. Without a publisher snapshot, availability isunavailable, not an empty ready state.
Use ctx.observeWorkflowActivity rather than importing internal workflow helpers. Root summaries distinguish live work from stored history: independent work remains working even when another stage needs attention; paused roots are idle, and unresolved waits without progressing work are blocked. A control event does not prove execution has stopped.
The workflows extension registers a publisher on activation and publishes this activity stream for its owning session: root snapshots and changes, plus workflow_lifecycle, workflow_stage_completed, and workflow_heartbeat hooks (the runtime state table is in workflows/operations.md). It does not change chat notifications. The built-in Herdr reporter consumes this stream to reflect workflow execution and human-input waits in the owning pane.
Run control actions describe the caller’s request: an already-aborted caller signal still emits
kill after run registration, and a whole-run pause at a task-result checkpoint emits pause while graceful suspension retains the paused outcome and exitReason: "quit". A control event alone does not mean execution has drained.
Use ctx.observeWorkflowActivity for status consumers. Registration captures a snapshot atomically with attaching the observer. Delivery is asynchronous, snapshot first, then FIFO updates. Each callback finishes before the next callback for that observer starts; a slow observer does not delay the publisher or other observers.
{ epoch: string, revision: number } cursor. Revisions increase within an epoch; lifecycle publication may leave gaps between activity revisions. A new publisher starts a new epoch and an unavailable snapshot. Never compare revision numbers across epochs. A ready snapshot has a roots array, including an empty array when known empty. recovering and unavailable snapshots omit roots. Subsequent snapshots replace all prior knowledge. Changes replace a complete root, not increment counters. Removals contain rootRunId. Root summaries include state, reason, execution/wait counts, and needsAttention. Roots published by the workflows extension also carry an optional graph: { nodes }. It holds the root’s expanded workflow graph, including the nodes of nested child workflows. Each node is a stage or tool node with id, runId, nodeId, name, status, parentIds, depth, and executionOrder when it is known. Tool nodes add their ordinal. Stages waiting on a prompt add pendingPrompt (id, kind, message, choices, createdAt). Prompts raised by a run rather than a stage appear in graph.runPrompts, each with the same fields plus its runId. A node status change publishes a changed frame. Treat graph as optional, because other publishers may omit it.
The workflows extension also calls pi.registerWorkflowRunControl(control) with a WorkflowRunControl to back session.workflows, the typed pause, quit, and resume API for SDK hosts (see Workflow run control). SDK hosts follow this same activity stream through session.workflows.observe(observer). The registration is released with the extension generation, and only the most recent registration is active.
Providers call pi.registerWorkflowActivityPublisher() and retain its returned WorkflowActivityPublisher. Its methods are publishSnapshot({ availability: "ready", roots }), publishSnapshot({ availability: "recovering" | "unavailable" }), publishChanged(root), publishRemoved(rootRunId), publishLifecycle(event), publishHeartbeat(event), and dispose(). Lifecycle input includes type: "workflow_lifecycle" and the envelope except cursor, which the host supplies. Heartbeat input includes type: "workflow_heartbeat". IDs, names, timestamps, zero counts and optional attribution are preserved. Roots are keyed by rootRunId; duplicate snapshot IDs use the last value at the first insertion position. Removing an absent ID is permitted. Changes do not turn an unknown source into ready; publish a snapshot to establish readiness.
Workflow hooks published during extension factory initialization are retained until the runner binds dispatch, then delivered asynchronously in publication order before later live publications. Buffered events keep their original payloads and cursors; publisher retirement and runner disposal fence them just like live events.
Observation leases and publisher disposal are idempotent. Runner retirement on reload disposes every observer and fences publishers. Already-running callbacks cannot be cancelled, but no queued observer callbacks run after disposal. Runner retirement, publisher disposal, and publisher replacement also fence every workflow hook handler that has not started, including later handlers in the same or another extension when a previous handler is awaiting. Already-published activity frames remain ordered before the new source snapshot. Activity recovery never synthesizes lifecycle completions; explicit lifecycle replay retains the supplied event ID and delivery: "replay".
The host hub retains at most 256 diagnostics, available through its host-side diagnostics() inspection API. These are diagnostic records, not thrown observation errors:
ObserverDisposed: an observation lease was retired.SourceRecovering: the provider is hydrating state.SourceUnavailable: no current source snapshot is known.ObserverDeliveryFailed: a callback threw or rejected; other observers and publication continue.ObserverOverflow: a per-observer queue reached its 256-frame limit. Pending frames are cleared and a fresh snapshot replaces them, invalidating continuity instead of silently losing updates.PublisherFenced: a disposed or superseded publisher attempted publication.