Skip to content

Extending Soma

Skills, extensions, events, APIs — build on top of Soma.

Built on Pi — inherits full extension system. Skills: markdown instructions in .soma/skills/ or ~/.soma/agent/skills/. Extensions: TypeScript hooks into agent lifecycle (before_agent_start, tool_result, session_shutdown). Built-in extensions: soma-boot (identity + protocols + muscles), soma-breathe (breath cycle + session rotation), soma-guard (safe file operations), soma-header (branded σῶμα header), soma-hub (community hub), soma-route (inter-extension communication), soma-scratch (scratch pad), soma-statusline (context/cost/git footer).

Soma is built on Pi and inherits its full extension system. You can add skills, extensions, and custom tools.

Skills

Skills are specialized instructions that load when a task matches their description. They’re framework-agnostic — a skill from Claude Code, Cursor, or any agent system works in Soma without modification.

What makes Soma different: muscles and protocols refine skills over time. A logo design skill teaches the technique. A muscle learns your specific preferences. A protocol enforces your brand standards. The skill provides raw expertise; Soma’s behavioral layers personalize and improve it through repeated use — without you ever asking.

Installing Skills

Install from the hub or place manually:

/hub install skill my-skill    # from Soma Hub

Or place skill directories in one of these locations:

LocationScope
.soma/skills/Project-local (only loads in this project)
~/.soma/agent/skills/Global (loads for all projects)

Creating Skills

Create a directory with a SKILL.md file:

my-skill/
└── SKILL.md

SKILL.md contains:

  • A description that tells Soma when to load it
  • Instructions for how to handle the task
  • Optional file references for additional context
# My Custom Skill

**Description:** Help with deploying to production servers.

## Instructions

When the user asks about deployment:
1. Check the deployment config at `deploy.yaml`
2. Verify all tests pass
3. ...

Place in .soma/skills/ (project) or ~/.soma/agent/skills/ (global).

Extensions

Extensions are TypeScript files that hook into Soma’s lifecycle events.

Extension Locations

LocationScopeWalk-up
<cwd>/.soma/extensions/Project-local — loads only when CWD is in this projectNo (cwd only)
~/.soma/extensions/User-global — loads for all your Soma sessionsNo
~/.soma/agent/extensions/Runtime-bundled — ships with Soma; do not write hereNo

Where to put YOUR extensions: ~/.soma/extensions/ for personal/global, or <project>/.soma/extensions/ for project-scoped. The ~/.soma/agent/extensions/ location is the runtime install — writes there are protected by soma-guard and won’t survive Soma updates.

Opt-out: --no-extensions (or -ne) skips extension discovery for one session. Explicit --extension <path> always works.

Extension Security

Extensions run with full system permissions. Configure an allowlist in settings.json to get warnings about unrecognized extensions:

{
  "extensions": {
    "allowlist": ["soma-boot.ts", "soma-breathe.ts", "..."]
  }
}

See Configuration → Extension Security for details.

Writing an Extension

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function myExtension(pi: ExtensionAPI) {
  // Register a command
  pi.registerCommand("hello", {
    description: "Say hello",
    handler: async (_args, ctx) => {
      ctx.ui.notify("Hello from my extension!", "info");
    },
  });

  // Hook into session lifecycle
  pi.on("session_start", async (_event, ctx) => {
    // Do something on session start
  });

  pi.on("turn_end", async (_event, ctx) => {
    // Do something after each agent turn
  });
}

Available Events

EventWhen
session_startSession loads (check event.reason: startup, reload, new, resume, fork)
turn_startAgent begins processing
turn_endAgent finishes processing
message_endMessage fully rendered
tool_resultTool call completes
before_agent_startBefore each agent turn — can modify the system prompt by returning { systemPrompt }
session_shutdownSession closing

Hot-reload: Pi’s /reload command re-loads extensions via jiti.import (mtime-keyed cache). Extension file edits are picked up without restarting the process. core/*.ts changes may hot-reload through the chain; dist/ changes require a full restart.

Cache-safe registration vs hot-reload (don’t conflate)

Four separate concerns. Each lever covers a different layer.

ConcernWhat it meansLayer
Cache-safe registrationCapability is added without busting Anthropic’s prompt cache (~$1-2/session saved). Lives in soma-route’s runtime registry, not in the system prompt.Anthropic API
Module hot-reload (/reload)Edits to an extension’s .ts file take effect after /reload. Pi re-imports every extension via fresh jiti (moduleCache: false), so source changes are picked up.Node module cache
Prompt rebuild (/rebuild)The compiled system prompt is restored from disk cache on /reload. To regenerate it from current body/*.md and current tool definitions, you need /rebuild.System prompt
Subprocess pickup (zero ceremony)Tool wrappers that exec/spawn an external script pick up edits to the script on the next invocation. The wrapper is JS-in-memory; the script is a fresh subprocess each call.OS process

Authority for each, verified:

  • agent-session.js:1894session.reload() shutdown→buildRuntime→session_start
  • extensions/loader.js:271loadExtensionModule creates fresh jiti per reload (moduleCache: false)
  • soma-boot.js cache-stickiness — system prompt restored from disk on reload (avoids cache write)
  • Pi changelog 0.56.0 (March 2026, #1720) — “Runtime tool registration now applies immediately in active sessions. Tools registered via pi.registerTool() after startup are available to pi.getAllTools() and the LLM without /reload.”
  • extensions/_shared/meta-tool-factory.ts:96-130 — addons auto-discovered via readdirSync + dynamic import() on session_start
  • extensions/soma-addons/code.ts:38 — canonical “thin wrapper around CLI” pattern (runCode() shells out via execSync)

Decision matrix for what to run after editing:

What you editedCache impactWhat to run
New pi.registerTool({name: "foo", ...}) added at runtime (any time after session start)Busts cache on next compile (def goes into system prompt)Nothing immediate — Pi 0.56.0+ makes the tool live without /reload. The system prompt stays stale until /rebuild (so the model sees your new tool’s description on next rebuild, not now).
pi.registerTool execute body of an existing tool (e.g. komodo.ts)None (definition unchanged in prompt)/reload — the executor function lives in JS memory and won’t change without re-import.
pi.registerTool description / parameters / promptSnippet of an existing toolBusts cache on next compile/reload (updates registry) + /rebuild (so the model actually sees the new fields). Without /rebuild, registry has new fields but prompt instructs the model to use old ones — tool calls fail with parameter-validation errors.
New route.provide("soma:foo.bar", impl) cap (e.g. dropping a new file in soma-addons/)Cache-safe — caps live in soma-route’s registry, not in prompt/reload — the meta-tool’s session_start handler scans *-addons/ and dynamic-imports new files. (No file watcher; rescan only fires on session_start.)
route.provide cap impl bodyCache-safe — caps not in prompt/reload — same Node-memory rule as pi.registerTool execute.
CLI script behind a wrapper (e.g. repos/agent/scripts/soma-code.sh while soma-addons/code.ts shells out to it via execSync)NoneNothing. Each cap invocation spawns a fresh subprocess; the OS reads the script at exec time. Edit the script, save, next call uses the new code.
amps/scripts/* drop-in (e.g. soma <name> script discovery)NoneNothing for the script itself. /reload only if you also edited soma-boot.ts’s discovery logic.
body/*.md (identity, soul, voice, mind template)Busts cache on next compile/rebuild if you want it live now; otherwise skip till next session.

Why prompt-visible-field edits need /rebuild too: the system prompt is compiled at session start and cached to disk (state/<id>). /reload rebuilds the extension runner and tool registry, but soma-boot’s before_agent_start handler restores the cached prompt to avoid a per-reload cache write. Result: the LOCAL tool registry has the new fields but the SYSTEM PROMPT sent to Anthropic still has the old ones. Renaming a parameter mid-session without /rebuild is the classic break.

Why route.provide is exempt: cap names + descriptions live in soma-route’s runtime registry. The system prompt only describes the meta-tool (soma, dev, etc.) — a stable description that doesn’t change when individual caps do. Cap details are returned at runtime by op:'list' / op:'help' invocations, fetched fresh from the registry each call.

Why CLI scripts behind wrappers need nothing: the wrapper (*.ts) does execSync("soma code ...") or spawn("bash", [scriptPath, ...]). The subprocess loads the script’s bytes from disk at exec time — not from any Node cache. Edit, save, next invocation runs the new code. This is the “tooling tied to CLI” pattern Curtis verified previously: e.g. editing scripts/_dev/soma-audit-tickets.sh doesn’t need /reload because dev-addons/audit.ts only spawns it; the bash script itself isn’t in JS memory.

Don’t conflate the four concepts: “cache-safe” doesn’t mean “hot-reloads automatically.” /reload doesn’t mean “the model sees your new tool description.” Pi 0.56.0’s runtime registration doesn’t mean “editing a tool’s code takes effect without /reload” — it means “adding a new tool at runtime doesn’t need /reload.”

Modifying the System Prompt

The before_agent_start event is the hook for prompt modification. Return a { systemPrompt } object to replace or augment what Pi compiled:

pi.on("before_agent_start", async (event, _ctx) => {
  // event.systemPrompt is Pi's compiled prompt (may include customPrompt
  // from .soma/SYSTEM.md + context files + skills XML + date/cwd)
  const modified = event.systemPrompt + "\n\n<!-- my addition -->";
  return { systemPrompt: modified };
});

Pi resets the system prompt to base each turn, so the handler must return the full prompt every time — not just the first turn.

Soma’s soma-boot.ts owns the main path (template-driven compile via _mind.md → full prompt). New extensions should generally augment rather than replace, or use Soma’s existing compile hooks instead of fighting them.

Available APIs

APIWhat it does
pi.registerCommand(name, opts)Add a /command
pi.registerTool(def)Register a tool (raw — prefer somaRegisterTool for Soma tools)
pi.sendUserMessage(text, opts)Inject a message
pi.appendEntry(type, data)Persist state in session
pi.on(event, handler)Listen to lifecycle events
pi.getActiveTools()Tool names enabled in this session
pi.getAllTools()All registered tools (info only — strips promptSnippet)
pi.getThinkingLevel()Current thinking level
ctx.ui.notify(msg, level)Show notification
ctx.ui.setHeader(factory)Custom header component
ctx.ui.setFooter(factory)Custom footer component
ctx.getContextUsage()Token usage stats
ctx.newSession(opts)Create new session

See the Pi extension docs for the full API reference.

Soma Tools

Soma tools are capabilities the agent can call. They’re registered through Pi’s tool API but wrapped by Soma’s somaRegisterTool() so _tools.md configuration applies and the full guidance (promptSnippet + per-tool promptGuidelines) reaches the system prompt.

Write tools with somaRegisterTool — not pi.registerTool — whenever the tool should be user-configurable or should benefit from the rich prompt rendering.

Writing a Soma tool

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { somaRegisterTool } from "../core/tool-registry.js";

export default function myExt(pi: ExtensionAPI) {
  somaRegisterTool(pi, {
    name: "my_tool",
    label: "My Tool",
    description: "Long-form explanation the model reads.",
    promptSnippet: "my_tool: one-line tagline for the 'Available tools' header",
    promptGuidelines: [
      "When X, prefer my_tool over bash",
      "Chain my_tool → read for full context",
    ],
    parameters: Type.Object({
      input: Type.String({ description: "What to process" }),
    }),
    executionMode: "parallel",
    execute: async (_id, { input }, _signal, _onUpdate, _ctx) => {
      return {
        content: [{ type: "text" as const, text: `Processed: ${input}` }],
        details: undefined,
      };
    },
  });
}

Fields:

  • name — identifier the model invokes
  • description — full explanation (used as fallback in prompt)
  • promptSnippet — snappy tagline (primary prompt render)
  • promptGuidelines — per-tool mechanics bullets prefixed [tool_name] in the prompt
  • parameters — TypeBox schema, validated by Pi before execute runs
  • executionMode"parallel" for read-only (safe concurrent) or "sequential" for side-effects
  • execute(toolCallId, params, signal, onUpdate, ctx) => Promise<AgentToolResult>

What somaRegisterTool does

  1. Reads merged _tools.md config from the soma body chain.
  2. If the tool’s name is in ## Disabled and not hardwired — skips Pi registration entirely.
  3. Merges any ## Overrides block onto the definition (description, promptSnippet, promptGuidelines, executionMode).
  4. Stores the effective definition in Soma’s prompt registry (for buildToolSection).
  5. Forwards to pi.registerTool() so the tool is invocable.

See Tools for the full _tools.md format and the bundled Soma tool set.

pi.registerTool vs somaRegisterTool

pi.registerToolsomaRegisterTool
Tool is invocable
Respects _tools.md disable
Applies _tools.md overrides
promptSnippet reaches system prompt✗ (Pi’s ToolInfo strips it)
promptGuidelines reach system prompt
Appears in /soma prompt diagnosticsdegradedfull

Use pi.registerTool directly only for experimental one-offs or when integrating a third-party library that registers its own tools.

Soma’s Built-in Extensions

Soma ships with these extensions:

ExtensionPurpose
soma-boot.tsIdentity loading, preload, /exhale, /soma commands, script discovery
soma-breathe.tsBreath cycle — /inhale, /breathe, /rest, session rotation, preload management
soma-guard.tsSafe file operation enforcement — intercepts writes to unread/critical files, blocks dangerous bash commands
soma-header.tsBranded σῶμα header with memory status
soma-hub.tsCommunity hub — /hub install, /hub find, /hub share, /hub fork
soma-route.tsCapability router — inter-extension communication via capabilities and signals
soma-scratch.tsScratch pad — /scratch save, /scratch list, cross-session snippet storage
soma-statusline.tsFooter with model, context %, cost, git status, auth type
soma-tools.tssoma:* namespace meta-tool — user-facing capability surface

These install to ~/.soma/agent/extensions/ and can be customized or replaced.

Multi-agent Workflows (soma-dev delegate)

Developers can compose child agents into named pipelines via soma-dev delegate <workflow>:

WorkflowPipelineUse when
prbrief → changelog_curator + pr_author + doc_writer + verifierAbout to open a PR; want rich CHANGELOG narrative + PR description auto-drafted
pr-briefsoma-pr-brief.sh only (no agents, instant)Just the structured brief for manual PR writing
ci-fix <url>issue_investigator → builder → verifierA nightly test failed and filed a GitHub issue
cycle <brief.md>intern (investigate) → intern (build) → verifier → pr_authorMulti-step cycle that exceeds builder’s 25-call default budget
changelogchangelog_curatorJust the rich [Unreleased] section
doc-updatedoc_writerRecent code changes need doc sync
audit [tickets...]auditorBatch verdict on kanban tickets (SHIPPED / STALE / STILL-VALID)

Each role has a budget cap (max-tool-calls + max-cost-usd) defined in body/children/<role>.md frontmatter. The intern role has the largest budget (80 calls / $0.80) for complex investigations + builds; builder is bounded for surgical edits (25 calls / $0.50).

For a deep cycle (cycle 16’s 9-step model-aware-breathe implementation took ~80+ tool calls), use soma-dev delegate cycle <brief.md> to chain investigate → build → verify → PR-author. See scripts/_dev/soma-dev/commands/delegate.sh.

Namespaces

Soma uses three top-level meta-tools to organize capabilities. Each is a single Pi tool registration with multiple addons routed through soma-route:

NamespaceAudienceDistributionCaps
soma:*Every Soma installShips in npm tarballsoma:agent.*, soma:body.*, soma:browser.*, soma:code.*, soma:docs.*, soma:focus.*, soma:github.*, soma:new.*, soma:terminals.*
somaverse:*Somaverse-licensedProprietary, separate installworkspace ops, plugin builder, AI helpers
dev:*Agent contributors onlyBuild-excluded from npm + soma-betadev:hub.* (hub introspection), dev:audit.* (deps + CI)

When to add a new cap

  1. Pick the namespace. If end users would benefit → soma:*. If only people working ON the agent need it → dev:*. If it’s part of the proprietary tier → somaverse:*.
  2. Pick the family. Group by domain (code, docs, hub, etc.). New family = new file under the namespace’s addon dir.
  3. Implement. Mirror an existing addon (e.g. soma-addons/docs.ts). Each cap is an *Impl async function + a route.provide registration. The meta-tool factory auto-discovers files under <namespace>-addons/ at session-start.
  4. No pi.registerTool for new top-level tools. That cache-busts the prompt prefix (~$1-2/session). Always add as an addon under an existing meta-tool.
  5. Test with <namespace>(op='list') and <namespace>(op='call', cap='<namespace>:<family>.<action>', args={...}).

Project-local caps (route.provide, no factory)

The createMetaTool factory only auto-discovers addons in the global ~/.soma/agent/extensions/<namespace>-addons/ directory. For caps that should only load when CWD is in a specific project (e.g. project-specific tooling like the Gravicity cycle audit), register directly in a project-local extension at <project>/.soma/extensions/<name>.ts:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function myProjectAddon(pi: ExtensionAPI) {
  // Defer to session_start so __somaRoute is initialized.
  pi.on("session_start", async () => {
    const route = (globalThis as any).__somaRoute;
    if (!route) return;

    route.provide(
      "soma:myfamily.action",
      async (args: any = {}) => {
        // Your logic. Return a string (or JSON-serializable value).
        return `Processed: ${args.input ?? "(nothing)"}`;
      },
      {
        provider: "myaddon (project-local)",
        description: "What this cap does. args: {input?:string}.",
      },
    );
  });
}

Same cache-safety as factory-discovered addons. Cap lives in soma-route’s runtime registry, not the system prompt. Zero cost.

Same invocation surface. soma(op='call', cap='soma:myfamily.action', args={...}) just works — no UI difference between project-local and global addons.

Why session_start, not module top-level: the route singleton is initialized when soma-route’s session_start handler fires. Registering before that is racey. The pi.on("session_start", ...) defer pattern is the same discipline createMetaTool uses internally.

Pattern: thin addon, fat CLI. For non-trivial logic, put the body in a CLI script (.mjs / .sh / any subprocess-runnable file) and shell out from the cap via execSync. Edits to the script are picked up next invocation — no /reload needed. Reference: soma-addons/code.ts shells out to soma code CLI; arzadon-fitness/.soma/extensions/meta-addon.ts shells out to meta.mjs.

When to use this vs. global factory addon:

ConcernProject-local route.provideGlobal factory addon
Where it loadsOnly when CWD is in this projectEverywhere
Where to put the file<project>/.soma/extensions/*.ts~/.soma/agent/extensions/<ns>-addons/*.ts
Auto-discovered?No — manual route.provide callYes — factory scans the dir
Need register(route) export?No — default-export an (pi) => voidYes — factory expects it
Cache impactZeroZero
Hot-reload edits/reload for the .ts; subprocess pickup for any shelled-out scriptSame

Project-local caps are the right call when the cap is genuinely project-specific (operates on this project’s files, this project’s secrets, this project’s structure). Promote to global when a second project would benefit from the same cap.

dev:* namespace (agent-contributor only)

The dev:* namespace is for tools that audit, lint, or inspect the agent itself — things only people working on Soma need. It’s intentionally NOT shipped to end users:

  • extensions/dev-tools.ts registers the meta-tool
  • extensions/dev-addons/*.ts are the cap families
  • build-dist.mjs builds them locally for dogfood
  • soma-release.sh § Step 3 strips them from the soma-beta copy
  • verify-bootstrap-clean.sh § Test 5 asserts the strip code is in place

Result: dev contributors can call dev:hub.audit to verify hub state; end users running npm install meetsoma never see the namespace.

For the full design + reasoning: .soma/releases/plans/active/dev-meta-tool/README.md.

soma-route.ts

Capability router for inter-extension communication. Extensions can’t import from each other — the router provides a clean API for sharing functions and broadcasting events.

Two patterns:

// Capabilities — one provider, many consumers (service registry)
const route = (globalThis as any).__somaRoute;
route.provide("my:capability", myFunction, { provider: "my-ext" });
// In another extension:
const fn = route?.get("my:capability");
if (fn) await fn();

// Signals — many emitters, many listeners (pub/sub)
route.on("my:event", (data) => { /* react */ });
route.emit("my:event", { key: "value" });

Built-in capabilities (provided by soma-boot and soma-statusline):

CapabilityProviderDescription
session:newsoma-bootStart fresh session
session:compactsoma-bootTrigger compaction
session:reloadsoma-bootReload extensions
keepalive:togglesoma-statuslineEnable/disable cache keepalive
keepalive:statussoma-statuslineGet keepalive state
context:usagesoma-bootGet token usage

Commands: /route shows all registered capabilities and signal listeners.

Why it exists: Pi’s sendUserMessage() can’t trigger slash commands (by design). The router bridges the gap — command handlers capture capabilities (like newSession) and share them with event handlers (like turn_end) that need them for features like auto-breathe rotation.

External tool → Soma bridge (the inbox)

Soma’s router includes a drop-in inbox for external tools (CLI scripts, CI jobs, browser automation, file watchers) to inject signals into a running Soma session.

How it works:

  • On session_start, soma-route.ts generates an 8-char session token and writes it to .soma/inbox/.token. The inbox directory is gitignored automatically.
  • External tools drop JSON files into .soma/inbox/:
    {
      "signal": "ci:result",
      "token": "a1b2c3d4",
      "data": { "status": "passed", "tests": 45 },
      "ts": "2026-04-19T10:00:00Z",
      "source": "github-actions"
    }
  • On every turn_end, soma-route.ts reads the inbox, verifies each file’s token + signal allowlist, emits valid signals via the router, and deletes the consumed file.
  • Any extension listening via route.on(signal, handler) receives the payload.

Allowed signals (hard allowlist — not configurable, enforced in soma-route.ts:INBOX_ALLOWED_SIGNALS):

SignalIntended use
studio:voteDesign-review votes from a studio tool
studio:feedbackDesign-review comments
ci:resultCI build/test outcomes
deploy:statusDeployment status updates
fs:changedFile watcher notifications
browser:captureBrowser automation screenshots
scheduled:taskCron/scheduled job output
external:notifyGeneric custom notification

Internal signals (session management, guard, breathe) are never injectable from outside — hard boundary.

Commands: /route inbox shows the current token, pending message count, and allowed signals list.

Use case: let a VS Code extension, browser tool, or CI job poke a running Soma without opening a socket or proxy.

soma-guard.ts

Graduated from the safe-file-ops muscle — the muscle teaches the pattern, this extension enforces it.

What it guards:

  • Write to unread file — confirms before overwriting files the agent hasn’t read this session
  • Critical paths — always confirms writes to identity files, settings, protocols, .env
  • Dangerous bash — confirms rm -rf, force push, git reset --hard, etc.
  • Safe paths exempt — preloads, session logs, review directories skip guards

Commands: /guard-status shows reads tracked, dirs listed, and intervention count.

Custom Model Providers

Extensions can register custom model providers using pi.registerProvider(). This enables corporate proxies, self-hosted models, OAuth flows, and custom APIs.

Override an Existing Provider

Route requests through a proxy without losing the provider’s model list:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.registerProvider("anthropic", {
    baseUrl: "https://proxy.corp.com/anthropic",
    headers: { "X-Corp-Auth": "CORP_TOKEN" }
  });
}

Register a New Provider

Add a provider with custom models:

export default function (pi: ExtensionAPI) {
  pi.registerProvider("my-llm", {
    baseUrl: "https://api.my-llm.com/v1",
    apiKey: "MY_LLM_API_KEY",
    api: "openai-completions",
    models: [
      {
        id: "my-model-large",
        name: "My Model Large",
        reasoning: true,
        input: ["text", "image"],
        cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
        contextWindow: 200000,
        maxTokens: 16384
      }
    ]
  });
}

Supported APIs

APIUse For
openai-completionsMost OpenAI-compatible servers
openai-responsesOpenAI Responses API
anthropic-messagesAnthropic or compatible proxies
google-generative-aiGoogle Generative AI

Unregister

pi.unregisterProvider("my-llm");

Key Resolution

apiKey and headers values support:

  • Literal: "sk-..." — used directly
  • Env var: "MY_API_KEY" — reads the named variable
  • Shell command: "!op read 'op://vault/key'" — executes and uses stdout

For a simpler approach without writing an extension, see Custom Providers in models.json.