{"_id":"@bonya-ai/tyto","_rev":"2-f978fced8b838d800ec0ae02178bb247","name":"@bonya-ai/tyto","dist-tags":{"latest":"1.0.0"},"versions":{"0.11.0":{"name":"@bonya-ai/tyto","version":"0.11.0","author":{"name":"Bonya"},"license":"MIT","_id":"@bonya-ai/tyto@0.11.0","maintainers":[{"name":"bonyaai","email":"muhamedadly@gmail.com"}],"dist":{"shasum":"da4a8d63dd9677c30793f9614ce083810fad1ac2","tarball":"https://registry.npmjs.org/@bonya-ai/tyto/-/tyto-0.11.0.tgz","fileCount":8,"integrity":"sha512-xWzC/2oBRA6tyVuFEOjUL7+1hG3DctlSALKYh34wGBrVqw05Wp23EKfLvYPJxIdCc5Ufc1aVfST3wR4EkzVWKQ==","signatures":[{"sig":"MEQCIESx2igAkYPnE3XgnsL+/CeLWWhFpUT1cQLhB9lvAUekAiBQdmQRBv6FrfaBtbmaOFG25SCzLgQGPLZvJfsPeZQZWA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":146673},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"gitHead":"885271be25e5796f42aa204f9a4833341eea5c99","scripts":{"dev":"tsup --watch","build":"tsup","typecheck":"tsc --noEmit"},"_npmUser":{"name":"bonyaai","email":"muhamedadly@gmail.com"},"_npmVersion":"11.9.0","description":"Official TypeScript SDK for the Tyto API","directories":{},"_nodeVersion":"25.6.1","dependencies":{"ws":"^8.18.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","@types/ws":"^8.5.13","typescript":"^5.8.3","@types/node":"^22.0.0"},"_npmOperationalInternal":{"tmp":"tmp/tyto_0.11.0_1781965215172_0.5462827538699615","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"_id":"@bonya-ai/tyto@1.0.0","dist":{"shasum":"c4e1836f6a9fd4ff36373f8602a55f68410e421d","tarball":"https://registry.npmjs.org/@bonya-ai/tyto/-/tyto-1.0.0.tgz","fileCount":71,"integrity":"sha512-2pephp8MED6EwGHEIq28THr7jgMkv/nzeNxCWEyRcpt3hRRRN4uUCHmi2ittumoLPYq6ef9UI7os4rN5x0sIIw==","signatures":[{"sig":"MEUCIBZ5k0WRuPqlrvPhKAurOsnDmy75wSwNjiCkrGPF7iCKAiEAjhUAGBwoK/IQNlTFZKfaipABRFL/KICeB2c2qTnffIU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIEcGZkd3UPumL0skBaJAa9Yql+92s77Mf/V/f6gx7O0BAiAT878HwvyJ7y/Sa80FQVYBFlEi0ivzD1I76iHdCjd+0w=="}],"unpackedSize":2081104},"main":"./dist/index.js","name":"@bonya-ai/tyto","type":"module","types":"./dist/index.d.ts","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"gitHead":"559a101d9946f35310ac3e37b6b14db75086a503","license":"MIT","scripts":{"test":"vitest run","build":"tsc -p tsconfig.json","check":"npm run typecheck && npm run test","proto":"bash scripts/gen-proto.sh","typecheck":"tsc -p tsconfig.check.json","test:watch":"vitest"},"version":"1.0.0","_npmUser":{"name":"bonyaai","email":"muhamedadly@gmail.com"},"_npmVersion":"10.8.2","description":"TypeScript SDK for the Tyto API (Bonya Compute)","directories":{},"maintainers":[{"name":"bonyaai","email":"muhamedadly@gmail.com"}],"_nodeVersion":"20.20.2","dependencies":{"@grpc/grpc-js":"^1.14.4"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^2.1.9","ts-proto":"^2.12.0","typescript":"^5.7.3"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/tyto_1.0.0_1789736244717_0.0824607089021887"}}},"time":{"created":"2026-06-20T14:20:15.074Z","modified":"2026-09-18T12:57:24.993Z","0.11.0":"2026-06-20T14:20:15.312Z","1.0.0":"2026-09-18T12:57:24.825Z"},"license":"MIT","description":"TypeScript SDK for the Tyto API (Bonya Compute)","maintainers":[{"name":"bonyaai","email":"muhamedadly@gmail.com"}],"readme":"# Tyto TypeScript SDK\n\nRun code in a fast, isolated sandbox — from TypeScript or JavaScript.\n\n[![npm](https://img.shields.io/npm/v/@bonya-ai/tyto)](https://www.npmjs.com/package/@bonya-ai/tyto)\n[![Node](https://img.shields.io/node/v/@bonya-ai/tyto)](package.json)\n[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)\n\n```bash\nnpm install @bonya-ai/tyto\n```\n\n```ts\nimport { Tyto } from \"@bonya-ai/tyto\";\n\nconst client = new Tyto();                                   // reads BONYA_API_KEY\nconst sandbox = await client.createSandbox({ template: \"bonya-dev\" });\n\nconst result = await sandbox.exec([\"echo\", \"hello\"], { check: true });\nconsole.log(result.stdout);                                   // hello\n\nawait sandbox.delete();\nclient.close();\n```\n\nThat is a real VM: it boots in about a second, runs anything Linux runs, and is\ngone when `delete` resolves.\n\nThe npm package is `@bonya-ai/tyto`. It ships ESM with bundled type\ndeclarations, and the public surface documented here is stable within `1.x`.\n\n> **Node only.** The transport is gRPC over `@grpc/grpc-js`, which needs\n> Node's `http2`. This package does not run in a browser or on edge runtimes.\n\n## Contents\n\n- [Install](#install)\n- [Configuration](#configuration)\n- [What you can do](#what-you-can-do)\n- [Create sandboxes](#create-sandboxes)\n- [Get and list](#get-and-list)\n- [Delete and cleanup](#delete-and-cleanup)\n- [Resume](#resume)\n- [Buffered exec](#buffered-exec)\n- [Streaming exec](#streaming-exec)\n- [TTY exec](#tty-exec)\n- [Managed console sessions](#managed-console-sessions)\n- [Files](#files)\n- [Jobs](#jobs)\n- [Job schedules](#job-schedules)\n- [Templates](#templates)\n- [Preview URLs](#preview-urls)\n- [Snapshots](#snapshots)\n- [Organizations](#organizations-1)\n- [Error model](#error-model)\n- [Troubleshooting](#troubleshooting)\n- [Examples](#examples)\n- [Development](#development)\n\n## What you can do\n\n| I want to… | Call |\n| --- | --- |\n| Start a sandbox | `client.createSandbox({ template })` |\n| Reconnect to one | `client.getSandbox(id)` / `.getSandboxByName(name)` |\n| Find my sandboxes | `client.listSandboxes()` |\n| Run a command | `sandbox.exec(cmd)` |\n| Watch output as it happens | `sandbox.execStream(cmd)` |\n| Keep a terminal alive across reconnects | `sandbox.createSession(...)` / `.attachSession(...)` |\n| Read and write files | `sandbox.readFile/writeFile/uploadFile/downloadFile/...` |\n| Expose a port to a browser | `sandbox.createPreview(port)` |\n| Save state for later | `sandbox.snapshot()` |\n| Pause and resume | suspend is automatic; `sandbox.resume()` is explicit |\n| Run a job to completion | `client.runJob(spec)` |\n| Start a job and check on it later | `client.startJob(spec)` / `client.getJobRun(runId)` |\n| Run something on a schedule | `client.createJobSchedule(schedule, spec)` |\n| See which templates are available | `client.listTemplates()` |\n| See which organizations I belong to | `client.listOrganizations()` |\n| Act in a specific organization | `client.organizationId = id`, or `organizationId` at construction |\n\nEvery operation is a flat method: `client.createSandbox(...)`,\n`client.getSandbox(id)`, `client.listSandboxes()`, `client.deleteSandbox(id)`,\n`client.resumeSandbox(id)` on `Tyto`; `sandbox.createSession(...)`,\n`sandbox.attachSession(...)`, `sandbox.createPreview(port)`,\n`sandbox.snapshot()`, and the file methods (`readFile`, `writeFile`, ...)\ndirectly on `Sandbox`. There is no separate namespace to navigate.\n\nClient-level convenience forms also exist for sandbox-scoped operations when\nall you have is an id — `client.createSession(sandboxId, name, cmd)`,\n`client.listSessions(sandboxId)`, `client.killSession(sandboxId, name)`,\n`client.attachSession(sandboxId, name)`, `client.createPreview(sandboxId, port)`,\n`client.listPreviews(sandboxId)`, `client.deletePreview(sandboxId, id)`,\n`client.createSnapshot(sandboxId)`, `client.deleteSnapshot(sandboxId, snapshotId)`\n— each does a `getSandbox()` first and then delegates to the `Sandbox` method\nof the same name, which costs one extra round trip compared to already\nholding the handle. Prefer `sandbox.createSession(...)` (or the equivalent)\ndirectly when a `Sandbox` is already in hand, such as right after\n`createSandbox()`; reach for the client-level form when all you have is an\nid.\n\n## Install\n\n```bash\nnpm install @bonya-ai/tyto\n```\n\nRequires Node 18 or newer.\n\n## Configuration\n\nEvery setting has an environment-variable fallback, so the common case needs no\noptions at all:\n\n```bash\nexport BONYA_API_KEY=byk_...\n```\n\n```ts\nconst client = new Tyto();\n```\n\n| Option | Environment variable | Default |\n| --- | --- | --- |\n| `apiKey` | `BONYA_API_KEY` | *required* |\n| `organizationId` | `BONYA_ORGANIZATION_ID` | your personal organization |\n| `caBundle` | `BONYA_CA_BUNDLE` | system trust store |\n| `timeout` | — | `30` (seconds) |\n| `maxRetries` | — | `2` |\n| `filesystemReadLimit` | — | 64 MiB |\n\n```ts\nconst client = new Tyto({\n  apiKey: process.env.BONYA_API_KEY,\n  organizationId: process.env.BONYA_ORGANIZATION_ID,\n  timeout: 30,\n});\n```\n\n`apiKey` is required.\n\n`caBundle` points to a PEM bundle used for private development CAs. If the file\ncannot be read, the constructor throws `InvalidRequestError`.\n\n`timeout` is the default per-operation deadline, in seconds. It must be\npositive. Buffered and streaming exec calls can override it per call.\n\n`maxRetries` controls SDK retries for retryable control-plane operations. It\nmust be non-negative. The SDK retries gRPC `UNAVAILABLE` for create, get, list,\ndelete, resume, snapshot create, and snapshot delete while preserving the same\nrequest and idempotency key where one exists. Exec calls are not retried, except\nfor one capability refresh when the SDK can prove an exec capability token is\nexpired before responses start. Filesystem calls are not retried on transport\nunavailability; they may refresh a rejected filesystem capability once.\n\n`filesystemReadLimit` caps bytes buffered by `sandbox.readFile()`. It must be\na non-negative integer and defaults to 64 MiB.\n\nClose clients when done — the process will not exit while channels are open:\n\n```ts\nconst client = new Tyto();\ntry {\n  const sandbox = await client.createSandbox({ template: \"bonya-dev\" });\n  // ...\n} finally {\n  client.close();\n}\n```\n\n### Organizations\n\n`organizationId` selects which organization the client's calls act on. When\nomitted, the server resolves the call against your **personal organization** —\nthe deterministic fallback every account has. An API key belongs to a user, not\nto an organization, so one key works across every organization you belong to;\n`organizationId` is how you say which one a given call means.\n\n`client.listOrganizations()` returns every organization the caller belongs\nto, including their personal one — see [Organizations](#organizations-1) for\nthe full method and the fields it returns. `organizationId` is also a\nsettable property: assigning to it changes which organization subsequent\ncalls run against, effective immediately, and rejects an empty value the\nsame way the constructor option does.\n\nThe REST equivalent is the `X-Bonya-Organization-ID` header. The SDK sends it as\n`bonya-organization-id` metadata on control-plane calls only. Exec, filesystem,\nand session calls go straight to the sandbox and are authorized by its\ncapability token, so they carry no organization context.\n\nAn empty value is an error rather than a silent fallback: `organizationId: \"\"`,\nor `BONYA_ORGANIZATION_ID` set to an empty string, throws\n`InvalidRequestError`. In CI this variable is usually written as an expansion of\nanother one, and quietly running every job against someone's personal\norganization is a worse outcome than failing at startup.\n\nNaming an organization you do not belong to is a not-found error, identical to\nnaming one that does not exist.\n\n**In CI, always set it explicitly:**\n\n```yaml\n# .github/workflows/integration.yml\nenv:\n  BONYA_API_KEY: ${{ secrets.BONYA_API_KEY }}\n  BONYA_ORGANIZATION_ID: ${{ vars.BONYA_ORGANIZATION_ID }}\n```\n\n```ts\n// Both values come from the environment; neither is defaulted away.\nconst client = new Tyto();\n```\n\n## Create Sandboxes\n\n```typescript\nimport { Tyto, Wait } from \"@bonya-ai/tyto\";\n\nconst client = new Tyto()  // reads BONYA_API_KEY;\nconst sandbox = await client.createSandbox({\n  template: \"bonya-dev\",\n  wait: Wait.READY,\n  idempotencyKey: \"create-job-123\",\n});\n```\n\n`client.createSandbox(options)` returns a `Promise<Sandbox>`.\n\nOptions:\n\n- `template: string` is required and must be non-empty.\n- `version?: string` uses the server's default template version when\n  omitted.\n- `wait?: Wait | \"ready\" | \"none\"` controls when create resolves. Defaults to\n  `Wait.READY`.\n- `idempotencyKey?: string` is sent to the service. If omitted, the SDK\n  generates one and reuses it for create transport retries.\n\nWait modes:\n\n- `Wait.READY` or `\"ready\"` asks the service to return a running sandbox. The\n  returned handle has `lastObservedStatus === Status.RUNNING`.\n- `Wait.NONE` or `\"none\"` returns after the service accepts the request. The\n  returned handle has `lastObservedStatus === Status.CREATING`.\n\nIf create exhausts its deadline, the SDK throws `SandboxCreationTimeoutError`.\nThe error carries the create `idempotencyKey` so you can decide whether to\nretry or inspect server state.\n\nSandbox fields:\n\n```typescript\nconsole.log(sandbox.id);\nconsole.log(sandbox.operationId);\nconsole.log(sandbox.template);\nconsole.log(sandbox.version);\nconsole.log(sandbox.lastObservedStatus);\n```\n\n## Get And List\n\nReconnect to an existing sandbox by ID:\n\n```typescript\nconst sandbox = await client.getSandbox(\"sbx_123\");\nconst result = await sandbox.exec(\"printf reconnected\", { check: true });\nconsole.log(result.stdout);\n```\n\n`get(sandboxId)` requires a non-empty ID and returns a usable `Sandbox`\nhandle. It does not explicitly resume the sandbox. Exec and filesystem\noperations are the user activity that wakes a suspended sandbox when the\nservice route supports automatic wake. If a capability is rejected because it\nexpired, the SDK refreshes the handle with `get()` once before retrying the\noperation.\n\nList sandboxes lazily:\n\n```typescript\nimport { Status } from \"@bonya-ai/tyto\";\n\nfor await (const summary of client.listSandboxes({ states: [Status.RUNNING, Status.SUSPENDED], limit: 20 })) {\n  console.log(summary.id, summary.lastObservedStatus);\n}\n```\n\n`list(options)` returns an `AsyncIterableIterator<SandboxSummary>`. It pages\nas you iterate. `limit: 0` yields nothing without an RPC.\n\nSupported state filters are:\n\n- `Status.CREATING`\n- `Status.RUNNING`\n- `Status.SUSPENDING`\n- `Status.SUSPENDED`\n- `Status.RESUMING`\n- `Status.FAILED`\n\n`Status.DELETED` is not a valid list filter.\n\n`SandboxSummary` contains `id`, `operationId`, `template`, `version`,\n`lastObservedStatus`, `failureCode`, and `failureMessage`. Summaries do not\ninclude Exec credentials and cannot run `exec`; call `get(summary.id)` for a\nusable sandbox handle.\n\n## Delete And Cleanup\n\n```typescript\nconst result = await sandbox.delete();\nconsole.log(result.sandboxId);\nconsole.log(result.alreadyDeleted);\n```\n\n`sandbox.delete()` returns `DeleteResult { sandboxId: string, alreadyDeleted:\nboolean }`. Calling it again on the same `Sandbox` object is local and\nidempotent: the second call returns `alreadyDeleted: true` without another\nRPC.\n\nThere is no automatic cleanup on scope exit; use `try`/`finally`:\n\n```typescript\nconst sandbox = await client.createSandbox({ template: \"bonya-dev\" });\ntry {\n  await sandbox.exec(\"printf work\", { check: true });\n} finally {\n  await sandbox.delete();\n}\n```\n\n## Resume\n\nUse `resume()` when you want to explicitly resume before running work:\n\n```typescript\nconst resume = await sandbox.resume({ idempotencyKey: \"resume-job-123\" });\nconsole.log(resume.sandboxId);\nconsole.log(resume.lifecycleOperationId);\nconsole.log(resume.alreadyRunning);\n\nconst result = await sandbox.exec([\"printf\", \"running\\n\"], { check: true });\n```\n\n`sandbox.resume(options)` returns `ResumeResult { sandboxId: string,\nlifecycleOperationId: string, alreadyRunning: boolean }`. It updates the\nsandbox's private Exec endpoint and capability when the service returns\nfresh values, and sets `lastObservedStatus` to `Status.RUNNING`.\n\n`idempotencyKey` is optional. If omitted, the SDK generates one and reuses it\nfor resume transport retries. On ambiguous connection failure, the thrown\nerror carries the idempotency key and the sandbox's local status/capability\nare left unchanged.\n\n`resume()` on a failed sandbox throws `SandboxFailedError` locally before an\nRPC.\n\nAutomatic wake is different from explicit resume: `get()` does not call\n`resume()`, and ordinary Exec/filesystem calls do not make a public\n`ResumeSandbox` RPC from the SDK. They use the sandbox's guest endpoint; the\nservice may wake the sandbox behind that route.\n\n## Buffered Exec\n\nUse `exec()` for commands with bounded output:\n\n```typescript\nconst result = await sandbox.exec([\"python3\", \"-c\", \"import os; print(os.environ['MODE'])\"], {\n  env: { MODE: \"development\" },\n  cwd: \"/workspace\",\n  timeout: 10,\n});\n\nconsole.log(result.stdout);\nconsole.log(result.stderr);\nconsole.log(result.exitCode);\nconsole.log(result.ok);\n```\n\nSignature:\n\n```typescript\nsandbox.exec(command, {\n  env,\n  cwd,\n  tty,\n  cols,\n  rows,\n  timeout,\n  check,\n  input,\n}): Promise<ExecResult>\n```\n\nCommands can be either:\n\n- `string`: executed as `[\"/bin/sh\", \"-c\", command]`; the string must be\n  non-empty.\n- `readonly string[]`: executed directly; the array must be non-empty and\n  cannot contain empty entries.\n\n`env` overlays string environment variables. Keys must be non-empty strings\nand cannot contain `=` or NUL. Values must be strings without NUL.\n\n`cwd` sets the remote working directory. It must be a non-empty string\nwithout NUL. When omitted, the service uses its default working directory.\n\n`input` can be a `string`, a `Uint8Array`, or omitted. Strings are encoded as\nUTF-8. The SDK writes the bytes to stdin and half-closes stdin before\ncollecting output. Buffered `input` requires `tty: false`.\n\n`exec()` returns `ExecResult`:\n\n```typescript\nresult.stdoutBytes; // Uint8Array\nresult.stderrBytes; // Uint8Array\nresult.stdout; // UTF-8 text (getter over stdoutBytes)\nresult.stderr; // UTF-8 text (getter over stderrBytes)\nresult.exitCode; // number\nresult.signaled; // boolean\nresult.signal; // number\nresult.ok; // exitCode === 0 && !signaled\nresult.toString(); // result.stdout\n```\n\n`check: true` calls `result.check()` before returning. If the command exits\nnon-zero or by signal, it throws `ExecFailedError`; the original result is\navailable as `error.result`.\n\n```typescript\nimport { ExecFailedError } from \"@bonya-ai/tyto\";\n\ntry {\n  await sandbox.exec([\"false\"], { check: true });\n} catch (error) {\n  if (error instanceof ExecFailedError) {\n    console.log(error.result.exitCode);\n  }\n}\n```\n\n`exec()` buffers stdout and stderr in client memory. Use `execStream()` for\nlarge output, long-running commands, interactive stdin, or cancellation.\n\n## Streaming Exec\n\nUse `execStream()` when you need events as they arrive:\n\n```typescript\nimport { Exit, Stderr, Stdout } from \"@bonya-ai/tyto\";\n\nconst session = sandbox.execStream([\"bash\", \"-lc\", \"echo out; echo err >&2\"]);\ntry {\n  for await (const event of session) {\n    if (event instanceof Stdout) {\n      process.stdout.write(Buffer.from(event.data).toString(\"utf-8\"));\n    } else if (event instanceof Stderr) {\n      process.stderr.write(Buffer.from(event.data).toString(\"utf-8\"));\n    } else if (event instanceof Exit) {\n      console.log(\"exit:\", event.exitCode);\n    }\n  }\n} finally {\n  session.close();\n}\n```\n\nSignature:\n\n```typescript\nsandbox.execStream(command, { env, cwd, tty, cols, rows, timeout })\n```\n\nThe command, `env`, `cwd`, `tty`, `cols`, `rows`, and `timeout` rules are the\nsame as buffered Exec. `execStream()` returns a session that is an async\niterable of `Stdout`, `Stderr`, and `Exit` events.\n\nWrite streaming stdin as bytes:\n\n```typescript\nconst session = sandbox.execStream([\"cat\"]);\nsession.write(new TextEncoder().encode(\"hello\\n\"));\nsession.closeStdin();\n\nfor await (const event of session) {\n  if (event instanceof Stdout) {\n    process.stdout.write(Buffer.from(event.data).toString(\"utf-8\"));\n  }\n}\n```\n\n`session.write(data)` accepts a `Uint8Array` and throws\n`InvalidRequestError` after the session or stdin is closed.\n`session.closeStdin()` is idempotent. `session.cancel()` is idempotent and\nsends a cancel frame when possible. `session.close()` cancels an unfinished\nsession.\n\nThe SDK keeps bounded request and response queues internally. If iteration\nreaches the session deadline before receiving the next event, the SDK\ncancels the remote Exec and throws `TimeoutError`.\n\n## TTY Exec\n\nSet `tty: true` for terminal semantics:\n\n```typescript\nconst result = await sandbox.exec([\"bash\", \"-lc\", \"stty size; printf done\"], { tty: true, check: true });\nconsole.log(result.stdout);\n// result.stderrBytes.length === 0\n```\n\nIn TTY mode stdout and stderr share the terminal stream. The SDK returns\nterminal output as stdout and leaves stderr empty. Streaming TTY sessions\nemit `Stdout` events for terminal output; they do not emit separate `Stderr`\nevents for the terminal stream.\n\nDefault TTY dimensions are 80 columns by 24 rows. On the wire the SDK sends\n`cols: 0, rows: 0` when you omit both dimensions; the guest runtime\ninterprets that pair as 80x24.\n\nProvide explicit dimensions by passing both `cols` and `rows`:\n\n```typescript\nconst session = sandbox.execStream([\"bash\"], { tty: true, cols: 120, rows: 40 });\nsession.write(new TextEncoder().encode(\"printf 'ready\\\\n'\\n\"));\nsession.resize({ cols: 100, rows: 30 });\nsession.closeStdin();\nfor await (const event of session) {\n  // ...\n}\n```\n\nTTY rules:\n\n- `cols` and `rows` must be provided together.\n- Each dimension must be an integer from 1 through 512.\n- Dimensions require `tty: true`.\n- Buffered `input` is not allowed with `tty: true`; use `execStream()` and\n  `session.write(...)`.\n- `session.resize({ cols, rows })` requires a TTY session, open stdin, and an\n  unfinished session.\n\n## Managed Console Sessions\n\nEvery `Sandbox` has flat methods (`createSession`, `listSessions`,\n`killSession`, `attachSession`) for named, persistent command sessions that\noutlive the client connection. This is different from `execStream()`: an\nExec process dies when its stream closes, but a managed session keeps\nrunning detached, and you can reattach later — even after the sandbox\nwarm-suspends and resumes — and replay what it produced while nobody was\nwatching.\n\n```typescript\nconst info = await sandbox.createSession(\"server\", [\"bash\"], { cols: 120, rows: 40 });\nconsole.log(info.name, info.status);\n\nconst session = await sandbox.attachSession(\"server\");\nsession.write(new TextEncoder().encode(\"npm run dev\\n\"));\nsession.resize({ cols: 140, rows: 45 });\nfor await (const event of session) {\n  // ...\n}\nsession.detach();\n\nconst list = await sandbox.listSessions();\nfor (const info of list) {\n  console.log(info.name, info.status);\n}\n\nawait sandbox.killSession(\"server\");\n```\n\n### Create\n\n```typescript\nsandbox.createSession(name, command, { env, cwd, cols, rows, replace })\n```\n\n`name` must match `^[a-z][a-z0-9-]{0,31}$`. `command` is a non-empty array\nof non-empty strings — there is no shell-string convenience like buffered\n`exec()`'s. `cols`/`rows` are `0` (server default) or an integer from `1`\nthrough `512`.\n\nCreating over an existing record throws `SessionExistsError` unless\n`replace: true`, and even then only a terminal record (exited, killed, or\nfailed) is replaced. A running or attached session is never replaced by\n`create()`; kill it first.\n\nReturns a `Promise<SessionInfo>`.\n\n### List\n\n```typescript\nconst list = await sandbox.listSessions();\nfor (const info of list) {\n  console.log(info.name, info.status);\n}\nconsole.log(list.sandboxSuspended);\n```\n\n`sandbox.listSessions()` returns a `SessionList`: an immutable, iterable\nsequence of `SessionInfo` that also carries `sandboxSuspended: boolean`.\nListing works on a suspended sandbox without waking it; `sandboxSuspended:\ntrue` marks a result served from the suspend-time snapshot rather than the\nlive guest.\n\n### Attach\n\n```typescript\nconst session = await sandbox.attachSession(\"server\", { cols: 120, rows: 40, maxReplayBytes: 0 });\nconsole.log(session.info.name, session.replayedBytes, session.historyDropped);\n\nfor await (const event of session) {\n  // ...\n}\n```\n\n`attach(name, { cols, rows, maxReplayBytes })` returns a `Promise<SessionStream>`\nthat resolves once the session is admitted. `session.info`,\n`session.replayedBytes`, and `session.historyDropped` are populated\nimmediately when `attach()` resolves, before you iterate anything: they\ndescribe the bounded replay the session accumulated while detached.\n`replayedBytes > 0` means output produced while nobody was attached is being\nreplayed now; `historyDropped: true` means the 1 MiB replay ring dropped\nsome of the oldest of it. Attaching to a suspended sandbox's session wakes\nit, the same way `execStream()` does.\n\nAttaching preempts any other attached client for that session: the previous\nstream receives a `SessionEnded(SessionEndedReason.TAKEOVER)` event and ends.\nA reconnect is never blocked by a half-dead previous connection.\n\nIterating a `SessionStream` yields:\n\n- `Stdout { data: Uint8Array }`: merged output. Sessions are TTY-only, so\n  there is no separate stderr stream.\n- `Exit { exitCode, signaled, signal }`: the process exited.\n- `SessionEnded { reason: SessionEndedReason }`: the attach ended without the\n  process exiting — `DETACHED` (you called `detach()`) or `TAKEOVER`\n  (another client attached instead).\n- `SessionOutputDropped { droppedBytes: number }`: live output was dropped\n  because the client was reading too slowly. This does not end the attach.\n\n`session.write(data: Uint8Array)` sends stdin. `session.resize({ cols, rows\n})` takes an integer from `1` through `512` for each dimension, the same\nrule as TTY Exec resize. `session.detach()` ends the attach gracefully\nwithout touching the process. `session.close()` calls `detach()` if the\nstream is still open.\n\n### Kill\n\n```typescript\nawait sandbox.killSession(\"server\", { signal: \"TERM\", graceMs: 5000 });\n```\n\nSignals the session's process group (default `TERM`), escalating to\n`SIGKILL` after `graceMs` if it has not exited. Returns a `Promise<SessionInfo>`,\nbut exit info is not guaranteed on that specific response: `kill()` signals\nand returns without waiting for the guest to reap the process, so a `list()`\nshortly afterward is the reliable way to observe the final exit code.\nKilling an unknown name throws `SessionNotFoundError`.\n\n### SessionInfo\n\n```typescript\ninfo.name; // string\ninfo.command; // readonly string[]\ninfo.workingDir; // string\ninfo.status; // SessionStatus\ninfo.attached; // boolean\ninfo.startedAt; // Date\ninfo.lastActivityAt; // Date\ninfo.endedAt; // Date | undefined\ninfo.exit; // Exit | undefined, set only once terminal\n```\n\n`SessionStatus` values are `UNSPECIFIED`, `STARTING`, `IDLE`, `ATTACHED`,\n`EXITED`, `KILLED`, and `FAILED`.\n\n### Suspend and resume\n\nA session's process never blocks idle suspend by itself. Only an *attached*\nstream does, for as long as it stays open; a quiet, detached session lets\nthe sandbox warm-suspend, and survives the resume with its process and\nreplay buffer intact — the same session, not a new one. Output from a\ndetached session still counts as activity and defers idle suspend while it\nkeeps producing it.\n\n### Capability refresh\n\nSession calls transparently reissue an expired capability and retry once,\nthe same way `execStream()` and the file methods (`readFile()`,\n`writeFile()`, ...) do. Call `sandbox.reissueCapability()` directly only if\nyou manage tokens yourself.\n\n## Files\n\nFile operations are flat methods directly on `Sandbox`:\n\n```typescript\nawait sandbox.writeFile(\"/workspace/message.txt\", \"hello\\n\");\nconst payload = await sandbox.readFile(\"/workspace/message.txt\");\nconsole.log(Buffer.from(payload).toString(\"utf-8\"));\n\nawait sandbox.uploadFile(\"local-input.bin\", \"/workspace/input.bin\");\nawait sandbox.downloadFile(\"/workspace/input.bin\", \"local-output.bin\");\n\nconst entries = await sandbox.listFiles(\"/workspace\");\nconst info = await sandbox.statFile(\"/workspace/message.txt\");\n\nawait sandbox.mkdirFile(\"/workspace/output\");\nawait sandbox.moveFile(\"/workspace/message.txt\", \"/workspace/output/message.txt\");\nawait sandbox.removeFile(\"/workspace/output\", true);\n```\n\nMethods:\n\n- `readFile(path: string): Promise<Uint8Array>`\n- `writeFile(path: string, data: Uint8Array | string): Promise<void>`\n- `uploadFile(localPath: string, remotePath: string): Promise<void>`\n- `downloadFile(remotePath: string, localPath: string): Promise<void>`\n- `listFiles(path: string): Promise<FileInfo[]>`\n- `statFile(path: string): Promise<FileInfo>`\n- `mkdirFile(path: string): Promise<void>`\n- `removeFile(path: string, recursive?: boolean): Promise<void>`\n- `moveFile(source: string, destination: string): Promise<void>`\n\nRemote paths must be non-empty strings without NUL. The SDK accepts absolute\nor relative remote paths and leaves interpretation to the guest runtime.\n\n`readFile()` buffers the entire remote file in memory and returns bytes. It\nthrows `FilesystemLimitError` before exceeding `filesystemReadLimit`.\n\n`writeFile()` accepts a `Uint8Array` or a string. Strings are encoded as\nUTF-8. It streams the payload in 64 KiB chunks, writes through a guest-side\ntemporary file, and publishes it by replacing the final directory entry. The\nfinal path is not followed when it is a symlink.\n\n`uploadFile()` streams a local file to the remote path in 64 KiB chunks.\n`downloadFile()` streams a remote file into a hidden temporary file in the\ndestination directory, fsyncs it, atomically replaces the destination with\n`fs.rename`, and fsyncs the parent directory where supported. If a read or\nwrite error happens before replacement, the temporary file is removed and\nthe previous destination is left unchanged.\n\n`listFiles()` returns immediate children sorted by name. It returns a\ncomplete array or throws; it does not return partial results after a remote\nlisting error.\n\n`statFile()` returns lstat-style metadata. A final symlink is reported as a\nsymlink rather than followed.\n\n`moveFile()` is same-filesystem, atomic, and no-overwrite. Cross-filesystem\nmoves throw `CrossFilesystemMoveError`; destination-exists errors throw\n`RemoteFileExistsError`.\n\n`removeFile(path, true)` removes directories recursively. Recursive remove\ndoes not follow symlinks and is not atomic.\n\n`FileInfo` is immutable:\n\n```typescript\nimport { FileKind } from \"@bonya-ai/tyto\";\n\nconst info = await sandbox.statFile(\"/workspace/output/message.txt\");\nconsole.log(info.path);\nconsole.log(info.name);\nconsole.log(info.kind === FileKind.FILE);\nconsole.log(info.size);\nconsole.log(info.mode.toString(8));\nconsole.log(info.modifiedAt); // Date\n```\n\n`FileKind` values are `FILE`, `DIRECTORY`, `SYMLINK`, and `OTHER`.\n\n## Jobs\n\nA job is a managed run of a command or script — on a new sandbox (created\nand, by default, deleted for you) or an existing one.\n\n```typescript\nconst run = await client.runJob({\n  newSandbox: { template: \"bonya-dev\" },\n  cmd: [\"./run-tests.sh\"],\n});\nconsole.log(run.status, run.result?.exitCode);\n```\n\n`runJob` blocks until the run finishes, bounded by the client's own timeout\n— use it for short jobs. `startJob` returns immediately with a run id\ninstead:\n\n```typescript\nconst { runId } = await client.startJob({\n  existingSandboxId: \"sbx-123\",\n  cmd: [\"./long-migration.sh\"],\n});\n// ... later ...\nconst detail = await client.getJobRun(runId);\nconsole.log(detail.status, detail.spec, detail.timeline);\n```\n\n`JobSpec` requires exactly one of `existingSandboxId`/`newSandbox`, and\nexactly one of `cmd`/`script`. `disposition` (`Disposition.DELETE`, the\ndefault, or `Disposition.KEEP`) controls what happens to a sandbox the job\nitself created once the run ends; it's not meaningful for\n`existingSandboxId`.\n\n`getJobRun` returns `JobRunDetail`, which adds the stored `spec` and an\nactivity `timeline` to the run summary `listJobRuns` returns. There is no\nendpoint to edit a run in place: to \"edit and rerun\" one, fetch it with\n`getJobRun`, change what you need on its `spec`, and pass that to `runJob`\nor `startJob` as a new run.\n\n```typescript\nfor await (const run of client.listJobRuns({ sandboxId: \"sbx-123\" })) {\n  console.log(run.runId, run.status);\n}\nawait client.cancelJobRun(runId); // requests cancellation; cleanup still runs\n```\n\n`cancelJobRun` cancels rather than terminates, so the run's own cleanup\n(e.g. deleting a sandbox it created) still executes.\n\n## Job Schedules\n\nA schedule wraps a `JobSpec` with timing — cron, interval, or a one-shot\nfuture time — so it runs without you calling `runJob` yourself.\n\n```typescript\nconst schedule = await client.createJobSchedule(\n  { intervalSeconds: 3600 },\n  { newSandbox: { template: \"bonya-dev\" }, cmd: [\"./nightly-report.sh\"] },\n);\nconsole.log(schedule.scheduleId, schedule.nextRunAtUnixNanos);\n```\n\n`ScheduleSpec` requires exactly one of `cronExpressions`, `intervalSeconds`,\nand `runAtUnixNanos` (a one-shot: a single future calendar time, refused if\nin the past). `overlap` (default `ScheduleOverlap.SKIP`) controls what a\nfire does when the previous run from the same schedule is still going.\n\n```typescript\nfor await (const schedule of client.listJobSchedules()) {\n  console.log(schedule.scheduleId);\n}\nconst schedule = await client.getJobSchedule(scheduleId);\n\n// updateJobSchedule replaces the whole schedule -- pass every field you\n// want to keep, not just the one you're changing.\nawait client.updateJobSchedule(scheduleId, { intervalSeconds: 7200 }, jobSpec);\n\nawait client.setJobSchedulePaused(scheduleId, true, { note: \"pausing for maintenance\" });\nawait client.triggerJobSchedule(scheduleId); // fires one run now, ignoring timing\nawait client.deleteJobSchedule(scheduleId);\n```\n\n`JobSchedule.recentRunIds` holds the last 10 fires' run ids (oldest first,\nincluding manual triggers), each a valid `getJobRun` id — so you can follow\na schedule straight to its recent runs without a separate `listJobRuns`\ncall.\n\n## Templates\n\n```typescript\nconst templates = await client.listTemplates();\nfor (const template of templates) {\n  console.log(template.id, template.version, template.isDefault, template.metadata.os);\n}\n```\n\nLists every `templateId`/version `createSandbox` and `runJob` will accept,\nwith metadata (OS, installed language stacks, which AI agent CLIs are\npreinstalled) to help pick one. Not paginated: the catalog is the same for\nevery caller. `createSandbox`'s `template` option may be omitted to use the\ndeployment's configured default template, if it has one.\n\n## Preview URLs\n\nA preview publishes one guest port at an HTTPS URL a browser can open. The\nserver must bind a port in 1024-65535; privileged ports are never\npreviewable, so a guest's ssh can't be handed out by accident.\n\n```typescript\nconst preview = await sandbox.createPreview(3000, { name: \"web\" });\nconsole.log(preview.url); // https://pv-<26 chars>.preview.tyto.run\n\nawait sandbox.listPreviews();\nawait sandbox.deletePreview(preview.id);\n```\n\n### Opening one in a browser\n\nA token-mode preview needs the sandbox's capability, and a URL is not a safe\nplace to leave one. `previewBrowserUrl()` produces a single-use entry point:\nthe gateway validates the token, trades it for a host-scoped `HttpOnly`\ncookie, and redirects to the same address without it, so no page is ever\nrendered at a URL containing the credential.\n\n```typescript\nconst url = sandbox.previewBrowserUrl(preview);\n// open `url` in a browser\n```\n\nOpen it once and let the cookie carry the session. **Do not share that URL**\n— anyone who receives it holds the sandbox's data-plane capability until it\nexpires. It throws on a public preview, which has no token to exchange.\n\n### Public previews\n\n```typescript\nimport { PreviewAuth } from \"@bonya-ai/tyto\";\n\nconst publicPreview = await sandbox.createPreview(8080, { auth: PreviewAuth.PUBLIC });\n```\n\n`PUBLIC` means exactly that: anyone with the URL reaches the service, with no\ncredential. The only thing protecting it is the 26 random characters in the\nhostname, so treat it as published to the internet. `TOKEN` is the default\nand an omitted `auth` never yields a public URL.\n\n### Capability upgrade\n\n`create()` returns a fresh capability and the SDK stores it on the sandbox\nautomatically, because the preview scope is newer than the token a sandbox\nis created with. A token minted before previews existed is otherwise valid\nand will be refused by the preview ingress with a permission error that is\ndeliberately *not* a refresh signal.\n\nIf you are holding a capability elsewhere, refresh it explicitly:\n\n```typescript\nawait sandbox.reissueCapability();\n```\n\n### Suspend and wake\n\nTraffic to a preview URL wakes a suspended sandbox and the request is served\nonce it is running. An idle sandbox therefore costs nothing until a visitor\narrives. The first request after a suspend pays the resume latency; if it\ntakes too long you get `503` with `Retry-After`, and retrying is the right\nmove.\n\n### Limitations\n\n- **Bind to the interface, not localhost only.** A server listening solely on\n  `127.0.0.1` inside the guest is reachable, but one bound to a specific\n  non-loopback address may not be. `0.0.0.0` is the reliable choice.\n- **Server-Sent Events reconnect.** An SSE stream is not an HTTP upgrade, so\n  it is cut at the 120-second request cap. `EventSource` reconnects\n  automatically; a long-lived stream that must not break should use a\n  WebSocket.\n- **WebSocket auth is cookie or bearer only.** WebSocket clients do not\n  follow redirects, so the `?bonya_token=` exchange does not work for them.\n  Open a normal page first to obtain the cookie, or send the capability as a\n  bearer header.\n- **A suspend cuts open connections.** Preview connections deliberately do\n  not defer idle-suspend, so an open WebSocket does not keep a sandbox\n  alive. The next request wakes it.\n\n## Snapshots\n\nCreate a snapshot from a running sandbox:\n\n```typescript\nconst snapshot = await sandbox.snapshot({ idempotencyKey: \"snapshot-job-123\" });\nconsole.log(snapshot.id);\nconsole.log(snapshot.sourceSandboxId);\n```\n\n`sandbox.snapshot(options)` returns `Promise<Snapshot>`. If `idempotencyKey`\nis omitted, the SDK generates one and reuses it for snapshot create\ntransport retries. Using the same key for the same source sandbox returns\nthe same snapshot identity when the service accepts idempotent replay.\n\nSnapshot create requires a running source sandbox. Locally deleted or\nobserved deleted sandboxes throw `SandboxDeletedError`; failed sandboxes\nthrow `SandboxFailedError`; suspended sandboxes throw\n`SandboxSuspendedError`.\n\nDelete snapshots when done:\n\n```typescript\nawait snapshot.delete();\nawait snapshot.delete(); // local no-op\n```\n\n`snapshot.delete()` resolves to `void` and is idempotent on the same\n`Snapshot` object. Snapshots can be deleted after deleting the source\nsandbox handle. A snapshot has its own identifier and object lifetime does\nnot control remote snapshot retention.\n\n## Organizations\n\nAn api key belongs to a user, not a single organization, so one key works\nacross every organization that user belongs to. Calls are scoped to whichever\norganization is current on the client.\n\n```typescript\nconst organizations = await client.listOrganizations();\nfor (const org of organizations) {\n  console.log(org.id, org.name, org.personal, org.role);\n}\n\nclient.organizationId = organizations[0].id;\n```\n\n`listOrganizations()` returns every organization the caller belongs to,\nincluding their personal organization. `Organization.personal` marks that\none — it's the deterministic tenant an omitted organization context resolves\nto, and every account has exactly one. TApi stores its name as the literal\nstring `\"personal\"`; render that however fits your UI rather than showing it\nverbatim.\n\nAssigning to `client.organizationId` changes which organization subsequent\ncalls run against, effective immediately. An empty value is rejected as an\n`InvalidRequestError` rather than silently falling back to the personal\norganization. To set it once at construction instead, pass `organizationId`\nin the constructor options; reading `client.organizationId` back returns\nwhatever is currently in effect.\n\n## Error Model\n\nAll SDK exceptions inherit from `TytoError`, which itself extends `Error`.\n\n```typescript\nimport { TytoError } from \"@bonya-ai/tyto\";\n\ntry {\n  await client.getSandbox(\"sbx_missing\");\n} catch (error) {\n  if (error instanceof TytoError) {\n    console.log(error.message);\n    console.log(error.sandboxId);\n    console.log(error.operationId);\n    console.log(error.idempotencyKey);\n  }\n}\n```\n\nPublic exceptions:\n\n- `AuthenticationError`: invalid or rejected API key.\n- `InvalidRequestError`: invalid local arguments or invalid service\n  response.\n- `SandboxNotFoundError`: sandbox missing, deleted, or not visible to the\n  API key.\n- `SandboxDeletedError`: operation cannot run because the sandbox is\n  deleted.\n- `SandboxSuspendedError`: operation reported a suspended sandbox.\n- `SandboxBusyError`: service rejected a lifecycle operation as busy.\n- `SandboxFailedError`: operation cannot run because the sandbox failed.\n- `SandboxCreationFailedError`: create reached a failed terminal state.\n- `SandboxCreationTimeoutError`: create deadline expired.\n- `CapabilityRejectedError`: guest capability was rejected and could not be\n  refreshed.\n- `SessionExistsError`: `createSession()` targeted a name that already has a\n  record and either no `replace: true` was given or the record is not\n  terminal.\n- `SessionNotFoundError`: `attachSession()` or `killSession()` named a\n  session that does not exist.\n- `JobRunNotFoundError`: `getJobRun()` or `cancelJobRun()` named a run that\n  does not exist.\n- `JobScheduleNotFoundError`: a job schedule call named a schedule that does\n  not exist.\n- `FilesystemError`: general filesystem failure.\n- `RemoteFileNotFoundError`: remote file or directory missing.\n- `RemoteFileExistsError`: remote destination already exists.\n- `CrossFilesystemMoveError`: remote move crosses filesystems.\n- `FilesystemLimitError`: client or service filesystem size/frame limit.\n- `ExecFailedError`: `ExecResult.check()` or `check: true` saw a non-ok\n  result.\n- `TimeoutError`: operation deadline expired.\n- `ConnectionError`: retryable transport failure exhausted retries.\n- `ServiceError`: service or unexpected transport failure not covered\n  above.\n\nThe SDK redacts API keys, capabilities, and selected operation identifiers\nsupplied to the error mapper from mapped service messages, and replaces\npath-like substrings in those messages with `[redacted-path]`.\n\nExamples:\n\n```typescript\nimport { AuthenticationError, SandboxNotFoundError } from \"@bonya-ai/tyto\";\n\ntry {\n  await client.getSandbox(\"sbx_123\");\n} catch (error) {\n  if (error instanceof AuthenticationError) {\n    console.log(\"check BONYA_API_KEY\");\n  } else if (error instanceof SandboxNotFoundError) {\n    console.log(\"sandbox does not exist or is not visible\");\n  } else {\n    throw error;\n  }\n}\n```\n\n```typescript\nimport { FilesystemError, RemoteFileNotFoundError } from \"@bonya-ai/tyto\";\n\ntry {\n  await sandbox.readFile(\"/workspace/missing.txt\");\n} catch (error) {\n  if (error instanceof RemoteFileNotFoundError) {\n    console.log(\"missing\");\n  } else if (error instanceof FilesystemError) {\n    console.log(\"filesystem failed:\", error.message);\n  } else {\n    throw error;\n  }\n}\n```\n\n```typescript\nimport { TimeoutError } from \"@bonya-ai/tyto\";\n\ntry {\n  await sandbox.exec([\"sleep\", \"60\"], { timeout: 1 });\n} catch (error) {\n  if (error instanceof TimeoutError) {\n    console.log(\"command timed out and was cancelled\");\n  } else {\n    throw error;\n  }\n}\n```\n\n## Resource Ownership\n\nUse `try`/`finally` for deterministic cleanup:\n\n```typescript\nconst client = new Tyto()  // reads BONYA_API_KEY;\ntry {\n  const sandbox = await client.createSandbox({ template: \"bonya-dev\" });\n  try {\n    const session = sandbox.execStream([\"cat\"]);\n    try {\n      session.write(new TextEncoder().encode(\"hello\\n\"));\n      session.closeStdin();\n      for await (const event of session) {\n        // ...\n      }\n    } finally {\n      session.close();\n    }\n  } finally {\n    await sandbox.delete();\n  }\n} finally {\n  client.close();\n}\n```\n\nOwnership rules:\n\n- `Tyto.close()` closes cached channels and is idempotent.\n- `sandbox.delete()` affects the remote sandbox and updates the local handle\n  to `Status.DELETED`. It is idempotent on the same `Sandbox` object.\n- Closing an unfinished `execStream()` session cancels the remote Exec.\n- Closing an unfinished `attachSession()` stream detaches the guest\n  process rather than killing it; the process keeps running.\n- `Snapshot.delete()` deletes the remote snapshot identity and is a local\n  no-op when repeated on the same object.\n\nFor intentionally persistent sandboxes, do not delete on scope exit. Store\n`sandbox.id`, close the client, and reconnect later with\n`client.getSandbox`.\n\n## Current Limitations\n\nThe current TypeScript SDK intentionally exposes only the merged public\nsurface, mirroring the Python SDK's scope:\n\n- The package is ESM-only; see \"Module format\" above.\n- There is no public `suspend()` method.\n- There are no public networking, fork, template-engine, or multi-host APIs.\n- Managed sessions are TTY-only; there is no non-TTY managed session mode.\n- There is no `sandbox.console()` attach-or-create convenience yet, and no\n  multi-attach or collaborative terminal mode — a new attach always\n  preempts the previous one.\n- `SandboxSummary` values are metadata only and cannot run Exec.\n- Buffered Exec stores stdout and stderr in memory.\n- `sandbox.readFile()` stores the full file in memory up to\n  `filesystemReadLimit`.\n- Filesystem writes, uploads, moves, mkdir, and removes are not retried\n  after ambiguous transport errors.\n- Remote filesystem path normalization, permissions, symlink traversal\n  inside parent directories, and service-side file size limits are\n  guest-runtime behavior, not TypeScript SDK behavior.\n- Proto codegen sources from the Buf Schema Registry (`buf.build/bonya/tyto`)\n  via `buf export` by default. Set `PROTO_DIR` to a local checkout of\n  `compute/proto` to generate from unpublished proto changes instead, e.g.\n  `PROTO_DIR=../../compute/proto npm run proto`.\n\n## Troubleshooting\n\n**`InvalidRequestError: api_key is required`**\nNothing supplied a key. Set `BONYA_API_KEY`, or pass `apiKey`. If you use the\n`tyto` CLI, `tyto login` saves a key — but to a config file the SDK does not\nread, so export it:\n\n```bash\nexport BONYA_API_KEY=byk_...\n```\n\n**`AuthenticationError`**\nThe key reached the server and was rejected. It may be revoked.\n\n**`InvalidRequestError: organization_id must be a non-empty string`**\n`BONYA_ORGANIZATION_ID` is set but empty — usually an unset variable expanded in\nCI. This is deliberately an error rather than a fallback to your personal\norganization; see [Organizations](#organizations).\n\n**The process does not exit**\nCall `client.close()`. Open gRPC channels keep Node's event loop alive, so a\nscript that finishes its work but never closes the client hangs at the end.\n\n**`SandboxNotFoundError` on a sandbox you just created**\nMost often an organization mismatch: the sandbox was created in one organization\nand looked up in another. Sandboxes are not visible across organizations, and a\nsandbox in an organization you cannot see is reported the same way as one that\ndoes not exist.\n\n**`SandboxCreationTimeoutError`**\nCreate did not reach a running state before the deadline. The error carries the\n`idempotencyKey` it used — retry `create()` with that same key to join the\noriginal creation rather than starting a second sandbox.\n\n**TLS/certificate errors against a private deployment**\nPoint `caBundle` (or `BONYA_CA_BUNDLE`) at the PEM bundle for your CA.\n\n**`FilesystemLimitError` from `readFile()`**\nThe file is larger than `filesystemReadLimit` (64 MiB by default). Raise the\nlimit, or use `downloadFile()`, which streams to disk instead of buffering.\n\n**A command hangs**\n`exec()` buffers all output and resolves only when the process exits, so a\nserver or REPL never resolves. Use `execStream()`, or run it as a\n[managed session](#managed-console-sessions).\n\n**`Cannot find module` / bundler errors in a browser or edge runtime**\nThis package is Node-only: `@grpc/grpc-js` needs `http2`, which browsers and\nmost edge runtimes do not provide. Call the API from a Node server instead.\n\n## Examples\n\nRunnable programs are in [`examples/`](examples):\n\n| File | Shows |\n| --- | --- |\n| [`quickstart.ts`](examples/quickstart.ts) | Create, exec, clean up |\n| [`exec-streaming.ts`](examples/exec-streaming.ts) | Streaming output and stdin |\n| [`files.ts`](examples/files.ts) | Read, write, upload, download, list |\n| [`sessions.ts`](examples/sessions.ts) | Persistent terminals and replay |\n| [`previews.ts`](examples/previews.ts) | Publishing a port to a browser |\n| [`snapshots.ts`](examples/snapshots.ts) | Capturing sandbox state |\n\n```bash\nexport BONYA_API_KEY=byk_...\nnpx tsx examples/quickstart.ts\n```\n\n## Development\n\n```bash\nmake check      # typecheck + test, the same checks CI runs\nmake test\nmake typecheck  # covers src, tests, and examples\n```\n\n`npm run build` compiles `src/` to `dist/`. It sets `rootDir` to `src`, so\ntests and examples cannot ride along with it — `npm run typecheck` uses\n[`tsconfig.check.json`](tsconfig.check.json) to check all three together.\n\nRegenerate the protobuf/gRPC code. By default this exports the protos from the\nBuf Schema Registry, so no checkout of the `compute` repository is needed —\n`buf` and `protoc` must be on `PATH`:\n\n```bash\nnpm run proto\n```\n\nTo generate against unpublished proto changes instead, point at a local\ncheckout:\n\n```bash\nPROTO_DIR=../../compute/proto npm run proto\n```\n\n## See also\n\n- [Go SDK](../go) · [Python SDK](../python)\n- [`tyto` CLI](../../cli) — the same API from a terminal\n","readmeFilename":"README.md"}