Neon Pilot Download

Extension Authoring Reference

The normal way to create a Neon Pilot extension is to ask your agent to build it for you. Start with Build an extension with your agent for the user-facing workflow and copy-paste prompt.

This reference covers the extension package contract: manifests, frontend/backend entries, tools, skills, agent hooks, event bus, permissions, build behavior, and integration testing. Use it when implementing or debugging an extension, not as the first stop for a user who just wants a new feature.

Terminology: extensions are Neon Pilot app packages that can add UI, tools, backend actions, settings, and skills. Agent plugins are portable Codex/Claude-style capability packages, often with files such as .codex-plugin/plugin.json; they are imported as managed compatibility packages and may contribute instructions or skills, but they are not native app extensions unless wrapped with extension.json and built extension artifacts.

Contents

Agent-first workflow

Users should usually describe the feature they want and let their agent create the extension:

Build a Neon Pilot extension that [does what].

Before implementation, produce a short UX brief and ask me focused product/design questions for anything ambiguous so you can build the right first version in one pass. The brief must name the primary user, the job-to-be-done, the primary surface, the default/empty/loading/error/success states, the main actions, and the shared UI primitives you will reuse from `@neon-pilot/extensions/ui`.

Use the extension manager/template if helpful. Pick the right surface:
- main page for a full app/workflow
- tab-local right rail for a compact conversation-specific tool panel inside the workbench
- workbench detail for split-pane workflows

Implement it with editable source files, build it, reload it, visually test the exact user path, fix any layout or interaction problems you see, and checkpoint the changes.

The agent should create editable src/ files, declare contributions in extension.json, build, validate, reload, inspect the UI when present, and checkpoint only touched files. Manual manifest/API details below are reference material.

Production readiness checklist

Before calling an extension done, an agent must be able to answer each item with evidence from the repo or app:

Core vs extensions

Neon Pilot core is the small, stable platform: agent and conversation runtime, model/tool execution protocol, transcript/event stream, durable storage primitives, knowledge/system-prompt assembly, extension host/manifest/API/permissions, security boundaries, desktop/web shell, routing, install/update plumbing, and shared UI primitives.

Everything user-facing, domain-specific, or workflow-specific should be an extension: pages, panels, tool renderers, slash/command surfaces, integrations, context providers, automations, import/export flows, diagnostics views, settings sections, and opinionated UX built on top of the platform.

When a feature cannot be built cleanly as an extension, add a general-purpose extension API or SDK primitive to core rather than hardcoding a one-off app feature. Core should make features possible; extensions should be where features live.

Some bundled extensions are required system extensions because they own platform repair or configuration surfaces such as extension management, Settings, Prompt Assembly inspection, Terminal, or background work. They still live behind the extension API, but the core extension host treats their availability as infrastructure: stale disabled config is ignored, user/API disable attempts fail, and circuit-breaker failures are recorded without quarantining the extension.

Extension Structure

A minimal extension looks like:

my-extension/
├── extension.json      # Manifest
├── package.json        # Dependencies (optional)
├── src/
│   ├── frontend.tsx    # UI components (optional)
│   └── backend.ts      # Backend handlers / protocol entrypoints (optional)
└── dist/               # Built output

Create a new extension by asking your agent to build it. Under the hood, the agent should use Extension Manager actions or the packaged local-extension-development skill; renderer/extension UI should use the public extension SDK clients/capabilities instead of reaching into desktop internals.

Extension frontend code must not depend on Electron IPC channels. Product data flows through host-provided HTTP clients/capabilities, realtime updates flow through host-provided realtime subscriptions, and native OS/Electron operations remain host-owned capabilities. If an extension needs a missing product API, add a reusable SDK capability rather than adding an extension-specific IPC bridge.

Packaging and distribution

Build before packaging. A packaged extension is a zip file containing one top-level extension directory with extension.json, source/docs/assets, and prebuilt dist/ bundles. Do not include node_modules, transient .dist.tmp-* folders, or local build caches.

From the repo root:

pnpm run extension:build -- ~/.local/state/neon-pilot/extensions/agent-board
neon-pilot-extension doctor ~/.local/state/neon-pilot/extensions/agent-board
neon-pilot extensions smoke agent-board
neon-pilot-extension pack ~/.local/state/neon-pilot/extensions/agent-board --out /tmp/agent-board.neon-extension.zip

neon-pilot-extension pack is a thin wrapper around scripts/extension-pack.mjs. It zips the package directory itself, excludes node_modules, sidecar/target, and .dist.tmp-*, and writes either <extension-dir>.zip or the path passed with --out.

Import/install expects the same shape: one safe top-level directory containing extension.json. The desktop runtime unzips into <state-root>/extensions/{extension-id} and loads the prebuilt dist/ files. Packaged desktop releases do not compile extensions at install time.

Optional first-party extensions are distributed from the separate patleeman/neon-pilot-extensions repo as GitHub release artifacts. Use the same package format for local, catalog, and GitHub installs:

pnpm run extension:build -- <extension-dir>
neon-pilot-extension doctor <extension-dir>
neon-pilot-extension pack <extension-dir> --out <extension-id>-<version>.neon-extension.zip

See Extension Distribution for GitHub repo manifests, release catalogs, compatibility metadata, and publishing guidance.

Manifest (extension.json)

The manifest declares what your extension contributes:

{
  "schemaVersion": 2,
  "id": "my-extension",
  "name": "My Extension",
  "description": "What it does",
  "version": "0.1.0",
  "permissions": ["storage:readwrite"],
  "frontend": {
    "entry": "dist/frontend.js",
    "styles": []
  },
  "backend": {
    "entry": "src/backend.ts",
    "actions": [
      {
        "id": "ping",
        "handler": "ping",
        "title": "Ping",
        "worker": { "enabled": true }
      }
    ],
    "protocolEntrypoints": [
      {
        "id": "acp",
        "handler": "runAcpProtocol",
        "title": "Agent Client Protocol"
      }
    ]
  },
  "contributes": {
    "views": [],
    "nav": [],
    "commands": [],
    "tools": [],
    "skills": [],
    "themes": []
  }
}

packageType: derived by the loader from install location: repo/app-bundled extensions are "system"; runtime-installed extensions are "user". The manifest field is accepted for compatibility but is not authoritative.

defaultEnabled: set to false for extensions that should install disabled until the user explicitly enables them.

permissions: See Permissions.

Contribution Types

Field Purpose Docs
views UI surfaces (pages, panels, sidebar replacements) See docs/views.md
nav Left sidebar navigation items; can reference a sidebar view with sidebarView
commands Extension actions invokable by command IDs See Commands and keybindings
cliCommands Product administration commands contributed to the neon-pilot CLI See below
keybindings Keyboard shortcuts that execute commands See Commands and keybindings
slashCommands /command in composer
tools Agent-callable tools
skillProviders Dynamic Prompt Assembly skill providers See below
toolProviders Dynamic Prompt Assembly tool providers See below
promptTemplateProviders Dynamic Prompt Assembly template providers See below
instructionProviders Dynamic Prompt Assembly instruction layers See below
promptAssemblyHooks Privileged Prompt Assembly hooks See below
modelProfiles Enabled extension runtime profiles matched by provider/model globs
mentions @-mention providers
skills Agent Skills (markdown)
themes Color themes
backend.protocolEntrypoints Extension-owned stdio protocols launched by the host CLI See below
transcriptRenderers Custom tool result rendering; set standalone: true to render outside internal-work clusters
promptReferences @-mention resolvers
turnContextProviders Ordered per-turn context injection See below
promptContextProviders Prompt Assembly context diagnostics/providers See below
conversationConnectionProviders Conversation-attached state/work/assets for shelves, right rail, and CLI See below
selectionActions Actions available for selected transcript/composer text See below
transcriptBlocks Extension-owned transcript block renderers See below
subscriptions Backend event subscriptions See below
secrets Secret declarations surfaced in Settings See below
activityTreeItemElements Custom content in thread/activity tree rows Activity tree
activityTreeItemStyles Custom row styling for thread/activity tree items Activity tree
quickOpen Command palette surfaces/tabs backed by extension providers See below
searchProviders Backend-powered global search providers See below
runtimeProviders Extension-advertised local/remote runtime targets See below
gatewayProviders External messaging gateway providers registered for shared gateway state See below
settings Settings schema contributions See below
settingsComponent Component panel in Settings See below
topBarElements Top bar indicator icons See below
conversationHeaderElements Badges in conversation header See below
messageActions Hover buttons on messages See below
composerShelves Sections above the composer See below
newConversationPanels Panels on the new conversation page See below
composerControls Component controls in the composer bottom row See below
composerButtons Legacy composer controls See below
composerInputTools Component tools beside composer controls See below
toolbarActions Icon buttons in composer toolbar See below
conversationDecorators Badges on conversation list items See below
contextMenus Right-click menu items See below
threadHeaderActions Component buttons in the Threads header See below
statusBarItems Labels in the composer status bar See below
conversationLifecycle Conversation-state banners/inline UI See below
composerAttachmentProviders Buttons that add/derive composer attachment context See below
composerAttachmentRenderers Renderers for extension-owned composer attachment chips See below
composerAttachmentResolvers Backend resolvers for extension-owned attachment refs See below
activityTreeItemActions Inline action buttons on thread/activity tree rows See below

Standard file change metadata

File-mutating tools should return standard details.fileChanges metadata when they can identify the exact mutation they performed. The desktop transcript renders this shape as an inline Pierre diff for any tool block, without requiring a tool-specific renderer.

type FileChange = {
  path: string;
  previousPath?: string;
  status: 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange' | 'unmerged' | 'changed';
  additions: number;
  deletions: number;
  patch?: string; // unified diff for this exact tool call
  truncated?: boolean;
};

return { text: 'Updated file.', details: { fileChanges: [change] } };

Omit patch and set truncated: true when the inline diff would bloat transcript state.

Model Profiles

Model profiles let an enabled extension declare that it provides model-specific runtime behavior. They are intentionally small: the profile only says which provider/model refs it matches. The extension implements behavior through normal extension mechanisms such as agent hooks, tools, tool replacements, context providers, or backend actions.

Models do not declare profiles. Disabled extensions do not secretly activate for matching models. If no enabled profile matches, the session uses normal default behavior.

{
  "backend": {
    "entry": "src/backend.ts",
    "agentExtension": "default"
  },
  "contributes": {
    "modelProfiles": [
      {
        "id": "codex-compatible",
        "title": "Codex Compatible",
        "description": "Codex-shaped runtime behavior for GPT coding models.",
        "match": ["openai-codex/*", "*/gpt-5.5"],
        "priority": 100
      }
    ],
    "tools": [
      {
        "id": "apply-patch",
        "name": "apply_patch",
        "description": "Apply a Codex-style patch.",
        "action": "applyPatch"
      }
    ]
  }
}

match patterns are simple * globs over the canonical ref:

<provider>/<model>

Examples:

Profile resolution is deliberately boring:

  1. Only enabled extensions contribute model profiles.
  2. Matching profiles are sorted by priority descending; missing priority is 0.
  3. Exactly one highest-priority profile wins.
  4. If multiple profiles tie at the highest priority, the match is ambiguous and no profile behavior should be assumed.

The profile contribution itself does not define tools or instructions. For example, the Codex Compatibility extension contributes a modelProfiles match for openai-codex/*, contributes the apply_patch tool, and uses its backend.agentExtension hooks to switch active tools to bash and apply_patch when a matching model is active. Explicit per-run tool allowlists still take precedence over extension profile behavior.

Profiles for local runtimes may declare startupAction, naming a backend action in the same extension. When that profile is the resolved match for the selected model, Neon Pilot invokes the action before the provider request is sent. Use this for idempotent runtime preparation such as starting a local server; the action should return quickly when the runtime is already healthy and surface setup errors clearly.

For session_start and model_select handlers, the desktop host augments the lifecycle context with:

type ModelProfileContext =
  | { kind: 'none'; modelRef: string | null }
  | { kind: 'resolved'; modelRef: string; profile: { id: string; extensionId: string; title?: string; match: string[]; priority: number } }
  | { kind: 'ambiguous'; modelRef: string; profiles: Array<{ id: string; extensionId: string; match: string[]; priority: number }> };

ctx.modelProfile: ModelProfileContext;
ctx.setActiveTools(toolNames: string[]): void;

ctx.setActiveTools is only available on these lifecycle contexts. Global pi.setActiveTools is blocked by the desktop runtime.

Views

Views are the primary way to add UI. Three locations:

{
  "id": "my-panel",
  "title": "My Panel",
  "location": "rightRail",
  "component": "MyComponent",
  "scope": "conversation",
  "icon": "app",
  "activation": "on-open"
}

activation controls when the component loads:

scope for rightRail views:

Message Actions (messageActions)

Add hover-reveal text buttons on messages, inline with copy/fork/rewind. Action-based — no frontend entry needed.

{
  "id": "summarize-message",
  "title": "Summarize",
  "action": "summarizeHandler",
  "when": "role:assistant && hasText",
  "priority": 10
}

when predicates:

Backend handler receives:

{
  messageText: string;
  messageRole: 'user' | 'assistant';
  blockId: string;
  conversationId: string;
}

Global Search Providers (searchProviders)

Use searchProviders for app-level search backed by extension backend actions. The provider appears as a command palette scope; when the user searches that scope, the backend action receives { query, limit, providerId }.

{
  "backend": {
    "actions": [{ "id": "searchTickets", "handler": "searchTickets", "worker": { "enabled": true } }]
  },
  "contributes": {
    "searchProviders": [
      {
        "id": "tickets",
        "title": "Tickets",
        "action": "searchTickets",
        "kinds": ["ticket"],
        "priority": 10
      }
    ]
  }
}

Return either an array of items or { "items": [...] }. Items support title, subtitle, snippet/meta, keywords, order, and optional action. Supported actions today are { "kind": "navigate", "to": "/path" } and { "kind": "command", "command": "extension.command", "args": {} }.

Conversation Lifecycle (conversationLifecycle)

Add React UI for conversation state transitions such as waiting for user input, blocked/takeover state, model or tool errors, active goal mode, compaction, or an active run.

{
  "contributes": {
    "conversationLifecycle": [
      {
        "id": "approval-banner",
        "component": "ApprovalBanner",
        "events": ["waiting-for-user", "blocked"],
        "slot": "banner",
        "priority": 10
      }
    ]
  }
}

Components receive { pa, lifecycleContext }, where lifecycleContext includes conversationId, cwd, event, isStreaming, hasGoal, isCompacting, and optional error.

For backend automation, subscribe to source: "conversation" and patterns like tool.started, tool.ended, tool.failed, run.started, run.ended, model.error, compaction.started, or compaction.ended. Handlers receive { subscriptionId, event, payload, sourceExtensionId }; payload.type is the lifecycle event name.

Prompt Context Providers (promptContextProviders)

Use promptContextProviders when an extension needs to expose prompt-assembly diagnostics or suggested hidden context that can be inspected from the Prompt Assembly page. New provider implementations should document what context they add and whether the user can disable it.

{
  "contributes": {
    "promptContextProviders": [{ "id": "suggested-context", "handler": "provide-prompt-context", "title": "Suggested context" }]
  }
}

Turn Context Providers (turnContextProviders)

Use turnContextProviders when an extension needs to add scoped hidden context before each submitted turn without mutating the system prompt. Providers are ordered by priority and invoked during prompt preparation.

{
  "contributes": {
    "turnContextProviders": [{ "id": "reminders", "handler": "provideTurnReminders", "title": "Turn reminders", "priority": 50 }]
  }
}

Handlers receive { prompt, conversationId, currentCwd, relatedConversationIds } and may return legacy { contextMessages } or { blocks }. Blocks are converted into extension turn-context messages.

Conversation Connection Providers (conversationConnectionProviders)

Use conversationConnectionProviders when an extension owns conversation-attached work, state, assets, context, integrations, or surfaces that should be discoverable by both UI and CLI. The host calls the provider action for /api/conversations/:id/connections, ctx.conversations.connections(...), and neon-pilot conversations connections <id>.

{
  "contributes": {
    "conversationConnectionProviders": [
      {
        "id": "scratchpad",
        "title": "Scratchpad",
        "action": "listScratchpadConnections",
        "kind": "state",
        "surfaces": ["rightRail", "cli"],
        "priority": 40
      }
    ]
  }
}

Provider actions receive { conversationId, providerId } and should return either an array of items or { "items": [...] }. Required item fields are id, kind, title, active, meaningful, visibility, source, and surfaces. The host prefixes returned IDs with the extension ID and silently skips malformed items.

Only return meaningful connections. Empty scratchpads, zero-item todo lists, dormant optional panels, and purely decorative surfaces should return { "items": [] }.

Runtime Providers (runtimeProviders)

Runtime providers advertise conversation execution targets such as SSH remotes. This is the registry/health boundary only; routing a conversation to a non-local runtime still requires host runtime support.

{
  "contributes": {
    "runtimeProviders": [{ "id": "ssh", "title": "SSH Remote Runtime", "handler": "listSshRuntimes" }]
  }
}

Handlers return { runtimes: [...] }, where each runtime includes id, title, kind, status, optional version, workspaceRoots, capabilities, and metadata. Backend actions can inspect providers through ctx.runtimes.list(), ctx.runtimes.get(id), and ctx.runtimes.healthCheck(id).

Gateway Providers (gatewayProviders)

Gateway providers advertise external messaging channels that can route messages into Neon Pilot conversations. Declaring a provider registers its provider ID for shared gateway state. The extension runtime owns credentials, transport, and provider-specific setup UI; do not rely on Telegram Gateway as a generic provider switcher.

{
  "contributes": {
    "gatewayProviders": [
      {
        "id": "discord",
        "label": "Discord",
        "description": "Route Discord messages into Neon Pilot.",
        "configurationLocation": "extension",
        "setupRoute": "/ext/discord-gateway",
        "docsUrl": "https://discord.com/developers/docs/intro",
        "order": 30
      }
    ]
  }
}

Gateway runtime code should import the focused backend seam:

import {
  attachGatewayConversation,
  ensureGatewayConnection,
  recordGatewayEvent,
  updateGatewayConnectionStatus,
} from '@neon-pilot/extensions/backend/gateways';

Use ensureGatewayConnection({ provider }) when the runtime is created, updateGatewayConnectionStatus(...) when credentials or transport state changes, attachGatewayConversation(...) when an external chat is bound to a conversation, detachGatewayConversation(...) when a binding is removed, and recordGatewayEvent(...) for user-visible gateway activity. Extension code must not import packages/desktop/server/gateways/* directly.

Composer Attachments

Use composerAttachmentProviders for buttons above the composer attachment shelf. The provider action receives { conversationId, cwd, composerText }; returning a string or { "text": "..." } appends text to the composer. composerAttachmentRenderers and composerAttachmentResolvers reserve the manifest/API seam for extension-owned attachment refs.

{
  "contributes": {
    "composerAttachmentProviders": [{ "id": "attach-ticket", "title": "Attach Ticket", "action": "attachTicket", "icon": "🎫" }],
    "composerAttachmentRenderers": [{ "id": "ticket-chip", "type": "jira-ticket", "component": "TicketAttachment" }],
    "composerAttachmentResolvers": [{ "id": "ticket-resolver", "type": "jira-ticket", "action": "resolveTicket" }]
  }
}

Activity Tree Item Actions (activityTreeItemActions)

Add compact inline buttons to thread/activity tree rows. The action receives { itemId, kind, title, conversationId, cwd }.

{
  "contributes": {
    "activityTreeItemActions": [{ "id": "summarize-thread", "title": "Summarize", "action": "summarizeThread", "icon": "Σ" }]
  }
}

Slash Commands (slashCommands)

Add /command entries to the conversation composer. Slash commands are listed in the composer slash menu and execute an extension backend action before the prompt is sent.

{
  "backend": {
    "entry": "dist/backend.mjs",
    "actions": [{ "id": "createTask", "handler": "createTask", "worker": { "enabled": true } }]
  },
  "contributes": {
    "slashCommands": [
      {
        "name": "task",
        "description": "Create a task from composer input.",
        "action": "createTask"
      }
    ]
  }
}

The backend action receives:

{
  commandName: string;
  argument: string;
  text: string;
  conversationId: string | null;
  cwd: string;
  draft: boolean;
}

The action can return a string, { prompt }, or { text } to send a generated prompt; { replaceComposerText } or { appendComposerText } to update the composer without sending; { notice: { text, tone } } to show feedback; or any other object/empty result to mark the command handled.

Use slashCommands for composer-triggered extension code. Use pi.registerCommand(...) inside backend.agentExtension only when the command must run inside the live agent session runtime; that does not automatically make the command appear in the composer slash menu.

CLI Commands (cliCommands)

Core owns the neon-pilot CLI shell. Extensions contribute product-specific verbs with contributes.cliCommands; the CLI discovers enabled extensions and routes matching commands to the declared backend action through the extension host boundary.

{
  "backend": {
    "actions": [{ "id": "manageExample", "handler": "manageExample" }]
  },
  "contributes": {
    "cliCommands": [
      {
        "id": "example-list",
        "command": "example list",
        "description": "List example records.",
        "usage": "example list [--json]",
        "examples": ["neon-pilot example list", "neon-pilot example list --json"],
        "argsSchema": { "type": "array", "items": { "type": "string" } },
        "flagsSchema": {
          "type": "object",
          "properties": { "json": { "type": "boolean" } },
          "additionalProperties": true
        },
        "mode": "read",
        "requiresApp": false,
        "idempotent": true,
        "outputModes": ["text", "json"],
        "smoke": {
          "argv": ["example", "list"],
          "expectJsonFields": ["records"]
        },
        "action": "manageExample",
        "jsonDefault": true
      }
    ]
  }
}

The action receives { action, cli: { command, rawArgv, args, flags, json, quiet, verbose, color, cwd } }, where action defaults to the final command token as a convenience hint. Return { text } for human output and structured fields for --json; the shell is human-first and only sets cli.json when the caller passes --json. Keep CLI commands coarse and administrative; do not expose every UI button as a command. Agents should use human command/help output for task selection, use --json only for scripts or stable machine parsing, and prefer extension-contributed CLI commands over direct runtime file edits. System extensions use this surface for extension-owned administration such as extensions ..., settings ..., and conversations .... CLI commands route through the same extension host and permission boundary as UI actions; do not expose secret reads or raw host file mutation through CLI handlers.

Each command should include a human-readable description; add usage and examples when the command accepts arguments or flags. pnpm run check:cli:surface validates command discovery and help output for all system extension CLI commands.

System extension CLI commands must also declare a contract: argsSchema, flagsSchema, mode, requiresApp, idempotent, and outputModes. The core shell validates declared args and flags before dispatch. Mutating commands (write, destructive, or background) must support and document --dry-run; the core CLI shell short-circuits declared dry-runs before invoking the backend action. Destructive commands require interactive confirmation or --yes; the shell adds --yes to destructive command contracts. Long-running commands should declare mode: "streaming", include jsonl in outputModes, and document flags such as --follow, --format text|json|jsonl, and --cancel-on-interrupt when supported. Use ctx.toolContext?.onUpdate or ctx.conversations.runTurn(..., { onEvent }) to emit real stream events for jsonl callers. Use optional smoke metadata for safe read-only contract tests.

Quick-open surfaces (quickOpen)

Add a top-level tab to the command palette. Each quick-open contribution registers one extension-owned surface. The palette uses section as the stable tab/surface id, title as the visible tab label, and provider as the frontend export that returns items.

{
  "contributes": {
    "quickOpen": [
      {
        "id": "knowledge-files",
        "provider": "knowledgeQuickOpenProvider",
        "title": "Knowledge",
        "section": "knowledge",
        "order": 10
      }
    ]
  }
}

Provider items can omit section; omitted values are assigned to the contribution's section (or id if no section is set). Items with a different section are ignored by that tab. Providers may expose list() for default results and search(query, limit) for content-backed search. order is optional and controls tab ordering after the built-in Threads tab.

Keybindings can open a quick-open surface directly with legacy commandPalette:<section> or the first-class command form command: "palette.open", args: { "scope": "knowledge" }.

Settings Component (settingsComponent)

Add one component-backed section to the main Settings page.

{
  "contributes": {
    "settingsComponent": {
      "id": "dictation",
      "component": "DictationSettingsPanel",
      "sectionId": "settings-dictation",
      "label": "Dictation",
      "description": "Enable local dictation via Whisper.cpp for the composer mic button.",
      "order": 30
    }
  }
}

The component receives pa and settingsContext. Use this for rich settings UIs; use settings for simple scalar settings managed by the built-in extension settings form.

Composer Controls (composerControls)

Add component-backed controls in the composer bottom row. Core owns the row layout and passes composer state/actions through controlContext; extensions own visible controls such as attachments, model preferences, dictation, and goal mode.

{
  "id": "dictation",
  "component": "DictationButton",
  "title": "Dictation",
  "slot": "preferences",
  "when": "!streamIsStreaming",
  "priority": 100
}

Slots are leading, preferences, and actions. Controls sort by priority ascending, then extension id, then contribution id. The component receives pa, controlContext, and the legacy alias buttonContext. controlContext.renderMode is inline or menu; insertText(text) inserts at the current composer selection; appendText(text) inserts at the end when available; openFilePicker() opens the core-owned attachment input; and model/goal fields expose the current composer preference state.

Composer Buttons (composerButtons)

Legacy alias for composer controls. Existing placement: "afterModelPicker" maps to slot: "preferences"; placement: "actions" maps to slot: "actions". New extensions should use composerControls.

Composer Input Tools (composerInputTools)

Add component-backed tools beside the attachment button in the composer input row. Use this for input-producing tools such as drawing editors or file-producing widgets, not submit-adjacent actions.

{
  "id": "draw",
  "component": "DrawButton",
  "title": "Create drawing",
  "when": "!streamIsStreaming",
  "priority": 10
}

The component receives pa and toolContext. toolContext.addFiles(files) routes files through the normal composer attachment pipeline. toolContext.upsertDrawingAttachment(payload) adds an Excalidraw-compatible drawing payload to the composer. Excalidraw tools should import shared serialization helpers from @neon-pilot/extensions/excalidraw instead of duplicating preview/source generation.

Prompt Assembly

Prompt Assembly is the single runtime surface for deciding what the agent sees before a turn starts. It inventories and explains skills, tools, prompt templates, prompt context provider blocks, and diagnostics from providers, hooks, validation, and runtime policy. The built-in Prompt Assembly page at /prompt-assembly is the inspection and management surface.

Use static manifest contributions first:

{
  "contributes": {
    "skills": [{ "id": "agent-board", "path": "skills/agent-board/SKILL.md" }],
    "tools": [{ "id": "create-task", "description": "Create a task.", "action": "createTask" }]
  }
}

Use dynamic providers only when a contribution is generated, external, or conditional at runtime:

{
  "contributes": {
    "skillProviders": [{ "id": "generated-skills", "handler": "listGeneratedSkills", "title": "Generated Skills" }],
    "toolProviders": [{ "id": "generated-tools", "handler": "listGeneratedTools" }],
    "promptTemplateProviders": [{ "id": "generated-prompts", "handler": "listGeneratedPrompts" }],
    "instructionProviders": [{ "id": "runtime-instructions", "handler": "listRuntimeInstructions" }]
  }
}

Provider handlers may return either an array or an object keyed by the contribution kind, for example { "skills": [...] }, { "tools": [...] }, { "templates": [...] }, or { "layers": [...] }. Providers are isolated: failures, timeouts, and malformed items become diagnostics and do not block the rest of assembly.

promptAssemblyHooks are the break-glass escape hatch for filtering or mutating the assembled plan:

{
  "contributes": {
    "promptAssemblyHooks": [{ "id": "filter-runtime-context", "handler": "filterRuntimeContext", "phase": "before-injection" }]
  }
}

Hooks are powerful. Prefer providers. Do not silently rewrite system instructions through hooks; use instruction/context providers once available, and expose clear diagnostics for any mutation.

Toolbar Actions (toolbarActions)

Add simple action-backed icon buttons in the composer toolbar row. Action-based — no frontend entry needed.

{
  "id": "open-browser",
  "title": "Open browser",
  "icon": "browser",
  "action": "openBrowserBackend",
  "when": "!streamIsStreaming",
  "priority": 10
}

when predicates:

Composer Shelves (composerShelves)

Add sections in the scrollable area above the composer input. Component-based — requires a frontend entry with a named component export.

{
  "id": "status-shelf",
  "component": "StatusShelf",
  "title": "Status",
  "placement": "bottom"
}

placement: "top" (before built-in shelves) or "bottom" (after).

The component receives:

{
  pa: NeonPilotClient;
  shelfContext: {
    conversationId: string;
    isStreaming: boolean;
    isLive: boolean;
  }
}

New Conversation Panels (newConversationPanels)

Add panels to the new conversation empty state, below the workspace selector and above the composer. Use this for draft-only guidance or prompt preparation UI that should not live inside the composer chrome.

{
  "id": "suggested-context",
  "component": "SuggestedContextPanel",
  "title": "Suggested Context",
  "priority": 100
}

The component receives:

{
  pa: NeonPilotClient;
  panelContext: {
    conversationId: string;
  }
}

Conversation Decorators (conversationDecorators)

Add badges, icons, or indicators on conversation tab items in the sidebar. Component-based — requires a frontend entry.

{
  "id": "gateway-badge",
  "component": "GatewayBadge",
  "position": "after-title",
  "priority": 10
}

position: "before-title", "after-title", or "subtitle" (below title).

The component receives:

{
  pa: NeonPilotClient;
  session: SessionMeta; // conversation metadata
}

Activity Tree Item Elements (activityTreeItemElements)

Add small component-backed elements to the shared activity tree rows used for conversations, executions, and future work items. Core owns row layout, routing, selection, and keyboard behavior; extensions only fill safe slots.

{
  "id": "thread-color-dot",
  "component": "ThreadColorDot",
  "slot": "leading",
  "priority": 10
}

slot: "leading", "before-title", "after-title", "subtitle", or "trailing".

Activity Tree Item Styles (activityTreeItemStyles)

Register backend providers for data-only row styling metadata such as accent colors, backgrounds, or tooltip text. Providers are sorted by priority; higher priority runs first.

{
  "id": "thread-color-style",
  "provider": "getThreadColorStyle",
  "priority": 10
}

The host will pass activity item metadata to the provider once the activity tree UI integration is enabled. Providers should return sanitized data, not arbitrary DOM or CSS ownership.

Context Menus (contextMenus)

Add right-click menu items. Action-based — no frontend entry needed.

{
  "id": "copy-deeplink",
  "title": "Copy Deeplink",
  "action": "copyDeeplinkHandler",
  "surface": "conversationList"
}

surface: "message" (on message blocks) or "conversationList" (on sidebar items).

Conversation list backend handler receives:

{
  conversationId: string;
  sessionTitle: string;
  cwd: string;
}

Thread Header Actions (threadHeaderActions)

Add compact component buttons beside the left sidebar conversation header. Use this for conversation-list actions such as importing a session.

{
  "id": "import-session",
  "component": "ImportSessionButton",
  "title": "Import Session",
  "priority": 10
}

The component receives { pa, actionContext }; actionContext includes activeConversationId and cwd when available.

Add a native extension surface that replaces the thread list area in the left sidebar while an extension nav item is active. Use this for extension-owned navigation models such as remote-agent sessions, external task lists, or project-specific explorers.

{
  "views": [
    {
      "id": "sessions-sidebar",
      "title": "Hermes Sessions",
      "location": "sidebar",
      "component": "HermesSessionsSidebar"
    }
  ],
  "nav": [
    {
      "id": "hermes",
      "label": "Hermes",
      "icon": "sparkle",
      "route": "/ext/hermes",
      "sidebarView": "sessions-sidebar"
    }
  ]
}

The host still owns the fixed app navigation stack and bottom settings area. The sidebar view owns only the body below the nav item list, so it should render its own header, filters, empty/loading/error states, and row actions.

Status Bar Items (statusBarItems)

Add labels in the status bar below the composer. Action-based — no frontend entry needed.

{
  "id": "gateway-status",
  "label": "Gateway",
  "action": "openGatewayPanel",
  "alignment": "right",
  "priority": 10
}

alignment: "left" or "right". priority: sort order (higher = closer to edge). Items without an action are static labels. Items with an action are clickable.

Composer host boundary

Composer contribution contexts expose intent methods such as insertText(text), appendText(text), addFiles(files), and openFilePicker(). Extensions request these actions; the host owns composer state, attachment ingestion, selection, focus, and caret restoration.

Rules:

Protocol entrypoints (backend.protocolEntrypoints)

Extensions can expose host-launched stdio protocols such as ACP. The host resolves these by protocol id and wires stdin/stdout/stderr into the backend handler.

{
  "backend": {
    "entry": "dist/backend.mjs",
    "protocolEntrypoints": [
      {
        "id": "acp",
        "handler": "runAcpProtocol",
        "title": "Agent Client Protocol"
      }
    ]
  }
}

The handler receives ExtensionProtocolContext, which extends the normal backend context with:

These entrypoints are intended for long-lived protocol sessions, not one-shot actions.

Tools

Extensions can register agent-callable tools. The agent sees them as extension_{extensionId}_{toolId} unless a custom name is given.

Tool registration is intentionally stable for the life of an agent session. Register tools once and return a clear validation error from the handler when the current app state does not support a call. Global pi.setActiveTools is blocked by the desktop runtime; model profile extensions may use ctx.setActiveTools(...) from session_start or model_select handlers to choose a session-scoped active tool surface over already-registered tools.

The tool definition already gives the model the description and JSON-schema inputSchema, including parameter descriptions. Keep promptGuidelines high-signal: use them only for behavior the schema cannot express, such as when not to use the tool, safety boundaries, or required follow-up behavior. One short sentence is the default. If a workflow needs more than that, contribute an extension skill instead of stuffing a mini manual into every prompt.

{
  "id": "summarize",
  "name": "summarize_text",
  "description": "Summarize a block of text",
  "action": "summarizeHandler",
  "inputSchema": {
    "type": "object",
    "properties": {
      "text": { "type": "string" }
    },
    "required": ["text"]
  }
}

#### Overriding built-in tools

Extension tools can replace built-in tools using the replaces field. When set, the tool registers under the built-in tool's name, overriding it.

{
  "id": "my-bash",
  "description": "Safer bash execution with logging",
  "action": "bashHandler",
  "replaces": "bash",
  "inputSchema": {
    "type": "object",
    "properties": {
      "command": { "type": "string" }
    },
    "required": ["command"]
  }
}

Supported overridable tools: bash, read, write, edit, grep, find, ls, notify, web_fetch, web_search.

Use when to only register a tool for specific providers or models. This is the right shape for model-specific tool replacements, because unsupported models never see the replacement tool.

{
  "id": "apply-patch-edit",
  "description": "Patch-based edit implementation for OpenAI models",
  "action": "applyPatchEdit",
  "replaces": "edit",
  "when": { "providers": ["openai", "openai-codex"], "models": ["gpt-5.2"] },
  "inputSchema": { "type": "object", "properties": { "patch": { "type": "string" } }, "required": ["patch"] }
}

when.providers matches provider/model refs such as openai/gpt-5.2; when.models matches either gpt-5.2 or openai/gpt-5.2.

The replacement tool must accept the same input schema as the original and return compatible output.

#### Streaming progress in tool handlers

Backend action handlers called from manifest-declared tools can stream progress updates during execution using ctx.toolContext?.onUpdate().

export async function longRunningHandler(input: unknown, ctx: ExtensionBackendContext) {
  // Send progress updates back to the agent
  ctx.toolContext?.onUpdate?.({
    content: [{ type: 'text', text: 'Step 1 of 3 complete...' }],
  });

  const result = await doWork();

  // Final result
  return { content: [{ type: 'text', text: 'Done!' }] };
}

This is useful for tools that take multiple seconds to complete — the agent sees intermediate progress instead of waiting silently.```

Actions invoked as manifest tools normally carry live-only agent context, including the abort signal and progress callback, so they stay in the host process by default. If an action only needs serializable toolContext fields and focused host capabilities, it may opt into worker execution with worker: { "enabled": true, "ignoreLiveContext": true }. Do not use this flag for actions that consume ctx.agentToolContext, rely on ctx.toolContext.onUpdate, or otherwise need live function-bearing context.

Frontend (UI)

Your src/frontend.tsx exports React components referenced in the manifest. The desktop app loads the extension registry once at the app shell and shares it through context; do not add per-message or per-tool registry fetches in frontend hosts.

import type { ExtensionSurfaceProps } from '@neon-pilot/extensions';

export function MyPanel({ pa, context }: ExtensionSurfaceProps) {
  return (
    <div>
      <button onClick={() => pa.ui.toast('Hello!')}>Test Toast</button>
    </div>
  );
}

The pa client provides:

See packages/extensions/src/index.ts for the full API.

Backend-only host APIs that should stay narrow can also be exposed through focused SDK subpaths such as @neon-pilot/extensions/backend/artifacts, /automations, /browser, /compaction, /conversations, /images, /mcp, /runs, /runtime, /telemetry, /terminal, and /webContent. Prefer a focused subpath over the broad backend barrel when bundling a system extension that only needs one backend service. Feature-specific data planes such as Knowledge should live inside their owning extension and use generic host capabilities (ctx.storage, ctx.filesystem, ctx.shell, ctx.git, events, and UI invalidation) rather than dedicated host subpaths. For daemon-backed shell work in a packaged system extension, keep the foreground path free of daemon imports and lazy-load background-run support only when the action actually starts or inspects background work.

The backend API is deliberately two-layered: public stubs under packages/extensions/src/backend/*.ts, and host implementations under packages/desktop/server/extensions/backendApi/*.ts. Extension source imports only @neon-pilot/extensions/backend/{name}. It must not import desktop server files, @neon-pilot/core, @neon-pilot/daemon, or agent-runtime internals directly. System extension source may use type-only Pi imports for extension hook types, but runtime value imports from Pi must go through a focused host seam. Host backend API modules should be thin adapters; lazy-load heavy desktop/runtime modules inside functions so packaged extension bundles do not accidentally drag in half the app. If a backend API wraps process-local host state and may run in the backend worker, it must use the worker capability bridge instead of importing that state directly in the worker. pnpm run check:extensions enforces this with scripts/check-extension-backend-api.mjs and packaged source/bundle checks before packaged bundle checks run.

Backend seam permission model: seams that run user-visible privileged workflows still require explicit extension permissions (agent:run, agent:conversations, etc.). Narrow host helpers such as /compaction, /runtime, and /webContent are trusted system-extension internals; they do not create standalone user-facing authority and should stay scoped to active hook/action context rather than growing into broad service APIs.

For model-backed extension workflows, use @neon-pilot/extensions/backend/agent instead of importing Pi directly. runAgentTask runs a host-owned one-shot hidden agent task with optional image inputs, tools: 'none', and timeout cleanup; the host owns model lookup, auth storage, session creation, cancellation boundaries, and runtime policy. When allowedToolNames is provided, the task can execute those tool calls across multiple hidden turns; it stops early only when a tool result returns terminate: true. Extensions must declare agent:run to use this seam.

For extension-owned chat, use createAgentConversation, sendAgentMessage, streamAgentMessage, getAgentConversation, listAgentConversations, abortAgentConversation, and disposeAgentConversation; conversations support hidden+ephemeral private worker sessions and visible+saved host conversations that appear in the normal conversation system. Both modes are scoped to the owner extension id and require agent:conversations.

Use streamAgentMessage from a manifest-declared SSE backend.routes handler when an extension needs to render its own private chat UI. It returns the same shaped event stream extensions should render in chat surfaces: user_message, agent_start, text_delta, thinking_delta, tool events, agent_end, turn_end, and error. Visible+saved conversations should instead subscribe to the host live-session event endpoint for that conversation id; those are already first-class main-chat sessions. Frontend extensions should import ChatView, ChatRailComposer, and their props from @neon-pilot/extensions/ui rather than reimplementing transcript or composer chrome.

Backend extensions can record fire-and-forget app telemetry through the dedicated telemetry seam:

import { recordTelemetryEvent } from '@neon-pilot/extensions/backend/telemetry';

recordTelemetryEvent({ source: 'agent', category: 'my_extension', name: 'action_completed', durationMs: 42 });

Backend action handlers can also use ctx.telemetry.record(...), which records the same event shape and adds the current extension id to metadata automatically.

Main page layout

Main-route extension pages should use the shared app page primitives instead of hand-rolled widths or padding:

<div className="h-full overflow-y-auto">
  <AppPageLayout shellClassName="max-w-[72rem]" contentClassName="space-y-10">
    <AppPageIntro title="Page title" summary="One sentence explaining what this page controls." actions={actions} />
    {/* page sections */}
  </AppPageLayout>
</div>

Use the same max-w-[72rem], space-y-10, and AppPageIntro title/summary pattern for normal pages. Only use a wider shell for table-heavy management surfaces that genuinely need it.

Styling guidance

Extension UIs should look native to Neon Pilot, not like embedded websites. Default to the shared primitives from @neon-pilot/extensions/ui and Tailwind utility classes that use app theme tokens.

<section className="space-y-4 border-t border-border-subtle pt-6">
  <div className="flex items-baseline justify-between gap-4">
    <h2 className="text-[18px] font-semibold tracking-tight text-primary">Section title</h2>
    <span className="text-[12px] text-dim">Optional metadata</span>
  </div>
  <p className="max-w-3xl text-[13px] leading-6 text-secondary">Short explanatory copy.</p>
  <ToolbarButton>Action</ToolbarButton>
</section>

Guidelines:

If a page needs a style that fights these defaults, first ask whether it should be a new shared primitive. One-off chrome is how UI entropy sneaks in wearing a fake mustache.

Backend (Server-side)

The backend runs in the Node.js server process. It exposes actions that the frontend can call via pa.extension.invoke(). A backend can also declare onEnableAction in extension.json to run an action immediately after the user enables the extension.

Desktop extension UI should use the PA client/action bridge (pa.extension.invoke, pa.extensions.callAction, or another typed pa.* capability) to communicate with backend code. The host backs these capabilities with the desktop HTTP data plane and WebSocket realtime plane, not Electron IPC. Extension HTTP routes are primarily integration surfaces for external or side-channel consumers: webhooks, OAuth callbacks, local protocol adapters, browser/webview callbacks, third-party tools that cannot use the typed PA SDK bridge, and long-lived streaming endpoints. If an extension UI needs backend push and no typed PA subscription exists, declare an SSE backend.routes entry with stream: "sse" and consume /api/extensions/<extension-id>/<route-path> with EventSource.

Backend extensions share the host process today, but backend module import, export lookup, and handler execution are host-owned through the ExtensionBackendRunner boundary. The current runner is in-process and applies the process-termination guard around imports, actions, services, subscriptions, protocol handlers, self-test smoke actions, and agent lifecycle factories. Future per-extension workers should replace this runner instead of changing product runtime callers or extension capability adapters.

The extension backend worker entrypoint currently supports wire-safe backend import/cache invalidation, export-availability checks, and narrow export execution with worker-safe context handles. The worker import runner uses a separate backend worker per extension for those operations. The worker transport also supports worker-to-host capability requests and host-to-worker capability events: a worker sends a typed capability name, operation name, and serializable input, the host returns a typed success or error response, and live host handles can publish callback events through the same worker channel. The first worker-safe backend context handles are serialized ctx.runtime metadata plus host-owned runtime refresh operations such as ctx.runtime.refreshSkillMcpConfig, ctx.log, ctx.events.publish, narrow conversation handles (ctx.conversations.get, create, ensureLive, sendMessage, abort, compact, fork, setTitle, setActiveTools, appendCustomEntry, appendTranscriptBlock, updateTranscriptBlock, getWorkspace, updateWorkspace, rollback, and metadata), extension registry reads/enablement through ctx.extensions, host-owned ctx.filesystem scoped root handles, ctx.git, ctx.models.list plus host-owned provider/model writes, ctx.notify, ctx.storage, ctx.secrets.get, non-streaming ctx.shell.exec, host-owned ctx.shell.spawn handles with stdout/stderr/exit callbacks, ctx.telemetry.record, ctx.ui.invalidate, and the serialized ctx.workspace file API. Product-critical system surfaces including artifacts, Automations scheduled-task/follow-up actions, Caffeinate process control, Codex apply-patch, conversation ask/inspect/title/cwd/deferred-resume/context-menu helpers, the worker-safe subset of the conversation tool (inspect, create, set_title, change_working_directory, ensure_live, send_message, abort, compact, fork, set_active_tools, workspace_get, workspace_update, append_transcript_block, update_transcript_block, and rollback), all extension manager actions, image probe agent tasks, knowledge state/sync/read/search/reference/memory/file mutation/import actions and all non-streaming Knowledge routes including binary assets, local dictation settings/model install/transcription, MCP settings/config/test/auth/logout actions and read-only tool inspection, onboarding bootstrap, prompt assembly inspection/toggles, skills inventory and enablement, telemetry aggregate reads, todos, web fetch, installable web search tools, installable local model lookup/discovery actions, installable DS4 provider/tool/runtime-control actions, Video Probe local runtime controls, Suggested Context cache warming, and background work (system-runs) run their declared worker-safe actions through this lane; broader backend handler execution still runs through the in-process runner until the remaining live capabilities are implemented on top of those capability handles.

Backend actions can opt into worker execution with worker.enabled: true when every code path they expose only needs worker-safe context handles. If only some object inputs are worker-safe, add worker.inputActions with the allowed string values for the input object's action field. Inputs outside that allowlist continue to run through the in-process runner.

Backend routes can opt into worker execution with worker.enabled: true when the route is non-streaming and only needs serializable request fields plus worker-safe context handles. The worker receives method, path, query, params, and body; live abort signals and SSE event streams stay on the in-process runner for now.

Host implementation code should not call extension backend handlers under one-off process guards. Add the needed operation to the runner boundary, then call it from the host-facing orchestrator. If guarded extension code calls process.exit(...), process.abort(), or process.kill(process.pid, ...), the call is blocked, surfaced as an extension health error, and runtime action paths disable the extension to prevent startup boot loops.

Repeated backend infrastructure failures trip a circuit breaker: three failures in ten minutes disables the extension and adds an Extension Manager diagnostic. This covers backend load/import failures, health checks, service startup, and similar host-level failures; normal action handler errors are returned to the caller and do not quarantine the extension. Startup also has a safe-mode marker. If the previous launch did not finish extension backend health checks, startup actions, service startup, and subscription installation, the next launch disables the active runtime/user extension when the marker identifies one. If the marker does not identify an active extension, safe mode reports the stale marker without disabling extensions. Re-enabling an extension clears its quarantine entry and any stale startup marker so the recovery action does not immediately retrigger safe mode.

import type { ExtensionBackendContext } from '@neon-pilot/extensions';

export async function ping(_input: unknown, ctx: ExtensionBackendContext) {
  ctx.log.info('ping received');
  return { ok: true, at: new Date().toISOString() };
}

Settings

Extensions can declare user-facing settings in their manifest. These appear in the Settings UI (under "Extensions") grouped by the group field — no React code required for basic types.

{
  "contributes": {
    "settings": {
      "myExt.timeout": {
        "type": "number",
        "default": 30,
        "description": "Timeout in seconds",
        "group": "My Extension",
        "order": 1
      },
      "myExt.featureEnabled": {
        "type": "boolean",
        "default": true,
        "description": "Enable the new feature",
        "group": "My Extension",
        "order": 2
      },
      "myExt.mode": {
        "type": "select",
        "default": "auto",
        "enum": ["auto", "manual", "off"],
        "description": "Operation mode",
        "group": "My Extension",
        "order": 3
      }
    }
  }
}

Each setting key is a dot-separated path (e.g. myExt.timeout). The Settings UI renders the appropriate control based on type:

Type Control
string Text input
boolean Checkbox
number Number input
select Dropdown

All settings are stored in a single <stateRoot>/settings.json file.

Property Description Required
type string, boolean, number, or select Yes
default Default value No
description Shown next to the field in the Settings UI No
group Groups settings together. Default "General" No
enum Allowed values for select type For select
placeholder Placeholder text for string inputs No
order Sort order within group. Default 0. No

Backend extensions that need manifest-declared settings should import the settings backend API instead of reaching into desktop internals:

import { readExtensionSettings } from '@neon-pilot/extensions/backend/settings';

const settings = await readExtensionSettings();
const enabled = settings['myExt.featureEnabled'] === true;

#### Settings vs Extension Storage

Extensions have two storage mechanisms for different purposes:

Mechanism Location Purpose
Settings <stateRoot>/settings.json (shared) User-facing non-secret config declared in manifest
Storage SQLite-backed, per-extension Internal runtime state (caches, session)
Database <stateRoot>/extension-data/{id}/databases/ Extension-owned relational data and indexes
Files <stateRoot>/extension-data/{id}/files/ and /cache/ Extension-owned blobs, exports, and caches

Backend Context (ctx)

The ExtensionBackendContext provides:

Property Purpose
ctx.storage Persistent key-value store per extension (SQLite-backed)
ctx.database Extension-owned SQLite databases with optional versioned migrations
ctx.attention Enqueue/list/cancel async conversation attention events (wakeups, callbacks)
ctx.automations Scheduled task management
ctx.runs Background run management
ctx.conversations Conversation read/write operations
ctx.filesystem Scoped filesystem authority for workspace, extension file storage, temp, artifact, and other host roots
ctx.workspace Workspace file operations (read, write, list); convenience wrapper over the filesystem authority
ctx.knowledge Knowledge base operations
ctx.git Git status, diff, log
ctx.shell Shell command execution
ctx.notify Toast, system notifications, badge (see below)
ctx.events Inter-extension event pub/sub
ctx.extensions Call actions on other extensions
ctx.ui Invalidate UI state topics
ctx.log Structured logging

Attention events

Use ctx.attention when an extension has async work that should resume or notify a conversation later. Extensions submit intent; core owns batching, ordering, retries, and delivery.

await ctx.attention.enqueue({
  prompt: 'The import finished. Summarize the result for the user.',
  title: 'Import finished',
  delivery: { mode: 'batchable', priority: 'normal' },
});

Delivery modes:

ctx.attention.enqueue uses the active conversation session when called from a tool/action context. Outside an active conversation, pass sessionFile and optionally conversationId.

Permissions: attention:write for enqueue/cancel, attention:read for list.

Conversation Write API

The conversations object in the backend context now supports write operations in addition to reads.

// Send a message into a live conversation
await ctx.conversations.sendMessage(
  conversationId,
  'Your message here',
  { steer: true }, // or { steer: false } for followUp
);

// Update the conversation title
await ctx.conversations.setTitle(conversationId, 'New Title');

// Trigger compaction
await ctx.conversations.compact(conversationId);

// Read operations (pre-existing)
await ctx.conversations.list();
await ctx.conversations.getMeta(conversationId);
await ctx.conversations.get(conversationId, { tailBlocks: 20 });
await ctx.conversations.searchIndex(sessionIds);

Permission required: conversations:readwrite for write operations.

The conversations capability also exposes first-class lifecycle helpers:

const created = await ctx.conversations.create({ title: 'Research thread', cwd, initialPrompt: 'Start here' });
const forked = await ctx.conversations.fork({ conversationId, title: 'Bug bash branch' });

ctx.conversations.create(...) accepts allowedToolNames for extension-created sessions that need a runtime-enforced tool allowlist:

await ctx.conversations.create({
  title: 'Web-only research',
  allowedToolNames: ['web_search', 'web_fetch'],
});

Use this for purpose-built conversations that must not receive the default local tool surface. The runtime applies the allowlist when the live session is created; do not rely on prompt instructions alone for tool restrictions.

Limitations:

Selection actions, transcript blocks, services, and subscriptions

Extensions can declare selection-aware actions for selected text, files, messages, or transcript ranges. Selection actions can include compact icon labels and static args; transcript selection menus merge those args with the active selection for composer actions such as composer.replyToSelection. The frontend SDK exposes pa.selection.get(), pa.selection.set(...), and pa.selection.subscribe(...); hosts and extensions publish the current selection through the same shared model.

{
  "contributes": {
    "selectionActions": [
      {
        "id": "reply-agree",
        "title": "Agree / proceed",
        "action": "composer.replyToSelection",
        "kinds": ["text", "transcriptRange"],
        "icon": "👍",
        "args": { "draftText": "👍 Agree" }
      }
    ]
  }
}

Extensions can declare custom durable transcript block renderers and write extension-authored blocks from backend code. Blocks get stable extensionBlockId metadata; updates mutate the live transcript block and fail if the block id is not found.

{
  "contributes": {
    "transcriptBlocks": [{ "id": "approval", "component": "ApprovalBlock", "schemaVersion": 1 }]
  }
}
await ctx.conversations.appendTranscriptBlock({ conversationId, blockType: 'approval', data: { status: 'pending' } });
await ctx.conversations.updateTranscriptBlock({ conversationId, blockId, blockType: 'approval', data: { status: 'approved' } });

Frontend extension components can mark and spotlight transcript targets without depending on host DOM internals. Use pa.transcript.targetProps(...) on the element that should receive focus, and pa.transcript.spotlight(...) to scroll it into view and flash a temporary border.

function ApprovalBlock({ pa, approvalId }) {
  const target = { kind: 'extension', extensionId: 'my-extension', targetId: approvalId };
  return (
    <button {...pa.transcript.targetProps(target)} onClick={() => pa.transcript.spotlight(target)}>
      Review approval
    </button>
  );
}

Supported spotlight targets are block, tool_call, background_run, and extension.

Long-lived backend services are declared under backend.services so the host can own lifecycle, health, and restart policy. Enabled extension services are started during extension startup; a service handler may return a stop function that the host calls on shutdown, disable, reload, or restart. Extension Manager shows declared services plus live runtime state (running, stopped, start time) from the host.

{
  "backend": {
    "entry": "dist/backend.mjs",
    "services": [{ "id": "sync", "handler": "startSync", "healthCheck": "checkSync", "restart": "on-failure" }],
    "onDisableAction": "stopSync",
    "onUninstallAction": "cleanup"
  }
}

Event subscriptions are declared under contributes.subscriptions for host-owned event sources such as workspace files, knowledge files, settings, conversations, routes, and selection changes. The host dispatches these through the extension event bus as host:{source} events; pattern narrows the event name. Current built-in producers include host:workspaceFiles for workspace writes/deletes/renames/moves, host:settings for settings updates, frontend host:selection notifications when shared selection changes, and host:conversation:* lifecycle events for live transcript/stream state.

{
  "contributes": {
    "subscriptions": [{ "id": "watch-notes", "source": "knowledgeFiles", "pattern": "notes/**", "handler": "onKnowledgeChange" }]
  }
}

Secrets are public manifest API, not an internal convention:

{
  "contributes": {
    "secrets": {
      "apiKey": { "label": "API key", "env": "MY_EXTENSION_API_KEY" }
    }
  },
  "permissions": ["secrets:read"]
}

Resolve them in backend code with ctx.secrets.get('apiKey'). Stored values take precedence; environment variables declared by the extension are used as a fallback when no stored value exists. Do not store API keys, bearer tokens, refresh tokens, bot tokens, or other reusable credentials in extension settings, ctx.storage, cache JSON, or extension-owned config files; keep those stores for non-secret preferences and state.

Extensions can declare dependencies on other extensions:

{
  "dependsOn": ["system-knowledge", { "id": "agent-board", "optional": true, "version": "^1.0.0" }]
}

Missing required dependencies are surfaced in Extension Manager diagnostics and block enabling the dependent extension. Optional dependencies are documentation/discovery contracts and should be checked with pa.extensions.getStatus(...) or ctx.extensions.getStatus(...) before use.

Inter-extension Communication

Extensions can communicate with each other through a shared event bus and by calling each other's actions.

Event Bus

Publish events that other extensions subscribe to:

// In extension A — backend.ts
await ctx.events.publish({
  event: 'task:completed',
  payload: { taskId: '123', result: 'success' },
});

Subscribe to events from other extensions:

// In extension B — backend.ts
const sub = ctx.events.subscribe('task:*', async (event) => {
  console.log(`Received ${event.event} from ${event.sourceExtensionId}`);
  // event.payload, event.publishedAt
});

// Later, to unsubscribe:
sub.unsubscribe();

Pattern syntax:

Cross-extension Action Calls

Call an action exposed by another extension:

const result = await ctx.extensions.callAction('other-extension', 'someAction', { key: 'value' });

List available extension actions:

const actions = await ctx.extensions.listActions();
// Returns: [{ extensionId, extensionName, actions: [{ id, title, description }] }]

Notifications and Badge

Extensions can send notifications and set dock badges:

// In-app toast
ctx.notify.toast('Hello!', 'info'); // "info" | "warning" | "error"

// System notification (macOS notification centre)
ctx.notify.system({
  title: 'Task Complete',
  message: 'Your background task finished.',
  subtitle: 'Optional subtitle',
  persistent: true, // stays until acknowledged
});

// Dock badge count (accumulated across all extensions)
ctx.notify.setBadge(5); // Set badge to 5
ctx.notify.clearBadge(); // Clear this extension's badge

// Check if system notifications are available
const available = ctx.notify.isSystemAvailable();

Permissions

Extensions must declare the permissions they need. The system currently enforces permissions for storage and conversation operations.

{
  "permissions": [
    "storage:read",
    "storage:write",
    "storage:readwrite",
    "attention:read",
    "attention:write",
    "conversations:read",
    "conversations:readwrite",
    "knowledge:read",
    "knowledge:write",
    "knowledge:readwrite",
    "runs:read",
    "runs:start",
    "runs:cancel",
    "ui:notify"
  ]
}

Custom permissions are also supported: "${string}:${string}".

Agent Lifecycle Hooks

Desktop manifest extensions can hook into the agent's lifecycle by exporting an ExtensionFactory function via the backend.agentExtension field.

// backend.ts — exported as the value referenced by agentExtension
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';

export default function (pi: ExtensionAPI) {
  // Subscribe to agent lifecycle events
  pi.on('before_agent_start', async (event, ctx) => {
    // Modify the system prompt before each turn
    return {
      systemPrompt: event.systemPrompt + '\nExtra instructions for this turn...',
    };
  });

  pi.on('tool_call', async (event, ctx) => {
    // Block or modify tool calls
    if (event.toolName === 'bash' && event.input.command?.includes('rm -rf')) {
      return { block: true, reason: 'Dangerous command blocked by extension' };
    }
  });

  pi.on('tool_result', async (event, ctx) => {
    // Post-process results
    if (event.toolName === 'read') {
      return { content: [{ type: 'text', text: event.content + '\n— End of file' }] };
    }
  });

  pi.on('session_start', async (event, ctx) => {
    ctx.ui.notify(`Session started: ${event.reason}`, 'info');
  });

  // Register custom tools
  pi.registerTool({
    name: 'my_tool',
    label: 'My Tool',
    description: 'A custom tool',
    parameters: { type: 'object', properties: {} },
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      return { content: [{ type: 'text', text: 'Done!' }] };
    },
  });

  // Override a built-in tool
  pi.registerTool({
    name: 'bash', // Same name as built-in → replaces it
    label: 'Safe Bash',
    description: 'Bash with guardrails',
    parameters: { type: 'object', properties: { command: { type: 'string' } } },
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      // Custom implementation
      return { content: [{ type: 'text', text: params.command }] };
    },
  });
}

Then in your manifest:

{
  "backend": {
    "entry": "src/backend.ts",
    "agentExtension": "default"
  }
}

The agentExtension field names the exported function that receives the ExtensionAPI. If set to "default", the default export is used.

All pi-coding-agent events are available:

Event When Use Case
before_agent_start Before the agent processes a prompt Inject context, modify system prompt
input User input received Intercept or transform input
context Before LLM call Modify messages
tool_call Before tool execution Block/modify tool calls
tool_result After tool execution Post-process results
session_start Session loaded Initialize state
session_shutdown Session ending Clean up resources
session_before_compact Before compaction Customize compaction
message_start/update/end Message lifecycle Custom rendering
turn_start/end Turn lifecycle Track progress
agent_start/end Agent cycle lifecycle Track agent activity

For the full list of Pi lifecycle events and signatures, inspect the installed @earendil-works/pi-coding-agent package docs that match the pinned dependency version.

Development Workflow

Building

Extensions need to be built before they can be loaded:

# From the repo for a local extension directory:
pnpm run extension:build -- /path/to/my-extension

Frontend builds bundle the authoring SDK UI modules (@neon-pilot/extensions/ui, /host, /workbench, /data, and /settings) into dist/frontend.js. The desktop host serves that built file as an extension bundle resource, so frontend dist output must not leave @neon-pilot/extensions/* as bare runtime imports.

The extension builder also copies extension-local bin/ and templates/ directories into dist/ so packaged desktop releases can load static sidecars and templates without source-tree access.

Hot Reload

After changing backend code, use Extension Manager's Reload button or restart the app. The frontend is re-evaluated on page load.

Testing Integration

Run the extension integration smoke tests to catch cross-extension issues before starting the app (manifest validation, route conflicts, missing backend/frontend entries, handler export mismatches, and packaged-runtime backend import failures):

# Run the full extension gate: static extension checks plus focused extension smoke tests
pnpm run check:extensions

# Run only the focused extension smoke tests
pnpm run test:extensions

# Or include in the full test suite
pnpm test

pnpm run check:extensions runs check:extensions:static, then the focused extension smoke tests. The static gate is defined in package.json and currently checks worker coverage, SDK/backend API shims, filesystem authority, conversation-storage boundaries, core/extension imports, product-runtime to extension-host seams, permission boundaries, extension-host runtime boundaries, and packaged extension bundles. The packaged check imports every bundled system extension backend from its built dist output, verifies backend action handler exports, smoke-calls known safe tool surfaces such as scheduled_task, and runs product-critical smoke calls for Knowledge, Automations, and Diffs extension actions. It fails on forbidden bare imports that are not available inside the packaged desktop app, such as @earendil-works/pi-coding-agent, @neon-pilot/core, @neon-pilot/daemon, jsdom, and @sinclair/typebox. It also rejects absolute or file: imports, forbidden bundled runtime path fragments, and backend bundles over their explicit byte budget. The packaged-extension hardening knobs live in scripts/extension-hardening-config.mjs, so smoke inputs and size budgets are explicit instead of being buried in the checker. This catches release-temp paths, accidental daemon bundling, runaway backend API seams, and the “works from repo node_modules, breaks in the signed app” class of extension bug before release.

The desktop server also runs an enabled-extension backend health check on startup. Failures are logged, surfaced as extension diagnostics, and shown by Extension Manager instead of silently making tools or actions disappear. System extension diagnostics are release blockers: the integration smoke suite fails when a system extension has registry errors, diagnostics, stale dist/ output, missing exports, forbidden imports, or backend import crashes. Extension builds write dist/build-manifest.json with output files, byte sizes, and remaining external imports. Use Extension Manager UI actions for local extension authoring: list, create, snapshot, build, validate, and reload. Run validate after each build to check manifest references, dist files, stale output, frontend/backend exports, tool schemas, skill files, forbidden process imports, non-portable bundled imports, and backend import crashes for one extension. The release publisher reruns the packaged-extension check against the built .app before notarization/upload.

The integration suite covers these categories:

Category What it validates
Manifest structure JSON parses, schemaVersion, version field, required fields, permissions format, routes, startup action validity, backend/no-backend consistency
Tool schema inputSchema has type:object + properties, replaces targets valid built-ins
Action references All action fields in context menus, commands, toolbar actions, nav badge actions reference known backend handlers or valid system patterns
Settings/Secrets Setting type/default consistency, select enum values, dot-separated key format, secret env var format
Frontend components Every component field in views/buttons/shelves/panels exists in the frontend bundle
Cross-extension conflicts Duplicate IDs, routes, tool names, commands, keybindings, settings, secrets, env variables, mention ids, prompt reference/context provider/quick open ids
Registry sanity All bundled system extensions registered, routes point to real extensions
Backend files dist/backend.mjs exists, source files present, handler names match
Frontend files dist/frontend.js exists, style files present
Agent extensions Registration listing, export names, backend entry references
Skills File existence, valid Agent Skills frontmatter
Summary report Printed overview with counts across 21 registration categories

Debugging

State

Extensions get persistent key-value storage:

// Write
await ctx.storage.put('my-key', { count: 42 });

// Read
const data = await ctx.storage.get('my-key');

// List
const items = await ctx.storage.list('prefix-');

// Delete
await ctx.storage.delete('my-key');

State is SQLite-backed and survives app restarts.

For relational state, use app-owned SQLite databases:

const db = await ctx.database.open('main', {
  migrations: [
    {
      version: 1,
      description: 'create tasks',
      up: (database) => database.exec('CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL)'),
    },
  ],
});

db.prepare('INSERT INTO tasks (id, title) VALUES (?, ?)').run('task-1', 'Build the thing');

For extension-owned files, use durable app files, disposable cache files, or temp workspaces:

const appFiles = await ctx.filesystem.app();
await appFiles.writeText('exports/report.md', markdown);

const cacheFiles = await ctx.filesystem.cache();
await cacheFiles.writeJson('remote-index.json', index);

Examples

See bundled system extensions in extensions/ and optional first-party extensions in patleeman/neon-pilot-extensions for practical examples:

Each extension has a complete extension.json manifest and src/backend.ts + optionally src/frontend.tsx.

Bundled system extensions keep source next to their built output for development. Backend dist/ output is authoritative by default in both dev and packaged runtimes: if backend.entry points at source (src/backend.ts), normal app startup loads sibling dist/backend.mjs; source recompilation is reserved for explicit extension-authoring mode (NEON_PILOT_EXTENSION_AUTHORING=1). If backend.entry already points at built output such as dist/backend.mjs, both dev and packaged builds load that file directly. System extension frontends are bundled into the desktop renderer from source so they share the app's React singleton; their dist/frontend.js bundles are still built and validated as release artifacts.

Optional extension repo packages are not bundled or auto-loaded. Users install released optional extensions from Settings → Extensions → Install, which downloads .neon-extension.zip artifacts from GitHub releases. After install, check the unified list in Settings → Extensions to enable and inspect the extension. For local development, build with pnpm run extension:build -- <extension-dir>, validate with neon-pilot-extension doctor <extension-dir>, and import the resulting zip or copy the built package into runtime state.