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 oncellCreate 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/supportCall 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.
Build your first agent
The quickstart shows the shape. This is the whole path — account to running agent — in the order you actually hit it. About ten minutes.
1 · Create a project and give it model access
A project owns the credentials its agents run on. Everything else belongs to one, so this comes first.
In the dashboard: Model Access → paste a credential. To use a Claude subscription, run claude setup-token and paste what it prints. Or from code:
const project = await oncell.projects.create({ name: "prod", authMode: "subscription" });
await oncell.projects.addCredential(project.projectId, { value: process.env.ANTHROPIC_TOKEN! });An agent whose project has no credentials still starts and serves — but every model call fails. If you see model_access=false, an empty pool is the first thing to check.
2 · Create an API key under that project
Create the key with the project selected, and anything built with it inherits that project's credentials — so your code never passes a project id around.
3 · Deploy the agent
Deploying creates the agent definition and its instance. It is idempotent: deploying the same name again updates it rather than making a second one.
const oncell = new OnCell({ apiKey: process.env.ONCELL_API_KEY });
await oncell.deploy("./agent.ts", { manifest: agent.toManifest() });4 · Talk to it
const convo = oncell.agent("support").chat({ user: "user_123" });
console.log(await convo.send("How do I reset my password?"));The first message to a paused agent wakes it, so it may take a few seconds. After that it stays warm until it goes idle again.
5 · Serve something, if it needs a UI
An agent that only answers messages needs nothing here. One that serves a page runs a service — bind $PORT, accept a connection within 30 seconds, and it is live at the agent's URL.
await oncell.agents.startService(agent.agentId, {
cmd: "node server.js", // must bind $PORT on 0.0.0.0
});A slow first boot — installing dependencies, say — must bind a placeholder listener immediately and hand off when ready, or the supervisor kills it at the 30-second mark. See Running agents.
6 · When something goes wrong
In order of how often they help:
await oncell.agents.serviceLogs(agentId, 200); // why a service will not start
await oncell.agents.get(agentId); // status, tier, whether it is awake
await oncell.projects.credentials(projectId); // is the pool empty, is a credential parkedWhat it costs to leave running
Agents idle-pause on their own and wake on the next request, so an agent nobody is talking to is not billed for compute. State survives the pause — that is the point of a cell — and a wake restores it.
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, start, pause, and fork other agents. 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 — spawning other agents, forking staging copies, pausing idle ones. See Running agents. 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 crashSkills
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 schedulesagent.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 schemaRuntime
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.
Projects & agents
Two objects you use, and one you never touch. (For what goes inside an agent — identity, capabilities, skills — see The Agent Model.)
Project ──1:N──▶ Agent
credentials, identity + capabilities + skills
quota, billing the unit you addressA project owns model credentials and the quota its agents draw on. Many agents share one.
An agent is what you address: its identity, the tools it can touch, and the skills it knows. Define it once, then run it.
A cell is an agent running. The platform creates and recycles instances as needed, and their IDs are internal — you never handle one. Everything you call is addressed by agent.
capabilities and skills are fields on an agent, not resources of their own. There is nothing to CRUD.
Instances are ephemeral; snapshots are the truth
When an agent goes idle its instance is snapshotted and deleted — compute, local disk, and all. What survives is the snapshot. Starting the agent again materialises a fresh instance from it.
This is the runtime's own principle — local disk is a cache, S3 is the truth — carried up into the API. An idle fleet costs storage and nothing else, and there is never an instance you have to clean up.
It also means there is no resume. A dormant agent has no instance to resume; you call start again, which is idempotent.
Credentials & model access
A project owns the credentials its agents run on. Create one, add credentials to its pool, and every agent in it has model access without you shipping a vendor key into a sandbox.
1 · Create a project
import { OnCell } from "@oncell/sdk";
const oncell = new OnCell({ apiKey: process.env.ONCELL_API_KEY });
const project = await oncell.projects.create({
name: "prod",
authMode: "subscription", // or "api_key"
});authMode decides how the gateway authenticates upstream — a coding-agent subscription token, or a metered vendor key. Everything below is identical either way.
Using your Claude Code subscription
A Claude subscription authenticates with an OAuth token rather than an API key. Generate one with the Claude Code CLI on your own machine:
claude setup-tokenThat prints a token. Add it to a project's pool — either paste it into Model Access in the dashboard, or:
await oncell.projects.addCredential(projectId, {
value: "<token from claude setup-token>",
authMode: "subscription",
label: "seat-1",
});That is the whole setup. Agents in the project now reach models through the gateway; nothing about the agent changes, and no token is ever written into a cell. An agent whose project has an empty pool still starts and serves its app, but every model call fails — if a cell reports model_access=false, an empty pool is the first thing to check.
Before you point production at this: subscription seats are licensed for individual use, and pooling them to serve your own end users likely falls outside that license. Confirm it for your case. The pool takes authMode: "api_key" credentials through the identical path, so moving to metered billing is a swap here, not a rewrite.
2 · Add credentials to the pool
await oncell.projects.addCredential(project.projectId, {
value: process.env.ANTHROPIC_TOKEN,
label: "team-seat-1",
});
// Metadata and live window counters — never the secret
const credentials = await oncell.projects.credentials(project.projectId);Credentials are write-only: encrypted with KMS on the way in, and never returned by any endpoint — including to you. Rotation means adding the new one and deleting the old, not reading the old one back.
Add more than one and the pool earns its keep: the gateway spreads calls across them and routes around whichever is currently rate-limited. The window counters are how you see which credentials are carrying load and which are parked.
The gateway
Every model call from inside an agent arrives at the gateway carrying a short-lived token scoped to that instance. The gateway resolves the agent's project, picks the least-loaded credential from its pool, swaps the auth header, and streams the answer back. The sandbox never holds a vendor credential, so rotating one is a control-plane operation rather than a fleet-wide redeploy.
Subscription credentials are limited per rolling window rather than priced per token, and nobody publishes the exact ceiling — so the gateway does not guess one. It routes to the least-used credential and learns the real limit from upstream rate limits, parking that credential until its window resets.
When every credential is parked you get a retryable 429 with code POOL_EXHAUSTED and a retry_after — hold the turn and retry; do not surface it to your user as a failure.
Each served turn is recorded against the agent and project that caused it. For a subscription pool the billable unit is the turn, not the token — which is what makes “does my fixed price per message clear my costs?” a decidable question.
Running agents
Define an agent once, then run it. Model access follows the chain: the agent belongs to a project, the project owns the credentials.
Define and start
const agent = await oncell.agents.create({
name: "builder",
projectId: project.projectId,
identity: { instructions: "You build and ship web apps." },
capabilities: ["workspace", "shell", "git"],
});
await oncell.agents.start({ agentId: agent.agentId });start is idempotent: if the agent is already running you get that instance back, and if it is dormant one is materialised from its latest snapshot. You never check first.
Pass snapshotKey to start from a specific snapshot instead of the agent's latest — how you boot every agent from a pre-built golden image rather than paying a cold install each time.
Exec
const { exitCode, stdout } = await oncell.agents.exec(agent.agentId, {
cmd: "npm test",
timeoutMs: 120_000,
idempotencyKey: "test-run-42",
});With an idempotencyKey, retries replay the stored response instead of running the command twice — kept for 24 hours, and served without materialising an instance at all.
Exec has no network. Commands run with networking disabled, so installs and outbound calls belong in a service command, which does have it.
The service
An agent runs exactly one long-lived process — the one its URL serves.
await oncell.agents.startService(agent.agentId, {
cmd: "node server.js",
env: { NODE_ENV: "production" },
});
// The first place to look when a service will not come up
const logs = await oncell.agents.serviceLogs(agent.agentId, 200);Three rules, enforced by the platform. The process must bind $PORT on 0.0.0.0 — that is the one the agent's URL routes to. It has to accept a connection within 30 seconds or the supervisor kills it, so a cold install must bind a placeholder listener first and hand off once ready. And the service is the only context with network access.
$PORT is the routed port, not the only one you may use. Each cell has its own network stack, so anything else you bind is private to that cell and cannot collide with another — a control API alongside a UI, a worker talking to itself over loopback. See Networking.
Your app owns its entire path space: everything under the agent's URL is forwarded to it verbatim, including /health, /projects, and /files. Nothing at the top level is reserved.
Snapshots and fork
const snapshot = await oncell.agents.snapshot(agent.agentId);
// Clone code, files, and database state atomically
await oncell.agents.fork(agent.agentId, "builder-staging");An agent's database is a file in its directory, so a fork clones code, files, and database state as one unit: the source is quiesced, snapshotted, and restored into the new agent — while the source keeps running.
Snapshots you create through the API are pinned and never garbage-collected, which is what makes one usable as a golden image.
Lifecycle & observability
// Snapshot and end the instance. Idle agents do this themselves.
await oncell.agents.pause(agent.agentId);
// Start again — materialises from the latest snapshot
await oncell.agents.start({ agentId: agent.agentId });
// Work with no live instance, served from the snapshot
await oncell.agents.journal(agent.agentId);
await oncell.agents.logs(agent.agentId, 100);
// Live counters — these need a running instance
await oncell.agents.metrics(agent.agentId);
// Permanent: removes the agent AND its snapshots
await oncell.agents.destroy(agent.agentId);Pause and destroy are different, and the difference matters. Pause is routine and non-destructive: it snapshots and ends the instance. Destroy is permanent — it removes the snapshots too, and the state is gone.
Journal and logs are served from the latest snapshot when nothing is running, marked with an x-source: snapshot header. Metrics are the one read that needs a live instance.
A pause that cannot be made safe is refused: the instance stays up rather than writing a bad snapshot. Losing an hour of compute is recoverable; snapshotting corrupt state is not.
Networking
Every cell has its own network stack — not a shared one with rules on top. That is what makes the guarantees below true rather than aspirational.
What a cell can reach
The internet, yes. Outbound traffic is NAT'd out of the host. npm install, API calls, package registries all work from the service context.
Other cells, no. Each cell sits on a point-to-point link with the host rather than a shared network, so its only neighbour is the host itself — there is no path to another cell to block, and nothing to impersonate. Your agent cannot reach another customer's cell, and no one can reach yours, including on ports you never meant to expose.
Instance metadata, no. The cloud metadata endpoint (169.254.169.254) is blocked. Code running in a cell cannot read the host's credentials, which matters because the code in a cell is usually written by a model.
The host, no. The host's own services are not reachable from a cell.
Ports
Bind $PORT for anything you want reachable at the agent's URL. Everything else you bind stays inside the cell.
Because the stack is private, $PORT is the same value in every cell and cannot collide with anything. Read it from the environment rather than hardcoding it — it is stable today, and reading it costs nothing.
Exec runs with no network at all, which is why installs belong in the service command and not in an exec.
Launch oncellclaw with the SDK/API
oncellclaw is the open-source personal assistant that lives in a cell (oncell.ai/claw). The dashboard provisions one in a click — this is the same contract, from code. Four calls: create the hosting cell, start the bootstrap service, wait for health, talk.
1 · Create the hosting cell
import { OnCell } from "@oncell/sdk";
const oncell = new OnCell({ apiKey: process.env.ONCELL_API_KEY });
const agent = await oncell.agents.create({
name: "claw-scout",
projectId: project.projectId,
identity: { instructions: "You are a personal assistant." },
});
await oncell.agents.start({ agentId: agent.agentId });Name it claw-<name> — the dashboard discovers claws by that prefix, so yours shows up there too. Create is idempotent: an existing cell comes back with its record.
2 · Start the service
One service call boots the whole assistant. cmd is a node-only bootstrap (fresh cells have no curl or git): it fetches scripts/cloud-start.sh from the repo with Node's own fetch and hands it to bash. Generate the web token with openssl rand -hex 32 — it becomes the Bearer token for every chat call.
await oncell.agents.startService(agent.agentId, {
cmd: "node -e 'const fs=require(\"fs\");const repo=process.env.ONCELLCLAW_REPO||\"https://github.com/anupsinghinfra/oncellclaw.git\";const ref=process.env.ONCELLCLAW_REF||\"main\";const m=repo.match(/github\\.com[:\\/]([^\\/]+)\\/([^\\/]+?)(?:\\.git)?$/);if(!m){console.error(\"cannot parse ONCELLCLAW_REPO: \"+repo);process.exit(1)}const url=\"https://raw.githubusercontent.com/\"+m[1]+\"/\"+m[2]+\"/\"+ref+\"/scripts/cloud-start.sh\";fetch(url,{headers:{\"User-Agent\":\"oncellclaw-bootstrap\"}}).then(async r=>{if(!r.ok){console.error(\"HTTP \"+r.status+\" for \"+url);process.exit(1)}const t=await r.text();if(!t.includes(\"cloud-start.sh\")){console.error(\"unexpected script body from \"+url);process.exit(1)}fs.writeFileSync(\"/tmp/cloud-start.sh\",t);console.log(\"fetched \"+url)}).catch(e=>{console.error(\"fetch failed: \"+e);process.exit(1)})' && bash /tmp/cloud-start.sh",
env: {
ONCELLCLAW_REPO: "https://github.com/anupsinghinfra/oncellclaw",
ONCELLCLAW_REF: "main",
ONCELL_API_KEY: "<your OnCell API key>",
ONCELL_API_URL: "https://api.oncell.ai",
ANTHROPIC_API_KEY: "<your Anthropic key>",
ONCELLCLAW_WEB_TOKEN: "<openssl rand -hex 32>",
ONCELLCLAW_GROUP: "assistant",
ONCELLCLAW_CELL_NAMESPACE: "scout",
},
});The env is the single source of truth: ONCELLCLAW_REPO/ONCELLCLAW_REF pick the code, ONCELLCLAW_CELL_NAMESPACE scopes the agents this claw creates (two claws in one account never collide), and PORT is deliberately absent — the platform allocates and injects it.
Three rules apply to any service, not just this one. An agent runs one supervised service, and it must bind $PORT on 0.0.0.0 — that is what the agent's URL routes to, though the cell may bind other ports privately. It has to accept connections within 30 seconds or the supervisor kills it — so a cold install that takes minutes must bind a placeholder listener first and hand off once it is ready, which is exactly what the bootstrap above does. And the service is the only context with network access, which is why installs live here rather than in an exec.
Your app owns its entire path space: everything under the agent's URL is forwarded to it verbatim, including /health, /projects, and /files. Nothing at the top level is reserved by the platform. (oncellclaw namespaces itself under /web/ by its own convention, not because it has to.)
oncell.agents.serviceLogs(agentId) tails stdout and stderr — the first place to look when a service will not come up.
3 · Wait for health
const health = await fetch(new URL("/web/health", agent.url), {
headers: { Authorization: "color:#5cdb7f">`Bearer ${webToken}` },
});
// installing -> 503 { ok: false, phase: "clone" | "toolchain" | "install" | "build" | "provision" }
// ready -> 200 { ok: true, groups: ["assistant"] }First boot clones and installs inside the sandbox — expect minutes; poll every few seconds and treat 502/503 as "still starting". Warm restarts take seconds.
4 · Talk
const auth = { Authorization: "color:#5cdb7f">`Bearer ${webToken}` };
// send
await fetch(new URL("/web/assistant/message", agent.url), {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ text: "hello" }),
});
// stream replies (SSE: replays after ?after=<cursor>, then live pushes)
const stream = await fetch(new URL("/web/assistant/stream", agent.url), { headers: auth });
// or poll the ordered transcript (both directions)
const transcript = await fetch(
new URL("/web/assistant/transcript?limit=50", agent.url),
{ headers: auth },
);This is the identical open-source code the one-click flow runs. GET /web/status reports channels, skills, and app integrations — Telegram pairing (POST /web/channels/telegram/pair) and the rest light up there as they go live.
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.projects:readList and inspect projects and their credential pools.projects:writeCreate and update projects; add and remove model credentials.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
Everything is addressed by agent. Instances are created and recycled by the platform, and their IDs never reach you.
npm install @oncell/sdkimport { OnCell } from "@oncell/sdk";
const oncell = new OnCell({ apiKey: process.env.ONCELL_API_KEY });
// ─── Projects: credentials and quota ───
const project = await oncell.projects.create({ name: "prod" });
await oncell.projects.addCredential(project.projectId, { value: token, label: "seat-1" });
await oncell.projects.credentials(project.projectId);
// ─── Agents: the definition ───
const agent = await oncell.agents.create({
name: "builder",
projectId: project.projectId,
identity: { instructions: "You build web apps." },
capabilities: ["workspace", "shell", "git"],
});
await oncell.agents.list();
await oncell.agents.get(agent.agentId);
await oncell.agents.update(agent.agentId, { identity }); // new version
await oncell.agents.destroy(agent.agentId); // permanent
// ─── Running ───
await oncell.agents.start({ agentId: agent.agentId }); // idempotent
await oncell.agents.status(agent.agentId);
await oncell.agents.pause(agent.agentId); // snapshot, end instance
// ─── Operating ───
await oncell.agents.exec(agent.agentId, { cmd: "npm test" });
await oncell.agents.startService(agent.agentId, { cmd: "node server.js" });
await oncell.agents.serviceLogs(agent.agentId, 200);
await oncell.agents.writeFile(agent.agentId, "app/page.tsx", source);
await oncell.agents.dbSet(agent.agentId, "key", value);
// ─── Snapshots ───
const snapshot = await oncell.agents.snapshot(agent.agentId);
await oncell.agents.fork(agent.agentId, "builder-staging");
// ─── Observability ───
await oncell.agents.journal(agent.agentId); // works with no live instance
await oncell.agents.logs(agent.agentId, 100);
await oncell.agents.metrics(agent.agentId); // needs a live instanceThere is no resume: a dormant agent has no instance to resume, and start is idempotent — it returns the running instance if there is one, and materialises from the latest snapshot if there is not.
Every non-2xx throws OnCellError with status and the parsed body. A 429 carrying POOL_EXHAUSTED is retryable — queue and retry rather than surfacing a failure.
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.