@agent-compose/sdk
v0.8.7
Published
Client library for agent-compose — define agents, runtimes, and workflows, and invoke them against an agent-compose server.
Readme
@agent-compose/sdk
TypeScript SDK for agent-compose. Use it to:
- Author workflows that run agent loops inside isolated sandboxes
- Use or define runtimes that drive a coding agent (Claude Code, Codex, Amp, OpenCode, Cursor, Droid, …) as an agent loop
- Register, invoke, observe, and cancel workflows over the HTTP API (
AgentComposeClient) - Manage factories, secrets, API keys, and snapshots programmatically
The hierarchy: a team (shown as a space in the dashboard) owns one or more factories; each factory owns workflow templates, secrets, runs and a drive. Workflows are versioned per (factory, name, version). Every team has a default factory, and every call that takes a factorySlug falls back to it.
Installation
npm install @agent-compose/sdk zod # zod ^4 is a peer dependencyAgentComposeClient runs on Node ≥ 20 or Bun. Bundling a workflow for
registration (bundleWorkflow, agentc register) needs Bun.
Authoring a workflow
A workflow is a typed chain of discrete steps:
defineWorkflow({ id, input, output }).step(defineStep(...)).build().
Every step is a durable replay checkpoint: the engine records each
step's validated output, so a retry or resume picks up after the last
completed step instead of re-running the whole workflow.
// my-workflow.ts
import { defineWorkflow, defineStep, agent, claudeRuntime } from "@agent-compose/sdk";
import { z } from "zod";
import PROMPT from "./prompt.md" with { type: "text" };
const Input = z.object({ repo: z.string() });
const Output = z.object({ ok: z.boolean(), summary: z.string().optional() });
const review = defineStep({
name: "review",
input: Input,
output: Output,
run: async (ctx) => {
const result = await agent({
sandbox: ctx.sandbox!, // the run's own VM
runtime: claudeRuntime,
prompt: `${PROMPT}\n\nRepository: ${ctx.input.repo}`,
tools: ["Bash", "Read", "Edit", "Write", "Grep", "Glob"],
budget: { turnsPerIteration: 40, maxIterations: 8 },
});
await ctx.setMetadata({ summary: result.lastStatus?.summary });
return { ok: result.lastStatus?.exit_signal ?? false, summary: result.lastStatus?.summary };
},
});
export default defineWorkflow({
id: "my-workflow",
input: Input,
output: Output,
// Optional: outbound network rules the runner sandbox enforces. Both
// providers enforce the host list and inject the headers; only Vercel
// also enforces per-host method/path rules. `$VAR` placeholders resolve
// from the workflow's secrets at dispatch, and the value never enters
// the sandbox.
networkPolicy: {
allow: {
"*": [],
"api.anthropic.com": [{ transform: [{ headers: { "x-api-key": "$ANTHROPIC_API_KEY" } }] }],
},
},
})
.step(review)
.build();The builder chain checks at compile time that each step's input
schema matches the previous step's output, and .build() verifies
the final step's output is the same Zod instance as the workflow's
declared output. networkPolicy, placeholders, snapshots,
resources and the other definition fields become metadata the bundler
picks up at registration.
The run-form (defineWorkflow({ run(ctx, sandbox) { … } })) has been
removed: defineWorkflow throws when it sees a run key. Step-form is
the only shape, and pausing only works there.
What a step can do with ctx
Each step's run(ctx) receives a StepContext:
interface StepContext<TInput> {
input: TInput; // validated against the step's `input` schema
stepName: string;
run: { id: string };
sandbox?: SandboxProvider; // the run's VM: pass to `agent({ sandbox: ctx.sandbox })`
abortSignal: AbortSignal; // fires when the run is canceled
requestContext: RequestContext; // tenant identity, for processors
agentEvents: AgentEventSink; // `agent({ events: ctx.agentEvents })`
setMetadata(data: Record<string, unknown>): Promise<void>;
step<T>(name: string, fn: () => Promise<T>): Promise<T>; // durable named sub-step
pause<T>(req: PauseRequest<T>): Promise<T>; // wait for a human, durably
sleep(durationMs: number): Promise<void>;
waitForEvent<T>(req: WaitForEventRequest<T>): Promise<T>;
requestDecision(req: DecisionRequest): Promise<{ decision: string }>;
invokeChild(name, input?, opts?): Promise<RunStatus>; // run another workflow
}ctx.step("phase-name", () => …) wraps a phase within a step for the
run timeline. It memoises the result (a pause-resume re-entry restores
it instead of re-running) and emits workflow_substep_started /
workflow_substep_completed / workflow_substep_failed lifecycle
events with the duration. Use it for setup, external API calls, or
anything you want visible on the run page.
The agent loop
agent({
sandbox, // ctx.sandbox, the run's VM
runtime, // claudeRuntime, codexRuntime, …, or your own via defineRuntime
prompt, // raw markdown; a `--- frontmatter ---` block is stripped
tools?, // model tool allowlist (defaults inside agentLoop; [] disables tools)
budget?, // { turnsPerIteration, maxIterations }; omitted: no turn cap, up to 3 iterations
workingDir?, // every shell command runs here (default: the run's dir on the drive)
responseSchema?, // zod; when set, the loop demands a `<response>` block on exit
mode?, // "auto" (default) or "hitl" (a human can steer it mid-run)
events?, // lifecycle sink, e.g. ctx.agentEvents
onAgentEvent?, // per-message hook
onIteration?, // per-iteration hook with the parsed `<status>` block
processors?, // pre/post pipeline around the loop
})
// → AgentLoopResult { agentId, label, sessionId, lastStatus, iterations, response? }The model emits XML-tagged blocks (<status>, <response>) that the loop
parses. lastStatus is the last <status> block: { summary, completed,
blockers, exit_signal }. See sdk/src/agent/protocol-suffix.md for the
instructions appended to every prompt.
Runtimes
A runtime wraps an agent's execution model (usually a coding CLI) so
agent can drive it. The SDK ships built-ins; you only need a custom one
for a provider it does not cover.
Built-in runtimes
Each comes as a ready default and a create… factory that takes config:
| Default | Factory | Drives |
|---|---|---|
| claudeRuntime | createClaudeRuntime | Claude through the Claude Agent SDK, from the runner process |
| claudeCodeRuntime | createClaudeCodeRuntime | the Claude Code CLI |
| codexRuntime | createCodexRuntime | the Codex CLI |
| ampRuntime | createAmpRuntime | Amp |
| opencodeRuntime | createOpencodeRuntime | OpenCode |
| cursorRuntime | createCursorRuntime | Cursor's cursor-agent CLI |
| droidRuntime | createDroidRuntime | Factory's droid CLI |
| | createVercelRuntime | a model through the Vercel AI SDK |
openAIDesktopRuntime is not in the package root: it pulls in sharp for
screenshots, whose native binding cannot be cross-compiled. Import it from
its subpath when you want it:
import openAIDesktopRuntime from "@agent-compose/sdk/runtimes/openai-desktop";Custom runtime
import { defineRuntime, type AgentMessage } from "@agent-compose/sdk";
export default defineRuntime({
create: (sandbox, opts) => ({
// Return a ModelExecutionContract; see sdk/src/types/runtime.ts
async *sendMessage({ prompt, sessionId, signal }): AsyncGenerator<AgentMessage> {
// Run the model against `sandbox` and yield AgentMessage chunks the loop parses.
},
}),
});AgentRuntime is { create(sandbox, RuntimeOptions) → ModelExecutionContract }.
There is no provider field on it: the runtime is bound to the
workflow at author time (you pass it to agent), not selected by the
server.
Registering a workflow
The agentc CLI bundles and registers in one step:
agentc register my-workflow.ts -n my-workflowUnder the hood that calls bundleWorkflow(workflowPath), which bundles the
source with Bun.build, checks that the default export is a
defineWorkflow(...) workflow, and returns the source with a manifest the
server verifies against the source hash. Then it posts to
/api/v1/factories/<slug>/templates. To register from your own build
pipeline (under Bun):
import { AgentComposeClient, bundleWorkflow } from "@agent-compose/sdk";
const client = new AgentComposeClient({
baseUrl: "https://your-server.example.com", // default: AGENT_COMPOSE_URL, else https://api.agentcompose.ai
apiKey: process.env.AGENT_COMPOSE_API_KEY!, // default: AGENT_COMPOSE_API_KEY
});
const bundled = await bundleWorkflow("./my-workflow.ts");
await client.register({
name: "my-workflow",
source: bundled.source,
manifest: bundled.manifest, // required
workflowPlan: bundled.workflowPlan,
schedule: "*/30 * * * *", // optional cron (UTC)
factorySlug: "default", // optional, defaults to "default"
// pass bundled.networkPolicy, placeholders, snapshots, resources, … to keep
// what the workflow declares
});register() needs the manage scope on the caller's key.
Invoking a workflow
Two flavours:
// Fire-and-forget: returns the run id at once.
const { id } = await client.invoke("my-workflow", {
repo: "owner/repo",
});
// Block until the run settles (default 30 min timeout, 1 s poll).
const status = await client.invokeAndWait("my-workflow", { repo: "owner/repo" }, {
timeoutMs: 5 * 60_000,
pollIntervalMs: 2000,
});
console.log(status.status); // "success" | "failed" | "abandoned" | "canceled"
console.log(status.output); // the workflow's return valueoutput is the final step's return value, validated against the
workflow's declared output schema. setMetadata() writes to a
separate metadata field, for side-channel facts (a PR URL, a plan URL)
that don't belong in the structured return.
invoke and invokeAndWait take { factorySlug, snapshots,
networkPolicy, placeholders, size, parentRunId, idempotencyKey } as the
third argument. Per-invocation snapshots merges field by field with
the registered default. factorySlug defaults to "default".
Auto parent/child tracing
The SDK reads process.env.RUN_ID (set by the runner sandbox on every
dispatch) and threads it as parentRunId on invoke() calls. Workflows
that fan out to other workflows get a parent/child tree in the dashboard
for free. Pass parentRunId: null to opt out.
Cancelling a run
await client.cancelRun(runId);Idempotent: cancelling a run that already ended returns its current state
without throwing. The server marks the run canceled, emits a
run_canceled event on the stream and kills its sandbox.
Streaming live events
streamRunLogs returns an async generator of RunEvents in real time,
over SSE. Pass lastEventId (the highest seq you have processed) to
resume after a reconnect.
for await (const ev of client.streamRunLogs(runId, { lastEventId: 0 })) {
console.log(ev.event, ev.seq);
if (ev.event === "run_complete" || ev.event === "run_failed" || ev.event === "run_canceled") {
break;
}
}AbortSignal works too: pass { signal } and call controller.abort()
to close the stream from the caller side.
Factories
Factories are containers within a team. Each has its own workflow
templates, secrets, runs and drive, and API keys can be limited to one.
New projects don't need to think about them: default exists in every
team and is what the SDK uses when factorySlug is omitted.
// CRUD on factories
await client.createFactory({ slug: "ci-bots", name: "CI Bots", description: "…" });
const factories = await client.listFactories();
const f = await client.getFactory("ci-bots");
await client.updateFactory("ci-bots", { name: "Continuous-Integration Bots" });
await client.deleteFactory("ci-bots");
// Templates: across factories, or scoped to one
const all = await client.listTemplates();
const scoped = await client.listTemplates({ factorySlug: "ci-bots" });
// Register, invoke and secret calls all accept factorySlug
await client.invoke("scrape", { url: "…" }, { factorySlug: "ci-bots" });
await client.setSecret("scrape", "GH_TOKEN", "ghp_…", { factorySlug: "ci-bots" });CLI equivalents: agentc factory list | create | use | delete | show,
plus --factory <slug> on most other commands.
Secrets
Secret values live in GCP Secret Manager and are write-only: the API only
returns metadata (key, timestamps). A run sees its factory's secrets plus
its workflow's own, the workflow's winning on a clash. A secret the
workflow's networkPolicy references as $VAR is brokered: the platform
adds it to matching requests at the network layer and the sandbox env holds
only a placeholder, or nothing. Any other secret is a plain env var in the
runner sandbox.
// Per-workflow
await client.setSecret("my-workflow", "ANTHROPIC_API_KEY", process.env.ANTHROPIC_API_KEY!);
const list = await client.listSecrets("my-workflow"); // [{ key, createdAt, updatedAt }]
await client.deleteSecret("my-workflow", "STALE_KEY");
// Factory-wide (every workflow in the factory inherits them)
await client.setFactorySecret("SLACK_WEBHOOK_URL", "https://hooks.slack.com/…", { factorySlug: "ci-bots" });Factory secrets need the manage scope. Changing a workflow's secrets
needs invoke plus write access to that workflow.
API keys
Mint and list keys programmatically (the caller's key needs admin). A new
key is returned once, in the same response as its metadata: copy the
ac_… value at once.
const created = await client.createApiKey({
name: "ci-dispatcher",
scopes: ["read", "invoke"], // omitted = ["read"]
expiresAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), // 30 days
// factorySlug: "ci-bots" // optional: limit the key to one factory
});
console.log(created.key); // "ac_…", the only time you will see it
const page = await client.listApiKeys();Scopes: read, invoke (dispatch runs), manage (operate a factory:
templates, secrets, repos) and admin (members, roles, keys). A key can
only mint scopes its holder has. CLI equivalent:
agentc keys create <name> --scopes read,invoke --expires-in 30d.
Usage
const usage = await client.getUsage(
new Date(Date.now() - 30 * 86_400_000),
new Date(),
);
// usage.data: [{ eventType, unit, total, tags }]CLI equivalent: agentc usage.
Snapshots (replay-friendly sandboxes)
A workflow can capture its runner sandbox as a snapshot on success
(snapshots: { saveLatest: true }), on Vercel or E2B. Other runs boot from
it with snapshots.bootFrom into the same prepared VM (deps installed, repo
cloned) instead of repeating the setup.
// Capture per-invocation:
await client.invoke("my-workflow", input, { snapshots: { saveLatest: true } });
// Boot from a captured snapshot (take the id from `agentc snapshot list`
// or the dashboard):
await client.invoke("my-workflow", input, {
snapshots: { bootFrom: { snapshotId: "snap_…" } },
});
// Set a default in the definition:
defineWorkflow({ id, input, output, snapshots: { saveLatest: true } }).step(…).build();
// Keep every step's snapshot (not just the latest):
defineWorkflow({ id, input, output, snapshots: { saveLatest: true, retainSteps: true } }).step(…).build();
// Browse and clean up:
const page = await client.listSnapshotsPage({ workflow: "my-workflow", limit: 50 });
const snaps = page.data;
await client.deleteRunSnapshot(snaps[0].runId, snaps[0].snapshotId);CLI equivalents: agentc snapshot list, agentc snapshot show <run-id>,
agentc snapshot delete <run-id> [snapshot-id].
Per-invocation overrides merge field by field
The snapshots object on invoke() is merged with the registered template's snapshots config: you can override bootFrom alone without losing saveLatest, or the other way round.
Authentication
The SDK sends a Bearer API key (ac_…). Mint one in the dashboard: sign
in, open the space's settings, Credentials → API keys. A new key gets
read by default; tick invoke to dispatch runs, manage to register
workflows or set secrets, and admin only to mint keys or manage members.
const client = new AgentComposeClient({
baseUrl: process.env.AGENT_COMPOSE_URL,
apiKey: process.env.AGENT_COMPOSE_API_KEY,
});Both options fall back to those env vars (and the URL to
https://api.agentcompose.ai), so new AgentComposeClient() works inside a
run sandbox. The dashboard itself uses a session cookie; the SDK is for
programmatic and server-to-server callers.
Public exports: quick reference
| Export | What |
|---|---|
| defineWorkflow | Typed step-workflow builder: defineWorkflow({ id, input, output }).step(...).build() |
| defineStep | Declare one typed workflow step ({ name, input, output, run }) |
| defineRuntime | Wrap an agent execution provider as an AgentRuntime |
| defineSandboxEnvironment | Declare a workflow whose purpose is to build a snapshot for others to boot from |
| agent / agentLoop | Embed an agent loop inside a workflow |
| runWorkflow | Local engine for running a workflow in-process (test harness) |
| bundleWorkflow | Bundle and validate a workflow for registration (Bun) |
| claudeRuntime, codexRuntime, … and their create… factories | Built-in runtimes (table above) |
| AgentComposeClient | HTTP client: register, invoke, cancel, stream, factories, snapshots, secrets, API keys, usage, conversations, sessions, projects |
| AgentComposeError | Thrown by every non-2xx HTTP response |
| parseAgentStatus / parseAgentResponse / AgentStatusSchema / AgentMessageSchema | Protocol parsers |
| parseSseStream | Generic SSE decoder (used by streamRunLogs) |
| createSandbox / reconnectSandbox / killAllSandboxes / killSandboxById / getSandboxQuotas / listOwnedSandboxes / deleteSandboxSnapshot | Sandbox-provider helpers (Vercel and E2B) |
Type exports include Workflow, Step, StepContext, WorkflowBuilder,
WorkflowHooks, AgentBudget, AgentRuntime, RuntimeOptions,
ModelExecutionContract, McpServerConfig, AgentMessage (and its
variants), AgentStatus, RunStatus, RegisterResult, RunEvent,
FactoryRow, SnapshotListEntry, ApiKey, ApiKeyCreated,
UsageRollupRow, UsageResponse, CancelRunResponse, AgentLoopResult,
AgentOpts, SandboxProvider, DesktopSandboxProvider,
SandboxNetworkPolicy, SandboxCreateOpts, OwnedSandbox and
BundledWorkflow.
For the exact signatures, follow your IDE's go-to-definition into
@agent-compose/sdk: sdk/src/index.ts is the public surface, and the
files it re-exports from carry full docstrings.
Errors
Every non-2xx HTTP response throws AgentComposeError. It carries status
and a message taken from the server's { error: string } body when
present (else the HTTP status text), plus code and capability on an
authorization denial and retryAfterMs when the server sent Retry-After:
import { AgentComposeError } from "@agent-compose/sdk";
try {
await client.invoke("missing-workflow");
} catch (err) {
if (err instanceof AgentComposeError && err.status === 404) {
// template not registered
}
}invokeAndWait throws AgentComposeError(504, …) on timeout.
