Extensions

September 20, 2026 · View on GitHub

Atomic can create extensions. Ask it to build one for your use case.

Extensions

Extensions are TypeScript modules that extend Atomic's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.

Placement for /reload: Put extensions in ~/.atomic/agent/extensions/ (global) or .atomic/extensions/ (project-local) for auto-discovery; legacy .pi paths remain supported. Use atomic -e ./path.ts only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with /reload.

Key capabilities:

  • Custom tools - Register tools the LLM can call via pi.registerTool()
  • Event interception - Block or modify tool calls, inject context, observe/cancel deletion-only compaction, and customize branch summaries
  • User interaction - Prompt users via ctx.ui (select, confirm, input, notify)
  • Custom UI components - Full TUI components with keyboard input via ctx.ui.custom() for complex interactions
  • Custom commands - Register commands like /mycommand via pi.registerCommand()
  • Session persistence - Store state that survives restarts via pi.appendEntry()
  • Reload-surviving state - Keep in-memory objects alive across /reload via sessionScopedExtensionState()
  • Custom rendering - Control how tool calls/results and messages appear in TUI

Example use cases:

  • Permission gates (confirm before rm -rf, sudo, etc.)
  • Git checkpointing (stash at each turn, restore on branch)
  • Path protection (block writes to .env, node_modules/)
  • Compaction policies (cancel compaction or provide exact deletion targets)
  • Conversation summaries (see summarize.ts example)
  • Interactive tools (questions, wizards, custom dialogs)
  • Stateful tools (todo lists, connection pools)
  • External integrations (file watchers, webhooks, CI triggers)
  • Games while you wait (see snake.ts example)

See examples/extensions/ for working implementations.

Atomic also ships an environment-gated Herdr reporter. It combines settled agent activity, extension prompt events, and observed workflow roots under one parent pane owner. It defers to loaded community or legacy reporters and can be disabled with herdr.enabled in settings. See Herdr setup for the supported version and status indicators for reported activity.

Where to go next

Extensions are TypeScript modules that add tools, commands, event handlers, and custom UI. Read this page for startup behavior, locations, imports, and a first extension, then continue:

  • Writing extensions — build one, manage its state, and register custom tools.
  • Extension events — every event, its payload, and its return contract.
  • Extension UI — render custom UI from an extension.
  • Extension API referenceExtensionContext, ExtensionCommandContext, ExtensionAPI methods, and error handling.
  • Extension examples — runnable examples shipped with Atomic.
  • Security — the project-trust boundary that decides whether a project's extensions load, and what an extension can reach once it does. Read this before installing an extension you did not write.

If an extension is heavier than you need, compare the lighter mechanisms on Build with Atomic.

Table of Contents

Startup and lazy discovery

Built-in MCP, workflow, subagent, web-access, and Intercom commands are available at startup. Their first use may wait for discovery or connection.

Commands still wait for the resources they need before returning results. These include /workflow list, named workflow runs/inputs, failed or durable workflow resume, /mcp, direct MCP tool calls, mcp({ search }), mcp({ describe }), mcp({ server }), and explicit reload/setup flows.

Discovery scope depends on the operation:

  • Cold-cache MCP proxy describe loads metadata only for prefix-matched or explicitly requested servers. A prefix-directed miss does not start unrelated servers.
  • Cold-cache unscoped MCP proxy search loads metadata from all uncached lazy servers to search the full configured tool set.
  • Env-selected MCP direct tools warm only their selected servers and refresh live tool registration when ready.
  • Paused live-workflow resume and pickers bypass full workflow discovery.
  • Autocomplete falls back to current/admin completions when lazy discovery fails.

Failed first-use initialization can be retried. Cancelling one caller does not cancel initialization needed by others. Web-access batches with no successful items report a tool error; partial successes retain their completed items.

MCP tool timeoutMs is an inactivity limit, not a total deadline: progress resets it. Omit it to use the MCP SDK default.

Interactive callback isolation

Interactive sessions isolate extension callbacks from terminal input handling, so a busy callback does not stop keyboard handling or spinners.

Escape requests the engine's own cooperative cancellation and waits for it, for as long as the engine takes. There is no deadline on that wait, and Escape never terminates or replaces the engine, so an interrupt cannot discard in-flight tool state.

Ctrl+C is the host's escape hatch, and it applies in two distinct situations.

The first is a remote custom UI. While an engine-owned ctx.ui.custom() component or overlay holds input, every key is forwarded to the engine child, so a component that never resolves would trap Ctrl+C too. Ownership of the key is declared per mount:

  • A component mounted with handlesCtrlC: true receives the press and keeps its own Skip, Close, or cancel binding. If that same component is still holding input on the next press, that press closes it, so a declared component cannot trap the keyboard either.
  • A component that did not declare it is closed by the first press, through the ordinary close path: its ctx.ui.custom() promise resolves with undefined, the child is told the component closed, the editor comes back, and the engine keeps running — including any other component that generation has mounted below or above this one.

Declare handlesCtrlC whenever your component's hint row offers ctrl+c for anything. This is a migration for existing components: an extension that already bound Ctrl+C keeps that binding only by adding the option. The bundled workflow surfaces and the /mcp, /mcp setup, and MCP OAuth panels declare it. Native host selectors, dialogs, input forms, session pickers, and unrelated native overlays are unaffected: they keep Ctrl+C as their own cancel.

await ctx.ui.custom<string | undefined>(
  (tui, theme, keybindings, done) => new PromptCard(tui, theme, done),
  { overlay: true, handlesCtrlC: true },
);

Both safety keys are matched by physical identity rather than by the configured app.clear action, so rebinding app.clear — even to Escape — can neither route Escape into a stop/restart branch nor take the host route away from Ctrl+C. A configured app.clear on any other key keeps its ordinary editor-clear behavior, and key-release events never act.

If the engine is unresponsive, an abort or replacement has waited over one second, or replacement failed, Ctrl+C terminates and replaces it. This takes precedence over custom UI bindings. A failed replacement remains recoverable with another Ctrl+C; Atomic does not keep retrying automatically.

After an unexpected engine stop, Atomic closes its custom UI and makes one automatic restart attempt. A failed restart leaves the editor usable and reports Interactive engine restart failed: …. A submission that never started returns as an exact draft, including pasted content and queued submissions in order. Accepted work is not offered for automatic retry.

If a saved tool call has no recorded result, reopening the session shows that its result is unavailable. Inspect files or external systems before retrying: the tool may already have had side effects.

When spawning processes from an extension, pass an explicit env derived from process.env. Engine-only bootstrap values are not exposed in that environment.

Dialogs and ctx.ui.custom() components are proxied to the host as rendered lines with asynchronous input forwarding. Custom UI results must be JSON-safe. APIs that require a synchronous callback in the terminal process—raw onTerminalInput transforms, synchronous getEditorText, custom editor factories, autocomplete wrappers, component-factory widgets, and custom header/footer factories—are unavailable in isolated interactive mode and produce a warning rather than executing extension code in the host. Print and public RPC modes retain their existing execution model.

Use ctx.ui.hostSessionPicker(request) for a session-style picker with responsive local navigation and search. Supply JSON-safe HostSessionPickerRow values: SessionInfo with createdAt and modifiedAt in epoch milliseconds.

The returned handle provides result, update(rows), error(message), and close(). result resolves to the selected path, or undefined on cancel. Your onDelete(path) callback owns deletion; the row remains until you call update or error. The API works in both interactive modes and is absent in print and headless RPC. See Host-native session picker.

For structured forms, use ctx.ui.hostInputForm(request). Supply JSON-safe descriptors of type string, text, number, integer, boolean, or select, each with a raw initialValue. It resolves to a raw string record or undefined on cancellation. Editing, navigation, validation, and configured keybindings work locally in both interactive modes. Print and headless RPC omit this optional API. See Host-native input form.

Quick Start

Create ~/.atomic/agent/extensions/my-extension.ts:

import type { ExtensionAPI } from "@bastani/atomic";
import { Type } from "typebox";

export default function (pi: ExtensionAPI) {
  // React to events
  pi.on("session_start", async (_event, ctx) => {
    ctx.ui.notify("Extension loaded!", "info");
  });

  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
      const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
      if (!ok) return { block: true, reason: "Blocked by user" };
    }
  });

  // Register a custom tool
  pi.registerTool({
    name: "greet",
    label: "Greet",
    description: "Greet someone by name",
    parameters: Type.Object({
      name: Type.String({ description: "Name to greet" }),
    }),
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      return {
        content: [{ type: "text", text: `Hello, ${params.name}!` }],
        details: {},
      };
    },
  });

  // Register a command
  pi.registerCommand("hello", {
    description: "Say hello",
    handler: async (args, ctx) => {
      ctx.ui.notify(`Hello ${args || "world"}!`, "info");
    },
  });
}

Test with --extension (or -e) flag:

atomic -e ./my-extension.ts

Extension Locations

Security: Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.

Extensions are auto-discovered from:

LocationScope
~/.atomic/agent/extensions/*.tsGlobal (all projects)
~/.atomic/agent/extensions/*/index.tsGlobal (subdirectory)
.atomic/extensions/*.tsProject-local
.atomic/extensions/*/index.tsProject-local (subdirectory)

Atomic also discovers extensions and package resources inherited from legacy ~/.pi/agent and .pi configuration. When an inherited Pi extension uses the exact same tool, command, prompt, flag, or shortcut name as an extension bundled with Atomic, Atomic keeps the bundled registration and ignores only that conflicting inherited registration. Other resources from the inherited extension remain available. Interactive startup reports all such overlaps in one yellow summary; print and RPC modes apply the same winners without changing the Pi settings or package files.

This compatibility rule applies only to inherited Pi resources. Extensions configured through .atomic or passed explicitly with --extension retain the normal intentional override and load-order behavior described below.

Additional paths via settings.json:

{
  "packages": [
    "npm:@foo/bar@1.0.0",
    "git:github.com/user/repo@v1"
  ],
  "extensions": [
    "/path/to/local/extension.ts",
    "/path/to/local/extension/dir"
  ]
}

To share extensions via npm or git as Atomic packages, see Atomic packages.

Available Imports

PackagePurpose
@bastani/atomicExtension types (ExtensionAPI, ExtensionContext, events)
typeboxSchema definitions for tool parameters
@bastani/pi-aiAI utilities (StringEnum for Google-compatible enums)
@earendil-works/pi-tuiTUI components for custom rendering

Registry dependencies work too. Add a package.json next to your extension (or in a parent directory), then install dependencies with Bun:

bun install

Imports from node_modules/ are resolved automatically.

For distributed Atomic packages installed with atomic install (npm or git), runtime deps must be in dependencies. Package installation uses production dependency installs by default, so devDependencies are not available at runtime; when npmCommand is configured, git packages use plain install for compatibility with wrappers.

Node.js built-ins (node:fs, node:path, etc.) are also available.

Writing an Extension

Moved to Writing extensions.

Async factory functions

Moved to Writing extensions.

Long-lived resources and shutdown

Moved to Writing extensions.

Extension Styles

Moved to Writing extensions.

Events

Moved to Extension events.

Lifecycle Overview

Moved to Extension events.

Startup Events

Moved to Extension events.

project_trust

Moved to Extension events.

Resource Events

Moved to Extension events.

resources_discover

Moved to Extension events.

Session Events

Moved to Extension events.

session_start

Moved to Extension events.

session_info_changed

Moved to Extension events.

session_before_switch

Moved to Extension events.

session_before_fork

Moved to Extension events.

session_before_compact / session_compact / session_compact_failed

Moved to Extension events.

session_before_tree / session_tree

Moved to Extension events.

session_shutdown

Moved to Extension events.

Agent Events

Moved to Extension events.

before_agent_start

Moved to Extension events.

agent_start / agent_end / agent_settled

Moved to Extension events.

ui_prompt_start / ui_prompt_end

Moved to Extension events.

turn_start / turn_end

Moved to Extension events.

message_start / message_update / message_end

Moved to Extension events.

tool_execution_start / tool_execution_update / tool_execution_end

Moved to Extension events.

context

Moved to Extension events.

before_provider_headers

Moved to Extension events.

before_provider_request

Moved to Extension events.

after_provider_response

Moved to Extension events.

Model Events

Moved to Extension events.

model_select

Moved to Extension events.

thinking_level_select

Moved to Extension events.

Tool Events

Moved to Extension events.

tool_call

Moved to Extension events.

Typing custom tool input

Moved to Extension events.

tool_result

Moved to Extension events.

User Bash Events

Moved to Extension events.

user_bash

Moved to Extension events.

Input Events

Moved to Extension events.

input

Moved to Extension events.

ExtensionContext

Moved to Extension API reference.

ctx.ui

Moved to Extension API reference.

ctx.hasUI

Moved to Extension API reference.

ctx.cwd

Moved to Extension API reference.

ctx.isProjectTrusted()

Moved to Extension API reference.

ctx.sessionManager

Moved to Extension API reference.

ctx.modelRegistry / ctx.model / ctx.scopedModels

Moved to Extension API reference.

ctx.signal

Moved to Extension API reference.

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

Moved to Extension API reference.

ctx.isProjectTrusted()

Moved to Extension API reference.

ctx.shutdown()

Moved to Extension API reference.

ctx.getContextUsage()

Moved to Extension API reference.

ctx.compact()

Moved to Extension API reference.

ctx.getSystemPrompt()

Moved to Extension API reference.

ctx.getSkillCatalog()

Moved to Extension API reference.

ExtensionCommandContext

Moved to Extension API reference.

ctx.waitForIdle()

Moved to Extension API reference.

ctx.newSession(options?)

Moved to Extension API reference.

ctx.fork(entryId, options?)

Moved to Extension API reference.

ctx.navigateTree(targetId, options?)

Moved to Extension API reference.

ctx.switchSession(sessionPath, options?)

Moved to Extension API reference.

Session replacement lifecycle and footguns

Moved to Extension API reference.

ctx.reload()

Moved to Extension API reference.

ExtensionAPI Methods

Moved to Extension API reference.

pi.on(event, handler)

Moved to Extension API reference.

pi.registerTool(definition)

Moved to Extension API reference.

Built-in tool prompt contributions

Moved to Extension API reference.

pi.sendMessage(message, options?)

Moved to Extension API reference.

pi.sendMessages(messages, options?)

Moved to Extension API reference.

pi.sendUserMessage(content, options?)

Moved to Extension API reference.

pi.appendEntry(customType, data?)

Moved to Extension API reference.

pi.registerEntryRenderer(customType, renderer)

Moved to Extension API reference.

pi.setSessionName(name)

Moved to Extension API reference.

pi.getSessionName()

Moved to Extension API reference.

pi.setLabel(entryId, label)

Moved to Extension API reference.

pi.registerCommand(name, options)

Moved to Extension API reference.

pi.getCommands()

Moved to Extension API reference.

pi.registerMessageRenderer(customType, renderer)

Moved to Extension API reference.

pi.registerMarkdownTransformer(transformer)

Moved to Extension API reference.

pi.registerShortcut(shortcut, options)

Moved to Extension API reference.

pi.registerFlag(name, options)

Moved to Extension API reference.

pi.exec(command, args, options?)

Moved to Extension API reference.

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

Moved to Extension API reference.

pi.setModel(model)

Moved to Extension API reference.

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

Moved to Extension API reference.

pi.events

Moved to Extension API reference.

Native providers

Moved to Extension API reference.

pi.registerProvider(name, config)

Moved to Extension API reference.

pi.unregisterProvider(name)

Moved to Extension API reference.

State Management

Moved to Writing extensions.

Session-scoped in-memory state

Moved to Writing extensions.

Custom Tools

Moved to Writing extensions.

Tool Definition

Moved to Writing extensions.

Constrained sampling

Moved to Writing extensions.

Fireworks deferred tool loading

Moved to Writing extensions.

Overriding Built-in Tools

Moved to Writing extensions.

Remote Execution

Moved to Writing extensions.

Output Truncation

Moved to Writing extensions.

Multiple Tools

Moved to Writing extensions.

Custom Rendering

Moved to Writing extensions.

renderCall

Moved to Writing extensions.

renderResult

Moved to Writing extensions.

Keybinding Hints

Moved to Writing extensions.

Best Practices

Moved to Writing extensions.

Fallback

Moved to Writing extensions.

Custom UI

Moved to Extension UI.

Dialogs

Moved to Extension UI.

Timed Dialogs with Countdown

Moved to Extension UI.

Manual Dismissal with AbortSignal

Moved to Extension UI.

Moved to Extension UI.

Autocomplete Providers

Moved to Extension UI.

Custom Components

Moved to Extension UI.

Overlay Mode (Experimental)

Moved to Extension UI.

Custom Editor

Moved to Extension UI.

Message Rendering

Moved to Extension UI.

Theme Colors

Moved to Extension UI.

Error Handling

Moved to Extension API reference.

Mode Behavior

ModeUI MethodsNotes
InteractiveFull TUINormal operation
RPC (--mode rpc)JSON protocolHost handles UI, see RPC mode
JSON (--mode json)No-opEvent stream to stdout, see JSON mode
Print (-p)No-opExtensions run but can't prompt

In non-interactive modes, check ctx.hasUI before using UI methods.

Examples Reference

Moved to Extension examples.

Workflow activity and lifecycle hooks

Moved to Extension events.