Skip to main content

Extension events

Events

Lifecycle Overview

Interactive trust-gated startup first emits session_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.
A 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 after session_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_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).
After a successful switch or new-session action, Atomic emits session_shutdown for the old extension instance, reloads and rebinds extensions for the new session, then emits session_start with reason: "new" | "resume" and previousSessionFile. 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.
After a successful fork or clone, Atomic emits session_shutdown for the old extension instance, reloads and rebinds extensions for the new session, then emits session_start with reason: "fork" and previousSessionFile. 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 from session_start or other session-scoped hooks.

Agent Events

before_agent_start

Fired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt.
The systemPromptOptions field gives extensions access to the same structured data Atomic uses to build the system prompt. This lets you inspect what Atomic has loaded — custom prompts, guidelines, tool snippets, context files, skills — without re-discovering resources or re-parsing flags. Use it when your extension needs to make deep, informed changes to the system prompt while respecting user-provided configuration. 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_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. Use agent_settled 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.

ui_prompt_start / ui_prompt_end

These notification-only events wrap blocking user-facing prompts. Each event has reason: "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 through ctx.ui.select(), ctx.ui.confirm(), ctx.ui.input(), ctx.ui.editor(), and ctx.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 and resume trust dialogs (including trust-hook select, confirm, and input dialogs and borrowed extension-source authorization), plus the built-in /trust selector. In isolated interactive mode, the engine owns startup/resume decisions and uses the host UI; host-owned /trust notifications are forwarded to the engine. If the current engine has not bound yet, the transport retains the start and end in order until it binds, even if the selector closes first. Separate completed dialogs retain separate lifecycle pairs when delivered together. This does not delay the trust decision; retiring that engine discards its pending notifications.
Startup first loads only permitted user/global, builtin, and explicitly authorized CLI extensions, and binds them to a real session before asking for trust. Existing handlers receive the live ExtensionContext while the dialog is waiting: ctx.cwd, ctx.sessionManager, and other session APIs are available. Approval completes resources in that same session without rerunning safe extension factories or their session_start handlers. Newly authorized project extensions receive session_start only after loading; they do not receive historical prompt events. Untrusted project and borrowed project-local code is never loaded just to observe a prompt. Silent saved/default/CLI policy decisions and noninteractive startup emit no artificial waits. Interactive resume trust dialogs use the outgoing session’s live extension context. Destination validation and session_before_switch cancellation precede trust preparation; failed preparation leaves that session active. Project resources load only when the prepared replacement continues after shutdown. Attaching subscribers does not replay earlier notifications. Atomic coalesces nested or overlapping prompts, including mixed reasons, into one shared outer span. The end event retains the original outer prompt’s reason, kind, and title and fires after every prompt in the span settles, including rejected promises and synchronous failures. Cancelling or disposing the /trust selector ends its wait. Rebinding the host UI context closes an active span before a prompt from the new context can begin. Notifications are not replayed to a replacement engine if the engine exits while a host selector is open. At session replacement, Atomic waits up to 1,000 ms for a snapshot of pending prompt notification deliveries before shutdown. Prompt display and answers never await observers. Start and end dispatch independently, invoking each observer in notification order without awaiting other observers; an earlier slow observer cannot make later subscribers receive an end before its start. An observer’s own asynchronous start and end work can overlap, so update lifecycle state before awaiting unrelated work. If an observer hangs, Atomic warns and continues replacement; its context is not guaranteed to remain valid after that finite boundary. Handlers run best-effort from the microtask queue. Atomic does not await them before opening or closing the prompt, so notifications do not block the UI.

turn_start / turn_end

Fired for each turn (one LLM response + tool calls).

message_start / message_update / message_end

Fired for message lifecycle updates.
  • message_start and message_end fire for user, assistant, and toolResult messages.
  • message_update fires for assistant streaming updates.
  • message_end handlers can return { message } to replace the finalized message. The replacement must keep the same role.

tool_execution_start / tool_execution_update / tool_execution_end

Fired for tool execution lifecycle updates. In parallel tool mode:
  • tool_execution_start is emitted in assistant source order during the preflight phase
  • tool_execution_update events may interleave across tools
  • tool_execution_end is emitted in tool completion order after each tool is finalized
  • final toolResult message events are still emitted later in assistant source order

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, so event.messages contains the rebuilt compacted context.

before_provider_headers

Fires after outgoing HTTP headers are assembled. Mutate event.headers to add, override, or remove headers. The event also identifies the provider and model.

before_provider_request

Fired after the provider-specific payload is built, right before the request is sent. Handlers run in extension load order. Returning undefined keeps the payload unchanged. Returning any other value replaces the payload for later handlers and for the actual request. 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.
This is mainly useful for debugging provider serialization and cache behavior.

after_provider_response

Fired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order.
Header availability depends on provider and transport. Providers that abstract HTTP responses may not expose headers.

Model Events

model_select

Fired when the model changes via /model command, model cycling (CTRL+P), or session restore.
Use this to update UI elements (status bars, footers) or perform model-specific initialization when the active model changes.

thinking_level_select

Fired when the thinking level changes. This is notification-only; handler return values are ignored.
Use this to update extension UI when pi.setThinkingLevel(), model changes, or built-in thinking-level controls change the active thinking level.

Tool Events

tool_call

Fired after tool_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. event.input is mutable. Mutate it in place to patch tool arguments before execution. Behavior guarantees:
  • Mutations to event.input affect the actual tool execution
  • Later tool_call handlers see mutations made by earlier handlers
  • No re-validation is performed after your mutation
  • Return values from tool_call control blocking via { block: true, reason?: string, terminate?: boolean }
  • terminate only applies to a blocked call; the agent stops early only when every finalized result in the batch is terminating
  • terminate applies only 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:
Use isToolCallEventType with explicit type parameters:

tool_result

Fired after tool execution finishes and before tool_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, details, or isError); omitted fields keep their current values
After all handlers finish, Atomic normalizes image blocks returned by the tool or inserted by a handler according to 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:
  1. Extension commands (/cmd) checked first - if found, handler runs and input event is skipped
  2. input event fires - can intercept, transform, or handle
  3. If not handled: skill commands (/skill:name) expanded to skill content
  4. If not handled: prompt templates (/template) expanded to template content
  5. Agent processing begins (before_agent_start, etc.)
Results:
  • continue - pass through unchanged (default if handler returns nothing)
  • transform - modify text/images, then continue to expansion
  • handled - skip agent entirely (first handler to return this wins)
Transforms chain across handlers. See input-transform.ts and input-transform-streaming.ts for 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 is unavailable, not an empty ready state. The workflows package also contains a pure root-activity projector. It combines a store snapshot with runtime ownership of executing stages and tools, retries, stopping runs, and acknowledged failures. Nested runs fold into one root summary; historical running status alone never counts as execution. Runnable stage handoffs remain working, while a stage parked on its own prompt contributes attention rather than execution. Independent work keeps the root working with needsAttention: true. With no work progressing, human waits and unresolved failures are blocked; paused runs are idle with reason paused, and completed or intentionally stopped runs are idle with reason quiescent. Live executor ownership also keeps a running root working with reason automatic_continuation after its nodes settle and before author code admits the next node. This requires all stages to be completed or skipped and all tools completed, with no prompt, active block, or stop. It does not add to the execution count. Historical snapshots without live ownership, paused runs, and parked nodes do not qualify. Stopping a child suppresses handoffs only in that child’s subtree, not in its parent or sibling runs. With no other active waits, a paused stage keeps the root idle with reason paused after independent execution finishes, even when the stored run status remains running. Its retained prompt does not request attention until the stage resumes; independent active waits still do. The projector does not change stored run or stage outcomes. Stopping ownership follows each run’s parentRunId chain and then its root identity, even when a named ancestor’s snapshot is absent. The root reports working with reason stopping only when all executing contributions are draining under a stop and no unaffected retry or handoff can progress. Independent work retains the usual retrying, executing, or automatic_continuation reason; a stopped run with no execution left does not select the reason. Removing history does not release runtime execution or stop ownership. The projection module’s workflowActivityNodeKey(runId, nodeId) helper builds ${runId}:${nodeId} keys for executingStageIds, executingToolNodeIds, and retryingStageIds. The first colon separates the runtime UUID run ID from the node ID, which may contain colons. Bare node IDs do not establish ownership: two runs can contain the same tool hash. stoppingRunIds and acknowledgedFailureRunIds use plain run IDs. The projector and its ownership-key helper are internal to the workflows package, not exports of the supported @bastani/atomic/workflows SDK. Extension consumers use ctx.observeWorkflowActivity rather than importing the projector. 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.
Frames are ordinary objects with a { 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. 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.

Next steps

Continue with extension UI to add user interaction. Look up the context available to event handlers in the Extension API reference.