@shardflux/sdk 0.6.1 → 0.7.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/CHANGELOG.md CHANGED
@@ -3,7 +3,111 @@
3
3
  Every API the README shows is available from the version named here. Below 1.0, a minor release may break
4
4
  compatibility; breaking changes are marked **Breaking**.
5
5
 
6
- ## 0.6.1 (not yet published; npm `latest` is 0.6.0)
6
+ ## 0.7.0 (not yet published; npm `latest` is 0.6.2)
7
+
8
+ Needs an API with the template editor (contracts §24); every new field is additive and older fields are unchanged.
9
+
10
+ ### Template editor: build a template from template.yaml
11
+
12
+ - `templates.buildFromFile(path, { templateSlug, autoPublish?, description?, displayName?, acknowledgedScanFindings?,
13
+ organizationId?, wait?, onProgress?, root?, parseYaml? })` (Node only): reads template.yaml (or a `.json` file with
14
+ the same document), uploads each local `from` path of `build.files` (folders packed as a reproducible tar, files as
15
+ they are; each distinct path once; bytes the organization already has are not sent), creates the recipe v2 build and
16
+ with `wait` follows it. Returns `{ build, recipe, uploads }`. Progress events: `pack`, `upload`, `build`.
17
+ `templates.buildFromRecipe(doc, { baseDir, ... })` does the same for a document in memory.
18
+ - YAML is read with the optional peer dependency `yaml` (`npm install yaml`) or `parseYaml`; JSON needs nothing. The
19
+ SDK keeps no runtime dependencies, and the Node-only code is loaded with dynamic imports, so browser bundles are
20
+ unaffected.
21
+ - The folder tar is byte-identical to the Python SDK's (`shardflux` 0.3.0) for the same folder: members sorted by UTF-8
22
+ path, mtime 0, uid/gid 0 without names, permission bits kept, symlinks as symlinks, pax records for long or
23
+ non-ASCII names. Absolute or escaping symlinks, devices, FIFOs and sockets are refused before any request, as the
24
+ build host would.
25
+ - `TemplateFileError` (a file that cannot be read, parsed or packed; nothing was sent) and `TemplateUploadError` (the
26
+ storage refused a PUT: `status`, `code` such as `BadDigest`; never the presigned URL). Exported: `packDirectory`,
27
+ `readTemplateFile`, `parseTemplateText`, the tar writer (`tarHeader`, `tarPadding`, `tarEnd`).
28
+
29
+ ### Template editor: the API surface (contracts §24.6)
30
+
31
+ - `templates.uploads.request({ sha256, size, kind })`, `templates.uploads.put(bytes | Blob | stream, { kind, sha256?,
32
+ size? })` (PUT with exactly the presigned headers, skipped when the organization has the bytes, confirmed after) and
33
+ `templates.uploads.putPath(path)` (Node).
34
+ - `templates.builds.create()` takes recipe v2 (`TemplateRecipeV2`), `description` and `acknowledgedScanFindings`.
35
+ `TemplateBuild.denied_hosts`. **Breaking (types only):** `TemplateBuild.recipe` is `{dockerfile}` or the stored
36
+ recipe v2 (`TemplateBuildRecipeV2`); narrow with `'dockerfile' in build.recipe` before reading `dockerfile`.
37
+ `waitForBuild()` takes `onChange`.
38
+ - `templates.versions.recipe(slug, version)`: the recipe and settings a version was built from, ready to build again.
39
+ - `templates.versionTestInstances.create(slug, version, { inputs?, key?, caps?, ... })`: a session workspace on a
40
+ registered version, published or not.
41
+ - `templates.languages(base)`: the languages and versions a base offers `build.languages` (`included` when the base
42
+ already has one).
43
+ - `templates.packages.search(ecosystem, query, { base?, limit? })` and `templates.packages.get(ecosystem, name)`.
44
+ - `workspaces.open({ inputs })`, `workspace.inputs()` / `workspaces.inputs(id)`, `workspace.startup` (start commands
45
+ and services: pending, running, ready or failed with the step, exit code and output tail).
46
+ - Drafts: `create({ displayName, inputs })`, `openTestInstance({ inputs })`, `publish({ settings })`;
47
+ `saveAsTemplate({ settings })`.
48
+ - Views: `TemplateVersion.settings`, `TemplateSummary.category` / `TemplateDetail.category`,
49
+ `WorkspaceEgressPolicy.template_egress` and `effective_policy`. Types: `TemplateSettings`, `TemplateSettingsInput`,
50
+ `TemplateInput`, `TemplateStartCommand`, `TemplateService`, `TemplateEgressDefault`, `TemplateUpload*`,
51
+ `TemplateVersionRecipe`, `TemplateLanguages`, `TemplatePackage*`, `WorkspaceStartup`, `WorkspaceInputs`.
52
+ - Error reasons: `invalid_recipe`, `base_not_layered`, `language_unavailable`, `language_conflict`, `upload_required`,
53
+ `upload_missing`, `upload_digest_mismatch`, `upload_too_large`, `invalid_settings`, `services_unsupported`,
54
+ `input_required`, `input_unknown`, `input_invalid`, `egress_widening`, `package_index_unavailable` and the operation
55
+ error `startup_failed` (`KnownErrorReason`).
56
+
57
+ ### Tool-call capture
58
+
59
+ `workspace.captureToolCalls(options)` saves the tool calls of your own agent harness (web search, SQL, HTTP APIs, MCP
60
+ servers) into the workspace: each call's input and full output become files under `/home/user/tool-calls/<run>/`
61
+ with a JSON-lines index, so the agent can compute on them with code and snapshots and forks keep them. The format is
62
+ docs/decisions/0006-tool-call-capture.md (shared with the Python SDK 0.3.0).
63
+
64
+ - Invisible: a wrapped tool returns the same value, the same promise object and the same thrown error; a synchronous
65
+ tool stays synchronous; capture never throws into the harness (failures go to `onError` and `capture.stats`).
66
+ Serialization runs in `setImmediate`, writes in the background (4 in parallel, index lines batched every 50 ms).
67
+ - Explicit capture: `capture.record(call)`, `capture.run(toolCall, fn)` (Anthropic `tool_use`, Responses
68
+ `function_call`, Chat `tool_calls`, `{ name, input }`), `capture.wrap(name, fn)` (async generators pass through and
69
+ are stored as `.jsonl`), `capture.tools(toolsObjOrArray)`, and `captureTool(name, fn)` with `capture.activate(fn)`
70
+ for tools defined at import time.
71
+ - Adapters (structural types, no dependency): `capture.aiSdk.tools()` / `.callbacks()` (Vercel AI SDK 7, streaming
72
+ tools included), `capture.mastra.hooks()` / `.tools()`, `capture.anthropic.tools()` (tool runner),
73
+ `capture.openaiAgents.tools()` / `.execute()` / `.attach(runner | agent)`, `capture.claude.hooks()` (Claude Agent
74
+ SDK, with a PreToolUse barrier for `mcp__shardflux__` tools), `capture.langchain.handler()`, `capture.mcp.instrument()`.
75
+ - Selection: explicit capture always records; hook-level adapters apply `include` / `exclude`. Call ids are
76
+ deduplicated (last 10 000), so a wrapper and a hook can be combined.
77
+ - Output encoding: JSON, text, HTML, bytes by magic number (PNG, JPEG, GIF, WebP, PDF, ZIP, gzip, Parquet), MCP results
78
+ and content blocks as a directory of parts; limits `maxOutputBytes` (32 MiB, text cut as `.part`),
79
+ `maxInlineInputBytes` (64 KiB), `maxPendingBytes` (128 MiB of files, index lines and inline inputs, then
80
+ `dropped: "queue_full"` with a small line), `maxPendingCalls` (10 000; past it a call is not recorded and `onError`
81
+ says so); `transform` for redaction (fails closed); streams are never read; out-of-range timestamps are recorded as
82
+ `started_at: null`. A wrapped async tool's promise is marked handled (no `unhandledRejection` if nobody awaits it).
83
+ - Read-your-writes: calls through the same client (exec and files through `workspace.cell()`, `workspaceTools`,
84
+ snapshot, fork, suspend, saveAsTemplate, close; also `cloud.workspaces.*(id)`) wait for capture writes recorded
85
+ before them, bounded by `settleTimeoutMs` (30 s). Lifecycle timing shows the wait as a new `capture_flush` phase.
86
+ `delete` and `reset` drop pending writes. Writes wake a suspended workspace (`wake: null` opts out).
87
+ - `capture.flush()` / `capture.close()` never reject (`{ complete, written, failed, dropped }`); `capture.promptHint()`
88
+ returns a paragraph for your system prompt.
89
+ - `executeToolCall()` passes the call's `id` / `call_id` to `execute` as `toolCallId`, and `WorkspaceTool.execute`'s
90
+ options accept it.
91
+ - **Breaking (types only):** `LifecyclePhase` gains `capture_flush`; an exhaustive `switch` over phases without a
92
+ `default` case needs the new member.
93
+
94
+ ## 0.6.2 (2026-09-28)
95
+
96
+ ### Starts that wait for capacity end
97
+
98
+ The API no longer lets a start (open, resume, restore, fork) wait in `capacity_pending` forever. One that no host
99
+ could admit 15 minutes after it was created fails with `capacity_unavailable` and `retryable: true`: nothing was
100
+ started, the concurrency slot is released, and a suspended workspace stays suspended with its state. Before, a VM could
101
+ boot (and bill) long after every wait had given up.
102
+
103
+ - `OperationFailedError.retryable`: the operation error's `retryable` flag (`false` when absent). `true` for
104
+ `capacity_unavailable` (retry later), `false` for a definitive failure. The SDK does not retry it itself.
105
+ - `OperationTimeoutError.deadlineAt`: when a wait gives up while the start is still `capacity_pending`, the time it
106
+ stops waiting for a host (`error.details.deadline_at`); the message says so. Null in other states.
107
+ - `onProgress`: a `capacity_pending` `phase` event carries `deadlineAt` when the API reports it.
108
+ - Docs: waits no longer say a pending start continues indefinitely.
109
+
110
+ ## 0.6.1 (2026-09-28)
7
111
 
8
112
  - `formatTiming()` joined two phases that followed each other with `∥` (ran together) when their 0.1 ms times summed
9
113
  with a floating-point error (1000.2 + 300.1 > 1300.3); it now prints `→`.
package/README.md CHANGED
@@ -4,16 +4,18 @@ TypeScript SDK for [Shardflux](https://shardflux.dev): cloud computers for AI ag
4
4
 
5
5
  Open a persistent workspace by key, run commands and move files in it, suspend it when idle, resume
6
6
  it later with its disk and memory intact, and fork it. Hand your agent framework-neutral workspace
7
- tools (exec, files, processes, PTY, git, browser) that plug into any model provider.
7
+ tools (exec, files, processes, PTY, git, browser) that plug into any model provider, and save your harness's own
8
+ tool calls into the workspace ([tool-call capture](#tool-call-capture-070)).
8
9
 
9
10
  > **Early access.** Shardflux is in early access. The API is versioned (`/v1`), but this SDK is
10
11
  > below 1.0: a minor release may contain breaking changes (see [Compatibility](#compatibility)).
11
12
 
12
- > **Versions.** This README describes 0.6.1. Anything marked **(0.6.0+)** is not in 0.5.0;
13
- > [CHANGELOG.md](./CHANGELOG.md) lists what each version added. Check yours with
13
+ > **Versions.** This README describes 0.7.0. Anything marked **(0.7.0+)** is not in 0.6.x and **(0.6.0+)** not in
14
+ > 0.5.0; [CHANGELOG.md](./CHANGELOG.md) lists what each version added. Check yours with
14
15
  > `npm ls @shardflux/sdk` or the exported `SDK_VERSION`.
15
16
 
16
- - ESM only, no runtime dependencies, Node.js 24 or later.
17
+ - ESM only, no runtime dependencies, Node.js 24 or later. Reading a YAML template file uses the optional peer
18
+ dependency `yaml` (`npm install yaml`); JSON template files need nothing.
17
19
  - Typed from the published OpenAPI documents.
18
20
  - Retries, idempotency keys, operation polling and tool-token refresh are handled for you.
19
21
 
@@ -127,6 +129,23 @@ means depends on `wait`:
127
129
  wait again with `cloud.workspaces.waitForOperation(err.operationId)`. Without `wait`, the returned operation is the
128
130
  handle for the work in progress: pass its `id` to `waitForOperation()` when you need it finished.
129
131
 
132
+ **A start waits for capacity for at most 15 minutes.** An open, resume, restore or fork that no host can admit yet
133
+ waits in `capacity_pending`. Its `error.details.deadline_at` says when it gives up; the `phase` progress event carries
134
+ it as `deadlineAt` **(0.6.2+)**, and so does `OperationTimeoutError` when your wait ends first. A start still pending
135
+ at the deadline fails with `capacity_unavailable`: nothing was started, the concurrency slot is released, and a
136
+ suspended workspace stays suspended with its state. `OperationFailedError.retryable` **(0.6.2+)** is `true` for it,
137
+ so you can tell "retry later" from a definitive failure. The SDK does not retry it for you.
138
+
139
+ ```ts
140
+ try {
141
+ await workspace.resume({ wait: true });
142
+ } catch (err) {
143
+ if (err instanceof OperationFailedError && err.retryable) {
144
+ // err.errorCode === 'capacity_unavailable': no host had room; nothing changed. Try again later.
145
+ } else throw err;
146
+ }
147
+ ```
148
+
130
149
  **Suspended workspaces wake on use.** A tool call on a suspended workspace resumes it (or joins the resume or open
131
150
  already running), then runs. A call made during a suspend or resume waits for the transition to finish. The call
132
151
  never runs twice: the cell executes nothing it refused.
@@ -148,9 +167,9 @@ await cell.exec.run(['make', 'test']); // resumes the wo
148
167
  the response until the operation changes (`Prefer: wait`, at most 20 s per request), so completion arrives within
149
168
  one round trip of the commit. Against an API without bounded waits (or with `serverWait: false`) it polls with
150
169
  backoff (250 ms doubling to 5 s, ±20 % jitter). If the timeout passes, it throws `OperationTimeoutError` and the
151
- operation keeps running server side; wait for it again with the same call. `templates.builds.waitForBuild()` waits
152
- the same way. A waited `open()` issues the first tool token together with the final workspace read, so the first
153
- tool call starts at once.
170
+ operation keeps running server side (a start waiting for capacity until its `deadlineAt`); wait for it again with the
171
+ same call. `templates.builds.waitForBuild()` waits the same way. A waited `open()` issues the first tool token
172
+ together with the final workspace read, so the first tool call starts at once.
154
173
 
155
174
  On Node 26 the default fetch sends `Connection: close`: its bundled undici 8 can stall a request on a reused
156
175
  keep-alive connection for tens of seconds. Pass your own `fetch`, or set `SHARDFLUX_HTTP_KEEPALIVE=1`, to change that.
@@ -283,6 +302,80 @@ await draft.discard(); // deletes the draft and en
283
302
  `states()`, `statesAll()` and `testInstances({ includeEnded })` list the draft's states and test instances. Only
284
303
  owners, admins and API keys with a tool permission may change drafts; others get 403 `template_dev_mode_role`.
285
304
 
305
+ ## Build a template from template.yaml (0.7.0+)
306
+
307
+ A template is a recipe v2: a base, what the build adds (languages, apt/pip/npm packages, files, named build steps) and
308
+ the settings a workspace gets when it opens (environment, open-time inputs, start commands, services, defaults).
309
+ `template.yaml` is that document in YAML. A file entry may name a local `from` path (relative to the file): a folder is
310
+ copied as a tar, a file as it is.
311
+
312
+ ```yaml
313
+ # acme/template.yaml
314
+ base: ubuntu-24.04@1
315
+ build:
316
+ languages: [{ id: python }, { id: node, version: "22" }]
317
+ packages:
318
+ apt: [jq]
319
+ pip: { packages: [pandas==2.3.2], requirements: [/home/user/app/requirements.txt] }
320
+ files:
321
+ - { from: ./app, to: /home/user/app, owner: user } # a folder: uploaded as a tar
322
+ - { from: ./config/settings.toml, to: /home/user/.config/acme/settings.toml, owner: user, mode: "0600" }
323
+ steps:
324
+ - { name: install, run: npm ci, user: user, cwd: /home/user/app }
325
+ settings:
326
+ env: { APP_ENV: development }
327
+ inputs:
328
+ PROJECT_NAME: { kind: text, required: true }
329
+ OPENAI_API_KEY: { kind: secret } # a stored secret of that name, bound at open
330
+ start: [{ name: seed, when: create, run: python seed.py, user: user, cwd: /home/user/app }]
331
+ services:
332
+ web: { run: npm start, user: user, cwd: /home/user/app, ready: { port: 3000 } }
333
+ ```
334
+
335
+ ```ts
336
+ const { build, uploads } = await cloud.templates.buildFromFile('acme/template.yaml', {
337
+ templateSlug: 'acme-dev',
338
+ autoPublish: false, // register it unpublished, test it, publish it later
339
+ wait: true, // until registered (or failed); default: return the queued build
340
+ onProgress: (e) => console.log(e.type, e.type === 'build' ? e.build.state : e.from),
341
+ });
342
+ console.log(build.state, build.template_version, build.provenance.recipe_sha256, uploads);
343
+ ```
344
+
345
+ `buildFromFile` is Node only (it reads the disk; browsers never load that code). Folders are packed as a
346
+ reproducible tar (sorted, mtime 0, no owner names; symlinks must stay inside the folder), the same bytes as the Python
347
+ SDK packs, so the same inputs give the same `recipe_sha256`. Uploads the organization already has are not sent
348
+ again. It throws `TemplateFileError` before any request for a file it cannot read or pack, and `TemplateUploadError`
349
+ when the storage refuses the bytes. A recipe already in memory: `templates.buildFromRecipe(doc, { templateSlug,
350
+ baseDir })`. Pass `root` to refuse local paths outside a directory, and `parseYaml` to use another YAML parser.
351
+
352
+ The pieces on their own:
353
+
354
+ ```ts
355
+ const up = await cloud.templates.uploads.put(bytes, { kind: 'file' }); // Uint8Array, Blob or a stream
356
+ const dir = await cloud.templates.uploads.putPath('./app'); // Node: a folder as the tar
357
+ await cloud.templates.builds.create(orgId, { templateSlug: 'acme-dev', recipe: { schema: 'shardflux.template-recipe.v2',
358
+ base: 'ubuntu-24.04@1', build: { files: [{ upload: up.ref, kind: 'file', to: '/etc/acme.conf' }] }, settings: {} } });
359
+ const exported = await cloud.templates.versions.recipe('acme-dev', 3); // the recipe to build again, and settings
360
+ const langs = await cloud.templates.languages('ubuntu-24.04@1'); // what build.languages offers on a base
361
+ const pkgs = await cloud.templates.packages.search('apt', 'ffmpeg', { base: 'ubuntu-24.04@1' });
362
+ ```
363
+
364
+ Test a version before publishing it, with its inputs, then open workspaces with inputs:
365
+
366
+ ```ts
367
+ const test = await cloud.templates.versionTestInstances.create('acme-dev', 4, { inputs: { PROJECT_NAME: 'demo' } });
368
+ console.log(test.startup); // start commands and services: pending | running | ready | failed
369
+ await test.close();
370
+ const ws = await cloud.workspaces.open({ key: 'customer-42/main', template: 'acme-dev', inputs: { PROJECT_NAME: 'acme' } });
371
+ console.log(await ws.inputs(), ws.startup);
372
+ ```
373
+
374
+ A failed start command or service fails the open (`OperationFailedError`, code `startup_failed`, retryable); the
375
+ workspace keeps running so you can inspect it, and `workspace.startup` names the step, its exit code and output tail.
376
+ The next open runs the failed step again. Versions report their `settings`, platform templates their `category`
377
+ (`os` or `stack`), builds the `denied_hosts` their build network refused (add them to `build.network.extra_hosts`).
378
+
286
379
  ## Agent tools
287
380
 
288
381
  `workspaceTools(workspace)` returns tools with a name, a description, a JSON Schema for the
@@ -302,6 +395,115 @@ const output = await executeToolCall(tools, { name: call.name, input: call.input
302
395
  Your agent loop and model calls stay in your application; the workspace is the computer the tools
303
396
  act on.
304
397
 
398
+ ## Tool-call capture (0.7.0+)
399
+
400
+ Your harness's tools (web search, SQL, HTTP APIs, MCP servers) run in your application, so their results reach the
401
+ model but not the workspace. `workspace.captureToolCalls()` saves every call's input and full output as files in the
402
+ workspace, where the agent can process them with `jq` or Python, and where snapshots and forks keep them.
403
+
404
+ ```ts
405
+ const capture = workspace.captureToolCalls();
406
+
407
+ // A hand-rolled loop (Anthropic tool_use, Responses function_call, Chat tool_calls item, or { name, input }):
408
+ for (const block of message.content) {
409
+ if (block.type !== 'tool_use') continue;
410
+ const output = await capture.run(block, () => myTools[block.name](block.input));
411
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(output) });
412
+ }
413
+ ```
414
+
415
+ Capture is invisible to the harness. A wrapped tool returns the same value, the same promise object and the same
416
+ thrown error; a synchronous tool stays synchronous. Nothing capture does throws into your code: write failures,
417
+ drops and a throwing `transform` go to `onError` and `capture.stats`. Serialization runs after the call returns and
418
+ the writes run in the background.
419
+
420
+ One line per framework:
421
+
422
+ | Harness | Integration |
423
+ |---|---|
424
+ | Hand-rolled loop | `capture.run(call, () => ...)`, `capture.wrap('name', fn)`, or `capture.record({ tool, input, output, callId })` |
425
+ | Shardflux tools | `executeToolCall(capture.tools(workspaceTools(workspace)), call)` (the call's `id` is recorded) |
426
+ | Vercel AI SDK 7 | `generateText({ tools: capture.aiSdk.tools(tools) })`, or `...capture.aiSdk.callbacks()` (`onToolExecutionEnd`; `{ legacy: true }` for `experimental_onToolCallFinish`) |
427
+ | Mastra | `new Agent({ tools: capture.mastra.tools({ weather }), hooks: capture.mastra.hooks() })` |
428
+ | Anthropic tool runner | `client.beta.messages.toolRunner({ tools: capture.anthropic.tools([weatherTool]) })` |
429
+ | OpenAI Agents JS | `const detach = capture.openaiAgents.attach(runner)`, or `tools: capture.openaiAgents.tools([...])` |
430
+ | Claude Agent SDK | `query({ prompt, options: { hooks: capture.claude.hooks(myHooks) } })` |
431
+ | LangChain.js / LangGraph.js | `agent.invoke(input, { callbacks: [capture.langchain.handler()] })` |
432
+ | MCP client | `const release = capture.mcp.instrument(client, { server: 'github' })` |
433
+
434
+ ```ts
435
+ // Vercel AI SDK: the wrapper keeps toolCallId, abortSignal and every other tool property. A streaming tool
436
+ // (async generator execute) passes through unchanged; its final value is recorded.
437
+ const result = await generateText({ model, tools: capture.aiSdk.tools({ weather, search }), prompt });
438
+
439
+ // Claude Agent SDK: your own hooks are kept. PostToolUse / PostToolUseFailure record every tool, and a
440
+ // PreToolUse hook matching ^mcp__shardflux__ waits for pending writes, so the Shardflux MCP server sees them.
441
+ for await (const m of query({ prompt, options: { hooks: capture.claude.hooks(), mcpServers } })) handle(m);
442
+
443
+ // OpenAI Agents JS: agent_tool_end reports a tool's error as the string the model got (status ok). For the exact
444
+ // error, wrap execute: tool({ name: 'get_weather', parameters, execute: capture.openaiAgents.execute('get_weather', fn) }).
445
+ const detach = capture.openaiAgents.attach(runner);
446
+
447
+ // Tools defined at import time in a multi-tenant server: late-bound to the capture active for the request.
448
+ export const lookup = captureTool('lookup', async (id: string) => db.find(id));
449
+ await capture.activate(() => handleRequest(req)); // lookup() records into this capture; elsewhere it passes through
450
+ ```
451
+
452
+ **Selection.** Explicit capture (`record`, `run`, `wrap`, `tools`, `captureTool`) always records. Hook-level
453
+ adapters (`callbacks()`, `hooks()`, `attach()`, `handler()`, `instrument()`) see every tool, Shardflux's own
454
+ included, filtered by `include` / `exclude` (names, a RegExp, or `(tool, source) => boolean`). A capture remembers the
455
+ last 10 000 call ids and records each once, so a wrapper and a hook can be combined.
456
+
457
+ **In the workspace** (`dir` defaults to `/home/user/tool-calls`; point it at a subdirectory, e.g.
458
+ `/home/user/tool-calls/conv-123`, to group runs):
459
+
460
+ ```
461
+ <dir>/README.md layout and jq recipes, for the agent
462
+ <dir>/<run>/index.jsonl one JSON line per call: seq, call_id, tool, status, input, output_path, ...
463
+ <dir>/<run>/000007-web_search.json the output (.json, .txt, .html, .png, .pdf, ... from its content)
464
+ <dir>/<run>/000010-github.search/ an MCP result or content blocks: part-1.txt, part-2.png, result.json
465
+ <dir>/<run>/000011-sql.input.json an input over 64 KiB
466
+ ```
467
+
468
+ `<run>` is `capture.runId` (start time plus a random suffix; `capture.runDir` is the full path). Read the index with
469
+ `jq -cR 'fromjson? // empty' <dir>/*/index.jsonl`, which skips a line torn by a failed append. `capture.promptHint()`
470
+ returns a paragraph that tells the agent where its tool calls are; add it to your system prompt if you want (it is
471
+ never injected).
472
+
473
+ **Read-your-writes.** Calls through the same client first wait for capture writes recorded before them (bounded by
474
+ `settleTimeoutMs`, 30 s; they never fail because of capture): `exec` and files calls through `workspace.cell()`,
475
+ `workspaceTools`, `snapshot`, `fork`, `suspend`, `saveAsTemplate` and `close` (on the workspace handle and on
476
+ `cloud.workspaces.*(id)`). A lifecycle call's timing shows the wait as a `capture_flush` phase. `delete` and `reset`
477
+ drop pending writes. A write to a suspended workspace wakes it (`wake: null` opts out).
478
+
479
+ **Serverless.** Writes finish in the background, so let them finish before the function is frozen:
480
+ `waitUntil(capture.flush())` on Vercel (or Next.js `after(() => capture.flush())`), `await capture.flush()` before
481
+ returning on AWS Lambda. `flush()` and `close()` never reject; they return `{ complete, written, failed, dropped }`.
482
+
483
+ **Redaction.** Nothing is redacted by default (the input came from the model). `transform(event)` gets a copy of each
484
+ call and returns it (changed or not) or `null` to drop it; if it throws, the call is dropped, never written unredacted.
485
+
486
+ ```ts
487
+ const capture = workspace.captureToolCalls({
488
+ exclude: ['exec'],
489
+ transform: (e) => (e.tool === 'crm_lookup' ? { ...e, output: redact(e.output) } : e),
490
+ onError: (err) => log.warn(err.kind, err.message),
491
+ });
492
+ ```
493
+
494
+ **Limits.** An output over `maxOutputBytes` (32 MiB) is cut when it is text or JSON (`.part`, `truncated: true`) and
495
+ not stored when it is binary (`dropped: "too_large"`). What pending calls hold in memory (files, index lines, inline
496
+ inputs) is bounded by `maxPendingBytes` (128 MiB): over it the output and a large input are dropped and the call keeps
497
+ a small index line with `dropped: "queue_full"`; the call never waits. At most `maxPendingCalls` (10 000) calls are
498
+ pending: past that, while the workspace takes no writes, a call is not recorded at all (`record()` returns null,
499
+ `onError` gets `queue_full`). A write is retried for `retryWindowMs` (120 s) and then dropped (`write_failed`).
500
+ Observing an async tool marks its promise handled: if your code never awaits a wrapped tool's promise and it rejects,
501
+ Node reports no `unhandledRejection` for it (the error is still in the index; Node has no way to observe a rejection
502
+ without handling it). About one write per call: more than roughly 100 calls per second per
503
+ workspace reaches the pending limit. `Response`, `ReadableStream`, Node streams and `Blob` results are never read
504
+ (`meta.note: "stream_not_captured"`). On template v1 the files API writes as root, so the agent can read the captured
505
+ files but not change them. The format is specified in `docs/decisions/0006-tool-call-capture.md`.
506
+
305
507
  ## Secrets
306
508
 
307
509
  Store credentials once and give them to a workspace's processes as environment variables. Values
@@ -338,15 +540,18 @@ await workspace.cell().exec.run(['python3', 'agent.py']); // sees $OPEN
338
540
  `message`, `requestId`, `retryable`, `details`, `operationId`, `retryAfterSeconds`, and `reason`
339
541
  (`details.reason`, e.g. `not_session`, `draft_not_found`, `legacy_disk_layout`; see `KnownErrorReason`).
340
542
  - `OperationFailedError`: an awaited operation ended `failed` or `canceled` (`errorCode`,
341
- `operation`, `timing`).
342
- - `OperationTimeoutError`: waiting gave up; the operation continues (`operationId`, `timing`).
543
+ `retryable` **(0.6.2+)**, `operation`, `timing`). `retryable` is the operation error's own flag: `true` for
544
+ `capacity_unavailable` (no host could admit the start before its deadline; retry later), `false` for a definitive
545
+ failure.
546
+ - `OperationTimeoutError`: waiting gave up; the operation continues (`operationId`, `lastState`, `lastReason`,
547
+ `deadlineAt` **(0.6.2+)** while it waits for capacity, `timing`).
343
548
  - `ShardfluxProtocolError`: a response was not the documented shape.
344
549
 
345
550
  Treat unknown error codes and reasons as generic errors: show `message`, and use `retryable`.
346
551
 
347
552
  ## More of the API
348
553
 
349
- The `Shardflux` object also has `templates` (including custom template builds), `volumes`
554
+ The `Shardflux` object also has `templates` (including custom template builds and the template editor), `volumes`
350
555
  (shared persistent storage attached to workspaces), `secrets` (see above), `egress` (outbound allowlists),
351
556
  `usage`, `billing`, `me()`, `entitlements(orgId)` and `request(method, path)` for any `/v1`
352
557
  route. The package exports the OpenAPI-generated types as well (`paths`, `components`,
@@ -356,7 +561,7 @@ route. The package exports the OpenAPI-generated types as well (`paths`, `compon
356
561
 
357
562
  - The SDK follows the API's `/v1` contract. New fields, enum values and error codes can appear in
358
563
  any release; ignore unknown fields.
359
- - While below 1.0, a breaking change bumps the minor version (0.5 to 0.6).
564
+ - While below 1.0, a breaking change bumps the minor version (0.6 to 0.7).
360
565
  - `SDK_VERSION` is exported; requests send `User-Agent: shardflux-sdk-ts/<version>`.
361
566
  - Examples in this README, in `examples/` and on shardflux.dev name the version they need. The examples on the
362
567
  website and in the console are checked against the version published on npm before they ship.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Framework adapters for tool-call capture. Structural types only: no framework is a dependency of the SDK, and each
3
+ * adapter's output is checked against the real framework types in test/capture-types.ts.
4
+ *
5
+ * Explicit wrappers (`tools()` of each framework, `capture.tools()`) always record. Hook-level adapters (AI SDK
6
+ * callbacks, Mastra hooks, OpenAI Agents `attach`, Claude Agent SDK hooks, LangChain callbacks, MCP instrumentation)
7
+ * see every tool and apply `include` / `exclude`. A capture remembers the last 10 000 call ids, so a wrapper and a hook
8
+ * on the same call record it once.
9
+ */
10
+ import type { CallRef, CaptureCall, CaptureFlushResult, CaptureSource, CaptureStatus } from './capture.js';
11
+ /** How the capture observes one call (internal). */
12
+ export interface ObserveSpec {
13
+ tool: string;
14
+ source: CaptureSource;
15
+ input: unknown;
16
+ callId: string | null;
17
+ meta?: Record<string, unknown>;
18
+ /** An async iterable result: record its last item (AI SDK streaming tools) instead of every item. */
19
+ final?: boolean;
20
+ /** Filtered by include/exclude (hook-level adapters). */
21
+ hookLevel?: boolean;
22
+ /** Maps a successful result to what is recorded (LangChain ToolMessage, MCP isError, Mastra ValidationError). */
23
+ post?: (output: unknown) => {
24
+ output: unknown;
25
+ error?: unknown;
26
+ status?: CaptureStatus;
27
+ };
28
+ }
29
+ /** What the adapters need from a capture (internal). */
30
+ export interface CaptureCore {
31
+ observe<T>(spec: ObserveSpec, fn: (...args: never[]) => T, thisArg: unknown, args: unknown[]): T;
32
+ hook(call: CaptureCall): CallRef | null;
33
+ settle(): Promise<unknown> | undefined;
34
+ flush(timeoutMs?: number): Promise<CaptureFlushResult>;
35
+ accepts(tool: string, source: CaptureSource): boolean;
36
+ }
37
+ /** A copy of `target` with its prototype and own properties, and `key` replaced by `value` (the original is untouched). */
38
+ export declare function copyWith<T extends object>(target: T, key: string, value: unknown): T;
39
+ /** A LangChain ToolMessage: recorded as its content (plus artifact), status error when the message says so. */
40
+ export declare function isToolMessage(v: unknown): v is {
41
+ content: unknown;
42
+ artifact?: unknown;
43
+ status?: string;
44
+ tool_call_id: string;
45
+ name?: string;
46
+ };
47
+ /** The part of an AI SDK 7 `onToolExecutionEnd` event capture reads (also the v6 `experimental_onToolCallFinish` shape). */
48
+ export interface AiSdkToolEndEvent {
49
+ toolCall?: {
50
+ toolCallId?: string;
51
+ toolName?: string;
52
+ input?: unknown;
53
+ } | undefined;
54
+ toolExecutionMs?: number | undefined;
55
+ toolOutput?: {
56
+ type?: string;
57
+ output?: unknown;
58
+ error?: unknown;
59
+ } | undefined;
60
+ success?: boolean | undefined;
61
+ output?: unknown;
62
+ error?: unknown;
63
+ durationMs?: number | undefined;
64
+ }
65
+ export interface AiSdkAdapter {
66
+ /** `generateText({ tools: capture.aiSdk.tools(tools) })`: wraps each `execute` (keeps toolCallId, abortSignal and every other property). */
67
+ tools<T>(tools: T): T;
68
+ /** `generateText({ ...capture.aiSdk.callbacks() })`: `onToolExecutionEnd` (hook-level). */
69
+ callbacks(opts?: {
70
+ legacy?: false;
71
+ }): {
72
+ onToolExecutionEnd: (event: AiSdkToolEndEvent) => void;
73
+ };
74
+ /** `{ legacy: true }`: the deprecated alias `experimental_onToolCallFinish`. */
75
+ callbacks(opts: {
76
+ legacy: true;
77
+ }): {
78
+ experimental_onToolCallFinish: (event: AiSdkToolEndEvent) => void;
79
+ };
80
+ }
81
+ /** Mastra `afterToolCall` context (ToolAfterHookContext). */
82
+ export interface MastraAfterToolCallContext {
83
+ toolName?: string;
84
+ input?: unknown;
85
+ context?: unknown;
86
+ metadata?: unknown;
87
+ output?: unknown;
88
+ error?: unknown;
89
+ }
90
+ export interface MastraHooksLike {
91
+ beforeToolCall?: (...args: any[]) => any;
92
+ afterToolCall?: (ctx: any) => void | Promise<void>;
93
+ }
94
+ export interface MastraAdapter {
95
+ /** `new Agent({ hooks: capture.mastra.hooks(myHooks) })`: records in `afterToolCall`, then calls yours. */
96
+ hooks<H extends MastraHooksLike = Record<never, never>>(existing?: H): H & {
97
+ afterToolCall: (ctx: MastraAfterToolCallContext) => Promise<void>;
98
+ };
99
+ /** `new Agent({ tools: capture.mastra.tools({ weather }) })`: wraps each `execute(inputData, context)`. */
100
+ tools<T>(tools: T): T;
101
+ }
102
+ export interface AnthropicAdapter {
103
+ /** `client.beta.messages.toolRunner({ tools: capture.anthropic.tools([...]) })`: wraps `run(input, context)`. */
104
+ tools<T>(tools: T): T;
105
+ }
106
+ export interface OpenAIAgentsAdapter {
107
+ /** `new Agent({ tools: capture.openaiAgents.tools([...]) })`: wraps each FunctionTool's `invoke`. */
108
+ tools<T>(tools: T): T;
109
+ /**
110
+ * `tool({ execute: capture.openaiAgents.execute('name', fn) })`: records around your execute, with the exact error
111
+ * (a FunctionTool's own error function turns a thrown error into a string result before `invoke` returns).
112
+ */
113
+ execute<F extends (...args: any[]) => any>(name: string, fn: F): F;
114
+ /** Subscribes `agent_tool_end` on a Runner or an Agent (hook-level); returns the function that unsubscribes. */
115
+ attach(target: {
116
+ on(event: 'agent_tool_end', listener: (...args: any[]) => void): unknown;
117
+ off(event: 'agent_tool_end', listener: (...args: any[]) => void): unknown;
118
+ }): () => void;
119
+ }
120
+ export type ClaudeHookCallback = (input: any, toolUseID: string | undefined, options: {
121
+ signal: AbortSignal;
122
+ }) => Promise<{
123
+ continue?: boolean;
124
+ }>;
125
+ export interface ClaudeHookMatcher {
126
+ matcher?: string;
127
+ hooks: ClaudeHookCallback[];
128
+ timeout?: number;
129
+ }
130
+ export interface ClaudeHookMatcherLike {
131
+ matcher?: string;
132
+ hooks: Array<(input: any, toolUseID: string | undefined, options: {
133
+ signal: AbortSignal;
134
+ }) => Promise<unknown>>;
135
+ timeout?: number;
136
+ }
137
+ export type ClaudeHooksLike = Partial<Record<string, ClaudeHookMatcherLike[]>>;
138
+ export type ClaudeCaptureHooks = {
139
+ PreToolUse: ClaudeHookMatcher[];
140
+ PostToolUse: ClaudeHookMatcher[];
141
+ PostToolUseFailure: ClaudeHookMatcher[];
142
+ };
143
+ export interface ClaudeAdapter {
144
+ /**
145
+ * `query({ prompt, options: { hooks: capture.claude.hooks(myHooks) } })`: PostToolUse and PostToolUseFailure record
146
+ * (hook-level); a PreToolUse hook matching `^mcp__shardflux__` waits for pending writes (bounded) so the Shardflux MCP
147
+ * server sees them. Your hooks are kept and run as before.
148
+ */
149
+ hooks<H extends ClaudeHooksLike = Record<never, never>>(existing?: H): Omit<H, keyof ClaudeCaptureHooks> & ClaudeCaptureHooks;
150
+ }
151
+ export interface LangChainToolHandler {
152
+ handleToolStart(tool: unknown, input: string, runId: string, parentRunId?: string, tags?: string[], metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): void;
153
+ handleToolEnd(output: unknown, runId: string, parentRunId?: string, tags?: string[]): void;
154
+ handleToolError(err: unknown, runId: string, parentRunId?: string, tags?: string[]): void;
155
+ }
156
+ export interface LangChainAdapter {
157
+ /** `{ callbacks: [capture.langchain.handler()] }` (a plain methods object: LangChain wraps it with fromMethods). */
158
+ handler(): LangChainToolHandler;
159
+ }
160
+ export interface McpAdapter {
161
+ /** Patches this client instance's `callTool` (hook-level). Returns the function that restores it. */
162
+ instrument(client: {
163
+ callTool: (...args: any[]) => any;
164
+ }, opts?: {
165
+ server?: string;
166
+ }): () => void;
167
+ }
168
+ export interface CaptureAdapters {
169
+ wrapAny<T>(tools: T): T;
170
+ aiSdk: AiSdkAdapter;
171
+ mastra: MastraAdapter;
172
+ anthropic: AnthropicAdapter;
173
+ openaiAgents: OpenAIAgentsAdapter;
174
+ claude: ClaudeAdapter;
175
+ langchain: LangChainAdapter;
176
+ mcp: McpAdapter;
177
+ }
178
+ export declare function createAdapters(core: CaptureCore): CaptureAdapters;