antpath 0.6.2 → 0.8.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/dist/skill.js CHANGED
@@ -1,41 +1,88 @@
1
+ import { SKILL_NAME_PATTERN } from "./_shared/index.js";
2
+ import { bundleSkillFiles, hashSkillBundle } from "./bundle.js";
3
+ import { readDirectoryAsFiles } from "./node-fs.js";
1
4
  /**
2
- * Workspace `Skill` and provider `Skill` are both modeled by ONE class with
3
- * a discriminated wire shape under `.ref`. Keeping a single class means
4
- * `submitRun({ skills: [...] })` accepts a uniform array; the SDK
5
- * normalises every entry to its `ref` before sending the wire payload.
5
+ * One `Skill` class, one mental model, three usage modes:
6
6
  *
7
- * Three workspace constructors:
7
+ * - **Workspace** uploaded to the antpath platform and reused across
8
+ * runs.
9
+ * ```ts
10
+ * const persisted = await Skill.fromFiles({ name, files }).upload(client);
11
+ * // or
12
+ * const ref = Skill.fromId("skl_abc123");
13
+ * ```
8
14
  *
9
- * - `Skill.fromId("skl_abc123")`reference an existing workspace
10
- * skill without re-uploading. Useful for sharing a skill across many
11
- * `submitRun` calls or in a `defineRun` factory.
12
- * - `client.skills.upload({ name, files })` — inline files map (UTF-8
13
- * strings or `Uint8Array`); SDK zips and uploads.
14
- * - `client.skills.fromPath("./dir", { name })` — local directory; SDK
15
- * reads, zips, and uploads (Node only).
15
+ * - **Provider built-in** references a provider-side skill (e.g.
16
+ * Anthropic web-search). Not uploaded to your workspace.
17
+ * ```ts
18
+ * const websearch = Skill.provider({ vendor: "anthropic", skillId: "web-search" });
19
+ * ```
16
20
  *
17
- * One provider constructor:
21
+ * - **Transient (per-run)** — built from local files, never persisted.
22
+ * The bytes ride alongside `submitRun` as a multipart part and the
23
+ * BFF tears them down at run terminal.
24
+ * ```ts
25
+ * const rules = await Skill.fromFiles({ name: "rules", files: {...} });
26
+ * await client.submitRun({ skills: [rules], ... });
27
+ * ```
18
28
  *
19
- * - `Skill.provider({ vendor, skillId, version? })` references a
20
- * provider built-in skill (e.g. Anthropic web-search). Provider
21
- * skills are NOT uploaded to your workspace and have no
22
- * soft-delete semantics.
29
+ * ## Lifecycle of an unstaged transient Skill
23
30
  *
24
- * Instances are immutable. The wire `ref` is the only state and never
25
- * carries credentials.
31
+ * After `Skill.fromFiles(...)` returns, the Skill is "unstaged transient":
32
+ *
33
+ * 1. Passing it to `submitRun` uploads the bytes for that one run only
34
+ * and assigns a positional slot id (`transient-0`, `transient-1`, …).
35
+ * The same instance may be passed to multiple `submitRun` calls
36
+ * until it is consumed by `.upload(...)`.
37
+ *
38
+ * 2. Calling `await skill.upload(client)` persists the bundle to the
39
+ * workspace skill store and returns a **new** `Skill` instance
40
+ * whose `.ref.kind === "workspace"`. The **original** is marked
41
+ * **consumed**:
42
+ * - any further use in `submitRun` throws a clear validation error,
43
+ * - `.upload(...)` on the consumed instance throws,
44
+ * - `JSON.stringify` / `toJSON()` on the consumed instance throws.
45
+ *
46
+ * Use the returned (workspace) Skill from then on. The consumed
47
+ * reference exists only to surface the mistake loudly the first
48
+ * time the user re-uses it; it never silently re-uploads as
49
+ * transient.
50
+ *
51
+ * Unstaged transient Skills do NOT round-trip through JSON (the bytes
52
+ * cannot be serialised back). `toJSON()` throws in that state too —
53
+ * persist via `.upload(client)` first, or pass the unstaged Skill
54
+ * directly to `submitRun`.
55
+ *
56
+ * The Skill class is the only public way to build a transient bundle;
57
+ * the SDK never accepts a raw `TransientSkillRef` object from user code.
26
58
  */
27
59
  export class Skill {
28
60
  /** Workspace skill record returned by the BFF (only set for workspace-uploaded skills). */
29
61
  record;
30
- ref;
62
+ #ref;
63
+ #transientBytes;
64
+ #consumed = false;
31
65
  /**
32
- * Internal constructor. Use `Skill.fromId`, `Skill.provider`, or
33
- * `client.skills.upload` / `client.skills.fromPath` to create
34
- * instances.
66
+ * Internal constructor. Use `Skill.fromId`, `Skill.provider`,
67
+ * `Skill.fromFiles`, or `Skill.fromPath` to create instances.
35
68
  */
36
- constructor(ref, record) {
37
- this.ref = ref;
69
+ constructor(ref, record, transientBytes) {
70
+ this.#ref = ref;
38
71
  this.record = record;
72
+ this.#transientBytes = transientBytes;
73
+ }
74
+ /**
75
+ * The wire-level reference. For workspace and provider skills this is
76
+ * stable. For unstaged transient skills it carries the placeholder
77
+ * `slot: "(unstaged)"` — `submitRun` rewrites the slot to a real
78
+ * positional id (`transient-0`, …) before sending.
79
+ *
80
+ * For consumed Skills, the getter still returns the original
81
+ * transient ref so error messages can be precise — but using a
82
+ * consumed Skill in any submit/serialise path throws.
83
+ */
84
+ get ref() {
85
+ return this.#ref;
39
86
  }
40
87
  /**
41
88
  * Reference an existing workspace skill by its id (e.g. `skl_abc123`).
@@ -47,8 +94,7 @@ export class Skill {
47
94
  if (typeof id !== "string" || !id) {
48
95
  throw new Error("Skill.fromId: id is required");
49
96
  }
50
- const ref = { kind: "workspace", id };
51
- return new Skill(ref);
97
+ return new Skill({ kind: "workspace", id });
52
98
  }
53
99
  /**
54
100
  * Reference a provider built-in skill. The vendor decides what the
@@ -70,13 +116,214 @@ export class Skill {
70
116
  : { kind: "provider", vendor: args.vendor, skillId: args.skillId };
71
117
  return new Skill(ref);
72
118
  }
73
- /** True for `Skill.fromId(...)` and skills returned by `client.skills.upload(...)`. */
119
+ /**
120
+ * Build an unstaged transient Skill from an inline files map. The SDK
121
+ * validates basic safety (no path traversal, size caps, has
122
+ * `SKILL.md`), deterministically zips the bundle, and computes the
123
+ * `sha256:<hex>` content hash that travels in the wire ref.
124
+ *
125
+ * The returned Skill carries the bytes privately. Pass it to
126
+ * `submitRun` for transient (per-run) use, or call `.upload(client)`
127
+ * to persist it as a workspace skill.
128
+ */
129
+ static async fromFiles(args) {
130
+ if (!args || typeof args !== "object") {
131
+ throw new Error("Skill.fromFiles: args is required");
132
+ }
133
+ if (typeof args.name !== "string" || !SKILL_NAME_PATTERN.test(args.name)) {
134
+ throw new Error(`Skill.fromFiles: name must match ${SKILL_NAME_PATTERN.source}`);
135
+ }
136
+ const bundled = bundleSkillFiles(args.files);
137
+ const contentHash = await hashSkillBundle(bundled.zip);
138
+ const ref = {
139
+ kind: "transient",
140
+ slot: UNSTAGED_SLOT,
141
+ name: args.name,
142
+ contentHash
143
+ };
144
+ return new Skill(ref, undefined, bundled.zip);
145
+ }
146
+ /**
147
+ * Read a local directory and build an unstaged transient Skill.
148
+ * Symlinks and non-regular files are skipped. Node-only.
149
+ *
150
+ * The returned Skill behaves identically to one built via
151
+ * `Skill.fromFiles` — pass it to `submitRun` for transient use, or
152
+ * call `.upload(client)` to persist as a workspace skill.
153
+ */
154
+ static async fromPath(rootDir, args) {
155
+ const files = await readDirectoryAsFiles(rootDir);
156
+ return Skill.fromFiles({ name: args.name, files });
157
+ }
158
+ /**
159
+ * Persist this unstaged transient Skill as a workspace skill and
160
+ * return a new `Skill` instance backed by the resulting `skl_*`
161
+ * record. After `upload` resolves successfully, the original
162
+ * instance is marked **consumed** — any further use throws a clear
163
+ * validation error.
164
+ *
165
+ * Only unstaged transient Skills can be uploaded. Calling this on a
166
+ * workspace-ref Skill, a provider-ref Skill, or a previously
167
+ * consumed Skill throws.
168
+ *
169
+ * Accepts either an `AntpathClient` (preferred) or its
170
+ * `SkillsClient` if the caller already has a handle to it.
171
+ */
172
+ async upload(client) {
173
+ if (this.#consumed) {
174
+ throw new Error(consumedMessage());
175
+ }
176
+ if (this.#ref.kind !== "transient" || !this.#transientBytes) {
177
+ throw new Error("Skill.upload: only unstaged transient Skills (built via Skill.fromFiles / Skill.fromPath) can be uploaded; " +
178
+ "use Skill.fromId(record.id) to reference an already-persisted skill");
179
+ }
180
+ const uploader = resolveUploader(client);
181
+ const record = await uploader({ name: this.#ref.name, body: this.#transientBytes });
182
+ // Only mark consumed AFTER a successful upload — a failed upload
183
+ // leaves the original instance reusable so the caller can retry.
184
+ this.#consumed = true;
185
+ return new Skill({ kind: "workspace", id: record.id }, record);
186
+ }
187
+ /**
188
+ * Persist this unstaged transient Skill as a workspace skill **only
189
+ * if** an existing skill with the same `(name, contentHash)` is not
190
+ * already live.
191
+ *
192
+ * Flow:
193
+ *
194
+ * 1. Hash is already known from `Skill.fromFiles` / `fromPath`.
195
+ * 2. Call `client.skills.findByHash({ name, contentHash })`. If a
196
+ * live row exists, mark this instance consumed and return a
197
+ * new `Skill` backed by that record.
198
+ * 3. Otherwise call `upload(client)`.
199
+ *
200
+ * Returns either the existing or newly-created workspace Skill;
201
+ * callers do not need to branch.
202
+ *
203
+ * Only unstaged transient Skills can be `uploadIfChanged`. Calling
204
+ * this on a workspace-ref Skill, a provider-ref Skill, or a
205
+ * previously consumed Skill throws.
206
+ */
207
+ async uploadIfChanged(client) {
208
+ if (this.#consumed) {
209
+ throw new Error(consumedMessage());
210
+ }
211
+ if (this.#ref.kind !== "transient" || !this.#transientBytes) {
212
+ throw new Error("Skill.uploadIfChanged: only unstaged transient Skills (built via Skill.fromFiles / Skill.fromPath) can be checked; " +
213
+ "use Skill.fromId(record.id) to reference an already-persisted skill");
214
+ }
215
+ const lookup = resolveLookup(client);
216
+ if (lookup) {
217
+ const existing = await lookup({
218
+ name: this.#ref.name,
219
+ contentHash: this.#ref.contentHash
220
+ });
221
+ if (existing) {
222
+ this.#consumed = true;
223
+ return new Skill({ kind: "workspace", id: existing.id }, existing);
224
+ }
225
+ }
226
+ return this.upload(client);
227
+ }
228
+ /** True for `Skill.fromId(...)` and skills returned by `.upload(...)`. */
74
229
  get isWorkspace() {
75
- return this.ref.kind === "workspace";
230
+ return this.#ref.kind === "workspace";
76
231
  }
77
232
  /** True for `Skill.provider(...)`. */
78
233
  get isProvider() {
79
- return this.ref.kind === "provider";
234
+ return this.#ref.kind === "provider";
235
+ }
236
+ /** True for any transient Skill (unstaged or consumed). */
237
+ get isTransient() {
238
+ return this.#ref.kind === "transient";
239
+ }
240
+ /**
241
+ * True only while the transient Skill still carries bytes and has
242
+ * not been consumed by `.upload(...)`. Unstaged Skills can be passed
243
+ * to `submitRun` or `.upload(client)`; consumed ones cannot.
244
+ */
245
+ get isUnstaged() {
246
+ return (this.#ref.kind === "transient" &&
247
+ this.#ref.slot === UNSTAGED_SLOT &&
248
+ !this.#consumed &&
249
+ this.#transientBytes !== undefined);
250
+ }
251
+ /** True after a successful `.upload(...)` — using this instance further throws. */
252
+ get isConsumed() {
253
+ return this.#consumed;
254
+ }
255
+ /**
256
+ * Internal: yield the unstaged transient bundle's payload so the
257
+ * client's `submitRun` can build a multipart part. Returns undefined
258
+ * for non-transient or consumed skills. Throws if the instance is
259
+ * marked consumed (a stronger signal than "no bytes").
260
+ *
261
+ * NOT part of the public API — the `_` prefix is a "do not call from
262
+ * user code" marker. Callers within the SDK pass the returned bytes
263
+ * into the multipart body once per `submitRun` invocation.
264
+ */
265
+ _takeUnstagedBundle() {
266
+ if (this.#consumed) {
267
+ throw new Error(consumedMessage());
268
+ }
269
+ if (!this.isUnstaged) {
270
+ return undefined;
271
+ }
272
+ if (this.#ref.kind !== "transient" || !this.#transientBytes) {
273
+ return undefined;
274
+ }
275
+ return {
276
+ name: this.#ref.name,
277
+ bytes: this.#transientBytes,
278
+ contentHash: this.#ref.contentHash
279
+ };
80
280
  }
281
+ /**
282
+ * JSON serialisation guard. Workspace and provider Skills serialise
283
+ * to their `ref`. Unstaged transient Skills throw — the bytes are not
284
+ * in the JSON, so a round-trip would silently drop them. Consumed
285
+ * Skills also throw, to surface the "you forgot to use the returned
286
+ * Skill from `.upload(...)`" mistake at the point it happens.
287
+ */
288
+ toJSON() {
289
+ if (this.#consumed) {
290
+ throw new Error(consumedMessage());
291
+ }
292
+ if (this.isUnstaged) {
293
+ throw new Error("Cannot JSON-serialise an unstaged transient Skill — the bytes are not in the JSON. " +
294
+ "Persist via skill.upload(client) and serialise the returned (workspace) Skill, " +
295
+ "or pass the unstaged Skill directly to submitRun without serialising.");
296
+ }
297
+ return this.#ref;
298
+ }
299
+ }
300
+ /** Sentinel slot id used by unstaged transient Skills. */
301
+ const UNSTAGED_SLOT = "(unstaged)";
302
+ function resolveUploader(client) {
303
+ const direct = client._uploadSkillBundle;
304
+ if (typeof direct === "function") {
305
+ return direct.bind(client);
306
+ }
307
+ const nested = client.skills?._uploadSkillBundle;
308
+ if (typeof nested === "function") {
309
+ return nested.bind(client.skills);
310
+ }
311
+ throw new Error("Skill.upload: client argument does not expose an upload entry point — " +
312
+ "pass the AntpathClient instance or its `client.skills`");
313
+ }
314
+ function resolveLookup(client) {
315
+ const nested = client.skills?.findByHash;
316
+ if (typeof nested === "function") {
317
+ return nested.bind(client.skills);
318
+ }
319
+ const direct = client.findByHash;
320
+ if (typeof direct === "function") {
321
+ return direct.bind(client);
322
+ }
323
+ return undefined;
324
+ }
325
+ function consumedMessage() {
326
+ return ("this Skill was already uploaded via skill.upload(client); use the returned Skill or " +
327
+ "Skill.fromId(record.id) for subsequent runs");
81
328
  }
82
329
  //# sourceMappingURL=skill.js.map
package/dist/skill.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"skill.js","sourceRoot":"","sources":["../src/skill.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,OAAO,KAAK;IAChB,2FAA2F;IAClF,MAAM,CAA0B;IAChC,GAAG,CAAW;IAEvB;;;;OAIG;IACH,YAAY,GAAa,EAAE,MAAoB;QAC7C,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;QACf,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;IAED;;;;;OAKG;IACH,MAAM,CAAC,MAAM,CAAC,EAAU;QACtB,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,CAAC,EAAE,EAAE,CAAC;YAClC,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;QAClD,CAAC;QACD,MAAM,GAAG,GAAsB,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC;QACzD,OAAO,IAAI,KAAK,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;IAED;;;;OAIG;IACH,MAAM,CAAC,QAAQ,CAAC,IAA0G;QACxH,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YACtC,MAAM,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAC;QACtD,CAAC;QACD,IAAI,OAAO,IAAI,CAAC,MAAM,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YACpD,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;QACxD,CAAC;QACD,IAAI,OAAO,IAAI,CAAC,OAAO,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;YACtD,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACzD,CAAC;QACD,MAAM,GAAG,GAAqB,IAAI,CAAC,OAAO;YACxC,CAAC,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE;YACzF,CAAC,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;QACrE,OAAO,IAAI,KAAK,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;IAED,uFAAuF;IACvF,IAAI,WAAW;QACb,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,KAAK,WAAW,CAAC;IACvC,CAAC;IAED,sCAAsC;IACtC,IAAI,UAAU;QACZ,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,KAAK,UAAU,CAAC;IACtC,CAAC;CACF"}
1
+ {"version":3,"file":"skill.js","sourceRoot":"","sources":["../src/skill.ts"],"names":[],"mappings":"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"}
@@ -84,6 +84,40 @@ Inside the run container, every session has the platform CLI mounted at `/antpat
84
84
 
85
85
  The CLI reads the per-run bearer from `/antpath/run-token`, attaches the `X-Antpath-Proxy-Protocol` header, and the BFF injects the bearer/header/query/basic credential before dispatching the outbound call. Only the response (subject to `responseMode` and `maxResponseBytes`) reaches the container. `--response-mode` can only narrow below the policy ceiling.
86
86
 
87
+ #### Keyless upstreams (`authShape: { type: "none" }`)
88
+
89
+ For public APIs that take no credential (Wikimedia Commons, Internet Archive, Library of Congress, NASA Images, NARA, GDELT, etc.), declare the endpoint with `authShape: { type: "none" }` and omit the matching `proxyEndpointAuth[]` entry entirely:
90
+
91
+ ```ts
92
+ const proxyEndpoints = [
93
+ {
94
+ name: "wikimedia",
95
+ baseUrl: "https://commons.wikimedia.org",
96
+ authShape: { type: "none" },
97
+ allowMethods: ["GET"],
98
+ allowPathPrefixes: ["/wiki/", "/w/api.php"]
99
+ }
100
+ ] as const;
101
+
102
+ const ref = await client.submitRun(template, {
103
+ proxyEndpoints,
104
+ secrets: { anthropic: { apiKey: process.env.ANTHROPIC_API_KEY! } }
105
+ });
106
+ ```
107
+
108
+ The keyless endpoint still routes through the antpath managed proxy: every call is allow-listed, audited, redacted, and counted against per-run budgets. The BFF injects no `Authorization` header and no query-string credential. Shipping a `proxyEndpointAuth` entry for a `none`-shape endpoint is rejected at submission time. Equivalent class-based form:
109
+
110
+ ```ts
111
+ import { ProxyEndpoint } from "antpath";
112
+
113
+ ProxyEndpoint.none({
114
+ name: "wikimedia",
115
+ baseUrl: "https://commons.wikimedia.org",
116
+ allowMethods: ["GET"],
117
+ allowPathPrefixes: ["/wiki/", "/w/api.php"]
118
+ });
119
+ ```
120
+
87
121
  `/antpath/antpath --help` reads endpoint details from `/antpath/index.json`. Runs that do not declare any `proxyEndpoints` still have the CLI and an empty manifest mounted, so agents never need to introspect whether the surface exists.
88
122
 
89
123
  ### Networking
package/docs/events.md CHANGED
@@ -23,6 +23,13 @@ for await (const event of ref.stream({ intervalMs: 1000 })) {
23
23
  }
24
24
  ```
25
25
 
26
+ > **Transport.** `stream()` polls under the hood — it issues `GET
27
+ > /api/runs/:id/events?since=…` calls at `intervalMs` until the run
28
+ > reaches a terminal status. There is no SSE / WebSocket push from the
29
+ > BFF today. For latency-sensitive UIs this is acceptable down to about
30
+ > 500 ms; below that, every dashboard request is one round-trip. A push
31
+ > transport is on the public backlog — not committed to a release.
32
+
26
33
  The CLI mirrors the same surface:
27
34
 
28
35
  ```bash
package/docs/mcp.md CHANGED
@@ -16,3 +16,31 @@ Rules:
16
16
  - Only provider-native bearer/OAuth auth is supported.
17
17
 
18
18
  Use allowlists for sensitive servers whenever possible.
19
+
20
+ ## Large-payload responses
21
+
22
+ antpath is a session dispatcher, not an MCP runtime. We intentionally do
23
+ **not** interpose on the transport between Claude and an upstream MCP
24
+ server, so we cannot elide MCP responses or write them to the session
25
+ filesystem on the user's behalf. Anything an MCP tool returns lands
26
+ directly in the model's context.
27
+
28
+ For ingestion-style tools that return large JSON blobs (search results,
29
+ catalogue dumps, bulk reads), use the **CLI-as-skill + managed proxy**
30
+ pattern instead of MCP:
31
+
32
+ 1. Package the upstream as a `Skill` — a CLI binary the agent invokes
33
+ with its bash tool.
34
+ 2. Route every upstream HTTPS call through a per-run `ProxyEndpoint`
35
+ (audit, byte caps, budget enforcement).
36
+ 3. Have the CLI write the full payload under one of the directories you
37
+ passed to `outputDirs`. Return only a small handle (path, item count,
38
+ summary) to the model.
39
+
40
+ The agent sees the handle in context; the bytes ride out through
41
+ `download()` as a normal captured output.
42
+
43
+ If you genuinely want everything in context (small responses, code
44
+ search, etc.), use MCP. If the payload would blow your context budget,
45
+ the CLI-as-skill pattern is the supported answer — there is no platform
46
+ flag to elide MCP responses.
package/docs/outputs.md CHANGED
@@ -4,24 +4,104 @@ title: Outputs
4
4
 
5
5
  # Outputs
6
6
 
7
- Every run captures provider session-scoped files into private Supabase Storage. List and download them through whichever surface is convenient.
7
+ Every run produces durable metadata (status, events, snapshots, cleanup state). File bytes are **opt-in** via the submission's `outputDirs` field. There is a single method, `RunRef.download()`, that returns the whole run — metadata plus opt-in file bytes — as a streaming zip.
8
+
9
+ ## Quickstart
8
10
 
9
11
  ```ts
10
- const outputs = await ref.outputs(); // OutputSummary[]
11
- const { url } = await client.createOutputLink(ref.runId, outputs[0].id);
12
+ const ref = await client.submitRun({
13
+ model: "claude-opus-4-7",
14
+ prompt: "Produce a report and stash working files",
15
+ outputDirs: ["/workspace/outputs", "/workspace/state"],
16
+ secrets: { anthropic: { apiKey } }
17
+ });
18
+
19
+ await ref.download({ to: "./run-archive.zip" });
12
20
  ```
13
21
 
14
22
  ```bash
15
- antpath outputs list <run-id> --api-token --workspace --dashboard-url
16
- antpath outputs download <run-id> <output-id> --out ./dir \
17
- --api-token … --workspace … --dashboard-url …
23
+ antpath download <run-id> --out ./run-archive.zip --api-token
24
+ ```
25
+
26
+ ## What `download()` returns
27
+
28
+ A zip with this layout:
29
+
30
+ ```
31
+ run.json # run record (status, runId, timestamps, snapshot)
32
+ events.jsonl # one event per line, ordered
33
+ outputs/<name> # one file per captured output object
34
+ manifest.json # { source, partial, outputs, missing }
35
+ ```
36
+
37
+ `manifest.json` carries:
38
+
39
+ | Field | Meaning |
40
+ | --- | --- |
41
+ | `source: "live" \| "s3"` | Where file bytes came from. `live` = streamed from Anthropic Files API (mid-session); `s3` = streamed from Supabase Storage (after capture completed). |
42
+ | `partial: boolean` | `true` if the run had not finished capture yet — more bytes may exist on a later call. |
43
+ | `outputs[]` | `{ id, path, byteSize, contentType? }` — each row corresponds to a file under `outputs/` in the zip. |
44
+ | `missing[]` | `{ id?, path?, reason, message? }` — anything the platform tried to include but could not. Reasons documented below. |
45
+
46
+ ## Lifecycle behaviour
47
+
48
+ `download()` is lifecycle-aware in one method. You never branch on state — check `manifest.partial` if you care about staleness.
49
+
50
+ | Run state | Behaviour |
51
+ | --- | --- |
52
+ | `pending` / `queued` / `provisioning` | HTTP 409 `run_not_started`. Wait for the run to start. |
53
+ | `provider_running`, mid-session | Live: stream-zip from Anthropic Files API + DB metadata/events. `source: "live"`, `partial: true`. |
54
+ | `cleaning_up` | Same as `provider_running` until S3 capture completes; then same as terminal. |
55
+ | `succeeded` / `failed` / `cancelled` / `terminated` | Terminal: stream-zip from S3 `output_objects` + DB. `source: "s3"`, `partial: false`. |
56
+
57
+ ## `outputDirs` — opt-in file capture
58
+
59
+ ```ts
60
+ client.submitRun({
61
+ /* ... */,
62
+ outputDirs: ["/workspace/outputs", "/workspace/state"]
63
+ });
18
64
  ```
19
65
 
20
- `/antpath/outputs` is a recommended convention, not a provider default. Templates may instruct agents to write important artifacts there, but every session-scoped provider file is captured unconditionally (bounded only by the workspace storage cap; skipped files are recorded as `output_capture_failures`).
66
+ Validation:
67
+
68
+ - absolute UNIX paths only (`/...`),
69
+ - no `..` segments, no NUL bytes,
70
+ - maximum 32 entries,
71
+ - maximum 512 bytes per entry.
72
+
73
+ Mechanism (no platform-magical paths — this is honest):
74
+
75
+ 1. Worker submits the run, sends the user prompt, streams events.
76
+ 2. At session-idle (the agent's primary task is done), the worker sends one synthetic `user.message` to the agent:
77
+ *"Run `/antpath/antpath outputs sync <dirs>` once."*
78
+ 3. The agent runs that command via its bash tool. The in-container CLI walks the listed dirs, prints a JSON line per file, and the agent's tool output triggers Anthropic Managed Agents' file registration.
79
+ 4. Worker walks the Files API, copies bytes into Supabase Storage, and tears down the session.
80
+
81
+ Cost: one extra agent turn (~hundreds of tokens, observable in `span.model_request_*` events). Document this against your token budget if you submit very high-volume runs.
82
+
83
+ Failure modes — anything that did not make it into the zip lands in `manifest.missing[]`:
84
+
85
+ | `reason` | What happened |
86
+ | --- | --- |
87
+ | `agent_did_not_sync` | The agent refused or skipped the synthetic instruction. Run still succeeded, just no file bytes. |
88
+ | `agent_reported_error` | The `/antpath/antpath outputs sync` invocation returned non-zero (e.g. dir did not exist). |
89
+ | `session_terminated_pre_sync` | Session was terminated (cancel / timeout) before the sync turn ran. |
90
+ | `storage_cap_exceeded` | Workspace storage quota would have been breached. |
91
+ | `download_failed` | Files API entry could not be fetched. |
92
+ | `pending_session_terminal` | Mid-session download — file may show up on a later `download()` once the session reaches terminal. |
93
+
94
+ ## Runs without `outputDirs`
95
+
96
+ Metadata still gets the full treatment. `download()` returns a zip with `run.json`, `events.jsonl`, and an empty `outputs/` directory (manifest `outputs: []`). No file bytes are captured because no path was opted in. This is the default and is intentional — see `references/architecture-decisions.md` for the design rationale.
97
+
98
+ ## Mid-session download semantics
99
+
100
+ Mid-session calls are **best-effort and side-effect-free**: the BFF reads whatever the agent has already registered with the Files API and streams that. It does **not** trigger a synthetic sync — that would interfere with the running agent's plan. If you need the full output set, wait for the run to reach terminal status and call `download()` again. The manifest's `partial: true` flag tells you when to retry.
21
101
 
22
- Safety rules:
102
+ ## Safety
23
103
 
24
- - filenames are sanitized;
25
- - downloads stay within the requested local directory;
26
- - signed download links are short-lived;
27
- - manifests contain provider file IDs, local paths, names, and sizes only — never file bytes.
104
+ - Filenames are sanitized for cross-platform safety; collisions are disambiguated with a short id suffix before the extension.
105
+ - Downloads stay within the requested local directory.
106
+ - The archive endpoint is workspace-scoped (`outputs:read` scope) and rate-limited (`ANTPATH_RATE_LIMIT_RUN_ARCHIVE_PER_MINUTE`, default 30/min/workspace).
107
+ - `manifest.json` never contains file bytes only ids, paths, sizes, content types.
@@ -45,3 +45,40 @@ antpath run ./template.json \
45
45
  ```
46
46
 
47
47
  Both surfaces hit the same dashboard BFF and operate on the same durable run records — pick whichever is most convenient.
48
+
49
+ ## Safe retries with `idempotencyKey`
50
+
51
+ Every `submitRun` call carries an `idempotencyKey`. When omitted the SDK auto-generates a UUID per call. Supplying your own key makes retries deterministic:
52
+
53
+ | Submit shape | Server response |
54
+ | --- | --- |
55
+ | New `idempotencyKey` | HTTP 201 — new run created. |
56
+ | Same key + identical request body hash | HTTP 200 — returns the original run. The SDK call resolves with the existing `RunRef`. |
57
+ | Same key + **different** request body hash | HTTP 409 — body `{ error: { message, code: "idempotency_conflict", details: { existingRunId } } }`. The SDK throws an `HttpError` carrying that body. Use `details.existingRunId` to adopt the pre-existing run, or pick a fresh key. |
58
+ | Omitted `idempotencyKey` | A new UUID is generated on every call — repeat submissions create new runs. |
59
+
60
+ The request hash is computed server-side over the canonical submission JSON (model, prompt, system, environment, skill refs, MCP server descriptors, proxy endpoints, `outputDirs`, etc.) so reordering JSON keys, adding whitespace, or rotating the inline secret bundle does **not** change the hash. Bumping a variable or changing the prompt does.
61
+
62
+ Pattern for safe retries:
63
+
64
+ ```ts
65
+ const idempotencyKey = crypto.randomUUID();
66
+ async function submitWithRetry() {
67
+ for (let attempt = 0; attempt < 3; attempt++) {
68
+ try {
69
+ return await client.submitRun({
70
+ model: "claude-haiku-4-5",
71
+ prompt: "...",
72
+ idempotencyKey,
73
+ secrets: { anthropic: { apiKey: process.env.ANTHROPIC_API_KEY! } }
74
+ });
75
+ } catch (err) {
76
+ if (err instanceof Error && err.message.includes("network")) continue;
77
+ throw err;
78
+ }
79
+ }
80
+ throw new Error("submitRun failed after retries");
81
+ }
82
+ ```
83
+
84
+ The same `idempotencyKey` reused with the same body will deterministically resolve to the same run id regardless of how many times the network drops between attempts.
package/docs/skills.md CHANGED
@@ -16,3 +16,52 @@ Local directories are packaged as zip files and mounted under `/antpath/skills/`
16
16
  Inline skills are uploaded as markdown files and mounted under `/antpath/skills/` unless overridden.
17
17
 
18
18
  The platform also mounts the `antpath` CLI at `/antpath/antpath` and a per-run manifest at `/antpath/index.json` on **every** run. Skills can invoke the managed HTTP proxy via `/antpath/antpath proxy …` — see `credentials.md` for the policy/auth model.
19
+
20
+ ## Workspace skills: upload, find, reuse
21
+
22
+ Workspace skills persist across runs. Build a `Skill` from files, upload once, reference by `skl_*` id thereafter:
23
+
24
+ ```ts
25
+ import { AntpathClient, Skill } from "antpath";
26
+
27
+ const client = new AntpathClient({ apiToken });
28
+
29
+ // First publish.
30
+ const draft = await Skill.fromPath("./skills/broll-provider-ingest", {
31
+ name: "broll-provider-ingest"
32
+ });
33
+ const persisted = await draft.upload(client);
34
+ console.log(persisted.record!.id); // skl_…
35
+ ```
36
+
37
+ ### `uploadIfChanged(client)`
38
+
39
+ Repeat publishes are common in CI. `uploadIfChanged` first checks whether a live skill with the same `(name, contentHash)` already exists, and only uploads when the bytes have actually changed:
40
+
41
+ ```ts
42
+ const skill = await Skill.fromPath("./skills/broll-provider-ingest", {
43
+ name: "broll-provider-ingest"
44
+ }).then((draft) => draft.uploadIfChanged(client));
45
+ ```
46
+
47
+ `contentHash` is `sha256:<hex>` of the canonical bundle zip (the SDK normalises file order, mtime, and permissions before hashing). Identical inputs always produce the same hash, so a no-op `uploadIfChanged` returns the existing record without re-uploading bytes.
48
+
49
+ After the call resolves, the returned `Skill` is workspace-backed regardless of whether bytes were uploaded — pass it straight to `submitRun({ skills: [...] })` or call `.record!.id` to persist the id in your manifest.
50
+
51
+ ### `client.skills.findByHash(...)` / `findByName(...)`
52
+
53
+ You can also look up workspace skills directly:
54
+
55
+ ```ts
56
+ const byHash = await client.skills.findByHash({
57
+ name: "broll-provider-ingest",
58
+ contentHash: "sha256:..."
59
+ });
60
+
61
+ const byName = await client.skills.findByName("broll-provider-ingest");
62
+ ```
63
+
64
+ Both return `null` when no live, undeleted skill matches. `findByHash` is an indexed lookup on `(name, hash)` — use it when you have a precomputed hash. `findByName` is a list-and-filter convenience for the common "what's the current id of this skill?" case.
65
+
66
+ Soft-deleted skills are never returned by either method. Runs that pinned a soft-deleted skill at submission time keep working via their snapshot — see `references/architecture-decisions.md` for the full snapshot model.
67
+