Documentation

Agents you can test, score, and ship.

You write the agent and its evals. We run it, test it, and watch it.

Start here

Overview

An oncell agent is one TypeScript file: instructions, a model, the tools it may use, a spending budget, and the evals that say what good looks like. Deploy that file and the platform takes it from there. Every agent.* call crosses one boundary between your code and the runtime, and that boundary is the run log, the trace, the cost meter, and the test harness at once. The five stages below are that log, read five ways.

01
Build

Write the agent as a flat config: new Agent(name, { instructions, model, tools, budget }). Memory, a database, files, a shell, secrets, human approval, and schedules are prebuilt tools; a custom tool is a plain function with a name, a description, and a schema. There is no infrastructure to declare and nothing to configure.

02
Test

Declare what good looks like next to the agent with agent.eval(). Run it locally with oncell dev, where every message you send is logged as a candidate test case. In production, fork any customer’s agent into a staging copy and test against real state without touching it.

03
Evaluate

oncell eval runs every case through the agent, grades it with your assert, a deterministic check, or a judge model, and prints a scorecard diffed against the last run: what got fixed, what regressed, how cost and latency moved. A failing case is a non-zero exit code, so a regression blocks the release.

04
Run

Deploy, and the run log becomes the audit trail: every model call, tool call, approval, and dollar, per agent and per user. Budgets are enforced by the runtime below the model. An agent waiting on a human or a schedule parks at no cost and resumes exactly where it stopped.

05
Improve

Run logs are trajectories and evals are the reward. Promote real runs into the dataset, then search prompts and models against it and read the trade-off in pass rate, cost, and latency. The same trajectories and rewards are what post-training a model you own needs later. Then build again.

In production

Two teams run this in production today, with real numbers. JustCopy.ai: $0.0006 per customer conversation, metered per business. OpenCrew: 1 cell per agent, with that agent's tools and nothing else. The customer stories show each concept below on a live agent.

Quickstart

Four commands from an empty directory to a deployed agent with a passing scorecard.

Install

shell
npm install oncell

Write the agent

Create agent.ts. The agent and what good looks like, in one file:

agent.ts
import { Agent, tools } from "oncell";

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

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

agent.eval({
  name: "refund-policy",
  input: { message: "Refund my order" },
  rubric: "Polite, cites the refund policy, never promises a refund",
});

agent.eval({
  name: "password-reset-cost",
  input: { message: "Reset my password" },
  assert: (out) => out.cost < 0.01,
});

export default agent;

tools.* are prebuilt handles the runtime resolves; budget.perDay is a ceiling the runtime enforces, not a hint to the model. The two evals show the two kinds of check: a rubric graded by a judge model, and an assert over the full Result, including what the run cost.

Run it locally

shell
oncell dev agent.ts

oncell dev runs the agent on http://localhost:3000 with a chat UI at /, restarts when the file changes, and appends every run to .oncell/support/production.jsonl. That log is where promoted eval cases come from; see Datasets and promoted runs.

Score it

shell
oncell eval agent.ts

Runs both evals through the local runtime, prints one line per case with its cost and latency, writes a report to .oncell/support/reports/, and diffs it against the previous report. Exit code 0 means every case passed; 2 means at least one did not. See Running oncell eval.

Deploy

Deploying sends the source and the manifest (agent.toManifest()) to POST /api/v1/deploy under your API key. Deploying the same name again publishes a new version rather than a second agent.

deploy.ts
import { readFileSync } from "node:fs";
import agent from "./agent";

const res = await fetch("https://api.oncell.ai/api/v1/deploy", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ONCELL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    agentName: agent.name,
    source: readFileSync("./agent.ts", "utf8"),
    manifest: agent.toManifest(),
  }),
});
const { agentName, version, url } = await res.json();

Call it

typescript
const res = await fetch("https://api.oncell.ai/api/v1/agents/support/chat", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ONCELL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ message: "How do I reset my password?" }),
});
const result = await res.json();   // { text, status, steps, cost, usage }

POST /api/v1/agents/<name>/chat sends a message; POST /api/v1/agents/<name>/<task> invokes a task with its input as the body. Both return the full Result. Add ?stream=true for server-sent events.

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

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

A project owns the credentials and the quota its agents run on. Everything else belongs to one, so this comes first.

typescript
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" });

2 · Give it model access

In the console: Model Access → paste a credential. To use a Claude subscription, run claude setup-token and paste what it prints. Or from code:

typescript
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. The full guide, including the gateway and how credentials are pooled, is under Model access.

3 · 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. Scopes are covered under API keys & scopes.

4 · Deploy the agent

Deploying creates the agent and its first version; deploying the same name again publishes a new version. The call is in the quickstart.

5 · Talk to it

typescript
await fetch("https://api.oncell.ai/api/v1/agents/support/chat", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.ONCELL_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ message: "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.

6 · 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.

typescript
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 ends it at the 30-second mark. See Running agents.

7 · When something goes wrong

In order of how often they help:

typescript
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 parked

What 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, which is the point of a cell, and a wake restores it. Model usage is metered at the boundary and attributed to the agent and the user, so you know what each agent costs each customer.

The agent

Config

new Agent(name, config). The name is what you deploy and address. The config is flat: who the agent is, what it runs on, what it may touch, and what it may spend.

agent.ts
import { Agent, tools } from "oncell";

const issueRefund = {
  name: "issue_refund",
  description: "Issue a refund for an order",
  params: { orderId: { type: "string" }, amount: { type: "number" } },
  run: async ({ orderId, amount }) => { /* ... */ return "refunded"; },
};

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

export default agent;
FieldTypeNotes
instructionsstringRequired. The system prompt: who the agent is and how it behaves.
modelstringA model alias: "claude-sonnet", "claude-haiku", or "claude-opus". Overridable per call with agent.llm(prompt, { model }) and per eval run with --model.
toolsarrayPrebuilt handles (tools.memory, tools.db, …) and custom tool definitions { name, description, params, run }. See Tools.
budget{ perDay }Dollars per day: "$5", "5.50", or 5. Enforced by the runtime below the model, not by the prompt.
skillsrecordNamed prompts for specific work, loaded when the work calls for them. Advanced; see Skills.
imagestringBase image for the agent’s cell, for example "oncell/node". Optional.
projectstringProject namespace. Agents in the same project share files. Optional.

Migrating from identity-form config

Earlier releases described an agent as { identity: { instructions, model, budgets }, capabilities: [...], skills: [skill(...)] }. That form still works. The constructor accepts either shape and both produce the same manifest, so nothing already deployed needs to change.

To move to the flat form, lift identity.instructions and identity.model to the top level, rename identity.budgets to budget, and rename capabilities to tools. A skill created with skill() becomes an entry in skills keyed by its name, with its description as when and its instructions as guide; the tools it scoped move to the agent’s tools.

Tools

Prebuilt tools are declared with tools.* handles and used from your code as agent.*. A handle carries no behaviour; it is a marker the runtime resolves behind the supervisor boundary, so a declaration is a typed, greppable value rather than a string. State tools run on local NVMe with S3 as the source of truth, scoped per agent and per user.

HandleWhat it does
tools.memoryDurable key-value store. get, set, append, list, transact. Per-user scoping with .forUser(id).
tools.dbSQL through tagged templates. Injection is impossible by construction.
tools.filesDurable filesystem for knowledge and artifacts. .search() for retrieval.
tools.shellA shell inside the cell. Array-args form for model-controlled input.
tools.secretsInjected at use. Never in source, never in the trace, never in snapshots.
tools.ask_humanHuman approval. The asking is a tool; the crash-proof waiting is the runtime.
tools.agentsDelegate to another deployed agent. Child spend counts against the parent’s budget.
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.

tools.workspace and tools.git are bundles of tools the model sees (read, write, list, search; clone, status, diff, commit). Narrow one with .pick(): tools.workspace.pick("read", "search") grants read-only access, and picking a tool the bundle does not expose throws at load time rather than silently granting nothing.

memory

typescript
// 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

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

files

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

shell

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

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

secrets

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

ask_human

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

Platform tools

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

typescript
// 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: starting other agents, forking staging copies, pausing idle ones. See Running agents. schedule requests future work, and agent.sleep() is sugar over the schedule intent:

typescript
// 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

A custom tool is the same shape to the model as a prebuilt one. Add it to tools:

typescript
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`UPDATE orders SET refunded = 1 WHERE id = ${orderId}`;
    return "refunded";
  },
};
// On rejection the model receives { denied: true, reason } and adapts; it does not crash

Skills

Advanced. A skill is a prompt for specific work plus the tools it may use. The base instructions say who the agent is; a skill says how to do one job well, and stays out of the context window until that job comes up.

lead-qualifier.ts
const agent = new Agent("lead-qualifier", {
  instructions: "You are the first filter between the inbound form and the sales team. ...",
  model: "claude-haiku",
  budget: { perDay: "$2" },
  skills: {
    "icp-rubric": {
      when: "Scoring any inbound lead",   // always in context
      guide: ICP_RUBRIC,                  // loaded when the work calls for it
    },
  },
});

Only the skill’s name and its when line ride in the base context. The guide loads when the work calls for it, so the window stays small as an agent’s expertise grows. That makes skills the context-engineering mechanism: put the long rubric, the policy, the checklist in a guide, not in instructions.

A skill may set its own model; the loop switches models mid-run for that phase, so a cheap model can triage and an expensive one can design. Skill names are kebab-case (refunds, icp-rubric). A skill draws on the agent’s tools: while it is active, those are the hands it works with.

Triggers

Same agent, different entry points. Every trigger runs as a durable run through the same loop, and every trigger is something an eval can target. JustCopy launches two kinds of agent per business, a chat front desk and a scheduled follow-up; see Sync and async agents.

agent.task() — HTTPS endpoint

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

agent.chat() — conversation

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

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

agent.schedule() — cron

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

agent.onWebhook() — external events

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

The loop

Inference is not something the agent has; it is what the agent is. agent.llm() is the loop: think, act, look, repeat, until done. The agent’s tools and the active skill decide what it can touch along the way. Durability, replay, and budgets are handled by the runtime.

typescript
// Simple call
const answer = await agent.llm("Summarize this ticket");
String(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

Every trigger, every eval, and every API call sees the same shape.

typescript
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

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

Evals

Declaring evals

Evals live next to the agent, in the same file, and run through the same handlers users hit. agent.eval() registers a case; oncell eval runs them.

lead-qualifier.ts
agent.eval({
  name: "clear-fit-vp-ops",
  trigger: "qualify",
  input: {
    name: "Priya N", company: "Meridian Logistics", email: "priya@meridian.example",
    notes: "VP Ops, 800-vehicle fleet, asked about API pricing and rollout timeline",
  },
  assert: (out) => /^QUALIFIED/.test(out.text),
});
FieldTypeNotes
namestringOptional. Defaults to eval-<n> in declaration order. Must be unique across the agent and its dataset.
triggerstring"chat" (the default) or a task name. Chat cases call the chat handler with { message }; task cases call the task with input.
inputobjectThe handler’s input: { message } for chat, the task input otherwise.
assert(out: Result) => booleanA deterministic check over the full Result. May be async.
rubricstringFree-text criteria graded by a judge model.
judgestringModel alias for the rubric. Defaults to "claude-sonnet".

A case needs an assert, a rubric, or both; declaring one with neither throws at load time, as does a duplicate name. The handler’s return value is normalised to a Result: return agent.llm(...) directly and the case sees text, status, steps, cost, and token usage.

Asserts and rubrics

Two kinds of grading, for two kinds of behaviour.

An assert is code. It receives the full Result and returns true or false, so anything you can compute is a check: a regex over the text, the shape of out.data, the number of steps, the cost. Asserts are deterministic, free, and instant, and they are the right tool whenever the correct answer has a shape.

typescript
const ONE_LINE = /^(QUALIFIED|DISQUALIFIED|ESCALATED) - \d{1,3} - .+$/;

agent.eval({
  name: "format-one-line",
  trigger: "qualify",
  input: { name: "Lee K", company: "Northwind Field Services", email: "lee@northwind.example", notes: "Director of Field Operations, 300 technicians, wants a demo next week" },
  assert: (out) => out.text.trim().split("\n").length === 1 && ONE_LINE.test(out.text.trim()),
});

A rubric is prose. A judge model reads the input, the agent’s response, and the rubric, and answers pass or fail with a one-sentence reason that appears in the scorecard when the case fails. Rubrics are for qualities that have no shape: tone, tact, whether the answer cites policy. They cost a judge call per case, tracked separately from the agent’s own cost.

typescript
agent.eval({
  name: "tone-polite-rejection",
  trigger: "qualify",
  input: { name: "Alex P", company: "Alex's Blog", email: "alex@blog.example", notes: "just browsing, no company" },
  rubric: "The verdict is DISQUALIFIED and the reason is polite and factual: no sarcasm, no judgement of the person, cites a rubric signal.",
  judge: "claude-sonnet",
});

One case can carry both. Every check on a case must pass for the case to pass.

Cost and latency

Evals receive the whole Result, so the cost of a run is a first-class assertion. This is how a prompt change that doubles token usage gets caught before it ships:

typescript
agent.eval({
  name: "cost-regression",
  trigger: "qualify",
  input: { name: "Priya N", company: "Meridian Logistics", email: "priya@meridian.example", notes: "VP Ops, 800-vehicle fleet, asked about API pricing and rollout timeline" },
  assert: (out) => out.cost < 0.002,
});

Cost is summed from the model calls the case made; latency is measured around the whole invocation, tool calls included. In a JSON dataset the same checks are maxCost and maxLatencyMs:

json
"expect": [
  { "type": "regex", "pattern": "^DISQUALIFIED" },
  { "type": "maxCost", "usd": 0.002 },
  { "type": "maxLatencyMs", "ms": 8000 }
]

Both appear in the diff against the previous run as percentages, so a change that keeps every case passing but makes the agent slower or dearer is still visible.

Datasets and promoted runs

Cases declared in code are the ones you thought of. The dataset is where the ones production thought of go. The JustCopy story shows a plumber’s invented price promoted into an eval, and the post The run that became a test walks through it. oncell eval reads <agent>.evals.json next to the agent file (or the path given with --dataset) and treats its cases exactly like agent.eval() declarations.

lead-qualifier.evals.json
{
  "agent": "lead-qualifier",
  "defaults": { "judge": "claude-sonnet" },
  "cases": [
    {
      "name": "prod-2026-09-02T18-40-11",
      "trigger": "qualify",
      "input": {
        "name": "Marcus O",
        "company": "Harbor Freight Lines",
        "email": "marcus@harborfreight.example",
        "notes": "Head of Fleet Operations, 1,200 trucks, evaluating integration with our dispatch API before Q4"
      },
      "expect": [{ "type": "regex", "pattern": "^QUALIFIED" }],
      "promotedFrom": "2026-09-02T18:40:11.000Z"
    },
    {
      "name": "prod-2026-09-02T19-02-37",
      "trigger": "qualify",
      "input": { "name": "Jo B", "company": "unknown", "email": "jo@gmail.example", "notes": "newsletter signup" },
      "expect": [
        { "type": "regex", "pattern": "^DISQUALIFIED" },
        { "type": "maxLatencyMs", "ms": 8000 }
      ],
      "promotedFrom": "2026-09-02T19:02:37.000Z"
    }
  ]
}
CheckFieldsPasses when
exactvaluethe trimmed text equals value
containsvalue, caseInsensitive?the text contains value
regexpattern, flags?the pattern matches the text
json-fieldpath, equalsthe field at path in out.data (or the JSON object in the text) deep-equals equals
maxCostusdthe run cost at most usd
maxLatencyMsmsthe run took at most ms milliseconds

defaults.judge and defaults.rubric apply to cases that do not set their own; a default rubric is only used by cases with no expect checks. Case names must be unique across the file and the agent. Cases carrying promotedFrom are shown as [promoted] in the scorecard.

Promoting a production run

oncell dev appends every run to .oncell/<agent>/production.jsonl: the trigger, the input, the text, the cost, and the time it took. Any of them can become a case.

shell
oncell eval runs lead-qualifier.ts
# #0 2026-09-02T18:40:11 qualify  {"name":"Marcus O","company":"Harbor Freight Lines",…} → QUALIFIED - 90 - …  $0.0011

oncell eval promote lead-qualifier.ts --last --expect "^QUALIFIED"
# ✓ promoted run 2026-09-02T18:40:11.000Z → prod-2026-09-02T18-40-11
#   saved  lead-qualifier.evals.json

promote takes the last run (--last) or a specific one (--index N) and appends it to the dataset as a case named prod-<timestamp> with promotedFrom set to the run’s timestamp. Give it something to check: --expect "^REGEX" adds a regex check, --max-cost 0.01 a maxCost check, --rubric "..." a rubric; at least one is required. The input is stored untouched, so the case replays exactly what a user sent.

Running oncell eval

shell
oncell eval <agent.ts> [--dataset path] [--model alias] [--judge alias] [--replay] [--no-cache] [--push]
oncell eval diff <agent.ts>
oncell eval runs <agent.ts>
oncell eval promote <agent.ts> [--last | --index N] [--expect "^REGEX"] [--max-cost usd] [--rubric "..."]

The scorecard

Cases run sequentially through one agent instance so every model call is attributed to the case that made it. Each case prints one line: pass or fail, the name, the first line of the response, cost, latency, and where the case came from ([agent], [dataset], or [promoted]). Failed checks print underneath with their reason. An illustrative run:

text
  oncell eval lead-qualifier.ts  8 cases

  ✓ clear-fit-vp-ops        QUALIFIED - 85 - VP Ops at an 800-vehicle fleet…   $0.0011  1.4s  [agent]
  ✓ clear-miss-student      DISQUALIFIED - 5 - Student newsletter signup…      $0.0009  1.1s  [agent]
  ✗ borderline-consultant   QUALIFIED - 72 - Detailed pricing question fro…    $0.0012  1.5s  [agent]
      ↳ assert: assert failed on: QUALIFIED - 72 - Detailed pricing question from a consultant…
  ✓ format-one-line         QUALIFIED - 80 - Director of Field Operations…     $0.0010  1.2s  [agent]
  ✓ tone-polite-rejection   DISQUALIFIED - 5 - No company or role signal…      $0.0009  1.3s  [agent]
  ✓ cost-regression         QUALIFIED - 85 - VP Ops at an 800-vehicle fleet…   $0.0011  0.1s  [agent] (cached)
  ✓ prod-2026-09-02T18-40-11  QUALIFIED - 90 - Head of Fleet Operations, 1,…   $0.0011  1.3s  [promoted]
  ✓ prod-2026-09-02T19-02-37  DISQUALIFIED - 5 - Newsletter signup with no…    $0.0008  1.0s  [promoted]

  7/8 passed (88%)   avg $0.0010   avg 1.1s   total $0.0081 (+$0.0042 judge)
  model claude-haiku-4-5 · agent 3f9c2a1b7d0e · 2026-09-03T10-12-44-000Z

  since 09:41:02 (claude-haiku-4-5) → now (claude-haiku-4-5)
  +1 fixed   -1 regressed   pass +0%   cost -4%   latency +2%
  ▼ borderline-consultant   pass → fail   cost +$0.0001
  ▲ format-one-line         fail → pass   cost -$0.0002

  report → .oncell/lead-qualifier/reports/2026-09-03T10-12-44-000Z.json

The summary line gives passed over total, the pass rate, average and total cost, average latency, and the judge cost separately. Below it, when there is a previous report, the diff: cases fixed, cases regressed, cases added or removed, and the change in pass rate, cost, and latency since that run.

Exit codes

0 when every case passed. 2 when at least one case failed, which is what makes oncell eval a release gate in CI. 1 for a usage error: no agent file, no evals, a dataset that does not parse.

Reports and diff

Every run writes .oncell/<agent>/reports/<timestamp>.json with the full per-case results, including each model call in the trace. oncell eval diff agent.ts re-renders the diff between the two most recent reports without running anything. A report records the model and a hash of the agent file, so a diff tells you whether it is comparing like with like. Add --push (or set ONCELL_PUSH_EVALS=1) with ONCELL_API_KEY in the environment to upload the report to your console at oncell.ai/dashboard/agents/<agent>/evals; a failed push warns but does not change the exit code.

The replay cache

Model responses are cached under .oncell/<agent>/cache, keyed by the exact request. In the default live mode a case calls the model and stores the answer, and falls back to the cache if the call fails. --replay serves from the cache only, so a suite that has run once replays instantly and deterministically, and a call with no cached answer is an error rather than a silent live call. --no-cache disables the cache both ways. Cases run at temperature 0.

The judge

A rubric is graded by a judge model: claude-sonnet unless the case sets judge or the run passes --judge, which overrides every case. The judge sees the input, the response, and the rubric, at temperature 0, and its calls go through the same cache, so replayed runs do not pay for grading twice. Judge cost is reported separately and is not counted against a case’s own cost checks. --model changes the model the agent runs with, not the judge; use both to try a cheaper agent under the same grader.

Runtime

Durability & replay

The runtime is never part of your vocabulary and always part of the product. Every agent.* call crosses one supervisor boundary, and that one boundary yields durability, observability, billing, and isolation.

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 model calls are never re-paid. Memoize your own non-idempotent side effects with agent.once().

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

  // Parks here and can wait days. The process can crash, redeploy, move hosts.
  const ok = await agent.askHuman({
    question: `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. Waking from a snapshot takes a few hundred milliseconds; the first message to a paused agent pays it, and the rest do not.

Observability

The run log is the trace. Every model call, tool call, state operation, approval, and sleep appears in it with zero instrumentation, with its cost, attributed to the agent and the user. Annotate it from code with agent.emit("Cloning repository…"); annotations also stream to connected UIs. The same log is what oncell eval reads to attribute cost per case, and what a promoted run is lifted from.

Budgets

Daily budgets (budget.perDay) are enforced at the supervisor boundary, not by your code and not by the prompt, so a clever input cannot argue with them. Per-call caps (maxCost, maxSteps) bound individual runs and surface in Result.status when they trip. Spend from delegated child agents cascades against the parent’s cap. JustCopy sets a $1 per day budget per business from the customer’s plan; see what a conversation costs.

Isolation

Agent code runs in its own cell with its own filesystem and no network. Every tool it has is an RPC through the trusted supervisor; the cell reaches nothing the supervisor does not proxy. One cell per customer, team, or tenant, and no path from one to another. See Networking for what a cell can and cannot reach. OpenCrew runs every agent in a crew this way, one cell per agent with that agent’s tools and nothing else.

Improve

Post-training an agent needs three things: an environment to run in, a reward, and trajectories from real usage. oncell has all three as a by-product of running the agent. Run logs are trajectories: every prompt, tool call, result, and approval, already recorded. Evals are the reward: behaviour, cost, and latency, scored the same way every time. Forked cells are the environment: a resettable copy of the agent with its real tools and state, so you can run a hundred rollouts and keep none of them.

Today the platform uses this to search prompts and models against the reward and report the trade-off in pass rate, cost, and latency. The same trajectories and rewards feed post-training of models you own later, served through the same gateway. Nothing is pooled across customers, and everything exports as files. Both customer stories end here: the same evals on two models, and approvals as the reward.

Platform

Projects & agents

Two objects you use, and one you never touch.

text
Project  ──1:N──▶  Agent

credentials,       instructions + tools + skills
quota, billing     the unit you address

A project owns model credentials and the quota its agents draw on. Many agents share one.

An agent is what you address: its instructions, 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.

tools 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 and 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.

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 cell.

1 · Create a project

typescript
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:

shell
claude setup-token

That prints a token. Add it to a project’s pool, either by pasting it into Model Access in the console, or:

typescript
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

typescript
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 cell 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

Deploy defines an agent; the platform API runs it. Model access follows the chain: the agent belongs to a project, the project owns the credentials.

Start

typescript
const agent = (await oncell.agents.list()).find((a) => a.name === "builder")!;

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, which is how you boot every agent from a pre-built golden image rather than paying a cold install each time.

Exec

typescript
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.

typescript
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 ends 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

typescript
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. A forked cell is the environment the improvement loop runs in, and how OpenCrew gives two agents their own copy of one repository instead of making them take turns.

Snapshots you create through the API are pinned and never garbage-collected, which is what makes one usable as a golden image.

Lifecycle & observability

typescript
// 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.

Browser

A persistent, per-customer logged-in browser your agent can drive. One real Chromium runs as the cell’s service, its profile stored in the cell, so a login survives pause, resume, and fork (it rides the cell’s snapshot). The human logs in once; the agent acts in that session afterward, on any site, described in plain words. Nothing here knows a specific site: a new site is a new login, never new code.

Create

oncell.browsers.create starts the browser cell and its service in one call. Idempotent end to end — call it per-connect or per-turn without checking first. The cell comes back as-is, and a service that is already running is handled by what the serviceToken tells the SDK: pass your own stable token (the recommended shape — you need it to attach() later anyway) and the running service, which already holds that token, is reused untouched; omit it and the service is restarted with the freshly generated one, since a token the SDK just invented cannot match the one the running service was started with. The token gates run() and the login handoff. Rotating to a different token is the one thing create will not do implicitly — stopService() first, then create.

typescript
import { OnCell } from "@oncell/sdk";

const oncell = new OnCell({ apiKey: process.env.ONCELL_API_KEY });

const browser = await oncell.browsers.create({
  agentId: agent.agentId,
  tier: "standard", // Chromium does not fit starter
});

Log in: the connect handoff

connectUrl() returns a link you hand to a human. They open it, see the cell’s real browser, and log in to the target site directly; the password goes to the site, never to oncell or to you. Only the session persists, in the cell.

typescript
const { connectUrl } = await browser.connectUrl({
  url: "https://example.com/login",
});
// Send connectUrl to the account owner. Valid 15 minutes.

The link is served from a stable oncell origin behind an opaque token (oncell.ai/connect/<token>), not the raw cell subdomain; a novel subdomain presenting a provider sign-in is what Safe Browsing flags as phishing. The page streams the browser over plain HTTP (GET /frame polled, POST /input for pointer and keyboard), so it works through the preview proxy with no WebSocket. Prefer this over building the login URL by hand.

Act: run a task

run() drives the logged-in session toward a goal in plain words. The model sees the page and acts one step at a time; it moves and types at human pace and presents a real user-agent, so ordinary sites see the owner’s own browser, not an obvious automation. It stops when the goal is done, or reports needs_login if the session has expired. The task runs in the background in the cell and run() polls it to completion (up to timeoutMs, default 5 min), so a multi-step goal is not cut off by a held-request gateway timeout.

typescript
const result = await browser.run({
  goal: "Find the most recent review and draft a reply.",
});
// result.outcome: "done" | "failed" | "needs_login"

browser.loggedIn() probes whether a session is still live before you act. Datacenter-IP reputation is the one thing a real session and human pacing cannot fix: a site running aggressive anti-automation may still challenge. When it does, hand the connect link back to the human to clear it rather than trying to evade.

API keys & scopes

Create keys in the console. 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.

ScopeGrants
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:

json
// 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.

shell
npm install @oncell/sdk
typescript
import { 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 agents = await oncell.agents.list();
const agent = await oncell.agents.get(agents[0].agentId);
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");

// ─── Browser (see the Browser section) ───
const browser = await oncell.browsers.create({ agentId: agent.agentId, tier: "standard" });
const { connectUrl } = await browser.connectUrl({ url: "https://example.com/login" });
await browser.run({ goal: "..." });
await browser.loggedIn();

// ─── 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 instance

There 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. Host-relayed errors carry a structured body — { error: { code, message, remediation } } — and the SDK surfaces code and remediation as fields on the error, with all three woven into message, so a log line reads SERVICE_ALREADY_RUNNING: … — stop it first: DELETE /cells/…/service rather than an opaque status. Branch on error.code, not on message text. A 429 carrying POOL_EXHAUSTED is retryable — queue and retry rather than surfacing a failure.

oncellCustomersBlogGitHubTalk to us