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/sdkimport { 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 | All three, behind one Kuiper client |
@kuiper-sh/models | Models: chat, streaming, embeddings, and settings for other clients |
@kuiper-sh/browsers | Browsers: sessions, profiles, recordings, files, quick actions |
@kuiper-sh/sandboxes | Sandboxes: commands, terminals, files, ports, hibernation |
@kuiper-sh/gateway | The gateway client and errors the others share |
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:
new Kuiper({ apiKey, region: 'eu-fsn' }); // gateway.eu-fsn.kuiper.sh
new Kuiper({ apiKey, gatewayUrl: 'https://gateway.example.com' }); // your own Kuiper's gatewaygatewayUrl 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#
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#
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#
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:
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#
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:
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 |
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#
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#
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#
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:
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 | |
|---|---|
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.