Skip to content
CompozyOS RuntimeExtensions

Develop Extensions

Build custom Compozy extensions with manifests, bundled resources, subprocess lifecycle, Host API permissions, and package examples.

Audience
Operators running durable agent work
Focus
Extensions guidance shaped for scanability, day-two clarity, and operator context.

An Compozy extension is a directory with a manifest and optional runtime code. Resource-only extensions package files Compozy already knows how to load. Subprocess extensions run code over JSON-RPC and can call the daemon through the Host API after capability checks.

Use extensions when a capability should be installed, versioned, enabled, disabled, and inspected as one package.

Extension Directory

prompt-enhancer/
  extension.toml
  package.json
  dist/
    index.js
  skills/
    review-context/
      SKILL.md

Compozy looks for extension.toml first and extension.json second. TOML is the common format. A manifest may put core metadata at the root or inside [extension]; do not define the same core field in both places with conflicting values.

Manifest Core

[extension]
name = "prompt-enhancer"
version = "0.1.0"
description = "Adds workspace context to assembled prompts"
min_compozy_version = "0.5.0"
FieldRequiredNotes
nameyesRegistry identity. Must match the installed registry row on daemon start.
versionyesSemantic version.
descriptionnoShown in extension metadata.
min_compozy_versionyesSemantic version compared against the current daemon version.

Network Participation Requirements

An extension that needs its bundle-owned work to participate Live must declare that requirement in the manifest. The block is top-level, next to [extension]:

[network_participation]
required = true
mode = "live"
channel_scopes = ["builders", "release"]
FieldRequiredNotes
requiredyesSet to true to declare a requirement. false cannot carry a mode or channel scopes.
modeyesMust be "live" when the requirement is enabled.
channel_scopesnoNamed channels the extension may require. Values are trimmed, deduplicated, sorted, and digested.

Compozy hashes the normalized block into the bundle activation's Network requirement digest. Preview shows the digest; activation requires explicit operator confirmation. If an extension update changes the digest, Compozy advances the activation version, clears the prior confirmation, and blocks projection until the operator reads the current activation and confirms it with that exact version. Declared channels remain inventory: confirmation does not enroll any execution into Live.

Resource-Only Extensions

Resource-only extensions do not need a persistent subprocess. They become active after Compozy loads and registers their resources.

[extension]
name = "review-pack"
version = "0.1.0"
description = "Review skills, hooks, and MCP helpers"
min_compozy_version = "0.5.0"

[resources]
skills = ["skills"]
agents = ["agents"]
bundles = ["bundles"]

[[resources.hooks]]
name = "review-session-ready"
event = "session.post_create"
mode = "async"
command = "/usr/bin/env"
args = ["bash", "{{config_dir}}/hooks/session-ready.sh"]

[resources.mcp_servers.git]
command = "uvx"
args = ["mcp-server-git"]
env = { REPO_ROOT = "{{env:REPO_ROOT}}" }

Resource paths are resolved inside the extension root. {{config_dir}} expands to that root. {{env:NAME}} expands from the daemon process environment.

ResourceManifest fieldLoaded as
Skillsresources.skillsMarkdown skill files parsed like normal SKILL.md files.
Agentsresources.agentsAgent definition markdown files.
Hooksresources.hooksHook declarations with extension metadata.
Bundlesresources.bundlesBundle specs that package agents, channels, automation, layouts, and bridges.
MCP serversresources.mcp_serversNamed MCP server declarations.

Bundle Profile Agents

Static resources.agents entries are always-on extension agents. Use bundle profile agents when the agent should exist only while a bundle profile is active.

marketing-linear/
  extension.toml
  bundles/
    marketing.toml
  agents/
    campaign-planner/
      AGENT.md
      mcp.json
      capabilities.toml
      SOUL.md
      HEARTBEAT.md
  layouts/
    two-up.json

bundles/marketing.toml:

name = "marketing-linear"
description = "Marketing team presets with Linear issue tracking"

[[profiles]]
name = "default"
description = "Marketing agents, channels, automation, and Linear bridge"

[profiles.channels]
primary = "marketing"

[[profiles.channels.items]]
name = "marketing"
description = "Marketing team coordination"

[[profiles.agents]]
path = "agents/campaign-planner"

[[profiles.layouts]]
path = "layouts/two-up.json"

[[profiles.jobs]]
name = "daily-campaign-sync"
agent = "campaign-planner"
prompt = "Summarize open campaign issues and next actions."
enabled = true

[profiles.jobs.schedule]
mode = "every"
interval = "24h"

The path is resolved relative to the extension root, must stay inside that root, and must point to a directory containing AGENT.md. Compozy loads that folder through the same agent loader used for normal agents, so mcp.json, capabilities.toml, and capabilities/ keep the same behavior.

Optional SOUL.md and HEARTBEAT.md files in the agent folder are packaged as read-only bundle defaults. Activating the profile materializes agent, agent.soul, and agent.heartbeat resources owned by the bundle activation. Compozy uses synthetic diagnostic paths such as .compozy/bundles/<activation-id>/agents/<agent>/SOUL.md; it does not copy package files into a writable workspace folder.

Activation fails when a bundled agent name conflicts with an existing visible agent in the target scope, or when a bundled job/trigger references an agent that is neither packaged by the profile nor available in the existing agent catalog.

Each profiles.layouts path must resolve inside the extension root and contain one strict window_layout JSON resource. Compozy validates the resource when the extension loads and again in the activation's global or workspace scope. The activation owns the projected layout and removes it on deactivation. See Bundles for the authoring and identity rules.

Subprocess Extensions

A manifest requires a subprocess when it declares [subprocess].command, capabilities.provides, or actions.requires.

[capabilities]
provides = ["prompt.provider"]

[actions]
requires = ["sessions/list"]

[subprocess]
command = "node"
args = ["dist/index.js", "serve"]
health_check_interval = "30s"
shutdown_timeout = "10s"

[subprocess.env]
LOG_LEVEL = "info"

[security]
capabilities = ["session.read"]

When Compozy starts an enabled subprocess extension, it runs this lifecycle:

  1. Discover the extension root from the stored manifest path.
  2. Parse extension.toml or extension.json.
  3. Validate manifest identity, versions, capabilities, actions, security grants, and bridge metadata.
  4. Register static resources: skills, agents, hooks, bundles, and MCP servers.
  5. Launch the subprocess when one is required.
  6. Send an initialize JSON-RPC request over stdio.
  7. Start health monitoring.
  8. Mark the extension active after successful activation.

The extension process must implement health_check and shutdown. The daemon also advertises execute_hook as a daemon request method. The TypeScript SDK binds initialize, health_check, shutdown, and provide_tools, and lets you register custom handlers.

Host API Permissions

Host API access is controlled by both method grants and security capabilities.

[actions]
requires = ["sessions/list", "memory/recall"]

[security]
capabilities = ["session.read", "memory.read"]
Manifest sectionPurpose
capabilities.providesInterfaces the extension implements, such as memory.backend or bridge.adapter.
actions.requiresHost API methods the extension wants to call, such as sessions/list.
security.capabilitiesCapability grants needed by those methods, such as session.read.

Publish declarative window layouts

A subprocess extension can publish strict window-layout resources through the generic resource Host API. Declare the methods, resource capabilities, family, and widest requested scope together:

[actions]
requires = ["resources/list", "resources/get", "resources/snapshot"]

[security]
capabilities = ["resources.read", "resources.write"]

[resources.publish]
families = ["window_layouts"]
max_scope = "workspace"

The manifest family window_layouts grants only the window_layout resource kind. This example requests workspace-scoped publication; use max_scope = "global" only when the extension must also publish global layouts. The extension source tier, operator policy, and runtime session can narrow the request further. resources/snapshot replaces that extension source's complete desired-state snapshot, so advance source_version monotonically and include every layout the source still owns. Omitted records are removed.

Every submitted spec passes through the canonical strict codec before persistence. Unknown fields, invalid topology, mismatched workspace binding, or an ungranted kind/scope reject the snapshot without partially applying it. The Go and TypeScript SDKs use the same generic resource record shape; no window-manager-specific SDK method is required.

Paged collection results

The paged collection methods below return bounded envelopes, not bare arrays. Continue with the cursor from the envelope while keeping the same workspace and filters. This is a hard contract: extension code must read the collection field and its page metadata together.

Host API methodResult envelope
tasksTasksResponse with tasks, page, and facets
automation/jobsAutomationJobsResult with jobs and page
automation/triggersAutomationTriggersResult with redacted triggers and page
tasks/inboxTaskInbox with groups, page, facets, and aggregate totals
network/threadsNetworkThreadsResponse with threads and page
network/thread/messagesNetworkThreadMessagesResponse with messages and page
network/directsNetworkDirectRoomsResponse with directs and page
network/direct/messagesNetworkDirectRoomMessagesResponse with messages and page

The generated TypeScript SDK exports these result types and binds them in HostAPIMethodMap, so a consumer that still expects an array fails at compile time instead of silently dropping pagination metadata.

Important current provide surfaces:

Provide surfaceCompozy calls into the extension with
memory.backendmemory/store, memory/recall, memory/forget
bridge.adapterbridges/deliver, bridges/targets/snapshot
model.sourcemodels/list

Clarification from extension tools

A persistent tool-provider extension may declare clarify/ask in actions.requires and call it only while handling an active daemon-issued tools/call. The Go SDK keeps the opaque invocation authority internal and exposes ToolRequest.AskClarification(ctx, ClarifyQuestion{Question: "...", Choices: []string{"..."}}). The call blocks until the session operator answers, the configured timeout returns the fallback sentinel, or the active tool call is canceled.

The daemon binds each invocation to the extension and active session, derives workspace and agent scope itself, and removes the binding when the tool call finishes. Extension code supplies only the question and optional choices; it cannot select another session or forge invocation authority.

bridge.adapter extensions must also declare bridge metadata:

[capabilities]
provides = ["bridge.adapter"]

[bridge]
platform = "slack"
display_name = "Slack"

Bridge adapters also return target snapshots through bridges/targets/snapshot. The daemon owns the target directory, freshness timestamps, persistence, and ambiguity checks; adapters only return provider-derived snapshots with immutable canonical routes, display names, types, qualifiers, and capabilities.

Bridge adapters are currently a trusted in-tree or local-source path. External bridge authoring is not yet a first-class public SDK workflow: the public SDKs do not cover the complete service and control contracts, marketplace bridge grants are unavailable, and there is no public bridge conformance harness. Follow the in-tree provider guide only when contributing inside the Compozy repository; the registry packaging flow below is not, by itself, a supported third-party bridge path.

Marketplace extensions run under a stricter policy. They are constrained to read-oriented grants: logs.read, memory.read, model.read, observe.read, session.read, skills.read, and tool.read.

Model Source Extensions

Extensions that declare the provide capability model.source enrich the daemon-owned provider model catalog. The daemon owns persistence and merge, so the extension only contributes source rows; it cannot rewrite global catalog state.

[capabilities]
provides = ["model.source"]

[actions]
requires = ["models/list", "models/refresh", "models/status"]

[security]
capabilities = ["model.read", "model.write"]

[subprocess]
command = "node"
args = ["dist/index.js"]

The extension service method models/list is dispatched by Compozy whenever the catalog refreshes the extension:<slug> source for a provider the extension declares. The slug is derived from the extension name and must match ^[a-z0-9][a-z0-9_-]*$; manifests that do not normalize cleanly are rejected at install time.

DirectionMethodPurpose
Compozy → extensionmodels/listReturns provider model rows for the extension's declared providers.
Host API callmodels/listReads the daemon-owned merged catalog projection, scoped by capability.
Host API callmodels/refreshTriggers a daemon-owned source refresh; serialized per provider.
Host API callmodels/statusReads daemon-owned source status, including last_refresh and last_error.

Capability areas align with the Host API authorization layer:

MethodAreaDefault in marketplace policy
models/listmodel.readAllowed (read-oriented grant).
models/statusmodel.readAllowed (read-oriented grant).
models/refreshmodel.writeRequires explicit grant; marketplace gate.

Extensions can attach deprecated, hidden, featured, and nullable release_date curation metadata to their rows. The daemon validates those fields through the same catalog merge and curation policy; invalid rows produce a recorded source status (with redacted last_error) instead of corrupting the merged projection. Refresh runs under a daemon-enforced deadline using the provider's auth/env/home policy, and refresh work for the same provider is coalesced — concurrent refreshes return identical source statuses when the in-flight refresh finishes.

The in-repository TypeScript SDK exports generated ProviderModel*, ModelCatalogSource*, and ModelSource* contracts from the daemon's OpenAPI source. The Go SDK currently exposes the generic HostAPI.Request boundary rather than generated model-catalog payload types. The TypeScript package is a workspace dependency in this repository and is not published by the Compozy release workflow; use it from a version-matched repository checkout instead of assuming @compozy/extension-sdk is available from a package registry.

The public @compozy/extension-sdk and @compozy/create-extension packages remain frozen on their legacy v0.1.x line. There is no v0.3 registry successor: the API-incompatible v0.3 workspaces are private repository sources, not package renames or drop-in upgrades.

Authored Context Host API

Soul, Heartbeat, and session health are agent-manageable through the Host API behind explicit grants. Extensions cannot bypass managed authoring — direct file writes from extension code, hooks, tools, MCP sidecars, or bridge adapters are forbidden. Each method is a separate grant so read, validate, mutate, history, rollback, status, wake, and health can be granted independently.

[actions]
requires = [
  "agents/soul/get",
  "agents/soul/validate",
  "agents/heartbeat/get",
  "agents/heartbeat/status",
  "agents/heartbeat/wake",
  "sessions/health/get",
]
Host API methodCapability areaNotes
agents/soul/getRead SoulReturns full resolved persona for the named agent (or caller).
agents/soul/validateValidate SoulChecks proposed body or current SOUL.md without writing.
agents/soul/putWrite SoulManaged write through the authoring service; requires expected_digest.
agents/soul/deleteDelete SoulManaged delete with expected_digest.
agents/soul/historyHistoryBounded revision history for Soul.
agents/soul/rollbackRollback SoulReplays a prior revision through validation/CAS; cannot restore forbidden content.
agents/heartbeat/getRead HeartbeatReturns the latest valid policy snapshot.
agents/heartbeat/validateValidate HeartbeatChecks proposed body without writing.
agents/heartbeat/putWrite HeartbeatManaged write with expected_digest. HTTP If-Match is rejected.
agents/heartbeat/deleteDelete HeartbeatManaged delete with expected_digest.
agents/heartbeat/historyHistoryBounded revision history for Heartbeat.
agents/heartbeat/rollbackRollback HeartbeatReplays a prior revision through validation/CAS.
agents/heartbeat/statusStatusPolicy + wake state + session health composition.
agents/heartbeat/wakeManual wakeAdvisory wake for an eligible session; never claims work or renews leases.
sessions/health/getRead session healthReturns metadata-only health for one session.

Write/delete/rollback/wake grants are separate from get/validate/history/status grants, so a read-only review extension can ship without ever requesting mutation rights. Marketplace extensions may only request the read-oriented grants from this list.

Authored Context Hooks

Authored context fires typed call-site hooks. They are observation hooks: payloads carry compact provenance (snapshot ids, digests, redacted actor/origin) and never include raw SOUL.md/ HEARTBEAT.md bodies, raw claim tokens, or full prompt transcripts. Hooks cannot mutate Soul, Heartbeat, or wake state.

EventSyncFires when
agent.soul.snapshot.resolvedasync onlyA session-start or refresh resolves a Soul snapshot.
agent.soul.mutation.afterasync onlyA managed put/delete/rollback succeeds.
agent.heartbeat.policy.resolvedasync onlyThe Heartbeat resolver materializes a new snapshot.
agent.heartbeat.wake.beforeyesThe wake service is about to send a synthetic wake; sync hooks may deny but cannot mint a token.
agent.heartbeat.wake.afterasync onlyA wake decision is recorded (sent, skipped, coalesced, rate_limited, or failed).
session.health.update.afterasync onlySession health changes state/health/eligibility; coalesced by session_health_hook_min_interval.

Authored Context Native Tools

Compozy exposes three native tools that run through the same managed services. They are agent-callable when policy permits, and they reuse the Host API authorization layer so a tool call and a JSON-RPC call resolve to the same grants.

Tool IDPurpose
compozy__session_healthRead session health, attachability, and wake eligibility for one session.
compozy__agent_heartbeat_statusRead Heartbeat policy and wake-state summary for an agent.
compozy__agent_heartbeat_wakeRequest one advisory wake for an eligible session; identical contract to manual wake.

There is intentionally no compozy__agent_soul tool — Soul read/validate/write/delete/history/ rollback flows happen through the dedicated CLI, HTTP, UDS, or Host API surfaces, not through a native tool, because Soul does not need an in-prompt invocation surface.

Authored Context SDK helpers

The in-repository TypeScript SDK exports generated AgentSoul*, AgentHeartbeat*, SessionHealth*, and AuthoredContext* contracts from the same OpenAPI source as the daemon. The Go SDK exposes the Host API method constants and generic request boundary, but does not currently ship generated authored-context payload structs. Keep a TypeScript extension on the same repository revision as the daemon; for Go, validate local request and response structs against the public API contract instead of assuming SDK parity that is not present.

Practical Example: Prompt Enhancer

This example mirrors the repository's sdk/examples/prompt-enhancer pattern and can be built and exercised from a Compozy repository checkout, where its package.json resolves @compozy/extension-sdk with workspace:*. It cannot be managed-installed as written: the runtime import remains in the built JavaScript, and the workspace dependency is a symlink outside the example root. The managed installer rejects runtime dependency symlinks that escape the package boundary. The extension packages a prompt.post_assemble hook and a persistent subprocess. The hook command is intentionally a one-shot process: it reads payload JSON from stdin and writes a PromptPatch to stdout. The persistent process handles JSON-RPC lifecycle and Host API calls.

extension.toml:

[extension]
name = "prompt-enhancer"
version = "0.1.0"
description = "Adds workspace context to assembled prompts"
min_compozy_version = "0.5.0"

[capabilities]
provides = ["prompt.provider"]

[actions]
requires = ["sessions/list"]

[[resources.hooks]]
name = "workspace-context"
event = "prompt.post_assemble"
mode = "sync"
executor.kind = "subprocess"
executor.command = "node"
executor.args = ["dist/index.js", "hook", "prompt_post_assemble"]

[subprocess]
command = "node"
args = ["dist/index.js", "serve"]

[security]
capabilities = ["session.read"]

src/index.ts:

import { pathToFileURL } from "node:url";
import {
  Extension,
  type ExecuteHookParams,
  type ExtensionOptions,
  type PromptPatch,
} from "@compozy/extension-sdk";

function enhance(prompt: string, workspace?: string, workspaceID?: string): PromptPatch {
  const label = workspace?.trim() || workspaceID?.trim() || "unknown";
  return {
    prompt: `[Workspace: ${label}]\n\n${prompt}`,
  };
}

async function readStdin(): Promise<string> {
  const chunks: Buffer[] = [];
  for await (const chunk of process.stdin) {
    chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
  }
  return Buffer.concat(chunks).toString("utf8");
}

async function runHook(name: string): Promise<void> {
  if (name !== "prompt_post_assemble") {
    throw new Error(`unsupported hook ${name}`);
  }

  const payload = JSON.parse(await readStdin()) as {
    prompt?: string;
    workspace?: string;
    workspace_id?: string;
  };

  process.stdout.write(
    JSON.stringify(enhance(payload.prompt ?? "", payload.workspace, payload.workspace_id)) + "\n"
  );
}

export function createExtension(options: ExtensionOptions = {}): Extension {
  const extension = new Extension(
    {
      name: "prompt-enhancer",
      version: "0.1.0",
      capabilities: { provides: ["prompt.provider"] },
      actions: { requires: ["sessions/list"] },
      security: { capabilities: ["session.read"] },
      supported_hook_events: ["prompt.post_assemble"],
    },
    options
  );

  extension.handle(
    "execute_hook",
    async (_ctx, params: ExecuteHookParams<"prompt.post_assemble">) => {
      return enhance(
        params.payload.prompt ?? "",
        params.payload.workspace,
        params.payload.workspace_id
      );
    }
  );

  extension.onReady(async host => {
    await host.sessions.list({});
  });

  return extension;
}

async function main(): Promise<void> {
  const [mode = "serve", hookName = ""] = process.argv.slice(2);
  if (mode === "hook") {
    await runHook(hookName);
    return;
  }
  await createExtension().start();
}

const entryPoint = process.argv[1];
if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) {
  void main().catch(error => {
    console.error(error);
    process.exitCode = 1;
  });
}

The important split is:

ModeCommandContract
One-shot hooknode dist/index.js hook prompt_post_assemblestdin payload, stdout patch.
Persistent extensionnode dist/index.js serveJSON-RPC initialize, health check, shutdown, Host API.

Do not point a hook declaration at a long-running server mode unless that process still reads one payload from stdin and exits with a patch on stdout.

Build the repository example

From the Compozy repository root:

bun install
bun run --cwd sdk/examples/prompt-enhancer build

This proves the repository-local SDK/example contract. Do not pass sdk/examples/prompt-enhancer to compozy extension install until its runtime SDK dependency has been materialized inside the extension package rather than linked to sdk/typescript outside it.

Install a self-contained extension

The install path must contain the manifest, built runtime, and every runtime dependency inside its own directory tree. Once an extension satisfies that boundary:

EXTENSION_DIR=/absolute/path/to/self-contained-extension
EXTENSION_NAME=prompt-enhancer

compozy extension install "$EXTENSION_DIR" --allow-unverified --yes -o json
compozy extension status "$EXTENSION_NAME" -o json

If package.json is present, Compozy copies runtime dependencies and optionalDependencies under node_modules; development dependencies are not copied. Symlinked runtime dependencies must still resolve within the extension root. This prevents a managed package from silently capturing files from the surrounding checkout.

Publish To A Registry

Marketplace installation expects an archive with extension.toml at the archive root or under one top-level directory:

prompt-enhancer-0.1.0.tar.gz
  prompt-enhancer/
    extension.toml
    package.json
    dist/
      index.js

The installer rejects ambiguous packages, including archive roots that look like both a skill package and an extension package. Keep extension packages centered on extension.toml.

For GitHub-backed registries, Compozy reads release metadata, downloads the selected release asset or tarball, computes the install checksum, stores registry metadata, and can later run compozy extension update.

Runtime Failure And Recovery

The extension supervisor monitors health checks and process exits. On failure, Compozy records the failure, applies exponential restart backoff, and relaunches the subprocess. After repeated consecutive failures, Compozy disables the extension, unregisters resources, marks it inactive, and stores the last error for compozy extension status.

Stop and reload behavior:

  • compozy extension enable <name> and compozy extension disable <name> reload the manager when the daemon is running.
  • Daemon shutdown sends cooperative shutdown first, then escalates through the subprocess layer if needed.
  • Manager.Reload is a stop followed by a fresh start from registry state.

Development Checklist

  1. Decide whether the extension is resource-only or needs a subprocess.
  2. Write extension.toml with name, version, and min_compozy_version.
  3. Add resources under resources.* and keep paths inside the extension directory.
  4. If using Host API, declare both actions.requires and security.capabilities.
  5. If providing bridge.adapter, declare [bridge] platform and display_name.
  6. If packaging hooks, make each hook command satisfy stdin payload to stdout patch.
  7. Materialize every runtime dependency inside the trusted extension root, then install that directory with compozy extension install <path> --allow-unverified --yes -o json.
  8. Inspect with compozy extension status <name> and compozy hooks list --source config when hooks are included.

Related references:

On this page