kuiper Docs

Agent services

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.

On this page

One SDK for every agent service#

@kuiper-sh/sdk wraps the model gateway, browsers and sandboxes behind one gateway key:

npm install @kuiper-sh/sdk
TypeScript
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:

PackageWhat it does
@kuiper-sh/sdkAll three, behind one Kuiper client
@kuiper-sh/modelsModels: chat, streaming, embeddings, and settings for other clients
@kuiper-sh/browsersBrowsers: sessions, profiles, recordings, files, quick actions
@kuiper-sh/sandboxesSandboxes: commands, terminals, files, ports, hibernation
@kuiper-sh/gatewayThe gateway client and errors the others share
TypeScript
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:

TypeScript
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 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#

TypeScript
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#

TypeScript
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#

TypeScript
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:

TypeScript
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#

TypeScript
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:

TypeScript
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.interactiveDebugUrlA 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
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#

TypeScript
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#

TypeScript
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#

TypeScript
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:

TypeScript
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, browsers and sandboxes.

Options#

Option
apiKeyThe gateway key
region, gatewayUrlWhich gateway to call
maxRetriesHow often a refused request is tried again (2)
timeoutMsHow long an ordinary request may take (60 s). Model calls allow 10 minutes; commands, transfers and streams set their own
headersHeaders for every request
fetchA fetch to use instead of the global one
WebSocketA WebSocket class for terminals on Node.js 20 (ws)

Every method also takes an AbortSignal as signal.