# @platinum-dev/sdk

Typed TypeScript client for [Platinum](https://github.com/kortix-ai/platinum) —
hardware-isolated sandbox microVMs (one Cloud Hypervisor VM per sandbox, sub-second
boots via a warm pool).

```sh
npm install @platinum-dev/sdk     # or: bun add @platinum-dev/sdk
```

ESM-first; CJS `require()` is supported via a bundled `dist/index.cjs`. Node ≥ 18 or Bun.
Server-side runtimes only — never ship an API key to a browser.

## Quickstart

```ts
import { Platinum } from '@platinum-dev/sdk';

const dn = new Platinum({
  token: process.env.PT_TOKEN,           // API key (pt_live_…) — mint in the dashboard
  url:   process.env.PT_API_URL,         // e.g. https://api.platinum.dev
});

// Pick a template that exists on YOUR deployment first:
console.log(await dn.templates.list());

// Create → running in one round-trip (server holds the POST until ready).
const sbx = await dn.sandboxes.create(
  { template: 'pt-base', env: { GREETING: 'hi' } },   // template names + cpu/ram minimums vary per deployment
  { waitForRunning: true },
);

const r = await sbx.exec('uname -a');   // argv array or plain string
console.log(r.exit_code, r.stdout);
r.check();                              // throws on non-zero exit

await sbx.delete();
```

Expose a port in the same call (no second round-trip):

```ts
const sbx = await dn.sandboxes.create(
  { template: 'pt-base', expose: [{ port: 8080, public: true }] },
  { waitForRunning: true },
);
console.log(sbx.exposedUrl(8080));   // reachable preview URL
```

Build a custom image inline (cache-hit on repeat, built on first use):

```ts
import { Template, Platinum } from '@platinum-dev/sdk';

const image = Template.fromPythonImage('3.12-slim').pipInstall(['fastapi', 'uvicorn']).workdir('/app');
const sbx = await dn.sandboxes.create({ image }, { waitForRunning: true, waitTimeoutMs: 600_000 });
```

## Configuration

| Option / env | Default | Meaning |
|---|---|---|
| `token` / `PT_TOKEN` | — (required) | API key `pt_live_…`, org-scoped bearer token |
| `url` / `PT_API_URL` | `http://127.0.0.1:3000` | Control-plane URL |
| `timeoutMs` | `60000` | Per-request timeout (file transfer calls override per call) |
| `fetch` | global fetch | Transport override (mocked tests) |

## Errors

Everything the SDK throws extends `PlatinumError` (`.status`, `.body`, `.code` — the
API's machine-readable error code, also folded into the message):

```ts
import { NotFoundError, ConflictError, RateLimitError } from '@platinum-dev/sdk';

try { await sbx.exec('true'); }
catch (e) {
  if (e instanceof NotFoundError)  { /* sandbox gone */ }
  if (e instanceof ConflictError)  { /* e.g. e.code === 'sandbox_not_running' */ }
  if (e instanceof RateLimitError) { /* back off e.retryAfterSeconds — the SDK never retries */ }
}
```

Subclasses: `ValidationError` (400) · `AuthenticationError` (401) · `ForbiddenError` (403)
· `NotFoundError` (404) · `ConflictError` (409) · `RateLimitError` (429) · `ServerError`
(5xx) · `PlatinumTimeoutError` / `PlatinumConnectionError` (client-side, `status === 0`).
**No automatic retries, ever** — a 429 or a failed create is surfaced, never silently retried.

## Surface

| Area | Methods |
|---|---|
| Sandboxes | `create` (inline image, inline expose, server-side wait) · `get` · `connect` · `list` · `iterate` (auto-pagination) · `delete` · `rename` |
| Lifecycle | `stop`/`start` (optional server-side wait) · `pause`/`resume` · `kill` · `resize` · `fork` · `clone` · `snapshot` · `listSnapshots` · `deleteSnapshot` · `restore` · `restoreFromBackup` · `archive` · `backup` · `waitRunning` · `waitState` · `refresh` |
| Execution | `exec(argv \| string)` · `sh(script)` · `runCode(code, {lang})` — defaults to the sandbox's create-time `language` |
| Processes | `process.start({cmd, cwd, env, pty, timeoutMs})` · `process.list()` · `process.get(handle)` (reconnect) — then per handle: `logs({stdoutOffset})` · `stream()` (async iterator, resumes on drop) · `stdin` / `closeStdin` · `signal` · `kill` · `resize` · `wait` · `info` |
| Files (vsock) | `files.read/write/delete/list/stat/mkdir/exists` · `find` (glob) · `grep` (content) · `replace` (bulk sed) · `watch` (async iterator of change events) |
| Networking | `expose(port, {public, ttlSeconds})` · `unexpose` · `exposedUrl` · `revokeExposeToken` · `setEgressPolicy` · `addSshKeys` |
| Observability | `metrics()` (live cpu/mem/disk) · `usage()` (billed usage) |
| Platform | `templates.list/get/delete` · `Template` builder · `webhooks.*` (create/list/update/delete/test/deliveries/retryDelivery) · `regions.list` · `me()` · `health.check` |

Watch for file changes:

```ts
for await (const ev of sbx.files.watch('/workspace', { maxSeconds: 60 })) {
  if (ev.event === 'change') console.log(ev.data.type, ev.data.path);
}
```

Run something that outlives the call, and tail it:

```ts
const p = await sbx.process.start({ cmd: ['npm', 'run', 'dev'], cwd: '/app' });

for await (const ev of p.stream()) {
  if (ev.type === 'stdout')    process.stdout.write(ev.text);
  if (ev.type === 'gap')       console.warn(`… ${ev.dropped} bytes dropped …`);   // a hole, not a statistic
  if (ev.type === 'reconnect') console.warn(`… resuming at ${ev.stdout_offset} …`);
  if (ev.type === 'exit')      console.log('exit', ev.exit_code);
}
```

Unlike `exec()`, nothing is killed when the request returns. Output is read by
**absolute byte offset**, so `stream()` resumes a dropped connection exactly where it
stopped, and `sbx.process.get(handle)` reconnects to a process started by a previous
run of your program. Reads are byte-exact (`ev.data` / `logs().stdout` are `Uint8Array`;
`ev.text` / `logs().stdout_text` are the lossy printable view). A sandbox whose in-VM
agent predates this feature answers 409 — `isManagedProcessesUnsupported(err)` tells you
to rebuild the template or fall back to `exec()`.

Runnable process example: [`examples/04-managed-process.ts`](./examples/04-managed-process.ts).

Not wrapped yet: interactive terminal (WebSocket), cross-host `migrate` (admin-only),
API-key management, audit-log queries, billing.

## Limits worth knowing (honest edition)

- **`files.write` bodies must stay ≤ 16 MiB** — larger writes currently truncate and
  fail through the vsock path. Split anything bigger into ≤ 16 MiB chunks: write
  sequential parts, then concatenate them in-guest with `exec` (`cat part.* > out`).
- **`exec`/`runCode` are buffered**, not streaming: output arrives after the command
  exits. Default timeout 30 s — pass `timeoutMs` for longer runs. `runCode` source is
  capped at 1 MiB.
- **Template names and minimums are per-deployment.** `pt-base` (busybox — no git, pip,
  node or `httpd` inside; `nc`/`wget` available) exists on default installs; hosted
  deployments may only offer other templates with higher cpu/ram minimums. Call
  `templates.list()` first; a below-minimum create fails with a clear 400.
- **Background processes are reaped when their `exec` call returns.** Use
  `sbx.process.start()` for anything that must outlive the call — it keeps the output
  and the exit code, which daemonizing does not. The `exec` workaround still works
  (`setsid sh -c '<server loop>' >/dev/null 2>&1 < /dev/null &`, see
  [`examples/03-expose-service.ts`](./examples/03-expose-service.ts)) and is the only
  option against a guest whose agent predates managed processes.
- **Managed processes do not survive a stop/start.** Handles are unique per agent boot,
  so a handle from before a stop, a cold boot or a snapshot restore reads as
  `process_not_found`. They DO survive pause/resume: the registry lives in guest memory,
  which is what the memory snapshot captures.

## Testing

`bun test` runs the offline unit suite (mocked fetch, no network). The live e2e is
opt-in: `PT_E2E=1 PT_API_URL=… PT_TOKEN=… bun test test/sdk.test.ts`.

## Examples

Runnable scripts in [`examples/`](./examples): create→exec→delete, expose + fetch a
live service.

```sh
PT_API_URL=… PT_TOKEN=… bun examples/01-create-exec-delete.ts
```

## License

MIT
