TypeScript
Programmatic access to the Froglet API. Mirrors the CLI one-for-one. Node 18+ or anywhere fetch is global.
Installed from npm as @kortix/froglet-sdk. No Node-specific imports; runs in Node, Bun, Deno, or the browser.
Install
npm install @kortix/froglet-sdk pnpm add @kortix/froglet-sdk
Quickstart
import { FrogletClient } from '@kortix/froglet-sdk';
const froglet = new FrogletClient({
apiKey: process.env.FROGLET_API_KEY,
// baseUrl defaults to https://api.froglet.sh
});
// 1. boot a sandbox
const created = await froglet.sandboxes.create({
templateSlug: 'ubuntu-24.04',
size: 'xs',
slug: 'demo',
});
// 2. grab a handle with the id pre-bound
const sb = froglet.sandbox(created.id);
await sb.waitForState('running');
// 3. run something inside it
const result = await sb.exec('uname -a');
console.log(result.stdout);
// 4. tear it down
await sb.destroy();You don't have to use the handle — every method is also available flat on the resource (froglet.sandboxes.exec(id, 'uname -a')). The handle just removes the repetition when you're doing several things with the same sandbox.
Client options
new FrogletClient({
apiKey: 'fl_…', // required
baseUrl: 'https://api.froglet.sh', // default
fetch: globalThis.fetch, // override for testing
userAgent: '@kortix/froglet-sdk/0.1.0', // sent on every request
timeoutMs: 60_000, // per-request timeout
});For session-cookie auth (e.g. from a browser talking to your own backend), pass a custom fetch that forwards the cookie. The SDK doesn't care how Authorization gets onto the request as long as the response envelope is { data: T } | { error: { code, message } }.
Recipes
Build a template from a Docker image, then use it
const build = await froglet.templateBuilds.create({
name: 'acme-app',
slug: 'acme-app',
sourceKind: 'image',
sourceRef: 'ghcr.io/acme/app:1.2',
});
// poll for state=ready (or stream the SSE log via templateBuilds.logStreamUrl)
let row;
do {
await new Promise((r) => setTimeout(r, 5_000));
const builds = await froglet.templateBuilds.list();
row = builds.find((b) => b.id === build.id);
} while (row && row.state !== 'ready' && row.state !== 'error');
if (row?.state === 'ready') {
const sb = await froglet.sandboxes.create({ templateSlug: 'acme-app', size: 's' });
await froglet.sandboxes.waitForState(sb.id, 'running');
}Exec with custom cwd + stdin
const r = await froglet.sandboxes.exec(
sb.id,
'python -c "import sys; print(len(sys.stdin.read()))"',
{
cwd: '/tmp',
stdin: 'hello\nworld\n',
timeoutMs: 30_000,
},
);
console.log(r.exit_code, r.stdout, r.stderr);Upload + download a file
await froglet.sandboxes.uploadFile(
sb.id,
'/data/input.txt',
new TextEncoder().encode('hello from node'),
);
const bytes = await froglet.sandboxes.downloadFile(sb.id, '/data/output.txt');
const text = new TextDecoder().decode(bytes);Expose a port
const port = await froglet.ports.create(sb.id, {
internalPort: 8080,
label: 'web', // public route at https://web--<slug>.froglet.sh
// public: false // gated route (token required); defaults to true
// ttlSec: 600 // auto-revoke after 10 min
});
console.log(port.url);One-time share link:
const { token, expires_at } = await froglet.sandboxes.mintPortShareToken(sb.id, port.id, 300);SSH
const access = await froglet.sshAccess.create(sb.id, { name: 'laptop', ttlHours: 4 });
console.log(access.command);
// ssh -p 2222 <jwt>@ssh.froglet.shSnapshot, mutate, restore
const backup = await froglet.sandboxes.backup(sb.id, 'before-experiment'); // ... break things ... const job = await froglet.sandboxes.restore(sb.id, backup.id); // state=in_progress; poll listRestoreJobs to track
Mount a persistent volume
const vol = await froglet.volumes.create({ name: 'shared-data', size_gb: 25 });
await froglet.volumes.attach(sb.id, {
volume_id: vol.id,
mount_path: '/data',
// mode: 'ro' // read-only
});
// later, on a different sandbox:
await froglet.volumes.attach(otherSb.id, { volume_id: vol.id, mount_path: '/data' });Wait for a sandbox to reach a state
await froglet.sandboxes.waitForState(sb.id, 'running', { timeoutMs: 30_000 });Stream a PTY into the browser
const ticket = await froglet.sandboxes.ptyTicket(sb.id); const ws = new WebSocket(ticket.url); // wss://…?ticket=<jwt> ws.onmessage = (ev) => term.write(ev.data); term.onData((data) => ws.send(data));
Sandbox handle
client.sandbox(id) returns a SandboxHandle whose methods are pre-bound to the id:
const sb = froglet.sandbox(sandboxId);
// status + lifecycle
const { sandbox, events } = await sb.fetch();
await sb.start();
await sb.stop();
await sb.destroy();
await sb.waitForState('running');
// exec + files
const r = await sb.exec('uname -a');
await sb.uploadFile('/tmp/x', new TextEncoder().encode('hi'));
const bytes = await sb.downloadFile('/tmp/x');
await sb.listFiles('/etc');
const tkt = await sb.ptyTicket();
// snapshots + backups
await sb.snapshot('checkpoint');
const b = await sb.backup('nightly');
await sb.listBackups();
await sb.restore(b.id);
await sb.setBackupPolicy({
enabled: true,
intervalMinutes: 60,
retainCount: 3,
retainDays: 7,
storage: 'r2',
});
// ports + ssh access
const port = await sb.exposePort({ internalPort: 8080, label: 'web' });
await sb.revokePort(port.id);
const t = await sb.mintPortShareToken(port.id, 300);
const access = await sb.createSshAccess({ name: 'laptop', ttlHours: 4 });
await sb.revokeSshAccess(access.id);
// volumes
await sb.attachVolume({ volume_id: vol.id, mount_path: '/data' });
await sb.attachedVolumes();
await sb.detachVolume(vol.id);The handle is purely a convenience — it forwards to client.sandboxes, client.ports, client.sshAccess, and client.volumes. Use whichever style fits the call site.
API surface
| Namespace | What it covers |
|---|---|
me | Who am I (workspace, role, scopes). |
sandboxes | Lifecycle, exec, files, PTY, snapshots, backups, restore. |
templates | Catalog + publish/unpublish/delete. |
templateBuilds | Build templates from Docker images. |
ports | Public routes per sandbox. |
sshAccess | SSH bastion tokens per sandbox. |
volumes | Persistent storage + attach/detach/search. |
keys | Workspace API keys. |
members | Workspace member management. |
quotas | Usage + limits + raise-quota requests. |
admin | Platform-admin operations (hosts, orders, cells). Workspace keys get 403. |
Each namespace returns plain TypeScript objects whose shapes are exported alongside the resource:
import type { Sandbox, ExecResult, Backup } from '@kortix/froglet-sdk';Errors
Every request returns the unwrapped data field on success and throws FrogletApiError on 4xx/5xx:
import { FrogletApiError } from '@kortix/froglet-sdk';
try {
await froglet.sandboxes.create({ templateSlug: 'unknown', size: 'm' });
} catch (e) {
if (e instanceof FrogletApiError) {
console.log(e.status, e.code, e.message);
// 404 not_found "template 'unknown' not found"
}
throw e;
}| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing / wrong API key. |
| 403 | forbidden_scope | Token doesn't carry the scope this route needs. |
| 403 | platform_admin_required | Workspace key on an admin/* route. |
| 404 | not_found | Resource doesn't exist or the token can't see it. |
| 429 | quota_exceeded | Workspace at the limit. Inspect error.payload. |
| 422 | backup_failed | Backup pipeline rejected the request (e.g. host loss). |
Browser usage
The SDK has no Node-specific deps — globalThis.fetch, Uint8Array, and a small structured-clone-safe envelope decoder are the only platform surfaces. Don't put your raw API key in a browser bundle. Either use a session cookie + your own fetch wrapper, or talk to a Vercel/Cloudflare Worker function that forwards requests with the key server-side.
Gotchas
sandboxes.createtakesslug, notname— the field that becomes the short human alias.sandboxes.execandsandboxes.listFilesreturn snake_case fields (exit_code,modified_ms). The rest of the API is camelCase. Already reflected in the SDK types.volumes.create,volumes.attach,volumes.detachuse snake_case input (size_gb,volume_id,mount_path).sandboxes.stopcan exceed 60s because the host takes an auto-snapshot before suspending. For hot sandboxes bump the timeout:new FrogletClient({ ..., timeoutMs: 180_000 }).volumes.searchreturns 503 on prod today — the API process is missingFROGLET_VOLUMED_URL. Operator-side fix.templateBuilds.liststates are'pending' | 'building' | 'done' | 'ready' | 'error'.'done'is the in-between state after the build finishes but before the template row is created — usually transient.
CLI vs SDK
| Use case | Reach for |
|---|---|
| Try things by hand | CLI (froglet sb new --template ubuntu-24.04 --size xs --name demo). |
| Script lifecycle | Either. CLI + `--json |
| Build into an app | SDK — the whole point. |
| Slug vs UUID | CLI accepts either (resolves via list). SDK always uses UUIDs returned by list/create. |
Both ship one-for-one. The dashboard uses the same SDK client.