@nimbus-sh/sdk
v0.8.1
Published
Nimbus SDK — Worker embedder, programmatic sandboxes, token mint, and client helpers.
Downloads
1,045
Maintainers
Readme
@nimbus-sh/sdk
Run code in a Nimbus sandbox from your own backend, and deploy the Worker that hosts it.
A sandbox has a filesystem, a shell, real processes, and ports you can reach from a browser. This package carries the Worker embedder, the programmatic sandbox handles, token mint and verify, typed errors, and session URL helpers.
Install
npm install @nimbus-sh/sdk @nimbus-sh/worker @nimbus-sh/configImport the deploy-time API from @nimbus-sh/sdk/worker. Install
@nimbus-sh/worker as well, because it carries the runtime assets and the
Durable Object implementation that entrypoint serves.
Create a deployable Nimbus Worker:
npx create-nimbus-app my-nimbus-worker
cd my-nimbus-worker
npm install
CLOUDFLARE_ACCOUNT_ID=<account-id> npx @nimbus-sh/cli setup cloudflare --name my-nimbus-worker
npx wrangler secret put JWT_SECRET
npx wrangler deploysrc/index.ts:
import {
NimbusSession,
SupervisorRPC,
NimbusAssetsRPC,
NimbusLoaderRPC,
NimbusLoadedWorker,
NimbusLoadedEntrypoint,
NimbusDurableObjectNamespace,
NimbusDOStub,
CirrusHmrRPC,
createNimbusHandler,
} from '@nimbus-sh/sdk/worker';
import { defineNimbusConfig } from '@nimbus-sh/config';
export {
NimbusSession,
SupervisorRPC,
NimbusAssetsRPC,
NimbusLoaderRPC,
NimbusLoadedWorker,
NimbusLoadedEntrypoint,
NimbusDurableObjectNamespace,
NimbusDOStub,
CirrusHmrRPC,
};
const nimbusConfig = defineNimbusConfig({
sandboxes: {
default: {
root: '/home/user',
runtimes: { preinstall: ['python'], onDemand: true },
tools: { namespace: 'sandbox', kind: 'sandbox' },
},
},
});
export default createNimbusHandler({
sdk: {
remote: true,
config: nimbusConfig,
},
});The bundled session UI carries an agent surface in the editor workspace.
Configure it with the Worker vars NIMBUS_CF_OAUTH_CLIENT_ID,
NIMBUS_CF_OAUTH_SCOPES, NIMBUS_AGENT_MODEL, and
NIMBUS_AGENT_GATEWAY_ID. For Cloudflare OAuth or owner-token Workers AI
access, add the secret NIMBUS_AGENT_COOKIE_SECRET or
NIMBUS_CLOUDFLARE_API_TOKEN.
User OAuth uses Authorization Code with PKCE and encrypted browser cookies. Nimbus keeps no user OAuth token in Durable Object storage.
Sandboxes from your own code
The same sandbox handle API works in two modes:
Nimbus.fromEnv(env, config)for a Worker or Durable Object with theNIMBUS_SESSIONbinding.Nimbus.connect({ endpoint, token, config })for any backend that can reach a deployed Nimbus Worker.
import { Nimbus } from '@nimbus-sh/sdk';
import { defineNimbusConfig } from '@nimbus-sh/config';
const nimbusConfig = defineNimbusConfig({
sandboxes: {
default: {
root: '/home/user',
tools: { namespace: 'sandbox', kind: 'sandbox' },
runtimes: {
preinstall: ['python', 'clang'],
onDemand: true,
allow: ['node', 'bun', 'npm', 'git', 'python', 'ruby', 'clang', 'shell'],
},
},
},
});
export default {
async fetch(_request: Request, env: Env) {
const box = Nimbus.fromEnv(env, nimbusConfig).sandbox('job-123', {
tenant: 'acme',
subject: 'agent',
});
await box.files.write('/home/user/example-app/main.py', 'print(2 + 2)\n');
await box.runtimes.ensure('python');
const result = await box.exec('python /home/user/example-app/main.py');
return Response.json(result);
},
};Remote use:
import { Nimbus, issueNimbusToken } from '@nimbus-sh/sdk';
const token = await issueNimbusToken(env, {
tn: 'acme',
sub: 'agent',
scopes: ['sandbox:use'],
sid: 'job-123',
});
const box = Nimbus.connect({
endpoint: 'https://my-nimbus.workers.dev',
token,
config: nimbusConfig,
}).sandbox('job-123');
const result = await box.exec('python -c "print(2 + 2)"');Enable the remote API in the Nimbus Worker:
export default createNimbusHandler({
sdk: {
remote: true,
config: nimbusConfig,
},
});Nimbus.connect() calls the versioned /api/nimbus/v1 API internally. The
Worker verifies the JWT, enforces sid pins and sandbox:use scope, and
applies the configured runtime policy. It then delegates to the same
NimbusSession RPC methods Nimbus.fromEnv() uses.
Sandbox destruction is a separate lifecycle operation. Tokens that call
box.destroy() must include session:destroy or session:admin in addition
to the normal sandbox scope.
Flue connector
Use @nimbus-sh/sdk/flue when an agent runtime expects Flue's sandbox
provider contract:
import { Nimbus } from '@nimbus-sh/sdk/sandbox';
import { nimbusFlue } from '@nimbus-sh/sdk/flue';
const box = Nimbus.fromEnv(env, nimbusConfig).sandbox('job-123');
await box.ready();
const factory = nimbusFlue(box);
const sessionEnv = await factory.createSessionEnv({
id: 'job-123',
cwd: '/home/user',
});
await sessionEnv.writeFile('/home/user/main.py', 'print(2 + 2)\n');
const result = await sessionEnv.exec('python /home/user/main.py');Sandbox handle API
const nimbus = Nimbus.fromEnv(env, config, { binding: 'NIMBUS_SESSION' });
// or:
const nimbus = Nimbus.connect({ endpoint, token, config });
const box = nimbus.sandbox('session-or-job-id', {
profile: 'default',
tenant: 'acme',
subject: 'agent-7',
root: '/home/user',
});
await box.ready();
await box.exec('node -e "console.log(2 + 2)"');
const proc = await box.startProcess('node --watch /home/user/example-app/server.js');
// returns immediately with proc.pid; poll box.processes.logs(proc.pid) for
// output and the exit record, or box.processes.kill(proc.pid) to stop it
await box.runCode('print(2 + 2)', { language: 'python', install: 'ifMissing' });
await box.files.write('/home/user/example-app/a.txt', 'hello');
await box.files.read('/home/user/example-app/a.txt');
await box.files.list('/home/user/example-app');
await box.files.stat('/home/user/example-app/a.txt');
await box.files.lstat('/home/user/example-app/link');
await box.files.rename('/home/user/example-app/a.txt', '/home/user/example-app/b.txt');
await box.files.chmod('/home/user/example-app/b.txt', 0o755);
await box.files.readRange('/home/user/example-app/b.txt', 0, 64);
await box.files.delete('/home/user/example-app/b.txt');
await box.runtimes.available();
await box.runtimes.installed();
await box.runtimes.install('python');
await box.runtimes.ensure(['python', 'clang']);
await box.processes.list();
await box.processes.logs(7);
await box.processes.kill(7);
box.ports.url(3000);
// https://my-nimbus.workers.dev/s/<session-or-job-id>/port/3000/ (owner only)
await box.ports.list();
// Share a running server with anyone who has the link.
const web = await box.apps.expose(3000, { visibility: 'public', name: 'web' });
// web.url, web.capability, web.owner, web.port, web.pid
await box.apps.list(); // adds status and restart policy per app
await box.apps.rotateLink('web'); // new link, old one revoked
await box.apps.expose('web', { visibility: 'scoped' }); // back to owner only
await box.apps.remove('web'); // stop it, release the port and its storageEvery server is durable: its journal row is restarted after a platform reset,
on the next request to its URL. Identity is derived from the command and its
working directory, so re-running the same command in the same directory is the
same app, and a shared link is bound to that program rather than to the port.
startProcess(cmd, { restart: 'on-failure' }) also restarts it after a crash;
the default restarts only after a reset. An app target can be a port, a pid, a
name, or an owner.
On deployments with a preview host suffix configured (the hosted product sets
NIMBUS_PREVIEW_HOST_SUFFIX=nimbus-os.dev), URLs take the hostname form:
https://<port>--<session-id>.<suffix>/ for the private preview,
https://<name>--<session-id>.<suffix>/ for a named app, and
https://<capability>--<name>--<session-id>.<suffix>/ for a public link.
Public links need the NIMBUS_PUBLIC_DIRECTORY Durable Object binding, which
@nimbus-sh/config emits with nimbusPublicDirectory: true. The
/s/<id>/port/<n>/ path route works on every deployment.
Runtime policy comes from the sandbox profile:
runtimes.preinstallis applied bybox.ready().runtimes.allowgates SDK runtime operations andrunCode()language use.runtimes.onDemand: falseblocks installing runtimes that are not listed inpreinstall.
Agent tool provider
box.tools({ namespace: 'sandbox', kind: 'sandbox' }) returns a provider-like
object, in the shape Proteus's tool provider takes. It carries
tools.exec.execute, runCode, readFile, writeFile,
listFiles/readdir, deleteFile, exists, startProcess, killProcess,
logs, exposePort, unexposePort, listPorts, exposeApp, listApps,
installRuntime, and listRuntimes.
provider.capabilities reports what Nimbus can do. Nimbus claims shell,
JavaScript/TypeScript, npm, git, owned filesystem, outbound fetch, inbound
HTTP-like port routing, process spawn/long-running processes, Python/Ruby
when allowed, and clang-backed WASI/WebAssembly execution. It does not claim
Docker, apt, GPU, custom Linux images, native Linux ELF execution, or raw TCP
listeners.
Mint a session token
import { issueNimbusToken } from '@nimbus-sh/sdk/token';
const token = await issueNimbusToken(
{ JWT_SECRET: process.env.JWT_SECRET! },
{ tn: 'acme', sub: 'alice' },
{ ttlMs: 60 * 60 * 1000 }, // 1h
);
// → 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6Im5pbWJ1cyIsInRu...'Verify a token
import { verifyNimbusToken } from '@nimbus-sh/sdk/token';
try {
const { claims, doInstanceName } = await verifyNimbusToken(env, token);
// claims.tn, claims.sub, claims.scopes, claims.sid
// doInstanceName = `${tn}:${sub || '_'}` — feed to idFromName
} catch (e) {
// every error extends NimbusAuthError with stable .code + .httpStatus
}Typed errors
import {
NimbusAuthError,
NimbusTokenExpiredError,
NimbusScopeError,
} from '@nimbus-sh/sdk/errors';
try { await verifyNimbusToken(env, token); }
catch (e) {
if (e instanceof NimbusTokenExpiredError) return Response.json({ refresh: true }, { status: 401 });
if (e instanceof NimbusScopeError) return Response.json({ scope: e.requiredScope }, { status: 403 });
if (e instanceof NimbusAuthError) return Response.json({ error: e.message, code: e.code }, { status: e.httpStatus });
throw e;
}| Error class | code | httpStatus |
|---|---|---|
| NimbusAuthConfigError | E_AUTH_CONFIG_MISSING | 500 |
| NimbusTokenMalformedError | E_TOKEN_MALFORMED | 401 |
| NimbusTokenSignatureError | E_TOKEN_SIGNATURE | 401 |
| NimbusTokenClaimsError | E_TOKEN_CLAIMS | 401 |
| NimbusTokenExpiredError | E_TOKEN_EXPIRED | 401 |
| NimbusTokenTtlError | E_TOKEN_TTL_TOO_LARGE | 400 |
| NimbusScopeError | E_SCOPE_MISSING | 403 |
| NimbusSessionPinError | E_SESSION_PIN_MISMATCH | 403 |
Session URL helpers
import { sessionAttachUrl, mintAndAttach } from '@nimbus-sh/sdk';
const url = sessionAttachUrl(
'https://my-nimbus.workers.dev',
'pretty-otter-1234',
token,
);
// → "https://my-nimbus.workers.dev/s/pretty-otter-1234/?nimbus_token=…"
// Or combined in one call:
const { token, url } = await mintAndAttach(
env,
{ tn: 'acme', sub: 'alice' },
{ endpoint: 'https://my-nimbus.workers.dev', sessionId: 'pretty-otter-1234' },
);Subpath exports
| Subpath | What |
|---|---|
| @nimbus-sh/sdk | Everything re-exported from a single entry. |
| @nimbus-sh/sdk/token | issueNimbusToken, verifyNimbusToken, types. |
| @nimbus-sh/sdk/errors | NimbusAuthError class hierarchy. |
| @nimbus-sh/sdk/session | sessionAttachUrl, mintAndAttach. |
| @nimbus-sh/sdk/sandbox | Nimbus, NimbusSandbox, programmatic exec/files/runtimes/processes/ports/tools. |
| @nimbus-sh/sdk/worker | Worker embedder entrypoint: NimbusSession, RPC classes, createNimbusHandler, auth helpers. |
Token wire format
JWT (HS256) with these claims:
{
scope: 'nimbus', // always — discriminator vs other JWTs
tn: 'acme', // tenant (required)
sub?: 'alice', // user within tenant (optional)
scopes?: ['session:create','session:attach'], // capability list
sid?: 'pretty-otter-1234', // pin to a specific session (optional)
iat: 1731612345, // issued-at (UNIX seconds)
exp: 1731615945, // expires-at (UNIX seconds)
}Both tn and sub must match [A-Za-z0-9._-]{1,128}. The pattern is
exported as ID_COMPONENT_RE if you need to validate user input.
The scope discriminator rejects a token minted for another product
(e.g. Mossaic VFS, scope: "vfs") even when the same secret signed it.
Secret rotation
Set JWT_SECRET_PREVIOUS during a rotation window. Both old and new
secrets verify; new tokens are always signed with the primary.
# Phase 1: New secret in place, old secret as fallback.
wrangler secret put JWT_SECRET # the new one
wrangler secret put JWT_SECRET_PREVIOUS # the old one
# Phase 2 (after the longest token TTL has elapsed):
wrangler secret delete JWT_SECRET_PREVIOUSMIT.
