Skip to main content

Extension API reference

ExtensionContext

All handlers receive ctx: ExtensionContext.

ctx.ui

UI methods for user interaction. See Custom UI for full details.

ctx.hasUI

false in print mode (-p) and JSON mode. true in interactive and RPC mode. In RPC mode, dialog methods (select, confirm, input, editor) work via the extension UI sub-protocol, and fire-and-forget methods (notify, setStatus, setWidget, setTitle, setEditorText) emit requests to the client. Some TUI-specific methods are no-ops or return defaults (see RPC mode).

ctx.cwd

Current working directory. Built-in cwd-sensitive tools (read, write, edit, search, find, ls, bash, powershell) resolve relative paths against ctx.cwd when an extension invokes them, falling back to the cwd captured when the tool was created. An extension that registers a tool and forwards its own context therefore gets paths resolved against the live session cwd rather than a stale one. Use CONFIG_DIR_NAME instead of hardcoding .atomic (or legacy .pi) when constructing project-local config paths. Rebranded distributions can use a different config directory name.

ctx.isProjectTrusted()

Returns whether project-local trust is active for the current session context. This includes temporary trust decisions and CLI trust overrides, not just saved decisions in the global trust store. Use this before reading project-local extension configuration that should only be honored for trusted projects.

ctx.sessionManager

Read-only access to session state. See Session Format for the full SessionManager API and entry types. For tool_call, this state is synchronized through the current assistant message before handlers run. In parallel tool execution mode it is still not guaranteed to include sibling tool results from the same assistant message.

ctx.modelRegistry / ctx.model / ctx.scopedModels

Access models, auth state, and provider-aware requests. Use ctx.modelRegistry.complete() for an extension model request that must use Atomic’s provider composition. It dispatches through the active ModelRuntime, retaining registered custom providers and resolved request auth: the credential-specific baseUrl, headers (including null suppression markers), and environment values. For streaming requests, use ctx.modelRegistry.streamSimple(model, context, options) with provider-neutral options, or stream() with API-specific options. Both use configured providers and request-time authentication, including extension registrations. Iterate the returned AssistantMessageEventStream for events and await .result() for the final message. Setup failures produce error events and error results. The global compatibility streaming functions do not see extension provider registrations.
Use getApiKeyAndHeaders() only when an extension must inspect auth before dispatch; normal requests do not need to resolve or overlay auth themselves. OpenRouter Chat Completions and Anthropic Messages requests send x-session-id by default when sessionId is supplied and prompt caching is enabled. Set the model’s compat.sendSessionAffinityHeaders to false to opt out, or set cacheRetention: "none" on the request to disable cache-related affinity. Explicit request headers override generated headers. await ctx.modelRegistry.refresh(options) returns { aborted, errors }, not just completion. errors is a per-provider map, so extensions can report partial refresh failures; aborted reports cancellation. Host integrations that call ModelRuntime.setRuntimeApiKey(providerId, apiKey, options) must note that it records the runtime credential but does not refresh the catalog; call refresh({ providers: [providerId], signal }) explicitly when a fresh catalog is needed. ctx.scopedModels is the read-only list of models scoped to the current session — the same set the /scoped-models command shows. It is resolved from the --models CLI flag and the enabledModels setting, matched against the available catalogue. It is empty when no scoping is configured, meaning every available model is usable. Each entry is { model, thinkingLevel? }, where thinkingLevel is set only when a pattern pinned it (for example anthropic/*:high). Use it to populate a model picker that mirrors the built-in one instead of enumerating the whole catalogue. The value is resolved at access time, so it tracks session replacement. Under the isolated interactive engine it reflects the engine’s catalogue rather than a stale host snapshot. It reports the scope and cannot change it. ctx.scopedModels is a getter with no setter, typed readonly ScopedModel[], so assigning to it or pushing an entry is a compile error. The guarantee also holds at runtime, where the type does not reach: each read returns a fresh copy — of the array, of every { model, thinkingLevel } entry in it, and of each entry’s model — and all three are frozen. A JavaScript extension, or one that asserts the readonly away, therefore cannot widen the set of models the session may use by pushing an entry, nor change which model it selects by swapping one in place; the attempt throws rather than quietly working. Read it, and change scope through the commands and settings that own it.
Both types are exported: ScopedModel for one entry, ExtensionScopedModels for the accessor’s own type. They are declared at the public extension type path (core/extensions/types.ts) and re-exported from the package root, so an extension never reaches into an internal module to describe what it just read.

ctx.signal

The current agent abort signal, or undefined when no agent turn is active. Use this for abort-aware nested work started by extension handlers, for example:
  • fetch(..., { signal: ctx.signal })
  • model calls that accept signal
  • file or process helpers that accept AbortSignal
ctx.signal is typically defined during active turn events such as tool_call, tool_result, message_update, and turn_end. It is usually undefined in idle or non-turn contexts such as session events, extension commands, and shortcuts fired while Atomic is idle.

ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()

Control flow helpers.

ctx.isProjectTrusted()

Returns whether project-local trust is active for the current extension context. Use this before reading project-local config, loading project-local resources, or exposing actions that should only run after the user has trusted the cwd.

ctx.shutdown()

Request a graceful shutdown of Atomic.
  • Interactive mode: Deferred until the agent becomes idle (after processing all queued steering and follow-up messages).
  • RPC mode: Deferred until the next idle state (after completing the current command response, when waiting for the next command).
  • Print mode: No-op. The process exits automatically when all prompts are processed.
Emits session_shutdown event to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts).

ctx.getContextUsage()

Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages.

ctx.compact()

Trigger Atomic’s verbatim line compactor without awaiting completion. The planner emits numbered deleted-line ranges only; Atomic validates them and reconstructs retained text mechanically. Use compression_ratio (fraction of compactable lines to keep), client-side preserve_recent (an exact context-visible message count), and query to tune the run, and onComplete/onError for follow-up actions.
The planner cannot author context text: only validated line ranges enter the mechanical reconstruction path. The query parameter guides relevance selection inside the fixed prompt; it is not replacement prose. Extensions that need an offline replacement can return compactedText from session_before_compact.

ctx.getSystemPrompt()

Returns Atomic’s current system prompt string.
  • During before_agent_start, this reflects chained system-prompt changes made so far for the current turn.
  • It does not include later context message mutations.
  • It does not include before_provider_request payload rewrites.
  • If later-loaded extensions run after yours, they can still change what is ultimately sent.

ctx.getSkillCatalog()

Returns the current loader-owned skill catalog when the host provides one. Use it to resolve exact skill selectors, including source-qualified names such as tdd@builtin, without falling back to the bare precedence winner.
pi.getCommands() already includes the same advertised /skill:name and /skill:name@source names. See Skill Commands.

ExtensionCommandContext

Command handlers receive ExtensionCommandContext, which extends ExtensionContext with session control methods. These are only available in commands because they can deadlock if called from event handlers.

ctx.waitForIdle()

Wait for the agent to finish streaming:

ctx.newSession(options?)

Create a new session:
Options:
  • parentSession: parent session file to record in the new session header
  • setup: mutate the new session’s SessionManager before withSession runs
  • withSession: run post-switch work against a fresh replacement-session context. Do not use captured old pi / command ctx; see Session replacement lifecycle and footguns.

ctx.fork(entryId, options?)

Fork from a specific entry, creating a new session file:
Options:
  • position: "before" (default) forks before the selected user message, restoring that prompt into the editor
  • position: "at" duplicates the active path through the selected entry without restoring editor text
  • withSession: run post-switch work against a fresh replacement-session context. Do not use captured old pi / command ctx; see Session replacement lifecycle and footguns.

ctx.navigateTree(targetId, options?)

Navigate to a different point in the session tree. Navigation rejects while a response, compaction, or branch summarization is active, even with summarize: false. Rejection leaves the active branch unchanged. Wait for the active operation to finish and retry.
Options:
  • summarize: Whether to generate a summary of the abandoned branch
  • customInstructions: Custom instructions for the summarizer
  • replaceInstructions: If true, customInstructions replaces the default prompt instead of being appended
  • label: Label to attach to the branch summary entry (or target entry if not summarizing)

ctx.switchSession(sessionPath, options?)

Switch to a different session file:
Options: To discover available sessions, use the static SessionManager.list() or SessionManager.listAll() methods:

Session replacement lifecycle and footguns

withSession receives a fresh ReplacedSessionContext, which extends ExtensionCommandContext with async sendMessage() and sendUserMessage() helpers bound to the replacement session. Lifecycle and footguns:
  • withSession runs only after the old session has emitted session_shutdown, the old runtime has been torn down, the replacement session has been rebound, and the new extension instance has already received session_start.
  • The callback still executes in the original closure, not inside the new extension instance. That means your old extension instance may already have run its shutdown cleanup before withSession starts.
  • Captured old pi / old command ctx session-bound objects are stale after replacement and will throw if used. Use only the ctx passed to withSession for session-bound work.
  • Previously extracted raw objects are still your responsibility. For example, if you capture const sm = ctx.sessionManager before replacement, sm is still the old SessionManager object. Do not reuse it after replacement.
  • Code in withSession should assume any state invalidated by your session_shutdown handler is already gone. Only capture plain data that survives shutdown cleanly, such as strings, ids, and serialized config.
  • Long-lived callbacks that need to classify a stale API error should use isStaleExtensionContextError(error) from @bastani/atomic, not match the host’s error message.
Safe pattern:
Unsafe pattern:

ctx.reload()

Run the same reload flow as /reload.
Important behavior:
  • await ctx.reload() emits session_shutdown for the current extension runtime
  • It then reloads resources and emits session_start with reason: "reload" and resources_discover with reason "reload"
  • The currently running command handler still continues in the old call frame
  • Code after await ctx.reload() still runs from the pre-reload version
  • Code after await ctx.reload() must not assume old in-memory extension state is still valid
  • After the handler returns, future commands/events/tool calls use the new extension version
For predictable behavior, treat reload as terminal for that handler (await ctx.reload(); return;). Tools run with ExtensionContext, so they cannot call ctx.reload() directly. Use a command as the reload entrypoint, then expose a tool that queues that command as a follow-up user message. Example tool the LLM can call to trigger reload:

ExtensionAPI Methods

pi.on(event, handler)

Subscribe to events. See Events for event types and return values.

pi.registerTool(definition)

Register a custom tool callable by the LLM. See Custom Tools for full details. pi.registerTool() works both during extension load and after startup. You can call it inside session_start, command handlers, or other event handlers. New tools are refreshed immediately in the same session, so they appear in pi.getAllTools() and are callable by the LLM without /reload. Use pi.setActiveTools() to enable or disable tools (including dynamically added tools) at runtime. Atomic always restores mandatory ordinary intercom; other tool behavior is unchanged. Use promptSnippet to opt a custom tool into a one-line entry in Available tools, and promptGuidelines to append tool-specific bullets to the default Guidelines section when the tool is active. Important: promptGuidelines bullets are appended flat to the Guidelines section with no tool name prefix. Each guideline must name the tool it refers to — avoid “Use this tool when…” because the LLM cannot tell which tool “this” means. Write “Use my_tool when…” instead. See dynamic-tools.ts for a full example.

Built-in tool prompt contributions

Atomic exports immutable prompt metadata for its built-in coding tools. Use these constants when a custom harness or tool registry needs the same prompt entries as the built-in factories:
Each contribution has a readonly snippet for the Available tools section and readonly guidelines for the active tool’s Guidelines entries. The seven exports are bash, edit, find, ls, read, search, and write; Atomic’s public search export is the corresponding surface for pi’s upstream grep tool. The built-in factories use these values directly, so consumers do not need to duplicate prompt text. Use Atomic’s export rather than importing StringEnum directly from Pi. It preserves Pi’s Google-compatible runtime schema while keeping the schema typed against Atomic’s direct TypeBox version.

pi.sendMessage(message, options?)

Inject a custom message into the session. The call returns void | Promise<void> for compatibility with synchronous hosts; use await Promise.resolve(pi.sendMessage(...)) when admission or routing failure must be observed. Atomic’s AgentSession runtime returns an admission receipt: it settles after the message is accepted by the local queue or workflow late-message route, without waiting for the resulting model turn to finish.
Options:
  • deliverAs - Delivery mode:
    • "steer" (default) - Queues the message while streaming. Delivered after the current assistant turn finishes executing its tool calls, before the next LLM call.
    • "followUp" - Waits for agent to finish. Delivered only when agent has no more tool calls.
    • "nextTurn" - Queued for next user prompt. Does not interrupt or trigger anything.
    • "interrupt" - With triggerTurn: true, aborts an active streaming turn and immediately starts a new turn with the custom message. When idle, behaves like a triggered custom message.
  • triggerTurn: true - If agent is idle, trigger an LLM response immediately. Required for "interrupt"; ignored for "nextTurn".
  • excludeFromContext: true - Render and persist the custom message without adding it to LLM context. With no deliverAs, this remains display-only even while the agent is streaming.
  • interruptAbortMessage - Optional text used to replace generic abort results (for example Operation aborted) when deliverAs: "interrupt" aborts an active turn.

pi.sendMessages(messages, options?)

Atomically admit a batch of custom messages in array order. The call returns void | Promise<void> for compatibility with synchronous hosts; use await Promise.resolve(pi.sendMessages(...)) when admission or routing failure must be observed. The promise is an admission receipt and does not wait for the resulting model turn. Admission is indivisible; use this when a prelude and terminal notice must stay contiguous without globally serializing other extension work.
The batch supports triggerTurn, excludeFromContext, and deliverAs: "steer" | "followUp" | "nextTurn". Interrupt delivery remains a single-message operation.

pi.sendUserMessage(content, options?)

Send a user message to the agent. Unlike sendMessage() which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn.
Options:
  • deliverAs - Required when agent is streaming:
    • "steer" - Queues the message for delivery after the current assistant turn finishes executing its tool calls
    • "followUp" - Waits for agent to finish all tools
  • expandPromptTemplates - Dispatch extension commands and expand skill commands and prompt templates instead of sending the text literally. Defaults to false, so an extension-authored message is sent as-is unless it opts in; an unknown command falls through to a literal send.
When not streaming, the message is sent immediately and triggers a new turn. When streaming without deliverAs, throws an error. See send-user-message.ts for a complete example.

pi.appendEntry(customType, data?)

Persist extension state (does NOT participate in LLM context).
Appending emits entry_appended with the durable entry. This lets extensions react to session entries without polling.

pi.registerEntryRenderer(customType, renderer)

Register a TUI renderer for durable custom entries created by pi.appendEntry(). These entries render in the transcript but do not enter model context.

pi.setSessionName(name)

Set the session display name (shown in session selector instead of first message).

pi.getSessionName()

Get the current session name, if set.

pi.setLabel(entryId, label)

Set or clear a label on an entry. Labels are user-defined markers for bookmarking and navigation (shown in /tree selector).
Labels persist in the session and survive restarts. Use them to mark important points (turns, checkpoints) in the conversation tree.

pi.registerCommand(name, options)

Register a command. If multiple extensions register the same command name, Atomic keeps them all and assigns numeric invocation suffixes in load order, for example /review:1 and /review:2.
Optional: add argument auto-completion for /command ...:

pi.getCommands()

Get the slash commands available for invocation via prompt in the current session. Includes extension commands, prompt templates, and skill commands. The list matches the RPC get_commands ordering: extensions first, then templates, then skills.
Each entry has this shape:
Use sourceInfo as the canonical provenance field. Do not infer ownership from command names or from ad hoc path parsing. Built-in interactive commands (like /model and /settings) are not included here. They are handled only in interactive mode and would not execute if sent via prompt.

pi.registerMessageRenderer(customType, renderer)

Register a custom TUI renderer for messages with your customType. The renderer options contain expanded and the current numeric outputPad, so custom output can align with built-in messages. The same options are provided in normal and isolated-engine rendering. See Custom UI.

pi.registerMarkdownTransformer(transformer)

Register a synchronous, display-only transformer for Markdown in normal user text, assistant text, and thinking blocks. Atomic runs transformers in extension load order. Each extension retains one transformer, so a later call from that extension replaces its prior transformer. Each transformer receives the Markdown returned by the prior transformer, then Atomic renders the final value with its built-in Markdown renderer. The transformer receives the Markdown string and a context with:
  • messageType"user", "assistant", or "assistant-thinking"
  • isStreamingtrue for partial assistant updates; false for user, finalized assistant, and restored messages
  • availableWidth — exact terminal columns available for the transformed Markdown content
Return the transformed Markdown:
If a transformer throws, Atomic keeps the Markdown produced so far and continues with the next transformer. The hook never changes the original message, session transcript, or model context. It runs for new user messages, assistant streaming updates, restored session messages, and terminal-width changes, so keep transformers synchronous and inexpensive. Isolated-engine rendering does not run host-side display transformers.

pi.registerShortcut(shortcut, options)

Register a keyboard shortcut. See Keybindings for the shortcut format and built-in keybindings.

pi.registerFlag(name, options)

Register a CLI flag.

pi.exec(command, args, options?)

Execute a shell command.

pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)

Manage active tools. This works for both built-in tools and dynamically registered tools. pi.getActiveTools() returns the active tool names as string[]; pi.getAllTools() returns metadata for all configured tools.
pi.getAllTools() returns name, description, parameters, promptGuidelines, and sourceInfo. Typical sourceInfo.source values:
  • builtin for built-in tools
  • sdk for tools passed via createAgentSession({ customTools })
  • extension source metadata for tools registered by extensions

pi.setModel(model)

Set the model for the current session. The change is recorded in session history and restored when that session is resumed, but it does not change the configured defaultProvider or defaultModel used by new sessions. Returns false if authentication is not configured for the model’s provider. See Custom models for configuring custom models.

pi.getThinkingLevel() / pi.setThinkingLevel(level)

Get the current thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"; "xhigh" and "max" require model support). Changes emit thinking_level_select. pi.setThinkingLevel() changes the thinking level for the current session. The change is recorded in session history and restored when that session is resumed, but it does not change the configured default used by new sessions.

pi.events

Shared event bus for communication between active extensions. A subscription made with on() is removed automatically when its extension reloads or the session disposes. Use the returned function if you need to stop listening sooner:
pi.events belongs to the extension instance that received it. Register listeners again when that instance reloads, and do not retain the object for later use: calling on() or emit() through a captured handle after reload or disposal throws. To keep in-memory state across /reload, pass this facade to sessionScopedExtensionState; do not capture the facade itself. If you implement an ExtensionRuntime for an embedded host, provide trackEventBusSubscription(unsubscribe) and retain each returned subscription until that extension runtime is reloaded or disposed. ExtensionUIContext.getChatRenderSettings() must return markdownTransformers; it may also return renderLatex to control terminal math rendering. These fields keep event subscriptions and display transforms scoped to the active extension instance.

Native providers

In addition to registerProvider(name, config), extensions can register a complete native Provider from @bastani/pi-ai with pi.registerProvider(provider). Use the native overload for provider-owned authentication, catalog refresh, and transport behavior; use the config overload for ordinary proxies and custom endpoints.

pi.registerProvider(name, config)

Register or override a model provider dynamically. Useful for proxies, custom endpoints, or team-wide model configurations. Calls made during the extension factory function are queued and applied once the runner initialises. Calls made after that — for example from a command handler following a user setup flow — take effect immediately without requiring a /reload. If you need to discover models from a remote endpoint, prefer an async extension factory over deferring the fetch to session_start. Atomic waits for the factory before startup continues, so the registered models are available immediately, including to atomic --list-models.
Config options:
  • name - Display name for the provider in UI such as /login.
  • baseUrl - API endpoint URL. Required when defining models.
  • apiKey - API key literal or explicit environment variable reference ($ENV_VAR or ${ENV_VAR}). Required when defining models (unless oauth provided).
  • api - API type: "anthropic-messages", "openai-completions", "openai-responses", etc.
  • headers - Custom headers to include in requests.
  • authHeader - If true, adds Authorization: Bearer header automatically.
  • models - Array of model definitions. If provided, replaces all existing models for this provider. Model definitions can set baseUrl to override the provider endpoint for that model.
  • oauth - OAuth provider config for /login support. When provided, the provider appears in the login menu.
  • auth.apiKey - Provider-owned API-key or connection setup for /login. Its name appears in the provider list and login({ signal, prompt }) returns the credential Atomic persists. Extension providers registered only in the isolated interactive engine child are synchronized into the host’s /login list; their login callback and credential-dependent model refresh still execute in the child, while prompts are rendered by the terminal host.
  • streamSimple - Custom streaming implementation for non-standard APIs.
See Custom providers for advanced topics: custom streaming APIs, OAuth details, model definition reference.

pi.unregisterProvider(name)

Remove a previously registered provider and its models. Built-in models that were overridden by the provider are restored. Has no effect if the provider was not registered. Like registerProvider, this takes effect immediately when called after the initial load phase, so a /reload is not required.

Error Handling

  • Extension errors are logged, agent continues
  • tool_call errors block the tool (fail-safe)
  • Tool execute errors must be signaled by throwing; the thrown error is caught, reported to the LLM with isError: true, and execution continues