New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@tenkicloud/convex

Package Overview
Dependencies
Maintainers
4
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@tenkicloud/convex

Tenki Sandboxes for Convex: cloud sandboxes for AI agents, with live state in your Convex tables.

latest
Source
npmnpm
Version
0.1.1
Version published
Weekly downloads
116
1350%
Maintainers
4
Weekly downloads
 
Created
Source

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). create is safe to retry and to call concurrently: every call ends up with the same sandbox.
  • Crash-safe. A create that dies mid-flight leaves a tagged session that the next call adopts. destroy also 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; pass resume: false to get it as it is. If a concurrent call for the same identity fails, this one throws the same error. options takes any @tenkicloud/sandbox create option: resources, image, template, env, allowDomains, maxDurationMs, snapshotId, ... With snapshotId, the size comes from the snapshot and the defaults for image, template and resources are skipped. Tags must be valid Tenki tags ([a-z0-9][a-z0-9_:.-]*), and the cvx: prefix is reserved.
  • pause({ wait? }) keeps memory and disk, so processes resume where they left off. It takes tens of seconds; with wait: false it returns pausing and a later refresh sees paused.
  • resume() returns once commands run again. It counts against maxActiveSandboxes.
  • extend({ additionalMs }) pushes the deadline out.
  • snapshot({ name?, expiresAt? }) captures the sandbox; listSnapshots() lists them. A snapshot without expiresAt is kept until you delete it in Tenki.
  • fork({ ownerId, from, to }) snapshots from and creates to from it. The two then run independently. It throws already_exists while to is live or being created, and checks maxActiveSandboxes, 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 a create still in flight for it fails with terminated. 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 (see example/convex/crons.ts).

Commands and files

  • exec({ command, cwd?, env?, timeoutMs?, maxOutputBytes? }) runs a command and waits. A string runs under bash -lc; an array runs as argv, without shell parsing (both need bash, head and cat in the image). Output is capped inside the sandbox at maxOutputBytes per 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 a processId at once. It keeps running after the action ends and across pause/resume.
  • processStatus({ processId, tailBytes? }) returns running (also while children it started in the background run), exited (with exitCode), killed, or lost (ended without an exit, e.g. the sandbox restarted), plus the tail of its output.
  • kill({ processId, signal? }) signals the process and its children with TERM (the default), KILL, INT or HUP. INT and HUP need an env with --default-signal in the image (GNU coreutils 8.31 or later); without it they have no effect.
  • readFile({ path, encoding?, maxBytes? }) returns a string, or an ArrayBuffer with encoding: "bytes". Files over maxBytes (16 MiB by default, Convex's limit for a function's return value) throw file_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's previews.

Reads

  • get() / list({ ownerId }) from an action. In queries, use components.tenki.sandboxes.get / .list directly.

Errors

Errors are ConvexErrors with a code:

CodeMeaning
not_foundNo sandbox for this identity.
already_existsfork's target is live.
not_readyThe row's phase doesn't allow the call; phase is included.
terminatedThe sandbox is gone; the row is now terminated.
invalid_stateTenki refused the call in the sandbox's current state; the row was re-synced.
insufficient_creditsThe Tenki workspace's balance is empty.
capacity_exceededmaxActiveSandboxes is reached; try again later.
invalid_argumentTenki rejected an option, e.g. a malformed tag.
snapshot_not_foundsnapshotId doesn't exist.
file_too_largereadFile on a file over maxBytes; size is included.
spawn_failedspawn couldn't start the command.
unavailable, timeoutTenki was briefly unavailable or a wait ran out; retry.
pause_failed, resume_failedTenki couldn't pause or resume; the row was re-synced from Tenki.
provisioning_timeoutA concurrent create of the same identity is still provisioning.
file_not_foundreadFile on a missing path.
unauthenticatedTENKI_API_KEY is missing or invalid.
quota_exceeded, rate_limited, capacity_unavailableTenki 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.

Keywords

convex

FAQs

Package last updated on 07 Oct 2026

Related posts