@prized/sdk 0.0.0-stage → 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +625 -2
  3. package/dist/cjs/commands.d.ts +168 -0
  4. package/dist/cjs/commands.js +567 -0
  5. package/dist/cjs/errors.d.ts +73 -0
  6. package/dist/cjs/errors.js +258 -0
  7. package/dist/cjs/files.d.ts +86 -0
  8. package/dist/cjs/files.js +366 -0
  9. package/dist/cjs/http.d.ts +210 -0
  10. package/dist/cjs/http.js +570 -0
  11. package/dist/cjs/index.d.ts +12 -0
  12. package/dist/cjs/index.js +22 -0
  13. package/dist/cjs/outcome.d.ts +75 -0
  14. package/dist/cjs/outcome.js +167 -0
  15. package/dist/cjs/output.d.ts +59 -0
  16. package/dist/cjs/output.js +288 -0
  17. package/dist/cjs/package.json +1 -0
  18. package/dist/cjs/sandbox.d.ts +218 -0
  19. package/dist/cjs/sandbox.js +810 -0
  20. package/dist/cjs/shell.d.ts +152 -0
  21. package/dist/cjs/shell.js +393 -0
  22. package/dist/cjs/toolbox.d.ts +48 -0
  23. package/dist/cjs/toolbox.js +86 -0
  24. package/dist/cjs/types.d.ts +99 -0
  25. package/dist/cjs/types.js +102 -0
  26. package/dist/cjs/version.d.ts +2 -0
  27. package/dist/cjs/version.js +5 -0
  28. package/dist/esm/commands.d.ts +168 -0
  29. package/dist/esm/commands.js +560 -0
  30. package/dist/esm/errors.d.ts +73 -0
  31. package/dist/esm/errors.js +250 -0
  32. package/dist/esm/files.d.ts +86 -0
  33. package/dist/esm/files.js +360 -0
  34. package/dist/esm/http.d.ts +210 -0
  35. package/dist/esm/http.js +553 -0
  36. package/dist/esm/index.d.ts +12 -0
  37. package/dist/esm/index.js +7 -0
  38. package/dist/esm/outcome.d.ts +75 -0
  39. package/dist/esm/outcome.js +152 -0
  40. package/dist/esm/output.d.ts +59 -0
  41. package/dist/esm/output.js +280 -0
  42. package/dist/esm/sandbox.d.ts +218 -0
  43. package/dist/esm/sandbox.js +806 -0
  44. package/dist/esm/shell.d.ts +152 -0
  45. package/dist/esm/shell.js +379 -0
  46. package/dist/esm/toolbox.d.ts +48 -0
  47. package/dist/esm/toolbox.js +81 -0
  48. package/dist/esm/types.d.ts +99 -0
  49. package/dist/esm/types.js +95 -0
  50. package/dist/esm/version.d.ts +2 -0
  51. package/dist/esm/version.js +2 -0
  52. package/package.json +53 -4
package/README.md CHANGED
@@ -1,3 +1,626 @@
1
- # Temporary Holding Version
1
+ # Prized Sandboxes (beta): TypeScript SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Create sandboxes (microVMs your code runs in) from TypeScript: run commands,
4
+ move files, pause for the price of storage, resume where you left off, and
5
+ fork a running or paused sandbox into many live copies.
6
+
7
+ ```ts
8
+ import { Sandbox } from '@prized/sdk';
9
+
10
+ const sandbox = await Sandbox.create({ template: 'prized-base-v1' });
11
+ const { stdout } = await sandbox.exec('python3 -c "print(6 * 7)"');
12
+ console.log(stdout); // 42
13
+ await sandbox.delete();
14
+ ```
15
+
16
+ Sandboxes are in beta. A workspace on free credit alone runs them on the
17
+ free tier: up to 10 at once, each up to 2 vCPU and 4 GB of memory and 60
18
+ minutes of running. A top-up or a paid plan lifts the limits: a sandbox then
19
+ runs up to 7 days at a stretch, and a paused one is kept until you delete it.
20
+
21
+ - [Install](#install)
22
+ - [Authentication](#authentication)
23
+ - [Create, connect, list](#create-connect-list)
24
+ - [Run commands](#run-commands)
25
+ - [Background commands](#background-commands)
26
+ - [Files](#files)
27
+ - [Pause, resume, refresh, delete](#pause-resume-refresh-delete)
28
+ - [Run time: extend anytime](#run-time-extend-anytime)
29
+ - [Fork](#fork)
30
+ - [Templates](#templates)
31
+ - [Errors](#errors)
32
+ - [Retries and timeouts](#retries-and-timeouts)
33
+ - [Limits](#limits)
34
+ - [Runtimes](#runtimes)
35
+ - [License](#license)
36
+
37
+ ## Install
38
+
39
+ ```sh
40
+ npm install @prized/sdk
41
+ ```
42
+
43
+ No runtime dependencies. ESM and CommonJS, with type declarations.
44
+
45
+ ## Authentication
46
+
47
+ Make an API key on the [API keys page](https://prized.dev/dashboard/workspace/api-keys)
48
+ and put it in `PRIZED_API_KEY`, or pass it in. New to Prized?
49
+ [Open Sandboxes](https://prized.dev/onboarding/sandboxes) signs you up (free
50
+ credit, no card) and makes one.
51
+
52
+ ```ts
53
+ const sandbox = await Sandbox.create({ apiKey: 'dcp_…' });
54
+ ```
55
+
56
+ The key acts as you and sees every sandbox in your workspace. A key limited
57
+ to fewer permissions needs **Sandboxes: Write** to create, run commands and
58
+ move files; **Sandboxes: Read** is enough for `list`, `connect`, `refresh` and
59
+ `templates`.
60
+
61
+ Every entry point (`create`, `connect`, `list`, `deleteByLabels`, `templates`) takes the same
62
+ client options:
63
+
64
+ | Option | Default | |
65
+ |---|---|---|
66
+ | `apiKey` | `process.env.PRIZED_API_KEY` | |
67
+ | `baseUrl` | `https://api.prized.dev` | The API origin. |
68
+ | `edgeUrl` | the sandbox's own `toolboxBase` | Where commands and files go. |
69
+ | `fetch` | global `fetch` | Bring your own (for a proxy, custom agent, tests). |
70
+ | `maxRetries` | `3` | See [Retries](#retries-and-timeouts). |
71
+ | `requestTimeoutMs` | `60000` | Per API request. |
72
+ | `pollIntervalMs` | `500` | The longest the SDK's own polling goes between checks of the sandbox (the first come sooner). It does not cover the request itself, or the time the API holds a create, pause or resume for you (up to 8 seconds). |
73
+ | `pollOutageMs` | `120000` | How long a streamed or long `exec` keeps polling through a dropped connection (or a 5xx, or a rate limit) before it stops the command. `exec` takes it per call too. |
74
+
75
+ A sandbox remembers the options it was made with: its methods need none.
76
+
77
+ ## Create, connect, list
78
+
79
+ ### `Sandbox.create(options?)`
80
+
81
+ Creates a sandbox and resolves once it is `running`, ready for commands.
82
+
83
+ ```ts
84
+ const sandbox = await Sandbox.create({
85
+ template: 'prized-agent-v1', // default 'prized-base-v1'
86
+ resources: { vcpu: 2, memoryGb: 4, diskGb: 20 }, // or memoryMb: 4096; omitted fields take the template default
87
+ envVars: { NODE_ENV: 'test' }, // for every command in the sandbox
88
+ labels: { job: 'nightly', owner: 'ci' }, // filter on them in Sandbox.list
89
+ maxRuntimeMin: 120, // run time in minutes; deleted when it ends (paid: 24 h by default, up to 7 days)
90
+ autoPauseMin: 15, // pause after 15 idle minutes (0: never)
91
+ autoDeleteMin: 60, // delete 60 minutes after pausing (0: never)
92
+ timeoutMs: 180_000, // the request, its retries and the wait for running
93
+ idempotencyKey: 'nightly-2026-10-14', // a retried create returns the same sandbox
94
+ });
95
+ ```
96
+
97
+ Commands and file transfers count as activity for `autoPauseMin`.
98
+
99
+ A large shape (more than 16 vCPU or about 58 GB of memory) can take a minute
100
+ or two to start: give such a `create` a `timeoutMs` of `300_000` or more.
101
+
102
+ If the sandbox fails to start or is not running within `timeoutMs`, it is
103
+ deleted and `create` throws (`sandbox_error` with the `reason`, or
104
+ `timeout`). Create, `pause` and `resume` ask the API to answer once the change
105
+ has landed, and only poll if it has not within a few seconds. `wait: false`
106
+ resolves as soon as the API accepts the create (state `starting`), still
107
+ within `timeoutMs`. Every create carries an idempotency key (the SDK makes
108
+ one when you give none), so the SDK's own retries never make a second
109
+ sandbox.
110
+
111
+ ### Many at once
112
+
113
+ Run a batch a few at a time, not all at once: a workspace caps how many
114
+ sandboxes it holds (10 on the free tier), and a quota refusal is not retried.
115
+ Each worker waits for its sandbox to be gone before it takes the next
116
+ command, because a deleting sandbox still counts for a moment. If a command
117
+ fails, the other workers finish the one they hold and stop, and `runAll`
118
+ throws once every sandbox is deleted.
119
+
120
+ ```ts
121
+ async function runAll(commands: string[], limit = 4): Promise<string[]> {
122
+ const out: string[] = [];
123
+ let next = 0;
124
+ let failed = false;
125
+ const worker = async () => {
126
+ try {
127
+ while (!failed && next < commands.length) {
128
+ const i = next++;
129
+ const sandbox = await Sandbox.create({ resources: { vcpu: 2, memoryGb: 4 }, labels: { job: 'batch' } });
130
+ try {
131
+ const r = await sandbox.exec(commands[i], { timeoutMs: 600_000 });
132
+ if (r.exitCode !== 0) throw new Error(`${commands[i]} exited ${r.exitCode}: ${r.stderr}`);
133
+ out[i] = r.stdout;
134
+ } finally {
135
+ await sandbox.delete({ wait: true });
136
+ }
137
+ }
138
+ } catch (err) {
139
+ failed = true; // the other workers finish the command they hold, then stop
140
+ throw err;
141
+ }
142
+ };
143
+ // allSettled, not all: every sandbox is deleted before runAll returns or throws.
144
+ const results = await Promise.allSettled(Array.from({ length: Math.min(limit, commands.length) }, worker));
145
+ for (const r of results) if (r.status === 'rejected') throw r.reason;
146
+ return out;
147
+ }
148
+ ```
149
+
150
+ ### `Sandbox.connect(id, options?)`
151
+
152
+ An existing sandbox by id, in whatever state it is in.
153
+
154
+ ```ts
155
+ const sandbox = await Sandbox.connect('sbx_0123456789abcdef');
156
+ if (sandbox.state === 'paused') await sandbox.resume();
157
+ ```
158
+
159
+ ### `Sandbox.list(options?)`
160
+
161
+ Your workspace's sandboxes, newest first (deleted ones are left out).
162
+ Filter by labels (all must match) and state:
163
+
164
+ ```ts
165
+ const mine = await Sandbox.list({ labels: { job: 'nightly' }, state: ['running', 'paused'] });
166
+ await Promise.all(mine.map((s) => s.delete()));
167
+ ```
168
+
169
+ ### `Sandbox.deleteByLabels(labels, options?)`
170
+
171
+ Deletes every sandbox carrying all of `labels` (at least one) in one call and
172
+ resolves with their ids. It is safe to repeat (one already deleting is listed
173
+ again; none matching resolves `[]`). A sandbox being deleted still counts
174
+ toward your quota until `Sandbox.list({ labels })` no longer returns it.
175
+
176
+ ```ts
177
+ const ids = await Sandbox.deleteByLabels({ job: 'nightly' });
178
+ ```
179
+
180
+ ### What a sandbox knows
181
+
182
+ ```ts
183
+ sandbox.id // 'sbx_…'
184
+ sandbox.state // 'starting' | 'running' | 'pausing' | 'paused' | 'resizing' | 'deleting' | 'deleted' | 'error'
185
+ sandbox.template // 'prized-base-v1'
186
+ sandbox.resources // { vcpu, memoryMb, memoryGb, diskGb }
187
+ sandbox.labels
188
+ sandbox.createdAt // Date
189
+ sandbox.expiresAt // Date: when its run time ends (null while a paused sandbox's clock is stopped)
190
+ sandbox.errorReason // why it is in 'error', when it is
191
+ sandbox.info // all of the above, plus autoPauseMin, autoDeleteMin, lastActiveAt, raw
192
+ ```
193
+
194
+ These reflect the last answer from the API; `await sandbox.refresh()` reads
195
+ them fresh.
196
+
197
+ ## Run commands
198
+
199
+ ### `sandbox.exec(command, options?)`
200
+
201
+ Runs a command and resolves with its output. `command` is a shell string, or
202
+ an argv array the SDK quotes for you.
203
+
204
+ ```ts
205
+ const r = await sandbox.exec('npm test', {
206
+ cwd: 'app', // relative paths start in /home/prized/work (the default cwd)
207
+ env: { CI: '1' }, // for this command, on top of the sandbox's envVars
208
+ timeoutMs: 120_000, // default 60 000; 0 = no limit
209
+ });
210
+ r.stdout; // string
211
+ r.stderr; // string, kept separate
212
+ r.exitCode; // number
213
+ r.durationMs;
214
+ r.truncated; // true if stdout or stderr hit the 4 MiB per-call cap
215
+
216
+ await sandbox.exec(['git', 'commit', '-m', "it's done"]); // quoted for you
217
+ ```
218
+
219
+ One call keeps the first 4 MiB of stdout and of stderr, and `truncated` says
220
+ when it cut one. Pass `fullOutput: true` for builds, test suites and anything
221
+ else whose output you need whole: the result keeps up to 100 MiB of each
222
+ stream (`truncated` past that). It is still one request when the output is
223
+ small; past 4 MiB the SDK reads it back from files in
224
+ `/home/prized/.prized/exec/`, then removes them. With it, a command a signal
225
+ ends gets `exitCode` 128+n (a plain exec may give -1), as a background command
226
+ does.
227
+
228
+ ```ts
229
+ const { stdout, exitCode } = await sandbox.exec('npm test', { timeoutMs: 180_000, fullOutput: true });
230
+ ```
231
+
232
+ A non-zero exit code is not an error: check `exitCode`. A command that runs
233
+ past its timeout is killed, and the call throws `CommandTimeoutError` (a
234
+ `PrizedError` with `code` `command_timeout`) whose `result` has what the
235
+ command printed before (`exitCode: -1`, `timedOut: true`):
236
+
237
+ ```ts
238
+ import { CommandTimeoutError } from '@prized/sdk';
239
+
240
+ try {
241
+ await sandbox.exec('npm test', { timeoutMs: 120_000 });
242
+ } catch (err) {
243
+ if (err instanceof CommandTimeoutError) console.log(err.result.stdout);
244
+ else throw err;
245
+ }
246
+ ```
247
+
248
+ Otherwise the SDK throws only when the command could not be run (see
249
+ [Errors](#errors)).
250
+
251
+ Stream output as it arrives with `onStdout` / `onStderr`:
252
+
253
+ ```ts
254
+ await sandbox.exec('npm install', {
255
+ timeoutMs: 600_000,
256
+ onStdout: (chunk) => process.stdout.write(chunk),
257
+ onStderr: (chunk) => process.stderr.write(chunk),
258
+ });
259
+ ```
260
+
261
+ How it runs: a command with no callbacks and a timeout up to 4 minutes is
262
+ one request that answers when the command finishes. With `onStdout` /
263
+ `onStderr`, a longer timeout, or `timeoutMs: 0`, the SDK starts the command
264
+ in the background and polls its output instead, so a long build is never cut
265
+ off by an idle connection and its output is never truncated. The result is
266
+ the same either way. If the connection drops meanwhile, the SDK keeps
267
+ polling for up to 2 minutes (`pollOutageMs`, on the call or the client)
268
+ before it gives up and stops the command; a refused key, or an answer that
269
+ settles it (the sandbox deleted or not running), stops it at once.
270
+ Background and streamed commands run with `PYTHONUNBUFFERED=1` unless
271
+ their environment already sets it (`env: { PYTHONUNBUFFERED: '' }` keeps
272
+ Python's buffering), so a Python program's output arrives as it prints.
273
+
274
+ Commands run as the user `prized` (home `/home/prized`, no sudo). The
275
+ templates come with git, curl, Python 3 with pip and venv, Node 22 with npm
276
+ and pnpm; `prized-agent-v1` adds Claude Code and Codex. Install what else
277
+ you need into your home: Python packages into a venv
278
+ (`python3 -m venv ~/.venv && ~/.venv/bin/pip install numpy`; plain
279
+ `pip install`, `--user` too, is refused), and `npm` with a user prefix.
280
+
281
+ ## Background commands
282
+
283
+ `background: true` starts the command and returns a `CommandHandle` at once.
284
+ The command runs in its own session: it keeps running after your process
285
+ exits, survives a pause and resume, and while it runs, a kill reaches every
286
+ process it started (not one that left its process group, such as a daemon).
287
+ Once the command has ended, `kill()` resolves false and signals nothing.
288
+
289
+ ```ts
290
+ const server = await sandbox.exec('npm run dev', { background: true, cwd: 'app' });
291
+ server.id; // 'run_…'
292
+ server.pid; // its process group id inside the sandbox
293
+
294
+ await server.status(); // { running: true, exitCode: null }
295
+ await server.logs(); // { stdout, stderr }: everything printed so far
296
+
297
+ await server.kill(); // SIGTERM to the command and its children; resolves false if it had already exited
298
+ await server.kill('SIGKILL');
299
+
300
+ const job = await sandbox.exec('make build', { background: true, timeoutMs: 30 * 60_000 });
301
+ const result = await job.wait({
302
+ onStdout: (chunk) => process.stdout.write(chunk), // replays from the start, then live
303
+ timeoutMs: 45 * 60_000, // bounds the wait only; the command keeps running
304
+ });
305
+ result.exitCode;
306
+ await job.cleanup(); // remove its output files from the sandbox
307
+ ```
308
+
309
+ A background command has no time limit unless you give `timeoutMs`
310
+ (enforced to the second: the command gets SIGTERM, then SIGKILL five seconds
311
+ later, and `wait()` throws `CommandTimeoutError`). Its output is kept under
312
+ `~/.prized/runs/<id>/` in the sandbox (`stdout`, `stderr`, `exit`) until you
313
+ call `cleanup()` after the command has ended: after `wait()` resolves or
314
+ throws `CommandTimeoutError` (after a `kill()`, await `wait()` first). On a
315
+ command still running, `cleanup()` does nothing. After it, the handle's
316
+ calls reject with `not_found`. A command that prints without end fills the
317
+ sandbox's disk there, so redirect such output yourself.
318
+ `exitCode` is `-1` when a command timed out or ended without reporting one
319
+ (for example after `SIGKILL`).
320
+
321
+ ## Files
322
+
323
+ `sandbox.files` reads and writes the sandbox's disk. Paths are absolute or
324
+ relative to `/home/prized/work`. Writes, reads, uploads and downloads must
325
+ stay inside `/home/prized`, are at most 100 MiB each, and do not follow
326
+ symbolic links. A write replaces the file whole, and a file it replaces keeps
327
+ its mode, so a script made executable stays executable; a new file gets the
328
+ sandbox's default (`0600` in `prized-base-v1` and `prized-agent-v1`). `mode` sets the permission
329
+ bits.
330
+
331
+ ```ts
332
+ await sandbox.files.write('app/main.py', 'print("hi")\n'); // string, Uint8Array, ArrayBuffer or Blob; parents are created
333
+ await sandbox.files.write('app/run.sh', '#!/bin/sh\necho hi\n', { mode: 0o755 }); // executable from the start
334
+ const text = await sandbox.files.read('app/main.py'); // UTF-8 string
335
+ const bytes = await sandbox.files.readBytes('/home/prized/work/out.bin'); // Uint8Array
336
+
337
+ const entries = await sandbox.files.list('app'); // default: /home/prized/work
338
+ // [{ name, path, size, isDir, isSymlink, mode: '-rw-r--r--', modified: Date }]
339
+
340
+ await sandbox.files.exists('app/main.py'); // true
341
+ await sandbox.files.remove('app/old.txt'); // a missing path is fine
342
+ await sandbox.files.remove('build', { recursive: true }); // directories need recursive
343
+
344
+ // Node, Bun and Deno: copy local files in and out
345
+ await sandbox.files.upload('./dist/bundle.js', 'app/bundle.js');
346
+ await sandbox.files.download('app/report.html', './report.html'); // parent directories are created
347
+ ```
348
+
349
+ ## Pause, resume, refresh, delete
350
+
351
+ ```ts
352
+ await sandbox.pause(); // keeps memory and disk (until you delete it; free tier: 30 days); only storage is billed. Resolves when paused.
353
+ await sandbox.resume(); // every process carries on where it was. Resolves when running.
354
+ // (A command or file call on a paused sandbox resumes it too.)
355
+ await sandbox.refresh(); // read the sandbox again (state, labels, …); not counted as activity
356
+ await sandbox.keepAlive(); // count as activity now: the autoPauseMin idle clock starts over
357
+ await sandbox.delete(); // resolves once accepted; the sandbox goes deleting, then deleted
358
+ await sandbox.delete({ wait: true }); // also wait until it is gone
359
+ ```
360
+
361
+ `pause` and `resume` take `{ wait: false }` to return without waiting, and
362
+ `{ timeoutMs }` to bound the request, its retries and the wait.
363
+ `delete`'s `{ timeoutMs }` (default 120 000) bounds the whole call. A sandbox that is deleting still counts
364
+ toward your workspace's sandbox quota until it is gone.
365
+
366
+ ## Run time: extend anytime
367
+
368
+ A sandbox is deleted when its run time ends. On a paid plan that is 24 hours
369
+ by default (`maxRuntimeMin` on create), up to 7 days, and every resume
370
+ restarts the clock with a fresh `maxRuntimeMin`; a paused sandbox's clock
371
+ does not run, and it is kept, memory and disk, until you delete it (or its
372
+ `autoDeleteMin` does). On the free tier it is 60 minutes of running in all:
373
+ paused time does not count, a resume carries on the time left, and a paused
374
+ one is kept 30 days after its last use.
375
+
376
+ Change it while the sandbox runs with the CLI or the API
377
+ ([Sandbox API: Run time](https://prized.dev/docs/sandboxes-api#run-time)):
378
+
379
+ ```sh
380
+ prized sandbox extend sbx_0123456789abcdef --by 2h # add 2 hours; never shortens (POST /api/v1/sandboxes/{id}/extend)
381
+ prized sandbox timeout sbx_0123456789abcdef --in 30m # deleted 30 minutes from now; may shorten (POST …/timeout)
382
+ ```
383
+
384
+ On a paid plan an extend stops at 7 days from now; on the free tier neither
385
+ moves the end later.
386
+
387
+ <!-- Held while sandbox ports at a URL are switched off: this section stays
388
+ out of the rendered README until they are on. In the change that turns them
389
+ on, delete these five lines and the closing line after the section, take
390
+ "a sandbox's ports at a URL" out of "Not yet" under Limits, and delete the
391
+ test "the README holds A port at a URL" (test/preview.test.ts).
392
+ ## A port at a URL
393
+
394
+ `sandbox.previewUrl(port)` is the https URL a port of the sandbox answers at:
395
+ a web app, an API, or a dev server listening on that port in the sandbox on
396
+ `127.0.0.1` or `0.0.0.0`. HTTP, websockets and server-sent events are
397
+ forwarded. A request needs a workspace-wide API key with Sandboxes: Write in
398
+ the `Prized-Key` header (`previewHeaders()`; the header never reaches your
399
+ app).
400
+
401
+ ```ts
402
+ await sandbox.exec('nohup python3 -m http.server 3000 >/dev/null 2>&1 &');
403
+ const page = await fetch(sandbox.previewUrl(3000), {
404
+ headers: sandbox.previewHeaders(),
405
+ redirect: 'manual', // never resend your key to wherever a redirect points
406
+ });
407
+ ```
408
+
409
+ Keep `redirect: 'manual'`. `fetch` sends a header like `Prized-Key` on to
410
+ any host a redirect names (so do axios, got, Python's requests and
411
+ `curl -L`), so a redirect from your app could hand that host your API key.
412
+ The preview URL keeps a redirect on its own origin: a `Location` on
413
+ `localhost:<port>` (where your app sees itself) comes back as that path on
414
+ the preview URL, and one that points anywhere else comes back as
415
+ `Prized-Location` instead, which no client follows. To follow a redirect,
416
+ resolve its `Location` against the preview URL and send `previewHeaders()`
417
+ again only when the result has the preview URL's origin, scheme included.
418
+ Call preview URLs from server-side code: a browser hides `Location` from
419
+ `'manual'`.
420
+
421
+ Nothing is created: the URL is the sandbox's own, and every port has one
422
+ (but 22 and 81). A request to a paused sandbox resumes it and waits up to 10
423
+ seconds for it, as `exec` does; send `Prized-Auto-Resume: false` to get
424
+ `503` instead. `previewUrl` throws `preview_unavailable` where this is not
425
+ turned on.
426
+ -->
427
+
428
+ ## Fork
429
+
430
+ `sandbox.fork({ count })` copies a **running or paused** sandbox into `count`
431
+ new sandboxes. Each copy has the parent's disk and every one of its processes
432
+ running, and is an ordinary sandbox from then on: its own id, its own
433
+ billing, its own pause, resume and delete. A paused parent stays paused.
434
+ The copies keep the parent's labels unless you pass `labels`, which gives
435
+ them only the labels you pass.
436
+
437
+ ```ts
438
+ await sandbox.exec('git clone https://github.com/acme/app && cd app && npm ci');
439
+ const copies = await sandbox.fork({ count: 4, labels: { role: 'worker' } });
440
+ const results = await Promise.all(copies.map((copy, i) => copy.exec(`cd app && npm test -- --shard=${i + 1}/4`)));
441
+ await Promise.all(copies.map((copy) => copy.delete()));
442
+ ```
443
+
444
+ `fork` resolves once every copy is running. It is
445
+ all or nothing: if any copy fails, or the wait runs out, every copy is
446
+ deleted and `fork` throws (the parent is left as it was). It takes
447
+ `wait: false`, `timeoutMs` (the request and the wait together), and
448
+ `idempotencyKey` like `create`. While forking is paused for maintenance it
449
+ answers `sandbox_fork_disabled`: retry later.
450
+
451
+ On a paid workspace each copy gets a fresh lifetime from the fork: the
452
+ parent's `maxRuntimeMin` by default, or `maxRuntimeMin` on the fork (up to
453
+ the workspace's maximum). On the free tier, copies keep the parent's
454
+ deletion time by default; `maxRuntimeMin` can only shorten it. Read each
455
+ copy's deletion time from its `expiresAt`.
456
+
457
+ ## Templates
458
+
459
+ ```ts
460
+ const templates = await Sandbox.templates();
461
+ // [{ id: 'prized-base-v1', defaults: { vcpu, memoryMb, memoryGb, diskGb }, min, max, durability, defaultMaxRuntimeMin }]
462
+ ```
463
+
464
+ | Template | What is in it |
465
+ |---|---|
466
+ | `prized-base-v1` | Ubuntu, git, curl, build tools, Python 3, Node 22 |
467
+ | `prized-agent-v1` | everything in base, plus Claude Code and Codex (bring your own credentials) |
468
+
469
+ ## Errors
470
+
471
+ Every failure is a `PrizedError`:
472
+
473
+ ```ts
474
+ import { PrizedError, Sandbox } from '@prized/sdk';
475
+
476
+ try {
477
+ await Sandbox.create();
478
+ } catch (err) {
479
+ if (err instanceof PrizedError) {
480
+ err.status; // HTTP status, or null when nothing came back
481
+ err.code; // machine-readable: branch on this
482
+ err.message; // a sentence for people
483
+ err.reason; // finer detail, when there is one
484
+ err.sandboxId; // the sandbox it concerns, when there is one
485
+ }
486
+ }
487
+ ```
488
+
489
+ | `code` | Status | Meaning |
490
+ |---|---|---|
491
+ | `paid_balance_required` | 409 | The workspace has only free credit, and sandboxes need a paid balance right now. Add funds or pick a paid plan. |
492
+ | `insufficient_balance` | 409 | The balance is empty. |
493
+ | `sandbox_quota_exceeded` | 429 | A workspace quota is full; `reason` says which: `sandbox_limit`, `paused_limit`, `running_vcpu`, `running_memory`, `retained_disk`, `max_runtime`. Paused sandboxes don't count toward `sandbox_limit`, but they keep their disk, so delete the ones you no longer need, or ask for more. On the free tier the body says `"tier": "free"` and gives `limit` in the reason's unit (a count, vCPU, MB, GB, minutes; `err.body?.limit`): a top-up lifts it. |
494
+ | `template_not_found` | 404 | No such template. |
495
+ | `invalid_resources` | 422 | The shape is outside the template's bounds, or past the free tier's per-sandbox size (`"tier": "free"` and `limit`, in vCPU or MB, in the body); `reason` names the field. |
496
+ | `unauthorized` / `token_expired` | 401 | The API key was refused. |
497
+ | `key_access_denied` | 403 | The key lacks the Sandboxes permission. |
498
+ | `not_found` | 404 | No such sandbox (or file) visible to this key. For a sandbox, most often one deleted when its run time ended; the message names it. |
499
+ | `sandbox_not_running` | 409 | The sandbox is not running and the call could not wake it: it is resizing (`refresh()` until it leaves `resizing`, then retry), failed, or being deleted. A paused sandbox is resumed by any command or file call. The SDK never turns waking off; a request that carries `Prized-Auto-Resume: false` (added by a custom `fetch`, say) gets this for a paused or starting sandbox too: drop the header to let the call wake it and wait. |
500
+ | `sandbox_not_ready` | 503 | A paused or starting sandbox did not reach running within the edge's 10-second wait. The wait starts when the edge, having checked the key, finds the sandbox not running, and calls with the same key arriving during it share it, so a later call may wait less. Nothing ran; retry. |
501
+ | `parent_not_running` | 409 | Only a running or paused sandbox can be forked. |
502
+ | `parent_not_forkable` | 409 | This sandbox cannot be forked. `reason` `resized_while_paused`: it has no memory to copy; resume it, then fork it. `reason` `parent_too_big`: its machine cannot hold even one copy beside it; fork a smaller sandbox. |
503
+ | `fork_capacity` | 409 | The parent's machine has room for only `fits` more copies (`err.body?.fits`). If `fits` is above 0, fork that many or fewer; or try again later, or start a new sandbox and fork that one, keeping the parent until it runs. The new sandbox and each copy count toward your quota: at the limit, delete sandboxes you no longer need with `delete({ wait: true })` (a deleting sandbox still counts), or top up on the free tier. Copy out what you need first, and never delete a parent while a copy forked from it is still starting. |
504
+ | `fork_unsupported_host` | 409 | The parent's machine cannot start copies of it. With `reason` `host_retiring` (`err.reason`), the machine is being replaced: it keeps the parent but starts no new sandboxes, so no retry or smaller `count` helps. Create a new sandbox, set it up again, and fork that one. |
505
+ | `sandbox_capacity` | 503 | No sandbox capacity is free right now. Nothing was created or billed; the SDK retries it after `Retry-After`. |
506
+ | `sandbox_fork_disabled` | 503 | Forking is paused for maintenance; retry later. |
507
+ | `sandboxes_disabled` / `sandbox_create_disabled` | 503 | Sandboxes are switched off right now. |
508
+ | `rate_limited` | 429 | Too many requests (retried for you, honouring `Retry-After`). |
509
+ | `sandbox_unreachable` | 502 | The sandbox did not answer. A command may or may not have run. |
510
+ | `path_outside_root` / `path_not_absolute` / `invalid_path` | 403 / 400 | A file path outside `/home/prized`, or with `..`. |
511
+ | `file_too_large` | 413 | Over the 100 MiB transfer limit. |
512
+ | `idempotency_conflict` | 409 | The idempotency key was used for a different request. |
513
+ | `sandbox_error` | | The sandbox failed while you waited; `reason` says why. |
514
+ | `sandbox_deleted` | 410 | The sandbox was deleted before or while a command or file call ran; a command that was running stopped with it. `pause()` and `resume()` of a sandbox being deleted throw it too. Not retried. A wait that finds the sandbox deleted throws it too, with no status. |
515
+ | `command_timeout` | | A command ran past its own `timeoutMs` and was killed. Thrown as `CommandTimeoutError`; `err.result` has what it printed. |
516
+ | `timeout` | | A wait ran past its `timeoutMs`, or a call past its bound ([Retries](#retries-and-timeouts)). One that ended because a retry could not start in time says what the last attempt met (also in `cause`). |
517
+ | `network_error` | | No answer: the API or the sandbox could not be reached, or the connection dropped. |
518
+ | `bad_response` | 2xx | An answer the SDK could not read. For a command or file write it did happen, and the message says so. |
519
+ | `missing_api_key` / `invalid_api_key` / `invalid_argument` | | Fix the call. `invalid_api_key`: the key has characters no key has (the docs' placeholder, a stray character); it is never quoted back. |
520
+ | `command_failed` | | A file helper (`list`, `remove`, `exists`) failed in the sandbox; `message` has its stderr. |
521
+
522
+ ## Retries and timeouts
523
+
524
+ - Reads, state changes (`pause`, `resume`, `delete`, `keepAlive`),
525
+ downloads, and creates and forks (which always carry an idempotency key)
526
+ are retried up to `maxRetries` times on a dropped connection, on 429, 502,
527
+ 503 and 504, and on every answer a command is retried on (below), with
528
+ exponential backoff and jitter, honouring `Retry-After`. A read
529
+ (`Sandbox.list`, `connect`, `refresh`, `templates`, a file download, and
530
+ the polling while `create`, `pause`, `resume`, `fork` or `delete` waits) is
531
+ retried on any other 500 too; a write is not, since it may already have
532
+ happened. Quota, balance and switched-off refusals are never retried.
533
+ - Retries stay within a call's bound: `timeoutMs` for `create`, `pause`,
534
+ `resume`, `fork` and `delete` (the request, its retries and the wait, its
535
+ polls included; with `wait: false` too), twice `requestTimeoutMs` (2
536
+ minutes by default) for `connect`, `list`, `templates`, `refresh` and
537
+ `keepAlive`, and 10 minutes for a file transfer. `Retry-After` is honoured
538
+ up to a minute. A retry that could not start in time is not sent: the call
539
+ throws `timeout`. A wait's last poll goes out just before its deadline.
540
+ - A command, an upload (`files.write`, `files.upload`), a `files.remove` or
541
+ a `kill` is sent again only when it provably never arrived: the connection never
542
+ opened, or the answer says so (rate limited, the sandbox not ready). If an
543
+ answer is lost after it was delivered, the SDK throws
544
+ (`sandbox_unreachable`, `network_error`, `timeout`) rather than risk running a command
545
+ twice or overwriting what the sandbox wrote to the file since.
546
+ - Did it run? When a command answered in one call (`exec` without
547
+ `background`, `onStdout` or `onStderr`, and a `timeoutMs` other than 0 and
548
+ at most 4 minutes), a file write or a `files.remove` fails without a plain refusal,
549
+ the error keeps its `code` (a 2xx it could not read is `bad_response`)
550
+ and its `message` says which of three it was. It never reached the sandbox (the connection never opened, or
551
+ the answer proves it, such as `sandbox_not_ready`): it had no effect. No
552
+ clear answer came back (a dropped connection, a timeout, or any other 5xx):
553
+ it may or may not have run, and the message says how to check (read the
554
+ file back, `files.exists()`). A 2xx came back that could not be read
555
+ (`bad_response`): it ran. A `kill` says the same of its signal
556
+ (`status()` tells you whether the command is still running). A background
557
+ command (or a streamed or long `exec`, which runs as one) whose start got
558
+ no clear answer is stopped by the SDK, and the error says so, or, when the
559
+ SDK could not confirm the stop, that it may still be running and under
560
+ which run id. So is a streamed or long `exec` whose polling fails while
561
+ it runs (a dropped connection or a 5xx for longer than `pollOutageMs`, a
562
+ refused key): the error keeps the poll's `code` and `status`, has the
563
+ poll's own error as its `cause`, and says whether the SDK stopped the
564
+ command or it may still be running. An error that settles it (the
565
+ sandbox deleted or not running, the run not found) is thrown as it came.
566
+ - Deleting what a failed create or fork made (a few seconds) comes on top
567
+ of `timeoutMs`.
568
+ - `create`, `exec` and waits take an `AbortSignal` (`signal`). Aborting a
569
+ `create` or a handle's `wait()` stops the call, not the sandbox or the
570
+ command (`handle.kill()` stops a command). Aborting an `exec` stops its
571
+ command too, as does anything else that ends a streamed or long one early
572
+ (an `onStdout` that throws, say): the call rejects once the SDK has tried
573
+ to stop it (a round trip or two; it gives up after about 20 seconds). With
574
+ `background: true` that covers the start only. The `aborted` error says
575
+ what became of the command: it never reached the sandbox (aborted before
576
+ it was sent); it may or may not have run, or run in part, since the
577
+ sandbox kills a command (and its process group) whose connection closes;
578
+ it ran (its answer had begun to arrive); or the SDK stopped its run, or
579
+ could not.
580
+ - A `create` or `fork` that may have made sandboxes it never handed back
581
+ deletes them, as a failed create does (in the background; the call rejects
582
+ at once): one aborted mid-request, or one where any attempt got no answer,
583
+ a 5xx other than a refusal, or an answer the SDK could not read, even if a
584
+ later retry was refused (rate limited, say). The SDK finds them by sending
585
+ the idempotency key again, after any `Retry-After` (up to a minute). With
586
+ your own `idempotencyKey` they are left instead, since that key may name an
587
+ earlier call's sandboxes: resend the call with the key to get them back.
588
+ This cleanup is best effort: if Prized cannot be reached, or stops taking
589
+ new sandboxes meanwhile (`sandbox_create_disabled`), one may be left until
590
+ its run time ends, or, if it pauses first (`autoPauseMin`), until you
591
+ delete it (free tier: 30 days after its last use). Label your sandboxes, and `Sandbox.list({ labels })`
592
+ finds any leftovers.
593
+
594
+ ## Limits
595
+
596
+ | | |
597
+ |---|---|
598
+ | Shape | up to 32 vCPU, 128 GB memory and 128 GB disk per sandbox, within each template's bounds (`Sandbox.templates()`) |
599
+ | Run time | paid plans: 24 hours by default, up to 7 days, restarting on every resume; the free tier: 60 minutes of running in all. Extend anytime with the CLI or API |
600
+ | Paused | kept until you delete it; on the free tier, 30 days after its last use |
601
+ | `autoPauseMin`, `autoDeleteMin` | 0 (never) to 40 320 (28 days) |
602
+ | Labels | up to 32; keys up to 64 characters, values up to 256 |
603
+ | `envVars` | up to 32; names up to 64 characters, values up to 16 KiB |
604
+ | Command output | 4 MiB per stream for one blocking call, 100 MiB with `fullOutput: true` (background and streamed commands have no cap) |
605
+ | One blocking command | up to 4 minutes in the SDK (longer timeouts run in the background automatically) |
606
+ | File transfers | 100 MiB per read or write, inside `/home/prized` |
607
+ | Fork | up to 256 copies per call, within your quotas |
608
+
609
+ Quotas per workspace (sandboxes, running vCPU and memory, retained disk,
610
+ runtime) are on the pricing and docs pages at prized.dev; a create or resume
611
+ past one fails with `sandbox_quota_exceeded`.
612
+
613
+ Not yet: custom templates (from a Dockerfile), egress allow and deny lists,
614
+ a sandbox's ports at a URL, more than 32 vCPU or 128 GB, GPUs, and regions
615
+ other than US West.
616
+
617
+ ## Runtimes
618
+
619
+ Node 18 or newer, Bun, Deno, and edge runtimes with a global `fetch`.
620
+ `files.upload` and `files.download` need a local file system (Node, Bun,
621
+ Deno); everywhere else use `files.write` and `files.readBytes`. Keep your
622
+ API key on a server: never ship it to a browser.
623
+
624
+ ## License
625
+
626
+ Apache-2.0. The full text is in the `LICENSE` file that ships with the package.