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.
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…
| Verb | Daemon required | What it does |
|---|---|---|
compozy extension init | no | Writes a project from an embedded template. |
compozy extension build | no | Compiles, runs describe mode, emits an immutable generation. |
compozy extension validate | no | Reads a bundle and reports issues plus derived consent. Runs no code. |
compozy extension dev | yes | Builds and links your source to the current workspace. |
compozy extension reload | yes | Builds and atomically swaps the running generation. |
compozy extension logs | yes | Reads or follows one instance's redacted stderr ring. |
compozy extension publish | no | Uploads 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| Template | Language | Shows |
|---|---|---|
tool-provider-ts | TypeScript | One tool with a typed handler. Default template. |
tool-provider-go | Go | The same tool with Go generics. |
hook-ts | TypeScript | A prompt.post_assemble hook returning a patch. |
memory-backend-ts | TypeScript | The memory.backend provide surface. |
loop-watch-source-go | Go | The 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 --followreloadbuilds a new generation and swaps atomically. If the new generation fails to activate, the last-good generation keeps serving and status reportsactivation_failedwith the running generation inlast_error. A broken edit never takes the extension down.--watchpolls the source tree everyextensions.dev.watch_interval(default2s), skipping.git,dist, andnode_modules. The watcher is client-side; the daemon never watches author directories.logsreads 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>;--followconsumes the namedextension_logSSE 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-extInside 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.0No 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 name | Handler | Tool ID |
|---|---|---|
hello | search | ext__hello__search |
notes | list_recent | ext__notes__list_recent |
my-ext | run-check | ext__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.
| Provide | Compozy calls the extension with | Public |
|---|---|---|
tool.provider | provide_tools, tools/call | yes |
memory.backend | memory/store, memory/recall, memory/forget | yes |
model.source | models/list | yes |
loop.watch_source | watch/poll | yes |
bridge.adapter | bridges/deliver, bridges/targets/snapshot | no |
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 method | Result envelope |
|---|---|
tasks | TasksResponse with tasks, page, and facets |
automation/jobs | AutomationJobsResult with jobs and page |
automation/triggers | AutomationTriggersResult with redacted triggers and page |
tasks/inbox | TaskInbox with groups, page, facets, and aggregate totals |
network/threads | NetworkThreadsResponse with threads and page |
network/thread/messages | NetworkThreadMessagesResponse with messages and page |
network/directs | NetworkDirectRoomsResponse with directs and page |
network/direct/messages | NetworkDirectRoomMessagesResponse 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 method | Notes |
|---|---|
agents/soul/get | Full resolved persona for the named agent or the caller. |
agents/soul/validate | Checks a proposed body or the current SOUL.md without writing. |
agents/soul/put | Managed write through the authoring service; requires expected_digest. |
agents/soul/delete | Managed delete with expected_digest. |
agents/soul/history | Bounded revision history. |
agents/soul/rollback | Replays a prior revision through validation and CAS; cannot restore forbidden content. |
agents/heartbeat/get | Latest valid policy snapshot. |
agents/heartbeat/validate | Checks a proposed body without writing. |
agents/heartbeat/put | Managed write with expected_digest. HTTP If-Match is rejected. |
agents/heartbeat/delete | Managed delete with expected_digest. |
agents/heartbeat/history | Bounded revision history. |
agents/heartbeat/rollback | Replays a prior revision through validation and CAS. |
agents/heartbeat/status | Policy, wake state, and session-health composition. |
agents/heartbeat/wake | Advisory wake for an eligible session; never claims work or renews leases. |
sessions/health/get | Metadata-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 ID | Purpose |
|---|---|
compozy__session_health | Read health, attachability, and wake eligibility. |
compozy__agent_heartbeat_status | Read Heartbeat policy and wake-state summary. |
compozy__agent_heartbeat_wake | Request 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.
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.jsonEach 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.
enableanddisablereload the manager while the daemon is running.- Daemon shutdown sends a cooperative
shutdownfirst, 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:
| Example | Shows |
|---|---|
sdk/examples/clarify-tool | A Go tool provider that asks the operator a bounded question. |
sdk/examples/notes-commands | Contributed operator commands: a flat leaf, a declared group, a nested leaf, and projected flags. |
sdk/examples/prompt-enhancer | A TypeScript extension with both a prompt.post_assemble hook and a persistent subprocess. |