@agent-compose/sdk 0.2.0 → 0.2.2

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.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * runAgent — canonical entry point for embedding an LLM agent inside a
3
+ * workflow. The workflow's `run()` body calls it; the loop executes
4
+ * against the runner's own VM.
5
+ *
6
+ * Glue packaged so workflows don't duplicate it:
7
+ * - Inject `{{VAR}}` placeholders into the prompt template.
8
+ * - Strip the `--- frontmatter ---` header authors use for IDE hints.
9
+ * - Append PROTOCOL_SUFFIX (status/response format instructions).
10
+ * - Append a response-format appendix when `responseSchema` is set.
11
+ * - Delegate to `agentLoop`.
12
+ */
13
+
14
+ import { z } from "zod";
15
+ import { agentLoop } from "./agent-loop.js";
16
+ import type { AgentLoopResult } from "./agent-loop.js";
17
+ import type { AgentMessage, AgentStatus } from "../types/protocol.js";
18
+ import type { AgentRuntime, RuntimeOptions } from "../types/runtime.js";
19
+ import type { SandboxProvider } from "../types/sandbox.js";
20
+ import type { AgentBudget } from "../types/workflow.js";
21
+ import PROTOCOL_SUFFIX_RAW from "./protocol-suffix.md" with { type: "text" };
22
+
23
+ const PROTOCOL_SUFFIX = stripFrontmatter(PROTOCOL_SUFFIX_RAW);
24
+
25
+ function stripFrontmatter(content: string): string {
26
+ if (!content.startsWith("---")) return content;
27
+ const end = content.indexOf("\n---", 3);
28
+ return end === -1 ? content : content.slice(end + 4).trimStart();
29
+ }
30
+
31
+ function inject(template: string, vars: Record<string, string>): string {
32
+ return Object.entries(vars).reduce((t, [k, v]) => t.replaceAll(`{{${k}}}`, v), template);
33
+ }
34
+
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ function zodTypeName(schema: any): string {
37
+ if (schema instanceof z.ZodString) return "string";
38
+ if (schema instanceof z.ZodNumber) return "number";
39
+ if (schema instanceof z.ZodBoolean) return "boolean";
40
+ if (schema instanceof z.ZodArray) return `${zodTypeName(schema.element)}[]`;
41
+ if (schema instanceof z.ZodEnum) return (schema.options as string[]).map((o: string) => `"${o}"`).join(" | ");
42
+ if (schema instanceof z.ZodOptional) return `${zodTypeName(schema.unwrap())} (optional)`;
43
+ if (schema instanceof z.ZodNullable) return `${zodTypeName(schema.unwrap())} | null`;
44
+ if (schema instanceof z.ZodLiteral) return `"${String(schema.value)}"`;
45
+ if (schema instanceof z.ZodObject) return "object";
46
+ return "any";
47
+ }
48
+
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ function buildResponseFormatAppendix(schema: z.ZodType<any>): string {
51
+ const header = ["## Response Format", "", "When you emit `exit_signal: true` in your `<status>` block, also include a `<response>` block containing JSON immediately after `<status>`:", ""];
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ const shape: Record<string, any> | null =
54
+ schema instanceof z.ZodObject ? (schema as z.ZodObject<z.ZodRawShape>).shape : null;
55
+ const body = shape
56
+ ? (() => {
57
+ const entries = Object.entries(shape);
58
+ const example = Object.fromEntries(entries.map(([k, v]) => [k, `<${zodTypeName(v)}>`]));
59
+ return [
60
+ "Fields:",
61
+ ...entries.map(([k, v]) => `- \`${k}\` (${zodTypeName(v)})`),
62
+ "", "Example:", "<response>",
63
+ JSON.stringify(example, null, 2),
64
+ "</response>",
65
+ ];
66
+ })()
67
+ : [
68
+ "The response must match this schema:",
69
+ `Expected type: ${zodTypeName(schema)}`,
70
+ "", "Example:", "<response>",
71
+ `<${zodTypeName(schema)}>`,
72
+ "</response>",
73
+ ];
74
+ return [...header, ...body].join("\n");
75
+ }
76
+
77
+ export interface RunAgentOpts<T = unknown> {
78
+ /** Sandbox the runtime executes commands against. Inside a workflow,
79
+ * always pass `ctx.sandbox` — a pre-constructed local provider for
80
+ * the runner's own VM. Exposed as a parameter so tests and non-workflow
81
+ * callers can substitute their own. */
82
+ sandbox: SandboxProvider;
83
+ /** Runtime definition from `createClaudeRuntime({...})` (or custom). */
84
+ runtime: AgentRuntime;
85
+ /** Prompt template. Authors can include YAML-style `--- frontmatter ---`
86
+ * at the top for IDE hints; it's stripped before the model sees it. */
87
+ prompt: string;
88
+ /** Substitution map for `{{VAR}}` placeholders in the prompt. `WORKING_DIR`
89
+ * and `DIFF_BASE` auto-populate from `opts.workingDir` unless overridden. */
90
+ promptVars?: Record<string, string>;
91
+ /** `cwd` forwarded to the runtime — every shell command runs here. */
92
+ workingDir?: string;
93
+ /** Tools the model may use. Defaults to a safe kitchen-sink set inside
94
+ * `agentLoop`. Pass [] to disable tool use entirely. */
95
+ tools?: string[];
96
+ /** Turn/iteration caps. Defaults: 40 turns/iteration, 8 iterations. */
97
+ budget?: AgentBudget;
98
+ /** If set, the loop demands a `<response>` block when `exit_signal=true`
99
+ * and validates it against this schema. The response format appendix is
100
+ * appended to the prompt on first iteration. */
101
+ responseSchema?: z.ZodType<T>;
102
+ /** Label prefix for runtime stderr ("[sbid][agent]" by default). */
103
+ label?: string;
104
+ /** Per-message event callback — wire this to your workflow's event
105
+ * telemetry if you want per-tool-call observability. */
106
+ onAgentEvent?: (iteration: number, msg: AgentMessage) => void;
107
+ /** Per-iteration status callback — fires after each model turn with the
108
+ * parsed `<status>` block (or null if the model didn't emit one). */
109
+ onIteration?: (iteration: number, status: AgentStatus | null) => void;
110
+ }
111
+
112
+ /**
113
+ * Run an agent loop inside a workflow. Returns the loop's final
114
+ * `AgentLoopResult`, including `response` when a `responseSchema` was
115
+ * supplied and the model validated against it.
116
+ */
117
+ export async function runAgent<T = unknown>(opts: RunAgentOpts<T>): Promise<AgentLoopResult> {
118
+ const workingDir = opts.workingDir ?? "";
119
+ const promptVars = { WORKING_DIR: workingDir, DIFF_BASE: "HEAD~1", ...opts.promptVars };
120
+
121
+ return agentLoop({
122
+ runtime: (runtimeOpts: RuntimeOptions) => opts.runtime.create(opts.sandbox, runtimeOpts),
123
+ ...(opts.label !== undefined ? { label: opts.label } : {}),
124
+ ...(opts.budget?.turnsPerIteration !== undefined ? { turnsPerIteration: opts.budget.turnsPerIteration } : {}),
125
+ ...(opts.budget?.maxIterations !== undefined ? { maxIterations: opts.budget.maxIterations } : {}),
126
+ ...(opts.tools !== undefined ? { allowedTools: opts.tools } : {}),
127
+ ...(opts.responseSchema !== undefined ? { responseSchema: opts.responseSchema } : {}),
128
+ ...(workingDir ? { cwd: workingDir } : {}),
129
+ buildPrompt: (_lastStatus, iteration) => {
130
+ // Mid-loop: session resumes via --resume, model has history; only the
131
+ // protocol suffix is needed so tool-use + status block grammar stays fresh.
132
+ if (iteration > 0) return PROTOCOL_SUFFIX;
133
+ const base = inject(stripFrontmatter(opts.prompt), promptVars);
134
+ return opts.responseSchema
135
+ ? `${base}\n\n${PROTOCOL_SUFFIX}\n\n${buildResponseFormatAppendix(opts.responseSchema)}`
136
+ : `${base}\n\n${PROTOCOL_SUFFIX}`;
137
+ },
138
+ ...(opts.onAgentEvent ? { onAgentEvent: opts.onAgentEvent } : {}),
139
+ ...(opts.onIteration ? { onIteration: opts.onIteration } : {}),
140
+ });
141
+ }
package/src/client.ts ADDED
@@ -0,0 +1,446 @@
1
+ /**
2
+ * AgentComposeClient — HTTP client for the agent-compose server API.
3
+ *
4
+ * Templates always live inside a factory (project). Methods that touch a
5
+ * specific template (`register`, `invoke`, `setSecret`, `listSecrets`,
6
+ * `deleteSecret`) take an optional `factorySlug`; when omitted, they target
7
+ * the team's auto-created `default` factory. There is no second URL space
8
+ * for "templates without a factory" — every workflow belongs to exactly one.
9
+ *
10
+ * `register()` accepts pre-built sources — use the CLI (`agent-compose
11
+ * register`) or build sources yourself and pass them directly.
12
+ */
13
+
14
+ import { ofetch } from "ofetch";
15
+ import { AgentComposeError } from "./errors.js";
16
+ import { parseSseStream } from "./sse.js";
17
+ import type { RunEvent } from "./types/events.js";
18
+ import type { SandboxNetworkPolicy } from "./sandbox.js";
19
+
20
+ /** UUID-v4-ish — matches the server-side predicate. Used to auto-detect
21
+ * that `process.env.RUN_ID` was injected by the runner sandbox (rather
22
+ * than being set by accident), so we only propagate it when it looks real. */
23
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+
25
+ /** Default factory slug — every team gets one auto-created at sign-up.
26
+ * Methods that don't take an explicit `factorySlug` resolve to this. */
27
+ const DEFAULT_FACTORY = "default";
28
+
29
+ /** Read-once cached parentRunId from `process.env.RUN_ID` at module load.
30
+ * `null` if unset or malformed. Exported so tests can override-and-reset. */
31
+ function detectAmbientParentRunId(): string | null {
32
+ const envRunId = (typeof process !== "undefined" ? process.env?.RUN_ID : undefined);
33
+ return envRunId && UUID_REGEX.test(envRunId) ? envRunId : null;
34
+ }
35
+
36
+ /** Build the `/api/v1/factories/:slug/templates/...` prefix. Slug + name are
37
+ * URL-encoded as defense-in-depth: today's server-side patterns
38
+ * (`^[a-z][a-z0-9-]*$`) can't produce reserved characters, but a future
39
+ * relaxation would otherwise quietly become an injection vector. */
40
+ function templatePath(factorySlug: string, ...rest: string[]): string {
41
+ const tail = rest.length > 0
42
+ ? "/" + rest.map(encodeURIComponent).join("/")
43
+ : "";
44
+ return `/api/v1/factories/${encodeURIComponent(factorySlug)}/templates${tail}`;
45
+ }
46
+
47
+ export interface RegisterResult {
48
+ id: string;
49
+ name: string;
50
+ version: string;
51
+ runtimes?: Array<{ name: string; version: string; id: string }>;
52
+ }
53
+
54
+ export interface RunStatus {
55
+ id: string;
56
+ status: "running" | "success" | "failed" | "abandoned" | "canceled";
57
+ output?: Record<string, unknown>;
58
+ }
59
+
60
+ /** Row shape returned by `GET /api-keys`. */
61
+ export interface ApiKey {
62
+ object: "api_key";
63
+ id: string;
64
+ name: string | null;
65
+ last4: string | null;
66
+ scopes: string[];
67
+ teamId: string;
68
+ createdByUserId: string | null;
69
+ /** Non-null when the key is restricted to a single factory. */
70
+ factoryId: string | null;
71
+ createdAt: string;
72
+ expiresAt: string | null;
73
+ lastUsedAt: string | null;
74
+ revokedAt: string | null;
75
+ }
76
+
77
+ /** Response from `POST /api-keys`. The `key` field is the plaintext token —
78
+ * shown once at creation, never retrievable again. */
79
+ export interface ApiKeyCreated extends ApiKey { key: string }
80
+
81
+ /** Single rollup row from `GET /api/v1/usage`. */
82
+ export interface UsageRollupRow {
83
+ eventType: string;
84
+ unit: string;
85
+ total: number;
86
+ tags: Record<string, unknown>;
87
+ }
88
+
89
+ /** Response from `GET /api/v1/usage`. */
90
+ export interface UsageResponse {
91
+ object: "list";
92
+ data: UsageRollupRow[];
93
+ has_more: boolean;
94
+ from: string | null;
95
+ to: string | null;
96
+ }
97
+
98
+ /** Response from `POST /api/v1/workflows/:id/cancel`. The endpoint is
99
+ * idempotent: cancelling a run that's already terminal returns its current
100
+ * outcome verbatim (rather than throwing or pretending it just canceled),
101
+ * so `status` widens to every terminal value the server might surface. */
102
+ export interface CancelRunResponse {
103
+ runId: string;
104
+ status: "canceled" | "success" | "failed" | "abandoned";
105
+ canceledAt: string;
106
+ }
107
+
108
+ export interface SnapshotListEntry {
109
+ runId: string;
110
+ workflow: string | null;
111
+ version: string | null;
112
+ vercelSnapshotId: string;
113
+ endedAt: string | null;
114
+ }
115
+
116
+ export class AgentComposeClient {
117
+ private readonly fetch: typeof ofetch;
118
+ private readonly baseUrl: string;
119
+ private readonly apiKey: string;
120
+
121
+ constructor(baseUrl: string, apiKey: string) {
122
+ this.baseUrl = baseUrl.replace(/\/$/, "");
123
+ this.apiKey = apiKey;
124
+ this.fetch = ofetch.create({
125
+ baseURL: this.baseUrl,
126
+ headers: { Authorization: `Bearer ${apiKey}` },
127
+ async onResponseError({ response }) {
128
+ const body = response._data as { error?: string } | undefined;
129
+ throw new AgentComposeError(response.status, body?.error ?? response.statusText);
130
+ },
131
+ });
132
+ }
133
+
134
+ /** Register (or update) a workflow template inside a factory. Defaults to
135
+ * the team's `default` factory when `factorySlug` is omitted. */
136
+ register(payload: {
137
+ name: string;
138
+ source: string;
139
+ version?: string;
140
+ schedule?: string;
141
+ runtimes?: Array<{ name: string; source: string }>;
142
+ networkPolicy?: unknown;
143
+ placeholders?: Record<string, string>;
144
+ /** Reference to a snapshot the runner should boot from at run start.
145
+ * A run UUID, workflow name, or `name@version`. The referenced
146
+ * workflow must have been registered with `--build` (or any prior
147
+ * successful run with `saveSnapshot: true`). Per-invocation
148
+ * `invoke({ snapshot })` overrides this default. */
149
+ snapshot?: string;
150
+ /** If true, runs default to capturing a long-lived sandbox snapshot on
151
+ * success. Individual invocations can override via
152
+ * `invoke(..., { saveSnapshot })`. */
153
+ saveSnapshot?: boolean;
154
+ /** Factory slug. Defaults to `"default"`. */
155
+ factorySlug?: string;
156
+ }): Promise<RegisterResult> {
157
+ const { factorySlug = DEFAULT_FACTORY, ...body } = payload;
158
+ return this.fetch(templatePath(factorySlug), { method: "POST", body });
159
+ }
160
+
161
+ /** Invoke a workflow. Returns run ID immediately — workflow runs asynchronously.
162
+ *
163
+ * `snapshot`: per-invocation override of the template-level `snapshot`
164
+ * field. Same forms — a run UUID, a workflow name, or `name@version`.
165
+ * Use this when one template needs to boot from many different
166
+ * snapshots (e.g. a benchmark workflow running against a different
167
+ * starting state per invocation). Does not change the template's
168
+ * registered default; only affects this run.
169
+ *
170
+ * `saveSnapshot`: `true` overrides the workflow default; `false` opts
171
+ * out. When the run captures a snapshot, its id is stamped on the row
172
+ * and can be referenced as the `snapshot` field on other workflows.
173
+ *
174
+ * `parentRunId`: links the new run to another of the same account's
175
+ * currently-running runs. When omitted, the client auto-detects from
176
+ * `process.env.RUN_ID` — set by the runner sandbox on every dispatch,
177
+ * so workflows invoking other workflows get the parent/child tree for
178
+ * free. Pass `null` to suppress auto-detection (e.g. invoking a top-level
179
+ * sibling workflow from inside a runner for a reason unrelated to the
180
+ * current run). Explicit non-null wins over auto-detection.
181
+ *
182
+ * `factorySlug`: defaults to `"default"`. */
183
+ invoke(
184
+ name: string,
185
+ input?: Record<string, unknown>,
186
+ opts?: {
187
+ snapshot?: string;
188
+ saveSnapshot?: boolean;
189
+ parentRunId?: string | null;
190
+ factorySlug?: string;
191
+ networkPolicy?: SandboxNetworkPolicy;
192
+ placeholders?: Record<string, string>;
193
+ },
194
+ ): Promise<{ id: string }> {
195
+ const parentRunId = opts?.parentRunId === undefined
196
+ ? detectAmbientParentRunId()
197
+ : opts.parentRunId;
198
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
199
+ return this.fetch(templatePath(factorySlug, name, "invoke"), {
200
+ method: "POST",
201
+ body: {
202
+ input,
203
+ ...(opts?.snapshot !== undefined ? { snapshot: opts.snapshot } : {}),
204
+ ...(opts?.saveSnapshot !== undefined ? { saveSnapshot: opts.saveSnapshot } : {}),
205
+ ...(parentRunId ? { parentRunId } : {}),
206
+ ...(opts?.networkPolicy !== undefined ? { networkPolicy: opts.networkPolicy } : {}),
207
+ ...(opts?.placeholders !== undefined ? { placeholders: opts.placeholders } : {}),
208
+ },
209
+ });
210
+ }
211
+
212
+ /** Invoke a workflow and wait for it to settle (success / failed / abandoned).
213
+ * Polls `getStatus` on a fixed interval. Rejects with `AgentComposeError`
214
+ * if the run settles non-success, or a plain `Error` on timeout.
215
+ *
216
+ * Defaults: `timeoutMs = 30min`, `pollIntervalMs = 1000ms`. Tune down for
217
+ * tests, tune up for long-running workflows. The parent-child auto-
218
+ * detection from `invoke()` applies here too. */
219
+ async invokeAndWait(
220
+ name: string,
221
+ input?: Record<string, unknown>,
222
+ opts?: {
223
+ snapshot?: string;
224
+ saveSnapshot?: boolean;
225
+ parentRunId?: string | null;
226
+ factorySlug?: string;
227
+ networkPolicy?: SandboxNetworkPolicy;
228
+ placeholders?: Record<string, string>;
229
+ timeoutMs?: number;
230
+ pollIntervalMs?: number;
231
+ },
232
+ ): Promise<RunStatus> {
233
+ const timeoutMs = opts?.timeoutMs ?? 30 * 60 * 1000;
234
+ const pollMs = opts?.pollIntervalMs ?? 1000;
235
+ const { id: runId } = await this.invoke(name, input, {
236
+ ...(opts?.snapshot !== undefined ? { snapshot: opts.snapshot } : {}),
237
+ ...(opts?.saveSnapshot !== undefined ? { saveSnapshot: opts.saveSnapshot } : {}),
238
+ ...(opts?.parentRunId !== undefined ? { parentRunId: opts.parentRunId } : {}),
239
+ ...(opts?.factorySlug !== undefined ? { factorySlug: opts.factorySlug } : {}),
240
+ ...(opts?.networkPolicy !== undefined ? { networkPolicy: opts.networkPolicy } : {}),
241
+ ...(opts?.placeholders !== undefined ? { placeholders: opts.placeholders } : {}),
242
+ });
243
+ const deadline = Date.now() + timeoutMs;
244
+ while (Date.now() < deadline) {
245
+ const status = await this.getStatus(runId);
246
+ if (status.status === "success" || status.status === "failed" || status.status === "abandoned") {
247
+ return status;
248
+ }
249
+ await new Promise((r) => setTimeout(r, pollMs));
250
+ }
251
+ // Use AgentComposeError (not plain Error) so catch-blocks handling SDK
252
+ // transport failures also handle timeouts uniformly. HTTP 504 is the
253
+ // closest idiomatic status for "upstream didn't answer in time."
254
+ throw new AgentComposeError(504, `invokeAndWait: run ${runId} did not settle within ${timeoutMs}ms`);
255
+ }
256
+
257
+ /** List runs this account has captured snapshots for. */
258
+ async listSnapshots(opts?: { workflow?: string; limit?: number }): Promise<SnapshotListEntry[]> {
259
+ const q = new URLSearchParams();
260
+ if (opts?.workflow) q.set("workflow", opts.workflow);
261
+ if (opts?.limit != null) q.set("limit", String(opts.limit));
262
+ const body = await this.fetch<{ object: "list"; data: SnapshotListEntry[]; has_more: boolean }>(
263
+ `/api/v1/snapshots${q.toString() ? `?${q}` : ""}`,
264
+ );
265
+ return body.data;
266
+ }
267
+
268
+ /** Delete the snapshot captured by a specific run. Frees Vercel storage. */
269
+ deleteSnapshot(runId: string): Promise<void> {
270
+ return this.fetch(`/api/v1/workflows/${runId}/snapshot`, { method: "DELETE" });
271
+ }
272
+
273
+ /** Poll run status. */
274
+ getStatus(runId: string): Promise<RunStatus> {
275
+ return this.fetch(`/api/v1/workflows/${runId}/status`);
276
+ }
277
+
278
+ /** List registered workflow templates. When `factorySlug` is supplied,
279
+ * scopes to that factory; otherwise returns every template the team can
280
+ * see across every factory in one round trip — each row carries its
281
+ * `factorySlug` so callers can route per-template actions to the right
282
+ * factory. */
283
+ async listTemplates(opts?: { factorySlug?: string }): Promise<Array<{ name: string; version: string; factorySlug: string }>> {
284
+ const path = opts?.factorySlug
285
+ ? templatePath(opts.factorySlug)
286
+ : "/api/v1/templates";
287
+ const body = await this.fetch<{ templates: Array<{ name: string; version: string; factorySlug: string }> }>(path);
288
+ return body.templates;
289
+ }
290
+
291
+ // ── Factories ──────────────────────────────────────────────────────────────
292
+ // Projects within a team. Workflows + secrets belong to exactly one factory.
293
+
294
+ /** List factories for the caller's team. */
295
+ async listFactories(): Promise<FactoryRow[]> {
296
+ const body = await this.fetch<{ factories: FactoryRow[] }>("/api/v1/factories");
297
+ return body.factories;
298
+ }
299
+
300
+ /** Create a factory. `slug` must be lowercase kebab-case and unique
301
+ * within the team. */
302
+ createFactory(payload: { slug: string; name: string; description?: string }): Promise<FactoryRow> {
303
+ return this.fetch<FactoryRow>("/api/v1/factories", { method: "POST", body: payload });
304
+ }
305
+
306
+ /** Get a factory by slug. */
307
+ getFactory(slug: string): Promise<FactoryRow> {
308
+ return this.fetch<FactoryRow>(`/api/v1/factories/${encodeURIComponent(slug)}`);
309
+ }
310
+
311
+ /** Rename or describe a factory. */
312
+ updateFactory(slug: string, updates: { name?: string; description?: string }): Promise<FactoryRow> {
313
+ return this.fetch<FactoryRow>(`/api/v1/factories/${encodeURIComponent(slug)}`, { method: "PATCH", body: updates });
314
+ }
315
+
316
+ /** Delete a factory. Refuses `default` and any factory still
317
+ * containing workflows. */
318
+ deleteFactory(slug: string): Promise<void> {
319
+ return this.fetch(`/api/v1/factories/${encodeURIComponent(slug)}`, { method: "DELETE" });
320
+ }
321
+
322
+ // ── Secrets ────────────────────────────────────────────────────────────────
323
+ // Scoped to (factory, workflow, key). Stored in GCP Secret Manager;
324
+ // `factorySlug` defaults to `"default"`.
325
+
326
+ /** Create or update a workflow secret. Value is stored in GCP Secret Manager. */
327
+ setSecret(workflowName: string, key: string, value: string, opts?: { factorySlug?: string }): Promise<{ key: string }> {
328
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
329
+ return this.fetch(templatePath(factorySlug, workflowName, "secrets"), {
330
+ method: "POST",
331
+ body: { key, value },
332
+ });
333
+ }
334
+
335
+ /** List secret keys registered for a workflow (metadata only — values are never returned). */
336
+ async listSecrets(workflowName: string, opts?: { factorySlug?: string }): Promise<Array<{ key: string; createdAt: string; updatedAt: string }>> {
337
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
338
+ const body = await this.fetch<{ secrets: Array<{ secretKey: string; createdAt: string; updatedAt: string }> }>(
339
+ templatePath(factorySlug, workflowName, "secrets"),
340
+ );
341
+ return body.secrets.map(s => ({ key: s.secretKey, createdAt: s.createdAt, updatedAt: s.updatedAt }));
342
+ }
343
+
344
+ /** Delete a workflow secret. */
345
+ deleteSecret(workflowName: string, key: string, opts?: { factorySlug?: string }): Promise<void> {
346
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
347
+ return this.fetch(templatePath(factorySlug, workflowName, "secrets", key), { method: "DELETE" });
348
+ }
349
+
350
+ // ── API keys ───────────────────────────────────────────────────────────────
351
+ // Both endpoints require an admin-scoped key as the bearer token.
352
+
353
+ /** Create a new API key on the caller's team. The plaintext `key` is
354
+ * returned once — it cannot be retrieved later.
355
+ *
356
+ * When `factorySlug` is set, the new key is restricted to that factory.
357
+ * Factory-scoped keys can only mint other keys bound to the same factory. */
358
+ createApiKey(input: {
359
+ name?: string;
360
+ scopes?: string[];
361
+ expiresAt?: string;
362
+ factorySlug?: string;
363
+ }): Promise<ApiKeyCreated> {
364
+ return this.fetch<ApiKeyCreated>("/api-keys", { method: "POST", body: input });
365
+ }
366
+
367
+ /** List API keys on the caller's team (metadata only — plaintext keys are
368
+ * never returned). */
369
+ async listApiKeys(): Promise<ApiKey[]> {
370
+ const body = await this.fetch<{ object: "list"; data: ApiKey[]; has_more: boolean }>("/api-keys");
371
+ return body.data;
372
+ }
373
+
374
+ // ── Usage ──────────────────────────────────────────────────────────────────
375
+
376
+ /** Billable usage rollup for the caller's team over the [from, to) window. */
377
+ getUsage(from: Date, to: Date): Promise<UsageResponse> {
378
+ const qs = `?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}`;
379
+ return this.fetch<UsageResponse>(`/api/v1/usage${qs}`);
380
+ }
381
+
382
+ // ── Run control ────────────────────────────────────────────────────────────
383
+
384
+ /** Cancel an in-progress run. Idempotent: cancelling an already-terminal
385
+ * run returns the current state without throwing. The server stamps the
386
+ * run as `canceled` and kills any live sandboxes. */
387
+ cancelRun(runId: string): Promise<CancelRunResponse> {
388
+ return this.fetch<CancelRunResponse>(
389
+ `/api/v1/workflows/${encodeURIComponent(runId)}/cancel`,
390
+ { method: "POST" },
391
+ );
392
+ }
393
+
394
+ /** Stream lifecycle events for a run as an async iterable. Yields parsed
395
+ * `RunEvent` payloads in order; caller breaks on terminal events
396
+ * (`run_complete`, `run_failed`, `run_canceled`).
397
+ *
398
+ * `lastEventId` enables resume — pass the highest `seq` you've already
399
+ * processed to receive only events you missed.
400
+ *
401
+ * `signal` can be used to abort the stream from the caller side.
402
+ *
403
+ * Uses raw `fetch` (not ofetch) because SSE requires access to the
404
+ * response's `ReadableStream`, which ofetch consumes when parsing. Auth
405
+ * + base URL are still sourced from the same constructor inputs, and
406
+ * non-2xx responses throw the same `AgentComposeError`. */
407
+ async *streamRunLogs(
408
+ runId: string,
409
+ opts?: { lastEventId?: number; signal?: AbortSignal },
410
+ ): AsyncGenerator<RunEvent> {
411
+ const headers: Record<string, string> = { Authorization: `Bearer ${this.apiKey}` };
412
+ if (opts?.lastEventId && opts.lastEventId > 0) {
413
+ headers["Last-Event-ID"] = String(opts.lastEventId);
414
+ }
415
+ const res = await fetch(`${this.baseUrl}/api/v1/workflows/${encodeURIComponent(runId)}/stream`, {
416
+ headers,
417
+ ...(opts?.signal ? { signal: opts.signal } : {}),
418
+ });
419
+ if (!res.ok || !res.body) {
420
+ let message = res.statusText;
421
+ try {
422
+ const body = await res.json() as { error?: string };
423
+ if (body.error) message = body.error;
424
+ } catch { /* non-JSON error body — fall back to statusText */ }
425
+ throw new AgentComposeError(res.status, message);
426
+ }
427
+ for await (const ev of parseSseStream(res.body)) {
428
+ // The server's `data` payload already includes `event`, `runId`, `seq`,
429
+ // `at`, and the per-event payload — so `ev.data` IS the `RunEvent`.
430
+ // Cast directly; unknown future event names flow through untyped, and
431
+ // callers using the discriminated union see them via the default branch.
432
+ yield ev.data as unknown as RunEvent;
433
+ }
434
+ }
435
+ }
436
+
437
+ /** A factory: a project-level grouping of workflows inside a team. */
438
+ export interface FactoryRow {
439
+ id: string;
440
+ teamId: string;
441
+ slug: string;
442
+ name: string;
443
+ description: string | null;
444
+ createdAt: string;
445
+ updatedAt: string;
446
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Allow importing .md files as plain text (via Bun's static text import)
2
+ declare module "*.md" {
3
+ const content: string;
4
+ export default content;
5
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** Thrown by AgentComposeClient when the server returns a non-2xx response. */
2
+ export class AgentComposeError extends Error {
3
+ constructor(
4
+ public readonly status: number,
5
+ message: string,
6
+ ) {
7
+ super(message);
8
+ this.name = "AgentComposeError";
9
+ }
10
+ }