# TypeScript SDK

> Kuiper's SDK for models, browsers and sandboxes. One gateway key, typed methods for everything the gateway does, ready settings for OpenAI's, Anthropic's, Google's, Glove's, the AI SDK's and Steel's clients, and errors you can act on.

Web page: https://kuiper.sh/docs/sdk/

## One SDK for every agent service

`@kuiper-sh/sdk` wraps the [model gateway](https://kuiper.sh/docs/models.md), [browsers](https://kuiper.sh/docs/browsers.md) and [sandboxes](https://kuiper.sh/docs/sandboxes.md) behind one [gateway key](https://kuiper.sh/docs/gateway-keys.md):

```sh
npm install @kuiper-sh/sdk
```

```ts
import { Kuiper } from '@kuiper-sh/sdk';

const kuiper = new Kuiper({ apiKey: 'kgw_…' });

const { text } = await kuiper.models.chat({ model: 'claude-sonnet-5-5', prompt: 'Say hello' });

const session = await kuiper.browsers.create();        // connect Playwright to session.websocketUrl
const sandbox = await kuiper.sandboxes.create();       // run commands with sandbox.exec(…)
```

It needs Node.js 20 or later and has no dependencies of its own. Each service is also its own package, if you only need one:

| Package | What it does |
| --- | --- |
| [`@kuiper-sh/sdk`](https://www.npmjs.com/package/@kuiper-sh/sdk) | All three, behind one `Kuiper` client |
| [`@kuiper-sh/models`](https://www.npmjs.com/package/@kuiper-sh/models) | `Models`: chat, streaming, embeddings, and settings for other clients |
| [`@kuiper-sh/browsers`](https://www.npmjs.com/package/@kuiper-sh/browsers) | `Browsers`: sessions, profiles, recordings, files, quick actions |
| [`@kuiper-sh/sandboxes`](https://www.npmjs.com/package/@kuiper-sh/sandboxes) | `Sandboxes`: commands, terminals, files, ports, hibernation |
| [`@kuiper-sh/gateway`](https://www.npmjs.com/package/@kuiper-sh/gateway) | The gateway client and errors the others share |

```ts
import { Sandboxes } from '@kuiper-sh/sandboxes';

const sandboxes = new Sandboxes({ apiKey: 'kgw_…' });
```

## The key and the gateway

**`apiKey`** is a gateway key, `kgw_…`: one you made in the dashboard or with `kuiper keys create`, or a unit's `${{models.API_KEY}}`. A unit gets one key for all three services, so any of its `API_KEY` outputs works. Keep it wherever your code keeps secrets and pass it in. If you leave `apiKey` out, the SDK reads `KUIPER_GATEWAY_KEY`, as the CLI does. An API token (`kpr_…`) is refused with a message that says which key to use.

**The gateway** is `https://gateway.kuiper.sh` unless you say otherwise:

```ts
new Kuiper({ apiKey, region: 'eu-fsn' });                          // gateway.eu-fsn.kuiper.sh
new Kuiper({ apiKey, gatewayUrl: 'https://gateway.example.com' }); // your own Kuiper's gateway
```

`gatewayUrl` also takes any one service's address, so a unit can pass its `${{browsers.BASE_URL}}`, `${{sandboxes.BASE_URL}}` or `${{models.BASE_URL}}` as it is.

`await kuiper.whoami()` says which project the key belongs to and which services it opens.

## Models

Call any model your organisation has a [provider key](https://kuiper.sh/docs/models/#add-a-provider-key) for. The model's name picks the provider: a provider's own name (`gpt-5`, `claude-sonnet-5-5`, `gemini-2.5-flash`, `kimi-k2.5`), or your key's name and the model (`openrouter/anthropic/claude-sonnet-5-5`, `my-vllm/llama-3.3-70b`).

### Ask and wait

```ts
const r = await kuiper.models.chat({
  model: 'claude-sonnet-5-5',
  system: 'You answer in one sentence.',
  prompt: 'What is a sandbox?',
});
r.text;          // the answer
r.usage;         // tokens, as the provider counted them
r.callId;        // the call in the dashboard's Models page, or kuiper.models.call(r.callId)
```

`prompt` and `system` are shorthand. Pass `messages` for a conversation, and any other field of Chat Completions as you would write it: `max_tokens`, `temperature`, `tools`, `tool_choice`, `response_format`, `reasoning_effort`. `r.message` is the assistant's message, ready to append for the next turn, and `r.toolCalls` holds the tools it called.

### Stream

```ts
const stream = kuiper.models.stream({ model: 'gpt-5-mini', prompt: 'Write a haiku about comets' });
for await (const text of stream) process.stdout.write(text);
const { usage, toolCalls } = await stream.result();
```

`stream.chunks()` gives the raw chunks instead, with reasoning and tool call deltas. Breaking out of the loop stops the call.

### Fallbacks, conversations, embeddings

```ts
await kuiper.models.chat({ model: 'claude-sonnet-5-5', fallbacks: ['openai/gpt-5'], prompt: '…' });

const run = kuiper.models.withSession(runId);   // every call of one agent run in one conversation
await run.chat({ model: 'gpt-5', messages });

const { embeddings } = await kuiper.models.embed({ model: 'text-embedding-3-small', input: ['a', 'b'] });
```

`kuiper.models.list()` lists every model your project's keys offer, and `kuiper.models.call(id)` reads one call's record: tokens, latency, cost, status. Never what was said.

### With OpenAI's, Anthropic's, Google's, Glove's or the AI SDK's client

Each of these clients wants a different base URL. The SDK gives each one its settings, so you never pick the URL yourself:

```ts
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';
import { GoogleGenAI } from '@google/genai';
import { createAdapter } from 'glove-core/models/providers';
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';

const openai = new OpenAI(kuiper.models.openai());
const anthropic = new Anthropic(kuiper.models.anthropic());
const gemini = new GoogleGenAI(kuiper.models.google());

const kimi = createAdapter({ provider: 'kimi', model: 'kimi-k2.5', ...kuiper.models.glove('kimi') });
const claude = createAdapter({ provider: 'anthropic', model: 'claude-sonnet-5-5', ...kuiper.models.glove('anthropic') });

const aiOpenAI = createOpenAI(kuiper.models.aiSdk.openai());
const aiAnthropic = createAnthropic(kuiper.models.aiSdk.anthropic());
const aiGoogle = createGoogleGenerativeAI(kuiper.models.aiSdk.google());
```

From `withSession(id)`, these settings carry the conversation too. `kuiper.models.baseURL`, `anthropicBaseURL` and `geminiBaseURL` are the URLs themselves, for anything else.

## Browsers

```ts
import { chromium } from 'playwright';

const session = await kuiper.browsers.create({ timeout: 15 * 60_000 });
const browser = await chromium.connectOverCDP(session.websocketUrl);
const page = browser.contexts()[0].pages()[0];
await page.goto('https://example.com');
await browser.close();
await session.release();
```

Puppeteer connects the same way: `puppeteer.connect({ browserWSEndpoint: session.websocketUrl })`.

`use()` releases the session when your function ends, even if it throws:

```ts
const title = await kuiper.browsers.use({}, async (session) => {
  const browser = await chromium.connectOverCDP(session.websocketUrl);
  const page = browser.contexts()[0].pages()[0];
  await page.goto('https://example.com');
  return page.title();
});
```

| | |
| --- | --- |
| `kuiper.browsers.create(options)` | Steel's options (`timeout`, `dimensions`, `proxyUrl`, `profileId`, `persistProfile`, …) and Kuiper's own under `kuiper`: `record`, `onInactive`, `provider` |
| `kuiper.browsers.get(id)` | Any session of the project, from any process |
| `kuiper.browsers.list({ status })`, `listAll()` | Live sessions, then ended ones, page by page |
| `kuiper.browsers.connectUrl(id)` | A CDP URL from only the session's ID, to reconnect later |
| `session.debugUrl`, `session.interactiveDebugUrl` | A live view to watch, or to take over |
| `session.hibernate()`, `session.resume()` | Give the browser back and keep the session's state; resume it later with the same ID |
| `session.recording()` | A recorded session's rrweb events, once it is released or hibernated |
| `session.files.upload()`, `list()`, `download()`, `zip()`, `delete()` | Files in the session's browser |
| `kuiper.browsers.profiles.list()`, `get()`, `delete()` | Saved [profiles](https://kuiper.sh/docs/browsers/#profiles) |
| `kuiper.browsers.scrape()`, `screenshot()`, `pdf()` | One action on a browser of its own |

Steel's own SDK works too: `new Steel(kuiper.browsers.steel())`.

## Sandboxes

```ts
const sandbox = await kuiper.sandboxes.create({ image: 'kuiper/coding', timeoutMs: 30 * 60_000 });

await sandbox.files.write('/workspace/hello.py', 'print("hello")');
const r = await sandbox.exec('python3 /workspace/hello.py');
r.stdout;    // "hello\n"
r.exitCode;  // 0

await sandbox.release();
```

`create` waits until the sandbox is live. `kuiper.sandboxes.use(options, fn)` releases it when `fn` ends, as for browsers.

### Commands

```ts
await sandbox.exec(['npm', 'test'], { cwd: '/workspace', env: { CI: '1' }, timeoutMs: 600_000 });

// Output as it comes; nothing is cut at 4 MiB.
await sandbox.exec('npm install', { onStdout: (d) => process.stdout.write(d) });

// Every event: start, stdout, stderr, exit.
for await (const ev of sandbox.stream('make build')) console.log(ev);

// In the background, for servers.
const dev = await sandbox.start('npm run dev -- --host', { cwd: '/workspace/app' });
await dev.status();   // { running, exitCode }
```

A command is a program and its arguments, or one string run with `sh -c`.

### A terminal

```ts
const term = await sandbox.terminal({ cols: 120, rows: 40 });   // bash, or sh
term.onData((data) => process.stdout.write(data));
term.write('ls -la\r');
term.resize(100, 30);
const { exitCode } = await term.exited;
```

`terminal({ cmd: ['python3', '-i'] })` runs something else, and `tty: false` gives plain pipes with standard error apart. `term.close()` hangs up. Terminals need a WebSocket that can send headers: Node.js 22 and later have one. On Node.js 20, install `ws` and pass it: `new Kuiper({ apiKey, WebSocket })`.

### Files, ports, time

| | |
| --- | --- |
| `sandbox.files.write(path, data, { mode })` | Text, bytes, a `Blob` or a stream, up to 100 MB |
| `sandbox.files.read(path)`, `readText(path)`, `stream(path)` | A file as bytes, text or a stream |
| `sandbox.files.list(path)`, `exists(path)` | What a directory holds (default `/workspace`) |
| `sandbox.files.tar(path)` | A directory as a tar archive, streamed |
| `sandbox.ports.open(5173)`, `open(3000, { public: true })` | Serve a port at its own URL; private ones carry a token |
| `sandbox.ports.url(port)`, `ports.close(port)` | A port's URL; stop serving it |
| `sandbox.extend(ms)` | Keep it at least that much longer, as a keep-alive |
| `sandbox.hibernate()`, `sandbox.resume()` | Keep `/workspace` and the home directory and stop billing it; bring it back with the same ID |
| `kuiper.sandboxes.list({ status })`, `listAll()`, `releaseAll()` | The project's sandboxes |

`sandbox.id`, `status`, `expiresAt` and the rest are as of the last answer; `sandbox.refresh()` asks again, and every method that changes the sandbox updates them.

## Errors

Every failure is a `KuiperError` with the gateway's `code`, its HTTP `status`, a `message` for people and `retryAfterMs` when the gateway said how long to wait:

```ts
import { isKuiperError } from '@kuiper-sh/sdk';

try {
  await kuiper.sandboxes.create({ size: 'large' });
} catch (err) {
  if (isKuiperError(err, 'no_capacity')) {
    // the region is full: try again, or a smaller size
  } else if (isKuiperError(err, 'budget_exceeded')) {
    // a budget ran out
  } else throw err;
}
```

`err.retryable` is true for rate limits, a full region and a gateway that couldn't be reached. Requests that were refused before anything happened, rate limits (429) and a full region (503), are tried again on their own, twice by default, waiting as long as the gateway asks, up to 30 seconds. A command or an upload is only tried again after a rate limit. `maxRetries: 0` turns this off. A request that can't reach the gateway has `code: 'connection_error'`, and one that takes too long `code: 'timeout'`.

The codes are listed on each service's page: [models](https://kuiper.sh/docs/models/#errors), [browsers](https://kuiper.sh/docs/browsers/#cost-and-limits) and [sandboxes](https://kuiper.sh/docs/sandboxes/#every-endpoint).

## Options

| Option | |
| --- | --- |
| `apiKey` | The gateway key |
| `region`, `gatewayUrl` | Which gateway to call |
| `maxRetries` | How often a refused request is tried again (2) |
| `timeoutMs` | How long an ordinary request may take (60 s). Model calls allow 10 minutes; commands, transfers and streams set their own |
| `headers` | Headers for every request |
| `fetch` | A `fetch` to use instead of the global one |
| `WebSocket` | A WebSocket class for terminals on Node.js 20 (`ws`) |

Every method also takes an `AbortSignal` as `signal`.
