Tenki
Tenki Sandboxes for Bijection: cloud sandboxes for AI agents, with live state in your Bijection tables.
git clone https://github.com/bijectionhq/tenki.git
cd tenki
git checkout eacb1453ef98f3ed5b9e5c871ce1d27a1f867801Bijection repository access is required. Request access.
Description
Tenki Sandboxes for Bijection: cloud sandboxes for AI agents, with live state in your Bijection tables.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: bijectionhq/tenki. Based on LuxorLabs/tenki-convex. License: MIT. Consult the repository for license terms, notices, and contributor attribution.
Compute runs with the external sandbox provider. Bijection connects application state and operations to that provider.
From the upstream publisher
Tenki Sandboxes for Convex: cloud sandboxes for AI agents, with live state in your Convex tables.
Explore the demo ↗Upstream overview
From the upstream package documentation.
Give every user or AI agent in your Convex app its own full Linux VM that boots in about a second. This component creates and drives those VMs from your Convex actions and mirrors each sandbox's state into a component table, so your UI subscribes with a plain Convex query and updates live as a sandbox goes from provisioning to ready to paused, with no polling. Sandboxes are keyed by (ownerId, key), so one call gets a user their machine: run shell commands and background processes, read and write files, expose ports as public preview URLs, pause and resume with memory intact, and snapshot or fork environments. create is idempotent and crash-safe, maxActiveSandboxes caps spend, and your Tenki API key stays on the server as a Convex environment variable.
Use cases
From the upstream package documentation.
- Coding agents with a real computer: give each chat or agent run its own VM to clone a repo, install dependencies and run tests, and expose
exec,readFileandwriteFileto the agent as tools called from Convex actions. - Per-user dev environments and live previews: start a dev server with
spawn, publish it withexposePort, and render the preview URL and sandbox phase straight from a Convex query. - Code interpreters and playgrounds for untrusted code: run user- or LLM-generated code in an isolated VM with CPU and memory limits, a hard
maxDurationMs, and an outbound allowlist (allowDomains). - Jobs longer than an action:
spawnbuilds, test suites, data jobs or headless browsers in the background, then checkprocessStatusfrom a scheduled function or cron. Processes keep running after the action returns and across pause/resume. - Pick up where users left off:
pausea sandbox when a user goes idle (CPU and memory stop billing), and the nextcreateresumes it with files, memory and running processes intact. - Checkpoint and branch:
snapshota prepared environment and start new sandboxes from it, orforka live sandbox to try a risky change or run parallel agent attempts without touching the original.
How it works
From the upstream package documentation.
Run npm install @tenkicloud/convex, create an API key in the Tenki dashboard, store it with npx convex env set TENKI_API_KEY tk_..., and register the component in convex/convex.config.ts with app.use(tenki). The Tenki SDK needs Node.js, so construct new Tenki(components.tenki, { defaults, maxActiveSandboxes }) in a "use node" file and call it from actions. defaults applies to every create and accepts any @tenkicloud/sandbox option. Without it you get 2 vCPU, 4 GB RAM and a 5 GiB disk; coding agents that clone repos usually want diskSizeGb: 20. Sandboxes scale up to 16 vCPU and 64 GB of RAM, and new workspaces start on the free Starter plan with $100 in credits.
Every method takes the action ctx and { ownerId, key }. Derive ownerId from ctx.auth in your own function, never from client input, because it is the only tenant boundary. create returns the sandbox for that identity, creating it if needed and resuming it if paused; it is safe to retry and to call concurrently, and a create that dies mid-flight is adopted by the next call. Every operation writes its result back to the component's table, so components.tenki.sandboxes.get and .list work as ordinary reactive queries in the default runtime for phase, previews and lastError.
exec runs a command and waits: a string runs under bash -lc, an array runs as argv. It is capped at 9 minutes to fit inside Convex's action limit, with 1 MiB of output per stream by default, so use spawn and processStatus for anything longer. readFile and writeFile take text or bytes within Convex's limits (16 MiB returned, 5 MiB as arguments). Pausing keeps memory, disk and running processes but closes open connections and clears /tmp, so keep durable files under /home/tenki.
Sandboxes bill per second for vCPU and memory; a paused sandbox stops that billing, and its saved state counts toward workspace storage. Each run lasts at most maxDurationMs (activity doesn't extend it; call extend). At the deadline Tenki pauses the sandbox and keeps it for 7 days by default (pauseRetentionMs). Schedule reconcile from a cron so rows catch up with sandboxes that hit their deadline, and set maxActiveSandboxes (5 or fewer on Starter); it is checked in the same transaction that reserves the row, so concurrent creates, forks and resumes can't exceed it. Errors are ConvexErrors with a typed code such as capacity_exceeded, insufficient_credits or not_ready. allowDomains requires egress policies to be enabled on your workspace, so ask Tenki support first. The repo's example/ folder is a complete React app with anonymous auth, preview URLs, pause/resume and fork.
Upstream package FAQ
How does Tenki Sandboxes handle concurrent create calls for the same user?
Tenki Sandboxes makes create idempotent: calling tenki.create() concurrently for the same ownerId and key always results in a single sandbox. If a concurrent create fails mid-flight, the next call adopts the tagged session rather than creating a duplicate. This means it is safe to retry creates from Convex actions without risk of provisioning multiple sandboxes for the same identity.
How does multi-tenant isolation work in Tenki Sandboxes?
Each sandbox is keyed by a combination of ownerId and a string key. The README explicitly states that ownerId is the only thing separating tenants and must always be derived from ctx.auth, never from client input. Typically you pass the authenticated user ID returned by getAuthUserId as the ownerId.
How do I limit how many sandboxes can run simultaneously?
Tenki Sandboxes accepts a maxActiveSandboxes option when constructing the Tenki instance: new Tenki(components.tenki, { maxActiveSandboxes: 20 }). Once the limit is reached, any attempt to start or resume a sandbox throws a capacity_exceeded error. The check runs inside the same Convex transaction that reserves the sandbox row, so concurrent creates and forks cannot exceed the limit.
What happens when a Tenki sandbox reaches its time limit?
Tenki pauses the sandbox at its maxDurationMs deadline and retains it for the configured pause retention period, defaulting to 7 days. The Convex row keeps the last known phase until a refresh, reconcile, or the next create call syncs it. Calling tenki.create() on a paused sandbox resumes it and starts a new duration window of the same length, so maxDurationMs bounds each run rather than the sandbox's total lifetime.
Does Tenki Sandboxes require a specific Convex runtime for actions?
Yes. The Tenki SDK depends on Node.js, so the action file that calls Tenki methods must include the 'use node' directive at the top. Queries that read sandbox state from the Convex table do not require Node.js and run in the default Convex runtime, which means UI subscriptions work without any special configuration.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Tenki with Bijection.
Read: https://bijection.com/components/tenki/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from LuxorLabs/tenki-convex, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
Tenki Sandboxes for Convex
Give every user or agent in your Convex app its own cloud machine.
Tenki sandboxes are microVMs that boot in about a second.
This component runs them from your Convex actions and keeps each sandbox's state
in a Convex table, so your UI updates live as a sandbox goes from provisioning
to ready to paused.
- One sandbox per identity. Sandboxes are keyed by
(ownerId, key).createis safe to retry and to call concurrently: every call ends up with the same sandbox. - Crash-safe. A
createthat dies mid-flight leaves a tagged session that the next call adopts.destroyalso cleans up any such orphans. - Everything an agent needs: shell commands, background processes, files, public preview URLs, pause/resume with memory intact, snapshots and forks.
- The API key stays on the server as a Convex environment variable.
Install
npm install @tenkicloud/convex
npx convex env set TENKI_API_KEY tk_...
Create the API key in the Tenki dashboard. Then add the component to your app:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import tenki from "@tenkicloud/convex/convex.config.js";
const app = defineApp();
app.use(tenki);
export default app;
Usage
The Tenki SDK needs Node.js, so create and call Tenki from a "use node"
file:
// convex/sandboxes.ts
"use node";
import { getAuthUserId } from "@convex-dev/auth/server";
import { v } from "convex/values";
import { Tenki } from "@tenkicloud/convex";
import { action, type ActionCtx } from "./_generated/server";
import { components } from "./_generated/api";
const tenki = new Tenki(components.tenki, {
defaults: { cpuCores: 2, memoryMb: 4096, maxDurationMs: 30 * 60_000 },
});
async function ownerId(ctx: ActionCtx) {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
return userId;
}
export const start = action({
args: {},
handler: async (ctx) =>
await tenki.create(ctx, { ownerId: await ownerId(ctx), key: "main" }),
});
export const run = action({
args: { command: v.string() },
handler: async (ctx, { command }) =>
await tenki.exec(ctx, {
ownerId: await ownerId(ctx),
key: "main",
command,
}),
});
Queries read the component directly and run in Convex's default runtime, so the UI can subscribe to a sandbox:
// convex/sandboxQueries.ts
import { getAuthUserId } from "@convex-dev/auth/server";
import { query } from "./_generated/server";
import { components } from "./_generated/api";
export const mine = query({
args: {},
handler: async (ctx) => {
const ownerId = await getAuthUserId(ctx);
if (!ownerId) return null;
return await ctx.runQuery(components.tenki.sandboxes.get, {
ownerId,
key: "main",
});
},
});
const sandbox = useQuery(api.sandboxQueries.mine);
// sandbox?.phase: "provisioning" | "ready" | "pausing" | "paused" | "resuming" | "terminated" | "error"
ownerId is the only thing separating tenants. Always derive it from
ctx.auth, never from client input.
API
Every method takes the action ctx and the sandbox's { ownerId, key }.
Lifecycle
create({ options?, resume? })returns the sandbox, creating it if needed. A paused sandbox, including one Tenki paused at its deadline, is resumed first; passresume: falseto get it as it is. If a concurrent call for the same identity fails, this one throws the same error.optionstakes any@tenkicloud/sandboxcreate option: resources, image, template, env,allowDomains,maxDurationMs,snapshotId, ... WithsnapshotId, the size comes from the snapshot and thedefaultsfor image, template and resources are skipped. Tags must be valid Tenki tags ([a-z0-9][a-z0-9_:.-]*), and thecvx:prefix is reserved.pause({ wait? })keeps memory and disk, so processes resume where they left off. It takes tens of seconds; withwait: falseit returnspausingand a laterrefreshseespaused.resume()returns once commands run again. It counts againstmaxActiveSandboxes.extend({ additionalMs })pushes the deadline out.snapshot({ name?, expiresAt? })captures the sandbox;listSnapshots()lists them. A snapshot withoutexpiresAtis kept until you delete it in Tenki.fork({ ownerId, from, to })snapshotsfromand createstofrom it. The two then run independently. It throwsalready_existswhiletois live or being created, and checksmaxActiveSandboxes, before taking the snapshot. The fork's snapshot expires after an hour, and Tenki deletes it once no sandbox uses it.refresh()re-reads the sandbox from Tenki.destroy()terminates it, and acreatestill in flight for it fails withterminated. The key can then be reused.reconcile({ limit? })refreshes the least recently updated live sandboxes across all owners. Run it from a cron so rows catch up with sandboxes that reached their deadline (seeexample/convex/crons.ts).
Commands and files
exec({ command, cwd?, env?, timeoutMs?, maxOutputBytes? })runs a command and waits. A string runs underbash -lc; an array runs as argv, without shell parsing (both needbash,headandcatin the image). Output is capped inside the sandbox atmaxOutputBytesper stream (1 MiB by default), and the timeout at 9 minutes, inside Convex's action limit.spawn({ command, cwd?, env? })starts a background command and returns aprocessIdat once. It keeps running after the action ends and across pause/resume.processStatus({ processId, tailBytes? })returnsrunning(also while children it started in the background run),exited(withexitCode),killed, orlost(ended without an exit, e.g. the sandbox restarted), plus the tail of its output.kill({ processId, signal? })signals the process and its children withTERM(the default),KILL,INTorHUP.INTandHUPneed anenvwith--default-signalin the image (GNU coreutils 8.31 or later); without it they have no effect.readFile({ path, encoding?, maxBytes? })returns a string, or anArrayBufferwithencoding: "bytes". Files overmaxBytes(16 MiB by default, Convex's limit for a function's return value) throwfile_too_large.writeFile({ path, data })takes either; Convex caps action arguments at 5 MiB.exposePort({ port, ttlMs?, slug? })returns a public URL and records it in the row'spreviews.
Reads
get()/list({ ownerId })from an action. In queries, usecomponents.tenki.sandboxes.get/.listdirectly.
Errors
Errors are ConvexErrors with a code:
| Code | Meaning |
|---|---|
not_found | No sandbox for this identity. |
already_exists | fork's target is live. |
not_ready | The row's phase doesn't allow the call; phase is included. |
terminated | The sandbox is gone; the row is now terminated. |
invalid_state | Tenki refused the call in the sandbox's current state; the row was re-synced. |
insufficient_credits | The Tenki workspace's balance is empty. |
capacity_exceeded | maxActiveSandboxes is reached; try again later. |
invalid_argument | Tenki rejected an option, e.g. a malformed tag. |
snapshot_not_found | snapshotId doesn't exist. |
file_too_large | readFile on a file over maxBytes; size is included. |
spawn_failed | spawn couldn't start the command. |
unavailable, timeout | Tenki was briefly unavailable or a wait ran out; retry. |
pause_failed, resume_failed | Tenki couldn't pause or resume; the row was re-synced from Tenki. |
provisioning_timeout | A concurrent create of the same identity is still provisioning. |
file_not_found | readFile on a missing path. |
unauthenticated | TENKI_API_KEY is missing or invalid. |
quota_exceeded, rate_limited, capacity_unavailable | Tenki limits; retry later. |
A failed create also records the error on the row (phase: "error" and
lastError), and the next create retries.
Limiting spend
new Tenki(components.tenki, { maxActiveSandboxes: 20 }) refuses to start or
resume a sandbox once 20 are active (not paused) across all owners, counting
creates in flight. The check runs in the same transaction that reserves the row,
so concurrent creates, forks and resumes can't exceed it.
Lifetime
A Tenki sandbox runs for at most maxDurationMs at a time, set at create time;
your workspace's limits set the default and the maximum. Activity does not
extend it; call extend. At the deadline Tenki pauses the sandbox and keeps it
for the pause retention (7 days unless you set pauseRetentionMs), then deletes
it. The row keeps its last known phase until refresh, reconcile or the next
create catches it up. create resumes a paused sandbox, and resuming one that
reached its deadline starts a new lifetime of the same length, so
maxDurationMs bounds each run, not the sandbox's total life. If sandboxes must
not outlive a limit, enforce it yourself (see example/convex/demo.ts).
Example and demo
example/ is a working app: a React page where each visitor signs in
anonymously, starts a sandbox, runs commands, serves a web page through a
preview URL, and pauses, resumes or forks it. See docs/demo.md
to run it.
Contributing
docs/architecture.md explains how the component works, and docs/testing.md covers the test suites.
Development needs Node 24 with npm 11.18 or later, but not npm 12. Node 24.20
and later bundle a matching npm, and nvm install installs Node 24 from
.nvmrc. With any other npm, devEngines in package.json makes
npm install, npm ci and npm run stop with an error. A dependency's install
scripts run only if allowScripts in package.json allows them, and
npm install-scripts ls lists any that still need a decision.