oncell

Documentation

You define who your agent is, what it can touch, and what it knows how to do. We run it forever.

Quickstart

npm install oncell

Create agent.ts. One file: who it is, what it can touch, what it knows how to do.

import { Agent, tools } from "oncell";

const agent = new Agent("support", {
  identity: {
    instructions: "You are Acme Corp's support agent. Warm, concise.",
    model: "claude-sonnet",
    budgets: { perDay: "$5" },
  },
  capabilities: [tools.memory, tools.files, tools.ask_human],
});

agent.chat(async ({ message }) => {
  return agent.llm(message, { autoContext: true });
});

export default agent;

Deploy with the SDK (one script, run once):

import { OnCell } from "@oncell/sdk";
import agent from "./agent";

const oncell = new OnCell({ apiKey: "oncell_sk_..." });
await oncell.deploy("./agent.ts", { manifest: agent.toManifest() });
// -> support deployed -> https://api.oncell.ai/api/v1/agents/support

Call it from your app:

import { OnCell } from "@oncell/sdk";
const oncell = new OnCell({ apiKey: "oncell_sk_..." });

const convo = oncell.agent("support").chat({ user: "user_123" });
const reply = await convo.send("How do I reset my password?");

No Dockerfile, no config, no region selection. The agent gets sandboxed compute, durable memory, a database, a filesystem, an HTTPS endpoint, and metered LLM access out of the box.

The Agent Model

An agent is described with three primitives. Everything else is runtime — out of the box, invisible.

identityWho it is. The base prompt, the model, and its boundaries — tone, policies, spend budgets. Stable, versioned like code.
capabilitiesWhat it can touch. Tools — prebuilt and custom are the same shape to the model.
skillsWhat it knows how to do. A prompt for specific work plus the tools it uses.
runtimeEverything else: durability, replay, sleep/wake, isolation, the trace, billing. Never in your code.
import { Agent, tools, skill } from "oncell";

const refunds = skill("refunds", {
  description: "Handle refund requests per Acme policy",   // always in context
  instructions: "Look up the order, verify eligibility...", // loaded when needed
  tools: [crmLookup, issueRefund, tools.ask_human],         // scoped to this work
});

const agent = new Agent("support", {
  identity: {
    instructions: "You are Acme Corp's support agent. Warm, concise.",
    model: "claude-sonnet",
    budgets: { perDay: "$5" },
  },
  capabilities: [
    tools.memory, tools.files, tools.ask_human,
    crmLookup, issueRefund,
  ],
  skills: [refunds, escalation],
});

export default agent;

Identity

The base prompt, the model, and the agent's hard limits. Budgets live here because what an agent may spend is part of who it is — the runtime enforces them infrastructurally. budgets.perDay accepts "$5", "5.50", or 5.

Capabilities

Tools. One kind of thing, one registration surface — a prebuilt handle (tools.*) and a custom function are the same shape to the model. See the Tools reference.

There is no llm capability

Inference is not something the agent has — it is what the agent is. agent.llm() is the loop: think, act, look, repeat. It lives below the line, with durability, replay, and budgets handled by the runtime.

Tools

Prebuilt tools are declared as capabilities with tools.* handles and invoked from task code as agent.*. State tools run on local NVMe with S3 as the source of truth, auto-scoped per agent and per user.

tools.memoryDurable KV. get/set/append/list/transact. Per-user scoping with .forUser(id).
tools.dbSQL via tagged templates. Injection impossible by construction.
tools.filesDurable filesystem. Knowledge base, artifacts. .search() for RAG.
tools.shellShell inside the gVisor sandbox. Array-args form for LLM-controlled input.
tools.secretsInjected at use — never in source, never in the trace, never in snapshots.
tools.ask_humanHuman-in-the-loop. The asking is a tool; crash-proof waiting is runtime.
tools.agentsDelegate to another deployed agent — agent-as-a-tool.
tools.cellsCreate / pause / resume / fork cells. An agent can operate the platform itself.
tools.scheduleRequest future work. The tool expresses intent; the runtime owns time.

memory

// Per-agent memory
await agent.memory.set("config", { theme: "dark" });
const config = await agent.memory.get("config");

// Per-user, explicit
await agent.memory.forUser("user_123").set("prefs", { lang: "ts" });

// Serialized read-modify-write — safe for concurrent counters
await agent.memory.transact("tickets-handled", "increment", 1);

db

// Tagged templates only — injection impossible
await agent.db.sql"color:#5cdb7f">`INSERT INTO tickets(title, priority) VALUES(${title}, ${priority})`;
const { rows } = await agent.db.sql"color:#5cdb7f">`SELECT * FROM tickets WHERE status = ${"open"}`;

files

// Durable files: upload KB docs, search them in any run
await agent.files.write("kb/faq.md", content);
const docs = await agent.files.search("password reset");

shell

// Runs inside the gVisor sandbox — no network, all IO through the supervisor
const { stdout, exitCode } = await agent.shell("npm test");

// Array-args form for LLM-controlled input
await agent.shell(["git", "clone", repoUrl, "."]);

secrets

const token = await agent.secrets.GITHUB_TOKEN;
// Injected at use. Never serialized into snapshots — re-injected on restore.
// Set via dashboard or: oncell secrets set GITHUB_TOKEN=ghp_...

ask_human

const ok = await agent.askHuman({
  question: "color:#5cdb7f">`Refund $${amount} for order ${orderId}?`,
});
if (ok.approved) { /* ... */ }
// The wait is durable — survives crashes, deploys, and host replacement.

Platform tools

Composition is first-class. agents delegates to another deployed agent:

// Delegate: child spend cascades against the parent's budget
const findings = await agent.spawn("researcher", "research", { topic });

cells lets an agent operate the platform itself — one cell per end-customer, forked staging copies, paused idle fleets. See Cells. schedule requests future work — agent.sleep() is sugar over the schedule intent:

// Park for 30 days. ~$0 while parked. Resumes mid-function —
// across crashes, deploys, and host replacement.
await agent.sleep({ days: 30 });
await agent.llm("Check if the customer is satisfied");

Custom functions

Custom tools are the same shape to the model as prebuilt ones. Declare them in capabilities:

const issueRefund = {
  name: "issue_refund",
  description: "Issue a refund for an order",
  params: { orderId: { type: "string" }, amount: { type: "number" } },
  confirm: { channel: "dashboard" },   // require human approval before running
  run: async ({ orderId, amount }) => {
    await agent.db.sql"color:#5cdb7f">`UPDATE orders SET refunded = 1 WHERE id = ${orderId}`;
    return "refunded";
  },
};
// On rejection: the model receives { denied: true, reason } — adapts, doesn't crash

Skills

A skill is a prompt for specific work plus the tools it uses.

import { skill, tools } from "oncell";

const refunds = skill("refunds", {
  description: "Handle refund requests per Acme policy",   // always in context
  instructions: `
    Look up the order in the CRM. Verify eligibility per policy.
    Ask a human before refunding more than $100.
  `,                                                      // loaded when the work calls for it
  tools: [crmLookup, issueRefund, tools.ask_human],        // scoped to this work
});

Two properties follow from this shape:

Skills are the context-engineering mechanism. Only name + description ride in the base context — descriptions are capped at 200 characters for exactly this reason. Full instructions load when the work calls for them. The window stays small as an agent's expertise grows.

Skills scope tools. While doing refund work, the agent's hands are the refund tools. Least-privilege expressed in your own vocabulary — no visible policy engine.

Skill names are kebab-case (refunds, order-lookup). Every tool a skill references must be a prebuilt tool or a declared capability — the constructor validates this at load time. Skills are versioned, packageable artifacts, shareable across agents.

Triggers & the Loop

Same agent, different entry points.

agent.task() — HTTPS endpoint

agent.task("fix", async ({ repo, issue }: { repo: string; issue: string }) => {
  await agent.emit("Cloning repository…");
  await agent.shell(["git", "clone", "color:#5cdb7f">`https://github.com/${repo}.git`, "."]);
  return agent.llm(issue, { maxSteps: 50, maxCost: 5.00 });
});

agent.chat() — conversational + embeddable UI

Auto-streams text and tool-call events. History is conversational by default. Uploaded files arrive in files and are visible to the model.

agent.chat(async ({ message, files }) => {
  return agent.llm(message, { autoContext: true });
});

agent.schedule() — cron

agent.schedule("followup", "every monday 9am", async () => {
  const open = await agent.db.sql"color:#5cdb7f">`SELECT * FROM tickets WHERE status = ${"open"} LIMIT ${20}`;
  for (const ticket of open.rows) {
    await agent.llm("color:#5cdb7f">`Write a status update for "${ticket.title}"`, { maxSteps: 3 });
  }
}, { maxCost: 20.00 });  // always budget schedules

agent.onWebhook() — external events

agent.onWebhook("/github", async ({ payload }) => {
  if (payload.action === "opened") {
    return agent.llm("color:#5cdb7f">`Review this PR: ${payload.pull_request.url}`);
  }
});

agent.llm() — the loop

Inference is the loop itself: think, act, look, repeat, until done. Capabilities and the active skill decide what it can touch along the way.

// Simple call
const answer = await agent.llm("Summarize this ticket");
console.log("color:#5cdb7f">`${answer}`);  // toString() returns .text

// Bounded run
const result = await agent.llm("Fix the failing tests", {
  maxSteps: 50,
  maxCost: 5.00,   // halt if cumulative cost exceeds $5
});

The Result type

interface Result<T = unknown> {
  text: string;
  status: "completed" | "max_steps" | "max_cost" | "max_tokens" | "timeout" | "stopped";
  steps: number;
  cost: number;
  usage: { inputTokens: number; outputTokens: number };
  resumeToken?: string;  // present when halted — resume via the client SDK
  data?: T;              // typed when an output schema is provided
  toString(): string;    // returns .text
}

Structured output

const result = await agent.llm("Extract invoice details", {
  output: {
    invoiceNumber: { type: "string" },
    amount: { type: "number" },
    dueDate: { type: "string" },
  },
});
result.data;  // validated against the schema

Runtime

Never part of your vocabulary, always part of the product. Every agent.* call crosses one supervisor boundary — and that one boundary yields durability, observability, billing, and security.

Durability & replay

Every await agent.* is a checkpoint in the run log. If the process crashes, the run replays to the last completed step and continues. Completed LLM calls are never re-paid. Memoize your own non-idempotent side effects with agent.once().

agent.task("process-refund", async ({ orderId, amount }) => {
  const order = await agent.db.sql"color:#5cdb7f">`SELECT * FROM orders WHERE id = ${orderId}`;

  // Parks here — can wait days. Process can crash, redeploy, move hosts.
  const ok = await agent.askHuman({
    question: "color:#5cdb7f">`Refund $${amount} for order ${orderId}?`,
  });

  if (ok.approved) {
    await agent.llm("Process the refund and notify the customer");
  }

  // Sleep 30 days, then follow up. ~$0 while sleeping.
  await agent.sleep({ days: 30 });
  await agent.llm("Check if the customer is satisfied");
});

Sleep & wake

Sleeping agents park to S3 at ~$0 and resume mid-function — across crashes, deploys, and host replacement. The host that wakes your agent does not need to be the host that put it to sleep.

Observability

The run log is the trace. Every LLM call, tool call, state operation, and sleep appears in it with zero instrumentation. Annotate it from code with agent.emit("Cloning repository…") — annotations also stream to connected UIs.

Budgets

Identity budgets (budgets.perDay) are enforced at the supervisor boundary — not by your code. Per-call caps (maxCost, maxSteps) bound individual runs, and spend from delegated child agents cascades against the parent's cap.

Isolation

Agent code runs in a gVisor sandbox with no network. Every capability it has is an RPC through the trusted supervisor — the sandbox reaches nothing the supervisor doesn't proxy.

Cells — for platform builders

Two product nouns, one runtime. An agent is deployed code: versioned bundles, runs, traces. A cell is a persistent, isolated workspace+state unit — one per end-customer. Fork it, snapshot it, exec in it.

Building a product where every customer gets their own AI workspace? Give each customer a cell. Each is gVisor-isolated, synced to S3, pauses when idle (cost ~ S3 storage only), and wakes on the next request.

Create

curl -X POST https://api.oncell.ai/api/v1/cells \
  -H "Authorization: Bearer oncell_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "acme", "tier": "standard" }'

# 201 -> { "cell_id": "you--acme", "status": "active", "tier": "standard",
#          "preview_url": "https://you--acme.cells.oncell.ai", ... }

Cell IDs derive from your developer ID and the customer_id. Creating an existing cell returns it — create is idempotent. Pass "snapshot_key": "..." to create from a snapshot you own. GET /api/v1/cells/tiers lists tiers (starter, standard, performance) and pricing.

Exec

Run a shell command inside the cell's sandbox:

curl -X POST https://api.oncell.ai/api/v1/cells/you--acme/exec \
  -H "Authorization: Bearer oncell_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "cmd": "npm test", "timeout_ms": 120000, "idempotency_key": "test-run-42" }'

# -> { "exit_code": 0, "stdout": "...", "stderr": "", "truncated": false, "duration_ms": 8100 }

With an idempotency_key, retries replay the stored response instead of re-running the command — replays carry the x-idempotent-replay: true header and are kept for 24 hours.

Request

Route a request to the agent runtime inside the cell. If the cell is paused, it resumes automatically first. Responses stream back as SSE when the runtime streams.

curl -X POST https://api.oncell.ai/api/v1/cells/you--acme/request \
  -H "Authorization: Bearer oncell_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "method": "generate", "params": { "instruction": "build a landing page" } }'

Snapshot

curl -X POST https://api.oncell.ai/api/v1/cells/you--acme/snapshot \
  -H "Authorization: Bearer oncell_sk_..."

# 201 -> { "snapshot_key": "you--acme/...", "size_bytes": 1048576, "created_at": "..." }

API-created snapshots are pinned — never garbage-collected by the retention policy. List them with GET /api/v1/cells/:cell_id/snapshots, or seed a new cell from one via create.

Fork — code + data + state, atomically

curl -X POST https://api.oncell.ai/api/v1/cells/you--acme/fork \
  -H "Authorization: Bearer oncell_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "acme-staging" }'

# 201 -> { "cell_id": "you--acme-staging", "forked_from": "you--acme", "status": "active", ... }

A cell's SQLite database is a file in its directory, so fork clones code, files, and database state as one atomic unit: the source is quiesced (WAL checkpoint + fsync), snapshotted, and restored into the new cell — while the source keeps running. Forked staging copies of production customers, in one call.

Forking onto an existing customer_id fails with a machine-readable 409: { error: { code: "FORK_TARGET_EXISTS", ... } }. A fork never inherits permanent — never-evict billing is opted into explicitly per cell via POST /:cell_id/permanent.

Lifecycle & observability

# Explicit pause / resume (idle cells also self-pause)
curl -X POST https://api.oncell.ai/api/v1/cells/you--acme/pause  -H "Authorization: Bearer oncell_sk_..."
curl -X POST https://api.oncell.ai/api/v1/cells/you--acme/resume -H "Authorization: Bearer oncell_sk_..."

# Observability
curl https://api.oncell.ai/api/v1/cells/you--acme/journal -H "Authorization: Bearer oncell_sk_..."
curl "https://api.oncell.ai/api/v1/cells/you--acme/logs?lines=100" -H "Authorization: Bearer oncell_sk_..."
curl https://api.oncell.ai/api/v1/cells/you--acme/metrics -H "Authorization: Bearer oncell_sk_..."

# Delete (idempotent)
curl -X DELETE https://api.oncell.ai/api/v1/cells/you--acme -H "Authorization: Bearer oncell_sk_..."

Journal and logs work even for paused cells — served from the latest snapshot, marked with an x-source: snapshot header. Metrics are live-runtime counters and require an active cell. A pause that cannot be made safe is refused: the cell stays active rather than snapshotting bad state.

API Keys & Scopes

Create keys in the dashboard. The full key (oncell_sk_...) is shown once and never stored in plain text. Authenticate with Authorization: Bearer oncell_sk_....

Keys created with a scopes array are restricted to exactly those scopes — deny-by-default. Keys created without scopes have full access.

cells:readList and inspect cells, snapshots, journal, logs, metrics.
cells:writeCreate, pause, resume, fork, delete cells; set permanence.
cells:execRun commands and route requests inside cells.
cells:snapshotCreate on-demand snapshots.
usage:readRead usage and billing data.
keys:manageManage API keys.
deploys:writeDeploy agents.
domains:manageManage custom domains.
secrets:manageManage agent secrets.
agents:runRun and stream deployed agents.

Scope failures are machine-readable:

// 403
{
  "error": {
    "code": "INSUFFICIENT_SCOPE",
    "message": "API key is missing required scope \"cells:exec\"",
    "required_scope": "cells:exec"
  }
}

Client SDK

The unit is the agent handle. Tasks are run/stream. Chat is a conversation object.

import { OnCell } from "@oncell/sdk";
const oncell = new OnCell({ apiKey: process.env.ONCELL_API_KEY });

const coder = oncell.agent("coder");

// Run a task
const result = await coder.run("fix", { repo: "acme/api", issue: "Tests failing" });

// Stream a task
for await (const event of coder.stream("fix", { repo, issue })) {
  // event.type: "emit" | "text" | "tool_call" | "tool_result" | "parked" | "done"
}

// Chat (conversation object owns continuity)
const convo = oncell.agent("support").chat({ user: "user_123" });
const reply = await convo.send("My invoice is wrong");

// Resume a halted run from its checkpoint — no step re-executes or re-bills
await coder.resume(result.resumeToken, { maxCost: 5.00 });

// Push files to the agent's durable knowledge base
await oncell.agent("support").files.push("./kb/");

// Manage
await oncell.agents.list();

Migrating from the legacy constructor

The flat constructor still works — it is the same runtime under a re-layered vocabulary:

// Legacy shape — still supported
const agent = new Agent("engineer", {
  instructions: "You are a senior software engineer.",
  model: "kimi-k3",
  skills: {
    design: { when: "architecture needed", model: "claude-opus",
              guide: "Think first. Write a design doc before code." },
  },
  tools: [myCustomTool],
});

Mapping: legacy skills.{when, guide} corresponds to skill()'s description/instructions. Legacy skills may set a per-skill model — the loop auto-switches models mid-run. The agent.* calls (memory, db, files, shell, secrets, askHuman) are the prebuilt tools invoked from task code, agent.sleep is sugar over the schedule intent, and agent.llm is unchanged — documented as the loop, not a capability.

oncell · Dashboard · GitHub