Skip to main content

Writing extensions

Writing an Extension

An extension exports a default factory function that receives ExtensionAPI. The factory can be synchronous or asynchronous:
Editable extensions and workflows support TypeScript without compilation. /reload picks up edits to an extension or its imported helpers; unchanged modules may retain module-scoped state. Imports from Atomic’s supplied core packages keep the running host’s classes and shared state across /reload, including on Windows. The supported @earendil-works/pi-coding-agent compatibility import shares those exports with @bastani/atomic, so class comparisons and instanceof checks work across both names after reload. Edits to your extension and its imported local helpers still take effect; restart Atomic after updating Atomic itself. In Bun single-file builds, installed builtin bundles retain module-scoped state across /reload. Restart Atomic to reload shipped code. If the factory returns a Promise, Atomic awaits it before continuing startup. That means async initialization completes before session_start, before resources_discover, and before provider registrations queued via pi.registerProvider() are flushed.

Async factory functions

Use an async factory for one-time startup work such as fetching remote configuration or dynamically discovering available models.
This pattern makes the fetched models available during normal startup and to atomic --list-models.

Long-lived resources and shutdown

Extension factories may run in invocations that never start a session, such as metadata commands or early configuration checks. Do not start background resources such as processes, sockets, file watchers, or timers from the factory. Defer background resource startup until session_start or the command/tool/event that needs the resource. Register an idempotent session_shutdown handler to close any session-scoped resources you start.

Extension Styles

Single file - simplest, for small extensions:
Directory with index.ts - for multi-file extensions:
Package with dependencies - for extensions that need npm packages:
The manifest key is the configured Atomic app name (atomic here, from the running Atomic package/config), not the extension package’s own "name" field. The legacy pi key is still accepted as a compatibility shim. Run bun install in the extension directory, then imports from node_modules/ work automatically.

State Management

Choose the store that matches the lifetime you need:
  • Tool result details — reconstructs across /branch and /resume from the transcript.
  • pi.appendEntry() — durable custom entries that survive process restart. They do not enter model context.
  • sessionScopedExtensionState() — in-memory objects that survive /reload for the current process. They do not survive process restart.
In Bun single-file builds, an editable file extension whose imported graph is unchanged can reuse its evaluated factory, so its module-scoped variables may survive /reload. An edit anywhere in that graph re-evaluates its modules and resets those singletons. The five fixed installed builtin bundles always reuse their evaluated factories and module state across /reload, as described above. Extensions with state that must follow conversation branches should store it in tool result details:

Session-scoped in-memory state

Import sessionScopedExtensionState from @bastani/atomic when an extension must keep a live object across /reload — registries, abort controllers, connection pools, or any other handle that cannot be rebuilt from the transcript.
Required scope. Pass the extension’s pi.events facade (or the session EventBus itself). The host resolves that facade to the canonical session bus, so every load generation of one session re-binds to the same object. Two in-process sessions with distinct buses stay isolated. Do not pass an arbitrary object: an unregistered scope is treated as its own bus and will not re-bind after reload. Session-wide key namespace. Keys are not automatically namespaced by extension. Two extensions that pass the same key on the same session receive the first extension’s object; the later factory is not called. Prefix every key with a stable extension identity. Key-versioning. Append a version suffix and bump it when the stored shape changes, for example "my-extension:counter:v1" → "my-extension:counter:v2". The new key declines the incompatible predecessor instead of reusing it under a new type. Reload behavior. /reload builds a new pi.events facade that still forwards to the same bus. Calling sessionScopedExtensionState again with the same namespaced key returns the existing object and does not invoke create. The reload transaction does not clone this object or roll back mutations that extension factory code makes to it. Keep factory setup idempotent, and mutate durable state only after the new generation starts when failed reloads must not affect it. Entries live exactly as long as that bus. They are not written to the session file; use pi.appendEntry() when the data must survive process restart. Shutdown. session_shutdown still runs for resources you opened. If the object holds sockets, watchers, or timers, close them there. The next session_start or first use can recreate them inside the same session-scoped object.

Custom Tools

Register tools the LLM can call via pi.registerTool(). Tools appear in the system prompt and can have custom rendering. Use promptSnippet for a short one-line entry in the Available tools section in the default system prompt. If omitted, custom tools are left out of that section. Use promptGuidelines to add tool-specific bullets to the default system prompt Guidelines section. These bullets are included only while the tool is active (for example, after pi.setActiveTools([...])). Important: promptGuidelines bullets are appended flat to the Guidelines section with no tool name prefix or grouping. 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. Some models include the @ prefix in tool path arguments. Built-in tools strip a leading @ before resolving paths. If your custom tool accepts a path, normalize a leading @ as well. If your custom tool mutates files, use withFileMutationQueue() so it participates in the same per-file queue as built-in edit and write. This matters because tool calls run in parallel by default. Without the queue, two tools can read the same old file contents, compute different updates, and then whichever write lands last overwrites the other. Example failure case: your custom tool edits foo.ts while built-in edit also changes foo.ts in the same assistant turn. If your tool does not participate in the queue, both can read the original foo.ts, apply separate changes, and one of those changes is lost. Pass the real target file path to withFileMutationQueue(), not the raw user argument. Resolve it to an absolute path first, relative to ctx.cwd or your tool’s working directory. For existing files, the helper canonicalizes through realpath(), so symlink aliases for the same file share one queue. For new files, it falls back to the resolved absolute path because there is nothing to realpath() yet. Queue the entire mutation window on that target path. That includes read-modify-write logic, not just the final write.

Tool Definition

parameters is required, including for no-argument tools (use Type.Object({})). Registration rejects missing, null, array, and primitive schema values before they can break a provider request. This checks the schema container, not its JSON Schema type: object-valued union and non-object-type schemas remain accepted and unchanged. Registration is not provider validation. If a root schema is an anyOf/oneOf union, give every branch an explicit type: "object"; xAI rejects a non-object root branch with tool parameter root must be an object type before the model can call the tool. Prefer a plain Type.Object root with optional properties when a union is not needed.
Signaling errors: Throw from execute to report an execution failure. When a failure also has useful data, return an error result with isError: true, content, and optional structuredContent. Return terminate: true from execute() to skip the automatic follow-up model call only when every finalized result in the tool batch also terminates. For a schema-backed decision, use createStructuredOutputTool({ schema, name }). Atomic does not register structured_output in normal sessions by default. The calling model supplies instructions, nonempty named state, and optional model and fallbackModels. The tool resolves those exact IDs through the session model registry, infers a value matching schema, and returns that value as terminating JSON. It does not treat the calling model’s arguments as the result. Omit model to use the current session chat model; explicit fallbacks run next, and the current chat model is the terminal fallback. A registered classifier runs only for a finite Choice schema. An incompatible schema skips that classifier. Provider errors, including refusals, advance to the next model. Plain-text responses without the required result are repaired up to three times before advancing. Cancellation stops the call. Each call also has a wall-clock deadline covering every repair attempt, retry, and fallback; it defaults to the session’s httpIdleTimeoutMs setting, and createStructuredOutputTool({ timeoutMs }) overrides it. Include a custom name such as final_decision in any explicit tool allowlist.
Important: Use StringEnum from @bastani/atomic for string enums. It retains Pi’s Google-compatible schema and composes with Atomic’s direct TypeBox types; Type.Union/Type.Literal doesn’t work with Google’s API.

Constrained sampling

ToolDefinition.constrainedSampling is preserved for extension tools, SDK customTools, wrappers, and isolated execution. It accepts false or the exported ConstrainedSamplingConfig:
Exact modes:
  • { type: "json_schema", strict: "prefer" } requests strict provider enforcement and falls back to ordinary tool calling when unavailable.
  • { type: "json_schema", strict: "require" } fails the request rather than silently weakening the constraint.
  • { type: "grammar", variants: { openai_lark?: string, openai_regex?: string } } requests an OpenAI custom grammar tool; Lark wins when both non-empty variants are present.
  • false explicitly opts out. Its runtime effect matches omission, but public tool inspection preserves false as a present property.
If Anthropic rejects a request because strict tools exceed its grammar-size, tool-count, or union-parameter limits, Atomic retries once with prefer tools using their ordinary schemas. Tools marked require remain strict. A response that has already started streaming is not retried this way. Built-in read, edit, write, bash, and its Windows PowerShell variant request strict JSON-schema sampling with prefer by default. This is a provider hint, not a schema rewrite or a sandbox. Unsupported providers retain ordinary tool calling. Other experimental tool hints still follow the experimental environment flag. Atomic preserves the optional property’s exact own-key state across wrappers, active-session inspection, staged extension inspection, bundled tools, and isolated transport: omission stays absent; explicitly present undefined stays present; false and config objects remain unchanged. This distinction matters to SDK/extension code that uses Object.hasOwn() rather than an ordinary property read. Grammar tools require an object schema with exactly one required string property. They are emitted only when model metadata advertises supportsOpenAIGrammarTools (also exposed as Atomic’s supportsGrammarTools alias); otherwise provider handling falls back to the normal function/JSON-schema path. Older OpenAI models and gateways that rewrite schemas cannot honor custom grammar tools. Typed RPC clients receive these claims through optional ModelInfo.compat. See Custom Models and RPC. Argument preparation: prepareArguments(args) is optional. If defined, it runs before schema validation and before execute(). Use it only when a custom tool must normalize arguments before validation. Return the object you want validated against parameters, keep the public schema strict, and avoid advertising deprecated fields.
Call ordering: when one assistant message contains several tool calls, Atomic starts them in message order and runs them concurrently unless a tool declares concurrency:
  • "shared" (default): may overlap other shared calls, but starts only after the latest earlier "exclusive" call finishes.
  • "exclusive": starts only after every earlier call in the message finishes, and later calls wait for it.
  • A function (args) => "shared" | "exclusive" decides per call. If it throws, the call runs exclusively.
Built-in edit, write, todo, and ask_user_question are exclusive, and bash is exclusive when it runs with pty: true. Other bash calls are shared, so a later call in the same message can run before a shell command’s file changes land. Declare "exclusive" for a tool that changes state later calls depend on. executionMode: "sequential" still runs the whole message one call at a time.

Structured results and nested tools

Declare outputSchema when a tool returns machine-readable data, and return a matching structuredContent alongside model-facing content. Script callers receive the structured value; tools without an output schema return text to scripts. A tool_result handler that redacts text must also replace structuredContent; replacing only content drops the structured value to prevent it leaking through another path. Inside execute, the context is an ExtensionToolContext. ctx.tools lists permitted callable tools, and ctx.executeTool(name, args, { signal, onUpdate }) returns an AgentToolCallOutcome. Nested calls use the ordinary argument preparation, schema validation, permission hooks, and result hooks. They cannot bypass session allowlists, exclusions, or model-only exposure. If your orchestrator invokes a tool with concurrency: "exclusive", give the orchestrator concurrency: "exclusive" too. A shared orchestrator cannot safely upgrade its nested call to exclusive access and receives an error before that target executes. File mutation queues remain required for read-modify-write operations. Nested execution events include parentToolCallId. Nested calls do not create separate conversation messages; the parent reports their results. Its final result may carry bounded nestedCalls metadata for exports and file-operation tracking. Report only the parent’s own usage: nested usage is aggregated automatically, including deeper calls.

Tool exposure

ToolDefinition.exposure controls whether a tool is declared to the model or callable by another tool. Omission means direct: Use model-only for interactive or orchestration tools that should not be called from scripts. codemode tools are advertised in the codemode tool’s catalog; deferred tools are discoverable without occupying that inline catalog. Session allowlists and exclusions remain authoritative. Exposure cannot restore a suppressed tool. Atomic’s workflow, subagent, Intercom/supervisor, and user-question tools are model-only: a script cannot launch agents, send coordination messages, or request interactive input through them. Ordinary permitted file, shell, MCP, and web-data tools remain callable. Custom tools that perform interactive or orchestration work should use the same exposure. direct and model-only tools activate by default. Set defaultActive: false to keep a registered tool inactive until you select it with pi.setActiveTools(). namespace: { name, description } groups related tools. Optional annotations carry readOnlyHint, destructiveHint, idempotentHint, and openWorldHint; these are advisory hints, not permission checks or verified guarantees. An active orchestrator may implement prepareLoadout(loadout). The loadout lists declared, callable, and registered tools and provides getExposure(name), getNamespace(name), and getPromptGuidelines(name). Return descriptions to replace model-facing descriptions or hiddenDeclarations to hide declarations while retaining permitted nested access. This changes presentation, not authorization.

Fireworks deferred tool loading

Extensions making requests directly through @bastani/pi-ai can use native deferred tool loading with Fireworks anthropic-messages models. Keep the discovery tool in context.tools. After the discovery tool result, append a chronological system message with toolsAdded (and toolsRemoved if needed) containing the newly loaded tool definitions. The provider serializes deferred definitions with defer_loading and inserts tool_reference content at the load point. Name the loader ToolSearch or tool_search to keep deferred schemas out of the initial prompt prefix. Other names work, but Fireworks includes the schemas in the prefix and loses that cache benefit. Fireworks GLM models and Kimi K3 still use Chat Completions; this feature does not change their routing. This is an AI SDK capability. Atomic’s pi.setActiveTools() updates the active tool list and records chronological system-message tool deltas for the next request; it does not attach tool names to a tool result. See the AI SDK deferred tool-loading guide for details.

Overriding Built-in Tools

Extensions can override built-in tools (read, bash, powershell, edit, write, find, search, ask_user_question, todo) by registering a tool with the same name. Interactive mode displays a warning when this happens.
Alternatively, use --no-builtin-tools to start without any built-in tools while keeping extension tools enabled:
See examples/extensions/tool-override.ts for a complete example that overrides read with logging and access control. Rendering: Built-in renderer inheritance is resolved per slot. Execution override and rendering override are independent. If your override omits renderCall, the built-in renderCall is used. If your override omits renderResult, the built-in renderResult is used. If your override omits both, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI. Prompt metadata: promptSnippet and promptGuidelines are not inherited from the built-in tool. If your override should keep those prompt instructions, define them on the override explicitly. Your implementation must match the exact result shape, including the details type. The UI and session logic depend on these shapes for rendering and state tracking. Built-in tool implementations:

Remote Execution

Built-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.):
ReadOperations may also provide stat and listDir to keep directory-tree reads on the injected filesystem. The Harness factory supplies both. A custom read backend without both members keeps the existing file-only remote behavior. Archive, SQLite, internal-resource, notebook, and path-variant helpers still use Atomic’s local filesystem unless the tool gains dedicated remote seams. Operations interfaces: ReadOperations, WriteOperations, EditOperations, BashOperations, LsOperations, GrepOperations, FindOperations For user_bash, extensions can reuse atomic’s local shell backend via createLocalBashOperations() instead of reimplementing local process spawning, shell resolution, and process-tree termination. The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution:
See examples/extensions/ssh.ts for a complete SSH example with --ssh flag.

Output Truncation

Tools MUST truncate their output to avoid overwhelming the LLM context. Large outputs can cause:
  • Context overflow errors (prompt too long)
  • Compaction failures
  • Degraded model performance
The built-in limit is 50KB (~10k tokens) and 2000 lines, whichever is hit first. Use the exported truncation utilities:
Key points:
  • Use truncateHead for content where the beginning matters (search results, file reads)
  • Use truncateTail for content where the end matters (logs, command output)
  • Always inform the LLM when output is truncated and where to find the full version
  • Document the truncation limits in your tool’s description
See examples/extensions/truncated-tool.ts for a complete example wrapping rg (ripgrep) with proper truncation.

Multiple Tools

One extension can register multiple tools with shared state:

Custom Rendering

Tools can provide renderCall and renderResult for custom TUI display. See TUI components for the full component API and tool-execution.ts for how tool rows are composed. By default, tool output is wrapped in a Box that handles padding and background. A defined renderCall or renderResult must return a Component. If a slot renderer is not defined, tool-execution.ts uses fallback rendering for that slot. Set renderShell: "self" when the tool should render its own shell instead of using the default Box. This is useful for tools that need complete control over framing or background behavior, for example large previews that must stay visually stable after the tool settles.
renderCall and renderResult each receive a context object with:
  • args - the current tool call arguments
  • state - shared row-local state across renderCall and renderResult
  • lastComponent - the previously returned component for that slot, if any
  • invalidate() - request a rerender of this tool row
  • toolCallId, cwd, executionStarted, argsComplete, isPartial, expanded, showImages, isError
  • durationMs - recorded execution time of the final result; undefined while running, when the tool did not run, or for older saved results
  • outputPad - configured horizontal padding; renderers with renderShell: "self" apply it themselves
  • keyText(), keyHint() - format the user’s configured keybindings, see Keybinding Hints
Use context.state for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders.

renderCall

Renders the tool call or header:

renderResult

Renders the tool result or output:
If a slot intentionally has no visible content, return an empty Component such as an empty Container.

Keybinding Hints

Use keyHint() and keyText() from the render context to display keybinding hints that respect the user’s keybinding configuration. Test keyText() for an empty string when an affordance should disappear if the action has no effective keybinding, and add surrounding punctuation only when there is text:
The same functions are available as options.keyHint() and options.keyText() in message and entry renderers:
  • keyHint(keybinding, description) - Formats a configured keybinding id such as "app.tools.expand" or "tui.select.confirm", followed by the description
  • keyText(keybinding) - Returns the raw configured key text for a keybinding id, or "" if the id has no keys
In renderers, use these instead of importing keyHint(), keyHintIfBound() or keyText() from @bastani/atomic. An extension can resolve its own copy of the package, for example from its own node_modules, and that copy does not see Atomic’s configured keybindings: app.* ids then format as "". rawKeyHint(key, description) formats a literal key string and is safe to import. Use namespaced keybinding ids:
  • Coding-agent ids use the app.* namespace, for example app.tools.expand, app.editor.external, app.session.rename
  • Shared TUI ids use the tui.* namespace, for example tui.select.confirm, tui.select.cancel, tui.input.tab
For the exhaustive list of keybinding ids and defaults, see Keybindings. keybindings.json uses those same namespaced ids. Custom editors and ctx.ui.custom() components receive keybindings: KeybindingsManager as an injected argument. They should use that injected manager directly instead of calling getKeybindings() or setKeybindings().

Best Practices

  • Use Text with padding (0, 0). The default Box handles padding.
  • Use \n for multi-line content.
  • Handle isPartial for streaming progress.
  • Support expanded for detail on demand.
  • Keep default view compact.
  • Read context.args in renderResult instead of copying args into context.state.
  • Use context.state only for data that must be shared across call and result slots.
  • Reuse context.lastComponent when the same component instance can be updated in place.
  • Use renderShell: "self" only when the default boxed shell gets in the way. In self-shell mode the tool is responsible for its own framing, padding, and background.

Fallback

If a slot renderer is not defined or throws:
  • renderCall: Shows the tool name
  • renderResult: Shows raw text from content

Next steps

Continue with extension events to hook into the session lifecycle. Use the Extension API reference to look up context properties and registration methods.