@aexhq/sdk 0.40.6 → 0.40.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -159
- package/dist/_contracts/asset-upload-helper.d.ts +38 -0
- package/dist/_contracts/asset-upload-helper.js +111 -0
- package/dist/_contracts/internal.d.ts +5 -9
- package/dist/_contracts/internal.js +5 -79
- package/dist/_contracts/operations.d.ts +9 -2
- package/dist/_contracts/operations.js +141 -16
- package/dist/_contracts/retry-core.d.ts +27 -0
- package/dist/_contracts/retry-core.js +75 -0
- package/dist/_contracts/runtime-types.d.ts +6 -0
- package/dist/_contracts/stable.d.ts +10 -5
- package/dist/_contracts/stable.js +10 -5
- package/dist/asset-upload.d.ts +2 -1
- package/dist/asset-upload.js +28 -61
- package/dist/asset-upload.js.map +1 -1
- package/dist/cli.mjs +297 -80
- package/dist/cli.mjs.sha256 +1 -1
- package/dist/client.d.ts +5 -0
- package/dist/client.js +8 -13
- package/dist/client.js.map +1 -1
- package/dist/retry.js +13 -41
- package/dist/retry.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/authentication.md +125 -125
- package/docs/billing.md +118 -118
- package/docs/concepts/agent-tools.md +66 -66
- package/docs/concepts/composition.md +37 -37
- package/docs/concepts/providers-and-runtimes.md +54 -54
- package/docs/concepts/runs.md +49 -49
- package/docs/concepts/subagents.md +88 -88
- package/docs/credentials.md +125 -125
- package/docs/defaults.md +64 -64
- package/docs/errors.md +196 -196
- package/docs/events.md +243 -243
- package/docs/limits-and-quotas.md +144 -144
- package/docs/limits.md +50 -50
- package/docs/mcp.md +44 -44
- package/docs/networking.md +163 -163
- package/docs/outputs.md +267 -267
- package/docs/public-surface.json +77 -77
- package/docs/quickstart.md +119 -119
- package/docs/release.md +38 -38
- package/docs/retries.md +129 -129
- package/docs/run-config.md +58 -58
- package/docs/run-record.md +55 -55
- package/docs/secrets.md +125 -125
- package/docs/testing.md +35 -35
- package/docs/vision-skills.md +91 -91
- package/docs/webhooks.md +127 -127
- package/examples/feature-tour.ts +282 -282
- package/examples/spike-settle-latency.ts +125 -125
- package/package.json +1 -1
package/docs/outputs.md
CHANGED
|
@@ -1,267 +1,267 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Outputs
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
# Outputs
|
|
6
|
-
|
|
7
|
-
Every session produces durable metadata (status, events, cleanup state) and an outputs namespace. By default, managed runs capture the regular files present in the container when the agent exits, EXCLUDING the inputs the platform itself materialized for you (your mounted `files`/`skills`) — those are excluded by IDENTITY (their exact destination paths and skill-dir prefixes), not by any before/after timing comparison. There is no default or official output directory. Use `outputs.allowedDirs` only when you want to narrow capture to specific roots, and `outputs.deniedDirs` to subtract noise. `session.download()` returns the public session record — metadata, typed events, and captured output bytes — as a zip; the per-namespace verbs (`session.outputs().download()` / `session.events().download()` / `session.downloadMetadata()`) return one slice each.
|
|
8
|
-
|
|
9
|
-
The output verbs below hang off the session's `outputs()` accessor
|
|
10
|
-
(`session.outputs().list()`, `.read()`, `.download()`, …). Reach a handle from a
|
|
11
|
-
live session (`openSession` / `run`) or reopen one later with
|
|
12
|
-
`aex.openSession(sessionId)`; the client also exposes cross-session reads under
|
|
13
|
-
`aex.sessions.*`.
|
|
14
|
-
|
|
15
|
-
## Quickstart
|
|
16
|
-
|
|
17
|
-
```ts
|
|
18
|
-
import { Models } from "@aexhq/sdk";
|
|
19
|
-
|
|
20
|
-
const session = await aex.openSession({
|
|
21
|
-
model: Models.CLAUDE_HAIKU_4_5,
|
|
22
|
-
apiKeys: { anthropic: apiKey }
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
await session.send("Produce a report and save it as a file.").done();
|
|
26
|
-
await session.wait();
|
|
27
|
-
await session.download({ to: "./session.zip" });
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
npx aex download <session-id> --out ./session.zip --api-key …
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
## The three namespaces
|
|
35
|
-
|
|
36
|
-
A session's downloadable content is organised into three logical namespaces, each with a matching verb. Every zip is assembled **client-side** from the public read endpoints (session record + `session.events().list()` + `session.outputs().list()` + per-output `/download`) — there is no server-side archive route.
|
|
37
|
-
|
|
38
|
-
| Namespace | What it holds | Verb | CLI |
|
|
39
|
-
| --- | --- | --- | --- |
|
|
40
|
-
| `outputs` | The session's real deliverables. | `session.outputs().download()` | `download <id> --only outputs` |
|
|
41
|
-
| `events` | Typed event-channel records (`events.jsonl`). | `session.events().download()` | `download <id> --only events` |
|
|
42
|
-
| `metadata` | The session record (`run.json`). | `session.downloadMetadata()` | `download <id> --only metadata` |
|
|
43
|
-
|
|
44
|
-
Platform diagnostics are stored outside the public archive under `runs/<runId>/internal/logs/` for internal/admin access only. They are not exposed by the SDK download helpers or the public CLI.
|
|
45
|
-
|
|
46
|
-
## What `session.download()` returns
|
|
47
|
-
|
|
48
|
-
`session.download()` is the **whole-session** verb — it bundles the public namespaces as top-level folders. It is distinct from `session.outputs().download(selector)`, which fetches a single file. Layout:
|
|
49
|
-
|
|
50
|
-
```
|
|
51
|
-
metadata/run.json # run record (status, runId, timestamps, snapshot)
|
|
52
|
-
metadata/submission.json # public-safe submission snapshot, when available
|
|
53
|
-
metadata/cost.json # public cost telemetry, when available
|
|
54
|
-
events/events.jsonl # typed event-channel records, ordered
|
|
55
|
-
outputs/<name> # one file per deliverable
|
|
56
|
-
manifest.json # RunRecordManifestV1
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
`manifest.json` is the versioned `RunRecordManifestV1` described in [Run record](run-record.md). It carries:
|
|
60
|
-
|
|
61
|
-
| Field | Meaning |
|
|
62
|
-
| --- | --- |
|
|
63
|
-
| `schemaVersion` / `runRecordSchemaVersion` | Manifest and run-record contract versions. |
|
|
64
|
-
| `runId` | The run the zip was assembled for. |
|
|
65
|
-
| `namespaces[]` / `files[]` | Namespace inventory and per-file presence state. Optional submission/cost files are marked `present` only when the client assembled actual entries; custody remains `pending` until its writer/read path exists. |
|
|
66
|
-
| `outputs[]` | `{ id, filename, sizeBytes?, contentType? }` — one row per file successfully written under `outputs/`. |
|
|
67
|
-
| `errors[]` | `{ namespace, id, filename, message }` — per-artifact byte fetches that failed during assembly. Best-effort: a failure records an entry here and is skipped from the tree rather than aborting the whole zip. |
|
|
68
|
-
|
|
69
|
-
The single-namespace verbs return the same per-file bytes at the zip root (e.g. `session.outputs().download()` -> `report.txt` + a `manifest.json`; `session.events().download()` -> `events.jsonl`).
|
|
70
|
-
|
|
71
|
-
## Downloading one output
|
|
72
|
-
|
|
73
|
-
`session.outputs().download(selector)` returns a `Uint8Array`. Omit the selector to download the whole outputs namespace as a zip; pass an output from `session.outputs().list()`, an `{ id }`, or a path selector against the listed `Output.filename` values to download one file:
|
|
74
|
-
|
|
75
|
-
```ts
|
|
76
|
-
const allOutputs = await session.outputs().download();
|
|
77
|
-
await session.outputs().download(undefined, { to: "./outputs.zip" });
|
|
78
|
-
|
|
79
|
-
const report = await session.outputs().download({ path: "reports/report.txt" });
|
|
80
|
-
console.log(new TextDecoder().decode(report));
|
|
81
|
-
|
|
82
|
-
const looseReport = await session.outputs().download({ path: "report.txt", match: "suffix" });
|
|
83
|
-
console.log(looseReport.byteLength);
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
## Reading one output as text
|
|
87
|
-
|
|
88
|
-
`session.outputs().read(selector, options?)` reads ONE output file as byte-capped, decoded UTF-8 text. It streams the file and stops at `options.maxBytes` (default 50 KB, ceiling 10 MB), so a large deliverable never fully buffers — this is the read built for handing a session's output to an LLM tool. Select the file by `{ path }` (suffix-matchable) or `{ id }`. To read from a session id without a live handle, use `aex.sessions.outputs(sessionId).read(selector, options?)` — the same accessor, addressed by id.
|
|
89
|
-
|
|
90
|
-
```ts
|
|
91
|
-
const { text, truncated, totalBytes } = await session.outputs().read(
|
|
92
|
-
{ path: "report.md", match: "suffix" },
|
|
93
|
-
{ maxBytes: 50_000, grep: "error" }
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
if (truncated) {
|
|
97
|
-
// text is a prefix of a larger file — narrow with `grep` or a tighter `path`.
|
|
98
|
-
}
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
Check `truncated` before treating `text` as complete. Pass `options.grep` (a substring or `RegExp`) to keep only matching lines of the capped text. The returned `output` is the matched `Output` record, and `totalBytes` is the file's full size when the server reports it.
|
|
102
|
-
|
|
103
|
-
## Finding outputs
|
|
104
|
-
|
|
105
|
-
`session.outputs().list(query?)` can filter the captured output list client-side. Use `session.outputs().find(query)` when you want discovery to be explicit, or `session.outputs().findOne(query)` when exactly one file is expected:
|
|
106
|
-
|
|
107
|
-
```ts
|
|
108
|
-
const images = await session.outputs().find({ type: "image" });
|
|
109
|
-
const jsonReports = await session.outputs().list({
|
|
110
|
-
dir: "reports",
|
|
111
|
-
extension: ".json"
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
const report = await session.outputs().findOne({
|
|
115
|
-
filename: "summary.json",
|
|
116
|
-
contentType: "application/json"
|
|
117
|
-
});
|
|
118
|
-
if (report) {
|
|
119
|
-
const bytes = await session.outputs().download(report);
|
|
120
|
-
}
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
Query fields compose with AND semantics:
|
|
124
|
-
|
|
125
|
-
| Field | Match |
|
|
126
|
-
| --- | --- |
|
|
127
|
-
| `path` | Exact normalized output path. Leading `/` and `outputs/` are ignored. |
|
|
128
|
-
| `filename` | Basename match, as a string or `RegExp`. |
|
|
129
|
-
| `dir` / `recursive` | Directory prefix. `recursive` defaults to `true`; set `false` for direct children only. |
|
|
130
|
-
| `extension` | Case-insensitive extension, with or without a leading dot. |
|
|
131
|
-
| `contentType` | Exact content type or a prefix wildcard such as `image/*`. |
|
|
132
|
-
| `type` | High-level type: `text`, `json`, `image`, `audio`, `video`, `pdf`, `archive`, `binary`, or `unknown`. |
|
|
133
|
-
|
|
134
|
-
`session.outputs().findOne(query)` returns `null` when nothing matches and throws `RunStateError` when the query matches more than one output.
|
|
135
|
-
|
|
136
|
-
## Searching outputs
|
|
137
|
-
|
|
138
|
-
Search is metadata-only (reference hits — filename / extension / content type — no bytes). `filename` accepts a `string` (case-insensitive substring) or a `RegExp`. A content-shaped query (`content`/`text`/…) throws a typed "content search unsupported" rather than silently returning zero hits.
|
|
139
|
-
|
|
140
|
-
```ts
|
|
141
|
-
// One session's outputs:
|
|
142
|
-
const hits = await session.outputs().search({ filename: /report/i });
|
|
143
|
-
|
|
144
|
-
// Across every run in the workspace (or scope with runIds):
|
|
145
|
-
const all = await aex.outputs.search({ extension: "md", runIds: ["run-a", "run-b"] });
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
Each hit is `{ runId, outputId, filename?, sizeBytes?, contentType? }`; read the bytes with `session.outputs().read(...)` / `.download(...)`.
|
|
149
|
-
|
|
150
|
-
## CLI
|
|
151
|
-
|
|
152
|
-
The `aex outputs` verb is a thin pass-through over the same SDK accessor, so every per-file operation has a subcommand (`npx aex` on a local install):
|
|
153
|
-
|
|
154
|
-
```bash
|
|
155
|
-
npx aex outputs <session-id> # list captured outputs (NDJSON)
|
|
156
|
-
npx aex outputs read <session-id> <path> # read one file as capped text (JSON)
|
|
157
|
-
npx aex outputs download <session-id> <path> --out f # download one file's raw bytes
|
|
158
|
-
npx aex outputs link <session-id> <path> # mint a temporary download URL (JSON)
|
|
159
|
-
npx aex outputs find <session-id> --name S --ext E --type T
|
|
160
|
-
npx aex outputs search --query S --ext E --run-id ID # cross-run metadata search
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
`aex outputs search` (no session id) is the cross-run search (`aex.outputs.search`); the whole-namespace zip stays `aex download <session-id>`.
|
|
164
|
-
|
|
165
|
-
## Temporary output links
|
|
166
|
-
|
|
167
|
-
Use `session.outputs().link(selectorOrQuery, options?)` when another process, browser, media tag, or downloader needs a direct artifact URL instead of bytes buffered through the SDK.
|
|
168
|
-
|
|
169
|
-
```ts
|
|
170
|
-
const link = await session.outputs().link(
|
|
171
|
-
{ path: "reports/summary.json" },
|
|
172
|
-
{ expiresIn: "15m" }
|
|
173
|
-
);
|
|
174
|
-
|
|
175
|
-
console.log(link.url, link.expiresAt);
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
Selectors can be an output id, an `Output` object, a path selector, or an `OutputQuery`. `expiresIn` accepts seconds or `"15m"`, `"1h"`, or `"1d"`; the default is `"1h"`.
|
|
179
|
-
|
|
180
|
-
The returned URL is a reusable bearer URL until it expires. Anyone who has it can read that artifact during the TTL. aex does not promise one-time use or early revocation for these direct artifact URLs.
|
|
181
|
-
|
|
182
|
-
For large files, `session.outputs().fetch()` mints the same temporary URL and returns the `Response` from fetching it directly, without adding the SDK API key to that second request:
|
|
183
|
-
|
|
184
|
-
```ts
|
|
185
|
-
const response = await session.outputs().fetch({ type: "video", filename: /clip\.mp4$/ });
|
|
186
|
-
const stream = response.body;
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
## Lifecycle behaviour
|
|
190
|
-
|
|
191
|
-
`session.download()` works at any session state — it reads whatever the public endpoints currently expose, so the zip reflects the session as of the call:
|
|
192
|
-
|
|
193
|
-
| Run state | Behaviour |
|
|
194
|
-
| --- | --- |
|
|
195
|
-
| `queued` / `claiming` / `provisioning` | `metadata/run.json` reflects the early state; `events/` and `outputs/` are typically empty. |
|
|
196
|
-
| `provider_running`, mid-session / `capturing_outputs` / `cleaning_up` | Whatever events + outputs have been captured so far. Call again after the session parks for the complete set. |
|
|
197
|
-
| `idle` / `suspended` (parked between turns) | The complete archive for every turn sent so far; a later turn appends to it. |
|
|
198
|
-
| `succeeded` / `failed` / `timed_out` / `cancelled` | The complete typed event archive + all captured outputs. |
|
|
199
|
-
|
|
200
|
-
## `outputs.allowedDirs` — override capture roots
|
|
201
|
-
|
|
202
|
-
```ts
|
|
203
|
-
aex.openSession({
|
|
204
|
-
/* ... */
|
|
205
|
-
outputs: {
|
|
206
|
-
allowedDirs: ["/workspace/reports", "/workspace/state"]
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
When omitted, aex captures the whole filesystem delta. When supplied, `outputs.allowedDirs` is a whitelist that replaces that default with the listed roots. In other words, explicit `outputs.allowedDirs` narrows capture; it does not add paths on top of `/`.
|
|
212
|
-
|
|
213
|
-
Validation:
|
|
214
|
-
|
|
215
|
-
- absolute UNIX paths only (`/...`),
|
|
216
|
-
- no `..` segments, no NUL bytes,
|
|
217
|
-
- maximum 32 entries,
|
|
218
|
-
- maximum 512 bytes per entry.
|
|
219
|
-
|
|
220
|
-
Runtime notes:
|
|
221
|
-
|
|
222
|
-
- The managed runtime captures the regular files under the capture roots at terminal time, EXCLUDING the inputs the platform itself materialized (your mounted `files`/`skills`) by IDENTITY — their exact destination paths and skill-dir prefixes are threaded into the capture filter, so an untouched mounted input is never re-emitted as an output regardless of path policy or timing.
|
|
223
|
-
- If you pass an explicit root that does not exist by terminal time, that root contributes no files.
|
|
224
|
-
|
|
225
|
-
## `outputs.deniedDirs` — subtract noise
|
|
226
|
-
|
|
227
|
-
```ts
|
|
228
|
-
aex.openSession({
|
|
229
|
-
/* ... */
|
|
230
|
-
outputs: {
|
|
231
|
-
deniedDirs: ["node_modules", "/var/cache", "*.tmp"]
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
`outputs.deniedDirs` is subtracted from the capture roots. Entries may be an absolute subtree (`/var/cache`), a bare path segment (`node_modules`), or a `*.ext` extension match. Denied entries beat allowed roots. Platform-mandatory excludes, including pseudo-filesystems and secret/platform paths, always apply and cannot be re-included.
|
|
237
|
-
|
|
238
|
-
Mechanism (no platform-magical paths — this is honest):
|
|
239
|
-
|
|
240
|
-
1. The hosted platform materializes the workspace (your mounted `files`/`skills`) and records the exact destination paths + skill-dir prefixes it wrote.
|
|
241
|
-
2. The agent runs normally. There is no extra model turn and no synthetic sync instruction.
|
|
242
|
-
3. When the agent exits, the runner scans the capture roots and drops any file whose path is a materialized INPUT (exact path or under a materialized skill dir) — inputs are excluded by WHO PUT THEM THERE (the platform), not by timing.
|
|
243
|
-
4. The runner uploads the remaining regular files to durable run artifact storage. Diagnostic log paths are routed to internal diagnostics under `runs/<runId>/internal/logs/`; other paths are routed to `outputs`.
|
|
244
|
-
|
|
245
|
-
Cost: output capture does not add a model turn. The runner pays a filesystem scan and upload cost near the end of the run.
|
|
246
|
-
|
|
247
|
-
Capture notes:
|
|
248
|
-
|
|
249
|
-
- Files over a configured per-file size cap are skipped.
|
|
250
|
-
- Once total file or byte caps are reached, remaining changed files are dropped from upload.
|
|
251
|
-
- Files that vanish between scan and upload are skipped.
|
|
252
|
-
- Upload failures are recorded in runner diagnostics. The zip's `manifest.errors[]` only records byte fetches that failed while assembling the download archive.
|
|
253
|
-
|
|
254
|
-
## Runs without explicit `outputs.allowedDirs`
|
|
255
|
-
|
|
256
|
-
Metadata still gets the full treatment. aex captures every regular file the run created or modified outside mandatory platform excludes. A run that produces no files still returns a zip with `run.json`, `events.jsonl`, and an empty `outputs/` directory (manifest `outputs: []`).
|
|
257
|
-
|
|
258
|
-
## Mid-session download semantics
|
|
259
|
-
|
|
260
|
-
Mid-session calls are **best-effort and side-effect-free**: they expose whatever artifacts have already been uploaded. Files written by the agent are normally uploaded near terminal, after the runner scans the capture roots. If you need the full output set, wait for the session to park and call `session.download()` again.
|
|
261
|
-
|
|
262
|
-
## Safety
|
|
263
|
-
|
|
264
|
-
- Filenames are sanitized for cross-platform safety; collisions are disambiguated with a short id suffix before the extension.
|
|
265
|
-
- Downloads stay within the requested local directory.
|
|
266
|
-
- The archive endpoint is workspace-scoped (`outputs:read` scope) and rate-limited (`AEX_RATE_LIMIT_RUN_ARCHIVE_PER_MINUTE`, default 30/min/workspace).
|
|
267
|
-
- `manifest.json` never contains file bytes — only ids, paths, sizes, content types.
|
|
1
|
+
---
|
|
2
|
+
title: Outputs
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Outputs
|
|
6
|
+
|
|
7
|
+
Every session produces durable metadata (status, events, cleanup state) and an outputs namespace. By default, managed runs capture the regular files present in the container when the agent exits, EXCLUDING the inputs the platform itself materialized for you (your mounted `files`/`skills`) — those are excluded by IDENTITY (their exact destination paths and skill-dir prefixes), not by any before/after timing comparison. There is no default or official output directory. Use `outputs.allowedDirs` only when you want to narrow capture to specific roots, and `outputs.deniedDirs` to subtract noise. `session.download()` returns the public session record — metadata, typed events, and captured output bytes — as a zip; the per-namespace verbs (`session.outputs().download()` / `session.events().download()` / `session.downloadMetadata()`) return one slice each.
|
|
8
|
+
|
|
9
|
+
The output verbs below hang off the session's `outputs()` accessor
|
|
10
|
+
(`session.outputs().list()`, `.read()`, `.download()`, …). Reach a handle from a
|
|
11
|
+
live session (`openSession` / `run`) or reopen one later with
|
|
12
|
+
`aex.openSession(sessionId)`; the client also exposes cross-session reads under
|
|
13
|
+
`aex.sessions.*`.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Models } from "@aexhq/sdk";
|
|
19
|
+
|
|
20
|
+
const session = await aex.openSession({
|
|
21
|
+
model: Models.CLAUDE_HAIKU_4_5,
|
|
22
|
+
apiKeys: { anthropic: apiKey }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
await session.send("Produce a report and save it as a file.").done();
|
|
26
|
+
await session.wait();
|
|
27
|
+
await session.download({ to: "./session.zip" });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx aex download <session-id> --out ./session.zip --api-key …
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## The three namespaces
|
|
35
|
+
|
|
36
|
+
A session's downloadable content is organised into three logical namespaces, each with a matching verb. Every zip is assembled **client-side** from the public read endpoints (session record + `session.events().list()` + `session.outputs().list()` + per-output `/download`) — there is no server-side archive route.
|
|
37
|
+
|
|
38
|
+
| Namespace | What it holds | Verb | CLI |
|
|
39
|
+
| --- | --- | --- | --- |
|
|
40
|
+
| `outputs` | The session's real deliverables. | `session.outputs().download()` | `download <id> --only outputs` |
|
|
41
|
+
| `events` | Typed event-channel records (`events.jsonl`). | `session.events().download()` | `download <id> --only events` |
|
|
42
|
+
| `metadata` | The session record (`run.json`). | `session.downloadMetadata()` | `download <id> --only metadata` |
|
|
43
|
+
|
|
44
|
+
Platform diagnostics are stored outside the public archive under `runs/<runId>/internal/logs/` for internal/admin access only. They are not exposed by the SDK download helpers or the public CLI.
|
|
45
|
+
|
|
46
|
+
## What `session.download()` returns
|
|
47
|
+
|
|
48
|
+
`session.download()` is the **whole-session** verb — it bundles the public namespaces as top-level folders. It is distinct from `session.outputs().download(selector)`, which fetches a single file. Layout:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
metadata/run.json # run record (status, runId, timestamps, snapshot)
|
|
52
|
+
metadata/submission.json # public-safe submission snapshot, when available
|
|
53
|
+
metadata/cost.json # public cost telemetry, when available
|
|
54
|
+
events/events.jsonl # typed event-channel records, ordered
|
|
55
|
+
outputs/<name> # one file per deliverable
|
|
56
|
+
manifest.json # RunRecordManifestV1
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`manifest.json` is the versioned `RunRecordManifestV1` described in [Run record](run-record.md). It carries:
|
|
60
|
+
|
|
61
|
+
| Field | Meaning |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `schemaVersion` / `runRecordSchemaVersion` | Manifest and run-record contract versions. |
|
|
64
|
+
| `runId` | The run the zip was assembled for. |
|
|
65
|
+
| `namespaces[]` / `files[]` | Namespace inventory and per-file presence state. Optional submission/cost files are marked `present` only when the client assembled actual entries; custody remains `pending` until its writer/read path exists. |
|
|
66
|
+
| `outputs[]` | `{ id, filename, sizeBytes?, contentType? }` — one row per file successfully written under `outputs/`. |
|
|
67
|
+
| `errors[]` | `{ namespace, id, filename, message }` — per-artifact byte fetches that failed during assembly. Best-effort: a failure records an entry here and is skipped from the tree rather than aborting the whole zip. |
|
|
68
|
+
|
|
69
|
+
The single-namespace verbs return the same per-file bytes at the zip root (e.g. `session.outputs().download()` -> `report.txt` + a `manifest.json`; `session.events().download()` -> `events.jsonl`).
|
|
70
|
+
|
|
71
|
+
## Downloading one output
|
|
72
|
+
|
|
73
|
+
`session.outputs().download(selector)` returns a `Uint8Array`. Omit the selector to download the whole outputs namespace as a zip; pass an output from `session.outputs().list()`, an `{ id }`, or a path selector against the listed `Output.filename` values to download one file:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const allOutputs = await session.outputs().download();
|
|
77
|
+
await session.outputs().download(undefined, { to: "./outputs.zip" });
|
|
78
|
+
|
|
79
|
+
const report = await session.outputs().download({ path: "reports/report.txt" });
|
|
80
|
+
console.log(new TextDecoder().decode(report));
|
|
81
|
+
|
|
82
|
+
const looseReport = await session.outputs().download({ path: "report.txt", match: "suffix" });
|
|
83
|
+
console.log(looseReport.byteLength);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Reading one output as text
|
|
87
|
+
|
|
88
|
+
`session.outputs().read(selector, options?)` reads ONE output file as byte-capped, decoded UTF-8 text. It streams the file and stops at `options.maxBytes` (default 50 KB, ceiling 10 MB), so a large deliverable never fully buffers — this is the read built for handing a session's output to an LLM tool. Select the file by `{ path }` (suffix-matchable) or `{ id }`. To read from a session id without a live handle, use `aex.sessions.outputs(sessionId).read(selector, options?)` — the same accessor, addressed by id.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const { text, truncated, totalBytes } = await session.outputs().read(
|
|
92
|
+
{ path: "report.md", match: "suffix" },
|
|
93
|
+
{ maxBytes: 50_000, grep: "error" }
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (truncated) {
|
|
97
|
+
// text is a prefix of a larger file — narrow with `grep` or a tighter `path`.
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Check `truncated` before treating `text` as complete. Pass `options.grep` (a substring or `RegExp`) to keep only matching lines of the capped text. The returned `output` is the matched `Output` record, and `totalBytes` is the file's full size when the server reports it.
|
|
102
|
+
|
|
103
|
+
## Finding outputs
|
|
104
|
+
|
|
105
|
+
`session.outputs().list(query?)` can filter the captured output list client-side. Use `session.outputs().find(query)` when you want discovery to be explicit, or `session.outputs().findOne(query)` when exactly one file is expected:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const images = await session.outputs().find({ type: "image" });
|
|
109
|
+
const jsonReports = await session.outputs().list({
|
|
110
|
+
dir: "reports",
|
|
111
|
+
extension: ".json"
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const report = await session.outputs().findOne({
|
|
115
|
+
filename: "summary.json",
|
|
116
|
+
contentType: "application/json"
|
|
117
|
+
});
|
|
118
|
+
if (report) {
|
|
119
|
+
const bytes = await session.outputs().download(report);
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Query fields compose with AND semantics:
|
|
124
|
+
|
|
125
|
+
| Field | Match |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| `path` | Exact normalized output path. Leading `/` and `outputs/` are ignored. |
|
|
128
|
+
| `filename` | Basename match, as a string or `RegExp`. |
|
|
129
|
+
| `dir` / `recursive` | Directory prefix. `recursive` defaults to `true`; set `false` for direct children only. |
|
|
130
|
+
| `extension` | Case-insensitive extension, with or without a leading dot. |
|
|
131
|
+
| `contentType` | Exact content type or a prefix wildcard such as `image/*`. |
|
|
132
|
+
| `type` | High-level type: `text`, `json`, `image`, `audio`, `video`, `pdf`, `archive`, `binary`, or `unknown`. |
|
|
133
|
+
|
|
134
|
+
`session.outputs().findOne(query)` returns `null` when nothing matches and throws `RunStateError` when the query matches more than one output.
|
|
135
|
+
|
|
136
|
+
## Searching outputs
|
|
137
|
+
|
|
138
|
+
Search is metadata-only (reference hits — filename / extension / content type — no bytes). `filename` accepts a `string` (case-insensitive substring) or a `RegExp`. A content-shaped query (`content`/`text`/…) throws a typed "content search unsupported" rather than silently returning zero hits.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// One session's outputs:
|
|
142
|
+
const hits = await session.outputs().search({ filename: /report/i });
|
|
143
|
+
|
|
144
|
+
// Across every run in the workspace (or scope with runIds):
|
|
145
|
+
const all = await aex.outputs.search({ extension: "md", runIds: ["run-a", "run-b"] });
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Each hit is `{ runId, outputId, filename?, sizeBytes?, contentType? }`; read the bytes with `session.outputs().read(...)` / `.download(...)`.
|
|
149
|
+
|
|
150
|
+
## CLI
|
|
151
|
+
|
|
152
|
+
The `aex outputs` verb is a thin pass-through over the same SDK accessor, so every per-file operation has a subcommand (`npx aex` on a local install):
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npx aex outputs <session-id> # list captured outputs (NDJSON)
|
|
156
|
+
npx aex outputs read <session-id> <path> # read one file as capped text (JSON)
|
|
157
|
+
npx aex outputs download <session-id> <path> --out f # download one file's raw bytes
|
|
158
|
+
npx aex outputs link <session-id> <path> # mint a temporary download URL (JSON)
|
|
159
|
+
npx aex outputs find <session-id> --name S --ext E --type T
|
|
160
|
+
npx aex outputs search --query S --ext E --run-id ID # cross-run metadata search
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`aex outputs search` (no session id) is the cross-run search (`aex.outputs.search`); the whole-namespace zip stays `aex download <session-id>`.
|
|
164
|
+
|
|
165
|
+
## Temporary output links
|
|
166
|
+
|
|
167
|
+
Use `session.outputs().link(selectorOrQuery, options?)` when another process, browser, media tag, or downloader needs a direct artifact URL instead of bytes buffered through the SDK.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const link = await session.outputs().link(
|
|
171
|
+
{ path: "reports/summary.json" },
|
|
172
|
+
{ expiresIn: "15m" }
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
console.log(link.url, link.expiresAt);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Selectors can be an output id, an `Output` object, a path selector, or an `OutputQuery`. `expiresIn` accepts seconds or `"15m"`, `"1h"`, or `"1d"`; the default is `"1h"`.
|
|
179
|
+
|
|
180
|
+
The returned URL is a reusable bearer URL until it expires. Anyone who has it can read that artifact during the TTL. aex does not promise one-time use or early revocation for these direct artifact URLs.
|
|
181
|
+
|
|
182
|
+
For large files, `session.outputs().fetch()` mints the same temporary URL and returns the `Response` from fetching it directly, without adding the SDK API key to that second request:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
const response = await session.outputs().fetch({ type: "video", filename: /clip\.mp4$/ });
|
|
186
|
+
const stream = response.body;
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Lifecycle behaviour
|
|
190
|
+
|
|
191
|
+
`session.download()` works at any session state — it reads whatever the public endpoints currently expose, so the zip reflects the session as of the call:
|
|
192
|
+
|
|
193
|
+
| Run state | Behaviour |
|
|
194
|
+
| --- | --- |
|
|
195
|
+
| `queued` / `claiming` / `provisioning` | `metadata/run.json` reflects the early state; `events/` and `outputs/` are typically empty. |
|
|
196
|
+
| `provider_running`, mid-session / `capturing_outputs` / `cleaning_up` | Whatever events + outputs have been captured so far. Call again after the session parks for the complete set. |
|
|
197
|
+
| `idle` / `suspended` (parked between turns) | The complete archive for every turn sent so far; a later turn appends to it. |
|
|
198
|
+
| `succeeded` / `failed` / `timed_out` / `cancelled` | The complete typed event archive + all captured outputs. |
|
|
199
|
+
|
|
200
|
+
## `outputs.allowedDirs` — override capture roots
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
aex.openSession({
|
|
204
|
+
/* ... */
|
|
205
|
+
outputs: {
|
|
206
|
+
allowedDirs: ["/workspace/reports", "/workspace/state"]
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
When omitted, aex captures the whole filesystem delta. When supplied, `outputs.allowedDirs` is a whitelist that replaces that default with the listed roots. In other words, explicit `outputs.allowedDirs` narrows capture; it does not add paths on top of `/`.
|
|
212
|
+
|
|
213
|
+
Validation:
|
|
214
|
+
|
|
215
|
+
- absolute UNIX paths only (`/...`),
|
|
216
|
+
- no `..` segments, no NUL bytes,
|
|
217
|
+
- maximum 32 entries,
|
|
218
|
+
- maximum 512 bytes per entry.
|
|
219
|
+
|
|
220
|
+
Runtime notes:
|
|
221
|
+
|
|
222
|
+
- The managed runtime captures the regular files under the capture roots at terminal time, EXCLUDING the inputs the platform itself materialized (your mounted `files`/`skills`) by IDENTITY — their exact destination paths and skill-dir prefixes are threaded into the capture filter, so an untouched mounted input is never re-emitted as an output regardless of path policy or timing.
|
|
223
|
+
- If you pass an explicit root that does not exist by terminal time, that root contributes no files.
|
|
224
|
+
|
|
225
|
+
## `outputs.deniedDirs` — subtract noise
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
aex.openSession({
|
|
229
|
+
/* ... */
|
|
230
|
+
outputs: {
|
|
231
|
+
deniedDirs: ["node_modules", "/var/cache", "*.tmp"]
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`outputs.deniedDirs` is subtracted from the capture roots. Entries may be an absolute subtree (`/var/cache`), a bare path segment (`node_modules`), or a `*.ext` extension match. Denied entries beat allowed roots. Platform-mandatory excludes, including pseudo-filesystems and secret/platform paths, always apply and cannot be re-included.
|
|
237
|
+
|
|
238
|
+
Mechanism (no platform-magical paths — this is honest):
|
|
239
|
+
|
|
240
|
+
1. The hosted platform materializes the workspace (your mounted `files`/`skills`) and records the exact destination paths + skill-dir prefixes it wrote.
|
|
241
|
+
2. The agent runs normally. There is no extra model turn and no synthetic sync instruction.
|
|
242
|
+
3. When the agent exits, the runner scans the capture roots and drops any file whose path is a materialized INPUT (exact path or under a materialized skill dir) — inputs are excluded by WHO PUT THEM THERE (the platform), not by timing.
|
|
243
|
+
4. The runner uploads the remaining regular files to durable run artifact storage. Diagnostic log paths are routed to internal diagnostics under `runs/<runId>/internal/logs/`; other paths are routed to `outputs`.
|
|
244
|
+
|
|
245
|
+
Cost: output capture does not add a model turn. The runner pays a filesystem scan and upload cost near the end of the run.
|
|
246
|
+
|
|
247
|
+
Capture notes:
|
|
248
|
+
|
|
249
|
+
- Files over a configured per-file size cap are skipped.
|
|
250
|
+
- Once total file or byte caps are reached, remaining changed files are dropped from upload.
|
|
251
|
+
- Files that vanish between scan and upload are skipped.
|
|
252
|
+
- Upload failures are recorded in runner diagnostics. The zip's `manifest.errors[]` only records byte fetches that failed while assembling the download archive.
|
|
253
|
+
|
|
254
|
+
## Runs without explicit `outputs.allowedDirs`
|
|
255
|
+
|
|
256
|
+
Metadata still gets the full treatment. aex captures every regular file the run created or modified outside mandatory platform excludes. A run that produces no files still returns a zip with `run.json`, `events.jsonl`, and an empty `outputs/` directory (manifest `outputs: []`).
|
|
257
|
+
|
|
258
|
+
## Mid-session download semantics
|
|
259
|
+
|
|
260
|
+
Mid-session calls are **best-effort and side-effect-free**: they expose whatever artifacts have already been uploaded. Files written by the agent are normally uploaded near terminal, after the runner scans the capture roots. If you need the full output set, wait for the session to park and call `session.download()` again.
|
|
261
|
+
|
|
262
|
+
## Safety
|
|
263
|
+
|
|
264
|
+
- Filenames are sanitized for cross-platform safety; collisions are disambiguated with a short id suffix before the extension.
|
|
265
|
+
- Downloads stay within the requested local directory.
|
|
266
|
+
- The archive endpoint is workspace-scoped (`outputs:read` scope) and rate-limited (`AEX_RATE_LIMIT_RUN_ARCHIVE_PER_MINUTE`, default 30/min/workspace).
|
|
267
|
+
- `manifest.json` never contains file bytes — only ids, paths, sizes, content types.
|