@prized/sdk 0.0.0-stage → 0.1.1

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