Skip to content

Develop Extensions

The code-first authoring loop for Compozy extensions — init, build, validate, dev, reload, logs, publish — plus provide surfaces, Host API access, and resources.

For people running agent work7 pages in this section

You write code. Compozy generates the manifest.

An extension is one installable unit of runtime behavior. A subprocess extension runs your code over JSON-RPC and can call the daemon through the Host API. A resource-only extension ships files Compozy already knows how to load and runs nothing.

New here? Build your first extension gets you to a working tool in three commands. This page is the loop around it.

The loop

Rendering diagram…

Author-owned verbs. Only publish and install cross into distribution; everything left of it is local and trust-free.
VerbDaemon requiredWhat it does
compozy extension initnoWrites a project from an embedded template.
compozy extension buildnoCompiles, runs describe mode, emits an immutable generation.
compozy extension validatenoReads a bundle and reports issues plus derived consent. Runs no code.
compozy extension devyesBuilds and links your source to the current workspace.
compozy extension reloadyesBuilds and atomically swaps the running generation.
compozy extension logsyesReads or follows one instance's redacted stderr ring.
compozy extension publishnoUploads a generation to a GitHub release with a digest sidecar.

Every verb supports -o human|json|jsonl|toon. Exact flags: Extension CLI Reference.

Start from a template

compozy extension init my-ext --template tool-provider-go
TemplateLanguageShows
tool-provider-tsTypeScriptOne tool with a typed handler. Default template.
tool-provider-goGoThe same tool with Go generics.
hook-tsTypeScriptA prompt.post_assemble hook returning a patch.
memory-backend-tsTypeScriptThe memory.backend provide surface.
loop-watch-source-goGoThe loop.watch_source provide surface.

Templates carry no manifest. They carry package.json/go.mod, a source file, and nothing else.

The published SDKs are @compozy/extension-sdk on npm and github.com/compozy/compozy/sdk/go as a Go module. Both are versioned with the daemon; build stamps the compatibility floor from the SDK you compiled against.

Declare once, in code

Identity, schemas, permissions, tools, and hooks are declared in one place:

extension := compozysdk.NewExtension(compozysdk.ExtensionDefinition{
	Name:        "hello",
	Version:     "0.1.0",
	Description: "Search extension-owned data",
	Subprocess:  compozysdk.DescribeSubprocess{Command: "./bin"},
	Permissions: compozysdk.PermissionsConfig{
		Requires: []compozysdk.HostAPIMethod{"sessions/list"},
	},
})

compozysdk.Tool[searchInput](extension, "search", compozysdk.ToolOptions{
	Description: "Search extension-owned data",
	ReadOnly:    true,
	InputSchema: searchInputSchema,
}, handleSearch)

build starts your binary with the __describe argument, reads the contract it prints, and writes extension.toml from it. There is no second declaration to keep in sync, so the schema-digest drift that a hand-written manifest allowed cannot occur.

Registering a tool adds tool.provider to capabilities.provides automatically.

Build and validate

compozy extension build
{
  "name": "hello",
  "generation_hash": "cc1358b134cbf3394c562604dc17ad10b41c5478d32f01d3acb4c1f7e61b6b13",
  "generation_dir": "/work/hello/dist/gen-cc1358…",
  "manifest_path": "/work/hello/dist/gen-cc1358…/extension.toml"
}

Every build lands in an immutable dist/gen-<hash> directory. The hash is the checksum of that tree and is the only generation identity any surface accepts — no path, symlink, or staging directory substitutes for it. Builds never mutate a prior generation, and identical source produces a byte-identical manifest.

Build detects the toolchain: a package.json build script runs through Bun or npm, a go.mod runs go build. Override with --build-command, raise the describe timeout with --timeout, and move the generation root with --output-dir (a custom output root is for standalone packaging — only <source>/dist can back a dev link).

compozy extension validate ./dist/gen-cc1358…

validate reads the bundle without executing anything and reports positioned issues plus the consent areas an operator will see. See Extension Permissions.

Dev, reload, logs

compozy extension dev .

dev builds, then links the generation to the current workspace. There is no trust prompt, no allow_unverified policy, and no marketplace vocabulary anywhere in the flow: it is your code, in your workspace.

A dev link is an overlay, not an install. It lives in its own side table and never displaces a published installation of the same name; while both exist, reads report overrides_published: true alongside dev, origin_path, generation_hash, and workspace_id.

compozy extension reload my-ext .
compozy extension dev . --watch
compozy extension logs my-ext --follow
  • reload builds a new generation and swaps atomically. If the new generation fails to activate, the last-good generation keeps serving and status reports activation_failed with the running generation in last_error. A broken edit never takes the extension down.
  • --watch polls the source tree every extensions.dev.watch_interval (default 2s), skipping .git, dist, and node_modules. The watcher is client-side; the daemon never watches author directories.
  • logs reads a bounded 256 KiB per-instance ring fed from subprocess stderr, redacted at ingestion so no transport ever sees a configured secret. Page with --after <sequence>; --follow consumes the named extension_log SSE event. The ring is live retention, not durable history.

Instances

Every runtime surface is keyed by instance — extension name plus workspace. The published installation is the global instance (empty workspace); each dev link is a workspace instance. Subprocess, coordinator, last-good generation, log ring, status, and events are per instance, so two workspaces linking the same extension share nothing.

The workspace is bound server-side from the operator's resolved workspace or an agent session's trusted scope — never from a request body or tool input. Global-instance logs are operator-transport only: compozy extension logs <name> --global.

Instance-acting verbs — reload, logs, remove — infer the workspace from the directory you run them in, or take --workspace. compozy extension list and compozy extension status read the global installed set, so a dev-only instance does not appear there; read it through GET /api/extensions?workspace=<id>, through an agent caller whose session binds the workspace, or through compozy extension logs <name>. compozy tool invoke also defaults to global scope — pass --workspace . to reach a dev-linked extension's tools.

Origin paths are canonicalized and must resolve inside the workspace root at link time and on every load. A missing or escaping origin loads as state: error with failure_code: missing_origin; the daemon never crashes boot and never runs a binary outside the recorded generation directory.

compozy extension remove my-ext

Inside a workspace this unlinks the overlay only, and the published installation resumes. --global removes the published installation.

Ship it

compozy extension publish ./dist/gen-<hash> --repository acme/hello --tag v0.1.0
compozy extension install github:acme/hello@v0.1.0

No pull request to Compozy, no catalog entry required. Full flow, digest semantics, and source options: Publish an Extension.

Tool IDs

An extension tool's canonical ID is:

ext__<extension>__<tool>

Each segment is lowercased; [a-z0-9] is kept, every other run of characters collapses to a single _, and leading/trailing underscores are trimmed. __ is the reserved separator and may not appear inside a segment.

Extension nameHandlerTool ID
hellosearchext__hello__search
noteslist_recentext__notes__list_recent
my-extrun-checkext__my_ext__run_check

Agents call that ID directly. Operators can call the same tool through a friendlier verb — see Extension Commands.

Provide surfaces

capabilities.provides declares which runtime interfaces the extension implements. The set is closed and validated at build, install, and load.

ProvideCompozy calls the extension withPublic
tool.providerprovide_tools, tools/callyes
memory.backendmemory/store, memory/recall, memory/forgetyes
model.sourcemodels/listyes
loop.watch_sourcewatch/pollyes
bridge.adapterbridges/deliver, bridges/targets/snapshotno

Missing a required service method for a declared provide fails the build. Declaring a value outside this set fails manifest load with the valid set in the error.

Model source extensions

An extension declaring model.source enriches the daemon-owned provider model catalog. It contributes source rows only; the daemon owns persistence, merge, and curation policy.

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

Rows may carry deprecated, hidden, featured, and nullable release_date curation metadata, validated through the same catalog merge and curation policy. Invalid rows produce a recorded source status with a redacted last_error instead of corrupting the merged projection. Refresh runs under a daemon-enforced deadline using the provider's auth, env, and home policy, and concurrent refreshes for one provider coalesce into a single result.

Reading the merged catalog from an extension needs models/list and models/status; triggering a refresh needs models/refresh. None of those are inside the published-source ceiling, so a model.source extension is a local, workspace, or bundled install today.

The authorization layer classifies models/list and models/status under model.read, and models/refresh under model.write. Operator-facing consent renders those areas as model:read and model:write; authors still declare only the method paths in permissions.requires.

Bridge adapters

External bridge authoring is a planned follow-up program, not a supported path today. Compozy does not yet publish the bridge service and control contracts, the grant surface, or a public bridge conformance harness, so bridge.adapter is excluded from the public completeness surface: an installed third-party manifest declaring it is rejected deterministically with that reason. The four public provide surfaces above are the complete third-party set for now.

In-tree bridge providers are unaffected — they are not installed extensions. Contributing one inside the Compozy repository follows the in-tree provider guide. Extension packaging is not, by itself, a third-party bridge path.

Call the Host API

Declare the methods you call in one list:

Permissions: compozysdk.PermissionsConfig{
	Requires: []compozysdk.HostAPIMethod{"sessions/list", "memory/recall"},
},

Compozy derives the operator-facing consent areas from that list and enforces it per call. The full 87-method catalog, the derived areas, and the ceiling applied per install source are in Extension Permissions.

Paged collection results

These methods return bounded envelopes, not bare arrays. Continue with the cursor from the envelope while keeping the same workspace and filters, and read the collection field together with its page metadata.

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 SDK contracts bind these result types, so code that expects an array fails at compile time instead of silently dropping pagination metadata.

Ask the operator a question

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

The daemon binds each invocation to the extension and active session, derives workspace and agent scope itself, and drops 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.

Publish declarative resources

A subprocess extension can publish strict resource records — window layouts today — through the generic resource Host API. Declare the methods, the families, and the widest requested scope:

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

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

The family window_layouts grants only the window_layout resource kind. Use max_scope = "global" only when the extension must publish global layouts; the source tier, operator policy under [extensions.resources], and the runtime session can each narrow the request further.

resources/snapshot replaces that extension source's complete desired-state snapshot: advance source_version monotonically and include every record the source still owns, because omitted records are removed. Every submitted spec passes the canonical strict codec before persistence — unknown fields, invalid topology, mismatched workspace binding, or an ungranted kind or scope reject the snapshot atomically. The Go and TypeScript SDKs use the same generic resource record shape; no window-manager-specific SDK method exists.

Authored context

Soul, Heartbeat, and session health are reachable behind per-method grants. Extensions cannot bypass managed authoring — direct file writes from extension code, hooks, tools, MCP sidecars, or bridge adapters are forbidden.

Host API methodNotes
agents/soul/getFull resolved persona for the named agent or the caller.
agents/soul/validateChecks a proposed body or the current SOUL.md without writing.
agents/soul/putManaged write through the authoring service; requires expected_digest.
agents/soul/deleteManaged delete with expected_digest.
agents/soul/historyBounded revision history.
agents/soul/rollbackReplays a prior revision through validation and CAS; cannot restore forbidden content.
agents/heartbeat/getLatest valid policy snapshot.
agents/heartbeat/validateChecks a proposed body without writing.
agents/heartbeat/putManaged write with expected_digest. HTTP If-Match is rejected.
agents/heartbeat/deleteManaged delete with expected_digest.
agents/heartbeat/historyBounded revision history.
agents/heartbeat/rollbackReplays a prior revision through validation and CAS.
agents/heartbeat/statusPolicy, wake state, and session-health composition.
agents/heartbeat/wakeAdvisory wake for an eligible session; never claims work or renews leases.
sessions/health/getMetadata-only health for one session.

Read and write grants are separate areas, so a read-only review extension ships without ever requesting mutation rights.

Authored context also fires observation hooks — agent.soul.snapshot.resolved, agent.soul.mutation.after, agent.heartbeat.policy.resolved, agent.heartbeat.wake.before (the only sync-eligible one; it may deny but cannot mint a token), agent.heartbeat.wake.after, and session.health.update.after. Their payloads carry compact provenance — snapshot ids, digests, redacted actor and origin — and never raw SOUL.md/HEARTBEAT.md bodies, raw claim tokens, or full prompt transcripts.

Agents can reach the same managed services through three native tools:

Tool IDPurpose
compozy__session_healthRead health, attachability, and wake eligibility.
compozy__agent_heartbeat_statusRead Heartbeat policy and wake-state summary.
compozy__agent_heartbeat_wakeRequest one advisory wake for an eligible session.

Soul authoring remains on its dedicated CLI, HTTP, UDS, and Host API surfaces; there is no native Soul tool.

Ship resources, not only code

Any extension may bundle static resources the daemon loads while it is enabled: skills, agent definitions, loops, bundles, hooks, and MCP servers. Paths resolve inside the extension root and may not escape it; {{config_dir}} expands to that root and {{env:NAME}} reads the daemon process environment.

Field-by-field reference: Extension Manifest.

Hooks

Extension hooks carry source extension and default priority 300, which places them after config hooks and before agent-definition hooks. A subprocess hook reads one payload from stdin and writes a patch to stdout; do not point a hook declaration at a long-running server mode.

See Hook Declaration Format.

Bundle profile agents

Static resources.agents entries are always-on. Use bundle profile agents when an 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

Each path resolves relative to the extension root, must stay inside it, and must point at a directory containing AGENT.md. Compozy loads it through the normal agent loader, so mcp.json, capabilities.toml, and capabilities/ behave identically. Optional SOUL.md and HEARTBEAT.md are packaged as read-only bundle defaults: activation materializes agent, agent.soul, and agent.heartbeat resources owned by the activation, using synthetic diagnostic paths under .compozy/bundles/<activation-id>/. No package files are copied into a writable workspace folder.

Activation fails when a bundled agent name collides with an existing visible agent in the target scope, or when a bundled job or trigger references an agent that is neither packaged by the profile nor present in the agent catalog. Layout paths must resolve inside the extension root and contain one strict window_layout resource; the activation owns the projected layout and removes it on deactivation. See Bundles.

Runtime failure and recovery

The 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 it disables the extension, unregisters its resources, marks it inactive, and stores the last error.

compozy extension status <name> reports the honest picture, including consecutive_failures and restart_backoff_ms, so a crash-looping extension is distinguishable from a stably-failed one.

  • enable and disable reload the manager while the daemon is running.
  • Daemon shutdown sends a cooperative shutdown first, then escalates through the subprocess layer.
  • A dev instance that fails activation keeps its last-good generation running.

Examples

sdk/examples/ in the Compozy repository holds runnable extensions that import only the published SDKs:

ExampleShows
sdk/examples/clarify-toolA Go tool provider that asks the operator a bounded question.
sdk/examples/notes-commandsContributed operator commands: a flat leaf, a declared group, a nested leaf, and projected flags.
sdk/examples/prompt-enhancerA TypeScript extension with both a prompt.post_assemble hook and a persistent subprocess.

On this page