@agent-compose/sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # @agent-compose/sdk
2
2
 
3
- Client library for building against agent-compose. Provides TypeScript types for defining agents, runtimes, and workflows, plus an HTTP client that auto-discovers dependencies and registers everything with a single call.
3
+ TypeScript SDK for [agent-compose](https://github.com/Layr-Labs/agent-compose). Use it to:
4
+
5
+ - **Author workflows** that run agentic LLM loops inside isolated sandboxes
6
+ - **Define runtimes** that wrap a coding-CLI tool (Claude Code, OpenAI Desktop, …) into a sandbox-portable agent loop
7
+ - **Register, invoke, observe, and cancel** workflows via the HTTP API (`AgentComposeClient`)
8
+ - **Manage factories, secrets, API keys, and snapshots** programmatically
9
+
10
+ The hierarchy: a **team** owns one or more **factories** (project containers); each factory owns workflow templates, secrets, and runs. Workflows are versioned per `(factory, name, version)`. New code that doesn't care about factories transparently lands in `default` — every team has one.
4
11
 
5
12
  ---
6
13
 
@@ -14,139 +21,428 @@ npm install zod
14
21
 
15
22
  ---
16
23
 
17
- ## Defining an Agent
24
+ ## Authoring a workflow
25
+
26
+ A workflow is `async (ctx, sandbox) => T`. Two positional args:
27
+
28
+ - **`ctx`** carries the run identity (`run.id`), the caller's `input`, plus
29
+ observability helpers (`setMetadata`, `step`).
30
+ - **`sandbox`** is a capability the engine constructs once for the run —
31
+ pass it to `runAgent({ sandbox, ... })` and to any helper that takes a
32
+ `SandboxProvider` (file writers, git utilities, command runners).
18
33
 
19
34
  ```typescript
20
- // my-agents/planner.ts
21
- import { defineAgent } from "@agent-compose/sdk";
22
-
23
- export default defineAgent({
24
- name: "planner",
25
- runtime: "claude", // registered runtime name
26
- prompt: "Explore the codebase and write a plan...",
27
- budget: { turnsPerIteration: 70, maxIterations: 1 },
35
+ // my-workflow.ts
36
+ import { defineWorkflow, runAgent, claudeRuntime } from "@agent-compose/sdk";
37
+ import PROMPT from "./prompt.md" with { type: "text" };
38
+
39
+ export default defineWorkflow({
40
+ async run(ctx, sandbox) {
41
+ const repo = (ctx.input?.repo as string | undefined) ?? "owner/repo";
42
+
43
+ const result = await runAgent({
44
+ sandbox,
45
+ runtime: claudeRuntime,
46
+ prompt: PROMPT,
47
+ promptVars: { REPO: repo },
48
+ tools: ["Bash", "Read", "Edit", "Write", "Grep", "Glob"],
49
+ budget: { turnsPerIteration: 40, maxIterations: 8 },
50
+ });
51
+
52
+ await ctx.setMetadata({ summary: result.status?.summary });
53
+ return { ok: result.status?.completed ?? false };
54
+ },
55
+ // Optional: outbound network rules that the runner sandbox will enforce
56
+ // (Vercel only — E2B ignores). Use `$VAR` placeholders for secrets that
57
+ // get resolved from the per-workflow secret store at dispatch time.
58
+ networkPolicy: {
59
+ allow: {
60
+ "*": [],
61
+ "api.anthropic.com": [{ transform: [{ headers: { "x-api-key": "$ANTHROPIC_API_KEY" } }] }],
62
+ },
63
+ },
28
64
  });
29
65
  ```
30
66
 
31
- ---
32
-
33
- ## Defining a Runtime
67
+ `defineWorkflow` is a thin sugar — it returns the bare `run` function with
68
+ `networkPolicy` / `placeholders` / `snapshot` / `saveSnapshot` attached as
69
+ metadata that the bundler picks up at registration time. A plain
70
+ `export default async (ctx, sandbox) => {...}` is also valid; you just lose
71
+ the metadata channel.
72
+
73
+ ### What the workflow can do with `ctx`
74
+
75
+ ```ts
76
+ interface WorkflowCtx {
77
+ run: { id: string };
78
+ input?: Record<string, unknown>;
79
+ setMetadata: (data: Record<string, unknown>) => Promise<void>;
80
+ step<T>(name: string, fn: () => Promise<T>): Promise<T>;
81
+ }
82
+ ```
34
83
 
35
- ```typescript
36
- // my-runtimes/claude.ts
37
- import { defineRuntime } from "@agent-compose/sdk";
38
- // ClaudeRunner is provided by the server — import from your server package
39
- import { ClaudeRunner } from "@agent-compose/server/runtimes/claude.js";
40
-
41
- export default defineRuntime({
42
- provider: "claude-code", // must match what createAgentSandbox knows about
43
- create: (sandbox, opts) => new ClaudeRunner(sandbox, opts),
44
- });
84
+ `step("phase-name", () => …)` wraps a phase for the run timeline — emits
85
+ `step_started` / `step_completed` / `step_failed` lifecycle events with
86
+ duration. Use it for setup, external API calls, or anything you want
87
+ visible on the dashboard's run detail page.
88
+
89
+ ### The `runAgent` loop
90
+
91
+ ```ts
92
+ runAgent({
93
+ sandbox, // the workflow's sandbox arg
94
+ runtime, // claudeRuntime, or your own via createClaudeRuntime / defineRuntime
95
+ prompt, // raw markdown — `--- frontmatter ---` is auto-stripped
96
+ promptVars?, // {{VAR}} substitutions; WORKING_DIR + DIFF_BASE auto-populate
97
+ tools?, // model tool allowlist (defaults inside agentLoop)
98
+ budget?, // { turnsPerIteration, maxIterations }
99
+ workingDir?, // every shell command runs here
100
+ responseSchema?, // zod — when set, the loop demands a `<response>` block on exit
101
+ onAgentEvent?, // per-message hook (e.g. wire to telemetry)
102
+ onIteration?, // per-iteration hook with parsed `<status>` block
103
+ })
104
+ // → AgentLoopResult { status?, response? (when responseSchema set), iterations, … }
45
105
  ```
46
106
 
107
+ The protocol is simple: the model emits XML-tagged blocks (`<status>` /
108
+ `<response>`) the loop parses. See `sdk/src/agent/protocol-suffix.md` for
109
+ the full instructions appended to every prompt.
110
+
47
111
  ---
48
112
 
49
- ## Writing a Workflow
113
+ ## Defining a runtime
50
114
 
51
- ```typescript
52
- // my-workflow.ts
53
- import type { WorkflowFn } from "@agent-compose/sdk";
115
+ A "runtime" wraps an agent's underlying execution model — usually a coding
116
+ CLI like Claude Code or OpenAI Desktop — so `runAgent` can drive it. The SDK
117
+ ships built-ins; you only need a custom one for an exotic provider.
54
118
 
55
- const myWorkflow: WorkflowFn = async (ctx) => {
56
- const planner = await ctx.spawnAgent("planner", {
57
- data: { task: ctx.input.task },
58
- });
119
+ ### Built-in runtimes
59
120
 
60
- await ctx.spawnAgent("implementer", {
61
- input: planner, // chains branch + response
62
- data: { task: ctx.input.task },
63
- });
121
+ ```ts
122
+ import {
123
+ createClaudeRuntime, // factory, takes config
124
+ claudeRuntime, // pre-built default (DEFAULT_CLAUDE_MODEL)
125
+ ClaudeRunner, // class, if you need to override
126
+ } from "@agent-compose/sdk";
64
127
 
65
- await ctx.setMetadata({ done: true });
66
- };
128
+ // openAIDesktopRuntime is NOT in the package root (it pulls in `sharp` for
129
+ // screenshot capture; the native binding can't be cross-compiled). Import
130
+ // directly when you actually want the desktop runtime:
131
+ import openAIDesktopRuntime from "@agent-compose/sdk/runtimes/openai-desktop.js";
132
+ ```
67
133
 
68
- export default myWorkflow;
134
+ ### Custom runtime
135
+
136
+ ```ts
137
+ import { defineRuntime, type AgentRuntime } from "@agent-compose/sdk";
138
+
139
+ const myRuntime: AgentRuntime = defineRuntime({
140
+ create: (sandbox, opts) => {
141
+ // Return a ModelExecutionContract — see sdk/src/types/runtime.ts
142
+ return {
143
+ sendMessage({ prompt, sessionId, signal }) {
144
+ // Async generator that yields AgentMessage chunks the loop parses.
145
+ return /* … */;
146
+ },
147
+ };
148
+ },
149
+ });
69
150
  ```
70
151
 
152
+ `AgentRuntime` is a tagged record with `create(sandbox, RuntimeOptions) →
153
+ ModelExecutionContract`. There is **no `provider` field** on it — the
154
+ runtime is bound to the workflow at author time (you pass it to `runAgent`),
155
+ not selected by the server.
156
+
71
157
  ---
72
158
 
73
- ## Registration
159
+ ## Registering a workflow
74
160
 
75
- `AgentComposeClient.register()` reads the workflow file, scans for `ctx.spawnAgent("name")` calls, resolves each agent's source file, reads `runtime: "name"` from each agent, resolves runtime sources — then bundles and POSTs everything in one call.
161
+ The `agentc` CLI handles the bundling-and-registration step for you:
76
162
 
77
- ```typescript
78
- import { AgentComposeClient } from "@agent-compose/sdk";
163
+ ```bash
164
+ agentc register my-workflow.ts -n my-workflow
165
+ ```
166
+
167
+ Under the hood that calls `bundleWorkflow(workflowPath)` (resolves imports,
168
+ inlines runtime sources via dynamic-require traversal) and `POST
169
+ /api/v1/factories/<slug>/templates` with the bundled source. If you need
170
+ to drive registration from your own build pipeline, you can do the same
171
+ thing via the SDK directly:
172
+
173
+ ```ts
174
+ import { AgentComposeClient, bundleWorkflow } from "@agent-compose/sdk";
79
175
 
80
176
  const client = new AgentComposeClient(
81
- "http://localhost:8080",
82
- process.env.API_KEY!,
177
+ "https://your-server.example.com",
178
+ process.env.AGENT_COMPOSE_API_KEY!,
83
179
  );
84
180
 
85
- // Auto-discovers agents and runtimes from the workflow file
181
+ const bundled = await bundleWorkflow("./my-workflow.ts");
86
182
  await client.register({
87
- name: "my-workflow",
88
- workflowPath: "./my-workflow.ts",
89
- // agentPaths and runtimePaths are optional overrides if auto-discovery fails
183
+ name: "my-workflow",
184
+ source: bundled.source,
185
+ runtimes: bundled.runtimes, // [{ name, source }] — embedded so the runner has them locally
186
+ schedule: "*/30 * * * *", // optional cron
187
+ factorySlug: "default", // optional — defaults to "default"
188
+ // snapshot, saveSnapshot, networkPolicy, placeholders — all optional
90
189
  });
91
190
  ```
92
191
 
93
- **Auto-discovery convention** — for agent `"planner"`, the client looks for:
94
- - `./planner.ts`
95
- - `./planner/index.ts`
96
- - `./agents/planner.ts`
97
- - `./agents/planner/index.ts`
98
-
99
- For runtime `"claude"`, it looks for:
100
- - `./claude.ts`
101
- - `./runtimes/claude.ts`
192
+ `register()` requires the caller's API key to carry the `admin` scope
193
+ (or full team-access for legacy keys without scopes).
102
194
 
103
195
  ---
104
196
 
105
- ## Invocation
197
+ ## Invoking a workflow
106
198
 
107
- ```typescript
108
- // Invoke a registered workflow (returns immediately with a run ID)
199
+ Two flavours:
200
+
201
+ ```ts
202
+ // Fire-and-forget — returns the run id immediately.
109
203
  const { id } = await client.invoke("my-workflow", {
110
- task: "Add rate limiting to the /projects endpoint",
204
+ repo: "owner/repo",
205
+ });
206
+
207
+ // Block until the run settles (default 30min timeout, 1s poll).
208
+ const status = await client.invokeAndWait("my-workflow", { repo: "owner/repo" }, {
209
+ timeoutMs: 5 * 60_000,
210
+ pollIntervalMs: 2000,
111
211
  });
212
+ console.log(status.status); // "success" | "failed" | "abandoned" | "canceled"
213
+ console.log(status.output); // workflow's return value
214
+ ```
215
+
216
+ `output` is the workflow's `run()` return value (whatever `defineWorkflow({
217
+ async run() { return … } })` resolves to). `setMetadata()` writes to a
218
+ separate `metadata` field — useful for "side-channel" facts (PR url, plan
219
+ url) without polluting the structured return.
220
+
221
+ `invoke` and `invokeAndWait` both accept `{ factorySlug, snapshot,
222
+ saveSnapshot, parentRunId }` as the third argument. `factorySlug` defaults
223
+ to `"default"`.
224
+
225
+ ### Auto parent/child tracing
226
+
227
+ The SDK detects `process.env.RUN_ID` (set by the runner sandbox on every
228
+ dispatch) and automatically threads it as `parentRunId` on subsequent
229
+ `invoke()` calls. Workflows that fan out to other workflows get a
230
+ parent/child tree in the dashboard for free. Pass `parentRunId: null`
231
+ to opt out.
232
+
233
+ ### Cancelling a run
234
+
235
+ ```ts
236
+ await client.cancelRun(runId);
237
+ ```
238
+
239
+ Idempotent — cancelling an already-terminal run returns the current state
240
+ without throwing. The server stamps the run as `canceled`, kills any live
241
+ sandboxes, and emits a `run_canceled` event on the stream.
242
+
243
+ ### Streaming live logs
112
244
 
113
- // Poll until done
114
- let status;
115
- do {
116
- await new Promise(r => setTimeout(r, 5000));
117
- status = await client.getStatus(id);
118
- } while (status.status === "running");
245
+ `streamRunLogs` returns an async generator of `RunEvent`s in real time,
246
+ re-attaching via SSE under the hood. Pass `lastEventId` (the highest
247
+ `seq` you've already processed) to resume after a reconnect.
119
248
 
120
- console.log(status.output); // workflow's setMetadata() payload
249
+ ```ts
250
+ for await (const ev of client.streamRunLogs(runId, { lastEventId: 0 })) {
251
+ console.log(ev.event, ev.seq, ev.data);
252
+ if (ev.event === "run_complete" || ev.event === "run_failed" || ev.event === "run_canceled") {
253
+ break;
254
+ }
255
+ }
121
256
  ```
122
257
 
258
+ `AbortSignal` works too — pass `{ signal }` and call `controller.abort()`
259
+ to tear the stream down from the caller side.
260
+
123
261
  ---
124
262
 
125
- ## Exported Types
263
+ ## Factories
264
+
265
+ Factories are project containers within a team. Each factory has its own
266
+ workflow templates, secrets, runs, and (optionally) scoped API keys. New
267
+ projects don't need to think about them — `default` is auto-created per
268
+ team and is what the SDK falls back to when `factorySlug` is omitted.
269
+
270
+ ```ts
271
+ // CRUD on factories
272
+ await client.createFactory({ slug: "ci-bots", name: "CI Bots", description: "…" });
273
+ const factories = await client.listFactories();
274
+ const f = await client.getFactory("ci-bots");
275
+ await client.updateFactory("ci-bots", { name: "Continuous-Integration Bots" });
276
+ await client.deleteFactory("ci-bots");
277
+
278
+ // Templates list — flat across factories, or scoped to one
279
+ const all = await client.listTemplates();
280
+ const scoped = await client.listTemplates({ factorySlug: "ci-bots" });
281
+
282
+ // Register / invoke / secret operations all accept factorySlug
283
+ await client.register({ name: "scrape", source, factorySlug: "ci-bots", … });
284
+ await client.invoke("scrape", { url: "…" }, { factorySlug: "ci-bots" });
285
+ await client.setSecret("scrape", "GH_TOKEN", "ghp_…", { factorySlug: "ci-bots" });
286
+ ```
126
287
 
127
- ```typescript
128
- // Factory functions
129
- defineAgent(pkg: AgentDefinition): AgentDefinition
130
- defineRuntime(pkg: AgentRuntime): AgentRuntime
288
+ CLI equivalents: `agentc factory list | create | get | update | delete`,
289
+ plus `--factory <slug>` on every other command.
290
+
291
+ ---
292
+
293
+ ## Per-workflow secrets
294
+
295
+ Secrets live in GCP Secret Manager, one row per `(factory, workflow, key)`.
296
+ They're injected as env vars into the runner sandbox at dispatch time,
297
+ never persisted in the VM. Values are write-only — the API only returns
298
+ metadata (key, timestamps).
299
+
300
+ ```ts
301
+ await client.setSecret("my-workflow", "ANTHROPIC_API_KEY", process.env.ANTHROPIC_API_KEY!);
302
+ const list = await client.listSecrets("my-workflow"); // [{ key, createdAt, updatedAt }]
303
+ await client.deleteSecret("my-workflow", "STALE_KEY");
131
304
 
132
- // Workflow authoring
133
- type WorkflowFn = (ctx: WorkflowCtx) => Promise<void>
134
- interface WorkflowCtx { spawnAgent, setMetadata, sendMessage, input, run, cost }
305
+ // Scope to a non-default factory:
306
+ await client.setSecret("scrape", "GH_TOKEN", "ghp_…", { factorySlug: "ci-bots" });
307
+ ```
308
+
309
+ Mutations require `admin` scope.
310
+
311
+ ---
312
+
313
+ ## API keys
314
+
315
+ Mint and list scoped keys programmatically (requires an `admin`-scoped
316
+ caller key). New keys are returned **once**, in the same response as the
317
+ metadata — copy the `ac_…` value immediately.
318
+
319
+ ```ts
320
+ const created = await client.createApiKey({
321
+ name: "ci-dispatcher",
322
+ scopes: ["read", "invoke"],
323
+ expiresAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), // 30 days
324
+ // factorySlug: "ci-bots" // optional — scopes the key to a single factory
325
+ });
326
+ console.log(created.key); // "ac_…" — the only time you'll see this
327
+
328
+ const all = await client.listApiKeys();
329
+ ```
330
+
331
+ CLI equivalent: `agentc keys create <name> --scopes read,invoke
332
+ --expires-in 30d`.
333
+
334
+ ---
335
+
336
+ ## Usage
337
+
338
+ ```ts
339
+ const usage = await client.getUsage(
340
+ new Date(Date.now() - 30 * 86_400_000),
341
+ new Date(),
342
+ );
343
+ // usage.rows: [{ day, runs, sandbox_seconds, … }]
344
+ ```
345
+
346
+ CLI equivalent: `agentc usage`.
347
+
348
+ ---
349
+
350
+ ## Snapshots (replay-friendly sandboxes)
351
+
352
+ Long-running workflows can capture the runner sandbox as a Vercel snapshot
353
+ on success (`saveSnapshot: true`). Other workflows reference that snapshot
354
+ via the `snapshot` field to boot into the same prepared VM (deps installed,
355
+ repo cloned, etc.) instead of repeating setup.
135
356
 
136
- // Agent types
137
- interface AgentDefinition { name, runtime, prompt, budget?, tools?, onStart?, onFinish? }
138
- interface AgentOpts { budget?, data?, input?, branch?, onStart?, onFinish? }
139
- interface AgentResult<T> { response: T; session: AgentSession }
357
+ ```ts
358
+ // Capture per-invocation:
359
+ await client.invoke("my-workflow", input, { saveSnapshot: true });
140
360
 
141
- // Runtime types
142
- interface AgentRuntime<S> { provider: string; create(sandbox: S, opts): ModelExecutionContract }
143
- interface ModelExecutionContract { sendMessage(opts): AsyncGenerator<AgentMessage> }
361
+ // Boot from a different snapshot per invocation (per-invocation override):
362
+ await client.invoke("my-workflow", input, { snapshot: "<run-id-or-name>" });
144
363
 
145
- // Protocol
146
- interface AgentStatus { summary, completed, blockers, exit_signal }
147
- type AgentMessage = AgentMessageInit | AgentMessageText | AgentMessageToolUse | ...
364
+ // Or set a default at registration time:
365
+ defineWorkflow({ run, saveSnapshot: true });
148
366
 
149
- // Sandbox
150
- interface SandboxProvider { sandboxId, cwd?, commands, files, kill() }
151
- interface DesktopSandboxProvider extends SandboxProvider { screenshot(), leftClick(), ... }
367
+ // Browse / clean up:
368
+ const snaps = await client.listSnapshots({ workflow: "my-workflow", limit: 50 });
369
+ await client.deleteSnapshot(snaps[0].runId);
152
370
  ```
371
+
372
+ CLI equivalents: `agentc snapshot list` / `agentc snapshot delete <run-id>`.
373
+
374
+ ---
375
+
376
+ ## Authentication
377
+
378
+ The SDK accepts a Bearer API key (`ac_…`). Mint one from the dashboard:
379
+ sign in at `<server-url>/login`, then **Settings → API Keys → Create key**.
380
+
381
+ Default scopes (`read + invoke`) are right for a CI / dispatch caller. Tick
382
+ `admin` only if this key needs to register templates, mint other keys, or
383
+ manage secrets.
384
+
385
+ ```ts
386
+ const client = new AgentComposeClient(
387
+ process.env.AGENT_COMPOSE_URL!,
388
+ process.env.AGENT_COMPOSE_API_KEY!,
389
+ );
390
+ ```
391
+
392
+ The dashboard itself uses the cookie-bound session path; the SDK is for
393
+ programmatic / server-to-server callers.
394
+
395
+ ---
396
+
397
+ ## Public exports — quick reference
398
+
399
+ | Export | What |
400
+ |---|---|
401
+ | `defineWorkflow` | Attach metadata to a workflow `run` function |
402
+ | `defineRuntime` | Wrap an agent execution provider as an `AgentRuntime` |
403
+ | `defineSandboxEnvironment` | Sugar for declaring a workflow whose primary purpose is to build a snapshot for others to boot from |
404
+ | `runAgent` / `agentLoop` | Embed an LLM loop inside a workflow |
405
+ | `runWorkflow` | Local engine for running a workflow in-process (test harness) |
406
+ | `bundleWorkflow` | Resolve + inline a workflow's runtime sources for registration |
407
+ | `claudeRuntime` / `createClaudeRuntime` / `ClaudeRunner` | Built-in Claude Code runtime + factory |
408
+ | `AgentComposeClient` | HTTP client — register, invoke, cancel, stream logs, factories, snapshots, secrets, API keys, usage |
409
+ | `AgentComposeError` | Thrown by every non-2xx HTTP response |
410
+ | `parseAgentStatus` / `parseAgentResponse` / `AgentStatusSchema` / `AgentMessageSchema` | Protocol parsers |
411
+ | `parseSseStream` | Generic SSE chunk decoder (used by `streamRunLogs`) |
412
+ | `createSandbox` / `reconnectSandbox` / `killAllSandboxes` / `killSandboxById` / `getSandboxQuotas` / `listOwnedSandboxes` / `deleteSandboxSnapshot` | Sandbox-provider helpers (Vercel + E2B) |
413
+
414
+ Type exports: `WorkflowFn`, `WorkflowCtx`, `WorkflowDefinition`,
415
+ `WorkflowHooks`, `AgentBudget`, `AgentRuntime`, `RuntimeOptions`,
416
+ `ModelExecutionContract`, `McpServerConfig`, `AgentMessage` (and its
417
+ variants), `AgentStatus`, `RunStatus`, `RegisterResult`, `RunEvent`,
418
+ `FactoryRow`, `SnapshotListEntry`, `ApiKey`, `ApiKeyCreated`,
419
+ `UsageRollupRow`, `UsageResponse`, `CancelRunResponse`, `AgentLoopResult`,
420
+ `RunAgentOpts`, `SandboxProvider`, `DesktopSandboxProvider`,
421
+ `SandboxNetworkPolicy`, `SandboxCreateOpts`, `OwnedSandbox`,
422
+ `BundledWorkflow`.
423
+
424
+ For the canonical signatures, follow your IDE's go-to-definition into
425
+ `@agent-compose/sdk` — `sdk/src/index.ts` is the public surface and the
426
+ files it re-exports from carry full inline docstrings.
427
+
428
+ ---
429
+
430
+ ## Errors
431
+
432
+ All non-2xx HTTP responses throw `AgentComposeError(status, message)`. The
433
+ `message` is the server's `{ error: string }` body when present, falling
434
+ back to the HTTP status text:
435
+
436
+ ```ts
437
+ import { AgentComposeError } from "@agent-compose/sdk";
438
+
439
+ try {
440
+ await client.invoke("missing-workflow");
441
+ } catch (err) {
442
+ if (err instanceof AgentComposeError && err.status === 404) {
443
+ // template not registered
444
+ }
445
+ }
446
+ ```
447
+
448
+ `invokeAndWait` throws `AgentComposeError(504, …)` on timeout for symmetry.