Skip to content

AI systemsProblems

Design a hosted agent platform

Design a managed agents platform that runs thousands of AI agents for other companies, step by step. Durable runs with an event log, microVM sandboxes, an MCP tool gateway with per-tenant credentials, fair scheduling, token budgets and model provider fallback, with code in Java, Python, C#, Go and TypeScript.

Senior35 min core · 55 min full depth

“Design a platform that runs AI agents for other companies. Customers define an agent, give it tools, and start runs through an API. Some runs take ten seconds, some take two hours.”

A company describes its agent: a model, a system prompt and a list of tools. The platform runs it, thousands of times at once, for thousands of companies. We call each company a tenant.

The agents belong to the tenants. The platform owns everything around them: where the loop runs, what survives a crash, where model-written code runs, whose passwords a tool call uses, and who gets the next free slot when everyone is busy. So this is an infrastructure problem with a model in the middle. We start with one server running one loop in memory, and grow it only when a number forces us to.

If you only have a minute, this is the design. The rest of the page explains each part.

  1. Every run is a log of steps. Each model reply and tool result is saved before the run moves on. Any worker can pick up any run after a crash, from its last step.
  2. A tool call that changes something gets a key from its place in the log. A resumed run sends the same key, so the tool does the refund once, not twice.
  3. Model-written code runs in its own tiny virtual machine, one per run, never reused, with no network by default.
  4. Every tool call goes through one gateway. It checks the allowed list, asks for approvals, and adds the tenant’s credentials. Passwords never enter a prompt.
  5. Slots and model capacity are shared fairly by tenant, so one customer’s burst never makes others wait.

A few questions change the design a lot. For the rest, we assume an answer and move on.

Interviewerminute 0

Design a platform that runs other companies’ AI agents.

What they are testing

Whether you find the questions that decide the shape: who writes the loop, how long runs last, whether agents run code, and how isolated tenants must be. Talking about prompts and models before asking who the tenants are reads as a chatbot answer to an infrastructure question.

These five questions matter most:

  • How many tenants and runs? About 2,000 companies, 500,000 runs a day.
  • How long does a run last? Seconds to hours, 4 minutes on average. Some wait days for a person to approve a step. If every run finished in 30 seconds, it could run inside one API call, with nothing saved.
  • Who writes the agent loop? We do. Tenants give us settings: model, prompt, tools, limits.
  • Which tools? Tenants’ own tool servers, plus built-in code running and a browser.
  • How isolated must tenants be? One shared fleet, but no tenant may ever reach another’s data. EU tenants’ data stays in the EU.

We leave out writing agents for tenants, training models, and a no-code agent builder.

Go deeperThe full requirements, and more questions3 min

Must do. Versioned agent settings, where each run is pinned to one version. Start, stream, cancel and get the result of runs. A run may wait up to 7 days for approval. Tools with the tenant’s credentials. Code and a browser in a sandbox. Limits per tenant (runs at once, tokens per minute, monthly spend) and per run. A trace, replay, usage and cost for every run.

Must be true.

  • Durability. A saved step is never lost. A run survives losing any worker, machine or zone.
  • Side effects. A tool call that changes something happens exactly once when the tool accepts a key, and at most once otherwise.
  • Isolation. No tenant can reach another’s data, secrets or network. One sandbox per run, never reused.
  • Fairness. A tenant within its plan waits under 5 seconds for 95% of runs, whatever others do.
  • Speed. Creating a run takes under 200 ms. Platform work per step stays under 150 ms, not counting model and tool time. A sandbox is ready in under 300 ms.
  • Availability. 99.95% for the API. Under 0.1% of runs fail because of us.
  • Billing. Usage per tenant within 1% of the model providers’ invoices.
More questions We assume If the answer were different
Which models? Several hosted providers. Tenants may bring keys Our own models make GPUs our problem (see the chat assistant)
How do tenants pay? Per token and sandbox minute A flat price per seat means hard caps become mandatory
Memory across runs? Out of scope. Tenants pass context in A memory store per agent, with its own privacy rules
Agents starting other agents? Yes, each child is its own run See orchestration
Interviewerminute 3

Where is the AI in this system, and what exactly does your platform own?

What they are testing

Whether you see that the platform itself is infrastructure, and draw a clear line between what it owns and what the tenant owns. Proposing to fine-tune a model for customers, or to judge their answers, is the red flag. The platform can’t know what a good answer is in every domain.

The AI lives in the tenants’ agents. The platform’s own decisions, like which run goes next, whether a tool call is allowed, or what a tenant has spent, are about money, security and fairness. So they are plain code. The real question is how much of the agent the platform takes over:

How much the platform owns

tenants still build crash recovery, sandboxes, tool security

a black box: we can't save steps, meter or check tools

we'd own answer quality in domains we don't know

Model proxy: keys and billing only

Host the tenant's own loop code

Run the loop for them

Picked. We see every step

Also write their prompts

Too far

tenants still build crash recovery, sandboxes, tool security

a black box: we can't save steps, meter or check tools

we'd own answer quality in domains we don't know

Model proxy: keys and billing only

Host the tenant's own loop code

Run the loop for them

Picked. We see every step

Also write their prompts

Too far

Scroll or pinch to zoom, drag to pan
  • Color key
  • Too little
  • What we run
  • Picked
  • Too far

We pick a managed loop. Every model call and tool call passes through our code, so we can save, meter, block and replay it. That is what makes crash recovery, fairness and isolation possible.

So the line is clear. The platform owns the loop, the run state, sandboxes, the tool gateway and budgets. The tenant owns the prompt, the model, its tool servers, approvals and what a good answer is. Tenants with unusual loops can run their code as a tool in a sandbox.

Interviewerminute 6

How big is this? What does a run cost?

What they are testing

Whether you size runs in flight (not just requests per second), model calls and tokens, and notice which cost dominates. Sizing GPUs for a platform that buys model calls from providers, or stopping at runs per day, both read badly.

Assumptions
Runs a day, all tenants
500,000
Peak over average
3×
Minutes per run, mean
4
Model calls per run
15
Input tokens per call
8000
Output tokens per call
400
Dollars per million input tokens
3
Dollars per million output tokens
15
Runs that need a sandbox
40%
Sandbox cold start, seconds
2
Event log per run
50 KB
Days of hot retention
30
Runs started6/s17/s at peak

500,000 runs a day ÷ 86,400 seconds, and about 3 times that at peak.

So we starting runs is easy. A small API and a queue handle this.

Runs in flight4,200at peak

17 runs a second × 240 seconds per run. (Runs in flight = arrival rate × time each one takes. This is called Little’s law.) A two-hour run holds a slot as long as 30 average ones.

So we size workers by runs held, not by requests. A worker waiting on the model holds about 200 runs, so about 21 workers.

Model calls260/s2.2 million tokens/s

17 runs a second × 15 model calls per run. Each call sends the conversation so far, about 8,000 tokens, and gets 400 back.

So we spread calls over several provider accounts. One account allows about 100 calls a second at these sizes.

Cost per run45 cents$225,000 a day

15 calls × (8,000 input tokens at $3 per million + 400 output at $15 per million) is 15 × 3 cents, at typical prices for a mid-sized model. A sandbox for 4 minutes is well under a cent.

So we put token budgets on every tenant and every run. Servers are a rounding error next to tokens.

Sandboxes1,700in use at peak

40% of the 4,200 runs in flight hold a sandbox for their whole life.

So we run about 55 sandbox machines, and keep about 140 ready per region so a burst never waits.

Two numbers decide this platform. It is sized by runs in flight, not requests per second. And 95% of the cost is tokens. So budgets and prompt reuse matter far more than packing machines tightly.

Go deeperThe calculator, the event log, and the warm pool2 min
Change an assumption
Runs started, peak
17/s
Runs in flight, peak
4,200
About 21 workers at 200 runs each: a worker spends its time waiting on the model.
Model calls, peak
260/s
Past one provider account (about 100 calls/s at these sizes): a model gateway spreads calls over accounts and providers.
Tokens per second, peak
2,200,000/s
Input is most of it, because every call resends the conversation so far.
Tokens a day
63 billion
Model cost per run, in cents
45
Before prompt caching. Sandboxes, workers and storage add well under one cent.
Model cost per 1,000 runs, in dollars
450
Model spend a day, in dollars
230,000
Every dollar belongs to a tenant: metering must match provider invoices.
Sandboxes in use, peak
1,700
About 55 hosts at 50 microVMs each, kept 60% busy.
Warm sandboxes per cell (10× burst)
140
Covers ten times the peak start rate for one cold start. Small next to the sandboxes in use.
Event log writes, peak
780/s
One Postgres primary takes this with room to spare.
Event log, hot retention
750 GB
Fits one database node.

The event log. About 3 events per model call: 780 writes a second. At about 50 KB per run (big outputs live in object storage), 30 days is 750 GB. One Postgres primary per region handles that.

The warm pool. Sandboxes start about 7 times a second. A cold start takes 2 seconds. To cover a burst ten times bigger for one cold start: 7 × 10 × 2 = 140 ready.

Change an assumption and see what flips. Ten times the runs puts the event log past one primary. Runs of an hour instead of 4 minutes multiply runs in flight and sandboxes by 15, without changing model calls a second.

We start with one server. Then each step fixes the biggest problem left. The drawing grows with each step.

Interviewerminute 11

Start simple. One customer, one agent. What runs it?

What they are testing

Whether you can state the loop and its budgets in two minutes and name what is naive. Drawing sandboxes and queues before saying what one step does reads as memorized.

An agent server runs the agent loop in memory. Ask the model. Run the tools it asks for. Add the results to the conversation. Repeat. Budgets are checked every turn, because a confused model will loop until someone stops it.

Hosted agent platform · architecture

Stage 1: One server, one loop

Tenant appsAgent serverloop in memory2125% busyModel providerone account255% busy
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasional

Stage 1 of 6

One server, one loop

Why this stage

Nothing yet.

This is the smallest design that runs one tenant’s agent end to end.

What we add

An agent server that takes a task, runs the loop in memory (model call, tool call, repeat) with a step budget, and returns the answer.

What it costs

A crash or deploy loses every run in flight, one server holds about 200 runs, and tools run inside our own process.

At 17 runs started per second, Agent workers is 2125% busy. Stage 2 is the fix.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one run looks like

  • A run lasts about 4 minutes. It makes 15 model calls and about 10 tool calls.
  • Each step writes about 3 events to the database.

What one machine can do

  • A worker holds about 200 runs at once, because it mostly waits on the model. So one worker takes about 0.8 new runs a second.
  • One Postgres primary takes about 5,000 small writes a second.
  • A sandbox host runs about 50 microVMs.

Outside limits

  • One model provider account allows about 100 calls a second at our prompt sizes. The stage 5 gateway pools about 10 accounts across providers.

Why sandboxes add up

  • About 40% of runs keep a sandbox for their whole life. That means about 96 sandboxes open for each run started per second.
AgentLoop.javaagent_loop.pyAgentLoop.csagent_loop.goagent-loop.ts
// Limits the tenant sets per agent. They are all that stands between a confused model and
// an endless bill, so the loop checks them on every turn, not at the end.
record Budget(int maxSteps, int maxTokens) {}
// Stage 1: one tenant, one process, everything in memory.
class AgentLoop {
private final Model model;
private final Map<String, Tool> tools;
private final Budget budget;
AgentLoop(Model model, Map<String, Tool> tools, Budget budget) {
this.model = model;
this.tools = tools;
this.budget = budget;
}
String run(String task) {
// The whole state of the run is this list. If the process dies, the run is gone:
// that is the flaw stage 2 fixes.
List<Message> messages = new ArrayList<>(List.of(Message.user(task)));
int tokens = 0;
for (int step = 0; step < budget.maxSteps(); step++) {
Reply reply = model.complete(messages, tools.values());
tokens += reply.tokens();
messages.add(reply.message());
if (reply.toolCalls().isEmpty()) {
return reply.text(); // no tool asked for: the model has answered
}
for (ToolCall call : reply.toolCalls()) {
messages.add(Message.tool(call.id(), call(call)));
}
if (tokens > budget.maxTokens()) {
// Stop between steps, never inside one, so the transcript stays valid.
return "Stopped: token budget used up.";
}
}
return "Stopped: step budget used up.";
}
private String call(ToolCall call) {
Tool tool = tools.get(call.name());
if (tool == null) {
// Models do ask for tools that don't exist. Tell the model; don't crash the run.
return "Unknown tool " + call.name() + ".";
}
try {
// Second flaw: the tool runs inside our process, with our permissions.
return tool.invoke(call.args());
} catch (Exception e) {
// A failed tool is information the model can act on, not a platform failure.
return "Tool error: " + e.getMessage();
}
}
}

It has three problems. A crash or deploy loses every run in flight. Tools run inside our process, with our permissions. And one server holds about 200 runs, while the peak needs 4,200.

Interviewerminute 13

A run takes two hours and you deploy twice a day. What happens to it?

What they are testing

Whether you save the run’s state step by step, and explain what a resumed run does differently. “Put it on a queue and retry”, without saying what is saved, is the red flag. The retry starts from scratch and repeats every side effect.

Runs must outlive the machines that run them. So a run becomes a log of saved steps. This is called durable execution (see deployment models).

  1. The API saves the run and its first event, and puts the run’s ID on a queue.
  2. Any worker takes it, with a lease: a claim that expires after 30 seconds unless renewed.
  3. The worker reads the run’s log, rebuilds the conversation, and takes the next step.
  4. Every model reply and tool result is saved before the run moves on.
Hosted agent platform · architecture

Stage 2: Durable runs

Tenant appsRuns APInew2 servers, 4% busyRun queueleases, 30 snew1% busyAgent workerslease a run, replay its log36 servers, 59% busyRun event logPostgres, (run, seq)new15% busyModel providerone account255% busy
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasional
Stage

Stage 2 of 6

Durable runs

Why this stage

Runs last from seconds to hours and some wait days for approval.

We deploy daily.

At the 17 runs/s peak, about 4,200 runs are in flight: 21 servers’ worth.

What we add

A runs API, a run queue with leases, a pool of stateless workers, and an event log that records every step, so any worker can resume any run.

What it costs

Every step writes to the log, and a resumed step may repeat a tool call: idempotency keys become mandatory.

At 17 runs started per second, Model providers is 255% busy. Stage 3 is the fix.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one run looks like

  • A run lasts about 4 minutes. It makes 15 model calls and about 10 tool calls.
  • Each step writes about 3 events to the database.

What one machine can do

  • A worker holds about 200 runs at once, because it mostly waits on the model. So one worker takes about 0.8 new runs a second.
  • One Postgres primary takes about 5,000 small writes a second.
  • A sandbox host runs about 50 microVMs.

Outside limits

  • One model provider account allows about 100 calls a second at our prompt sizes. The stage 5 gateway pools about 10 accounts across providers.

Why sandboxes add up

  • About 40% of runs keep a sandbox for their whole life. That means about 96 sandboxes open for each run started per second.
DurableRun.javadurable_run.pyDurableRun.csdurable_run.godurable-run.ts
record Event(int seq, String type, Map<String, Object> data) {}
// Another worker already wrote this slot of the log.
class ConflictException extends RuntimeException {}
interface EventLog {
List<Event> read(String runId);
// INSERT INTO run_events (run_id, seq, type, data). The primary key (run_id, seq) throws
// ConflictException when the slot is taken, so the log doubles as the run's lock.
void append(String runId, int seq, String type, Map<String, Object> data);
}
// Stage 2: a run is its event log. Any worker can load any run and take the next step,
// because everything the run knows was written down before it was acted on.
class DurableRun {
private final EventLog log;
private final Model model;
private final ToolCaller tools;
private final String runId;
private final List<Event> events;
DurableRun(EventLog log, Model model, ToolCaller tools, String runId) {
this.log = log;
this.model = model;
this.tools = tools;
this.runId = runId;
this.events = new ArrayList<>(log.read(runId)); // resuming and starting are one path
}
private void append(String type, Map<String, Object> data) {
int seq = events.size() + 1; // the next free slot; whoever writes it first owns the step
log.append(runId, seq, type, data); // throws ConflictException for a stale worker
events.add(new Event(seq, type, data));
}
// Takes one step. Returns false once the run has finished.
boolean advance() {
if (events.get(events.size() - 1).type().equals("RunFinished")) return false;
Event reply = null;
for (Event e : events) if (e.type().equals("ModelReplied")) reply = e;
if (reply != null) {
Set<Object> done = new HashSet<>();
for (Event e : events.subList(reply.seq(), events.size()))
if (e.type().equals("ToolCompleted")) done.add(e.data().get("key"));
@SuppressWarnings("unchecked")
List<ToolCall> calls = (List<ToolCall>) reply.data().get("calls");
for (int i = 0; i < calls.size(); i++) {
// The key comes from the reply's slot in the log, so it is identical on every
// retry. A tool that already acted on this key returns its first result.
String key = runId + ":" + reply.seq() + ":" + i;
if (!done.contains(key)) {
String result = tools.call(calls.get(i), key);
append("ToolCompleted", Map.of("key", key, "result", result));
return true; // one effect per step, so a crash repeats at most one call
}
}
}
// Rebuilt from the log, the prompt is exactly what the crashed worker would have sent.
Reply r = model.complete(Messages.from(events));
append("ModelReplied", Map.of("text", r.text(), "calls", r.toolCalls(),
"tokens", r.tokens())); // saved before any tool runs
if (r.toolCalls().isEmpty()) {
append("RunFinished", Map.of("answer", r.text()));
return false;
}
return true;
}
// The lease (30 s, renewed every step) says which worker may run this; the log's
// primary key catches the one case the lease can't: a worker that paused past it.
static void work(Lease lease, DurableRun run) {
try {
while (lease.renew() && run.advance()) { }
} catch (ConflictException e) {
// Someone else owns the run now; walking away is the correct move.
}
}
}

Two details make this correct:

  • Two workers can’t split a run. Each event goes into a numbered slot: 1, 2, 3, with no gaps. Saving fails if the slot is taken. A worker that froze past its lease tries to save, finds the slot taken, and walks away.
  • A repeated tool call is safe. Each tool call’s key comes from its place in the log: the run, the reply number, and the call number. A resumed run sends the same key, and the tool returns its first result instead of acting twice.
A worker dies mid-run, and another one continues it
ToolWorker 2Event logWorker 1Run queueToolWorker 2Event logWorker 1Run queuecrash before saving the resultno renewal, the lease expires after 30 slease run_8f2 for 30 sread events 1 to 23save 24 ModelReplied with call issue_refundissue_refund, key run_8f2:24:0refundedlease run_8f2read events 1 to 24call 0 of reply 24 has no result yetissue_refund, the same key run_8f2:24:0already done, here is the first resultsave 25 ToolCompletednext step, as if nothing happenedToolWorker 2Event logWorker 1Run queueToolWorker 2Event logWorker 1Run queuecrash before saving the resultno renewal, the lease expires after 30 slease run_8f2 for 30 sread events 1 to 23save 24 ModelReplied with call issue_refundissue_refund, key run_8f2:24:0refundedlease run_8f2read events 1 to 24call 0 of reply 24 has no result yetissue_refund, the same key run_8f2:24:0already done, here is the first resultsave 25 ToolCompletednext step, as if nothing happened
Scroll or pinch to zoom, drag to pan
  • Color key
  • Service
  • Background worker
  • Queue or stream

The cost is about 3 log writes per model call. And every tool that changes state needs to accept a key. “Stress it” covers tools that can’t.

Go deeperFollow a new run, and the tables behind the log3 min
Hosted agent platform · architecture

Trace a request: Start a run

Tenant appsRuns APIRun queueleases, 30 sAgent workerslease a run, replay its logRun event logPostgres, (run, seq)Model providerone account
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasionalHop in motionHops doneResponse

Start a run: step 1 of 4

Tenant apps to Runs API

POST /v1/runs with the tenant’s API key and an Idempotency-Key. The API checks the key, that the agent version exists, and the tenant’s queue depth limit.

5 ms of a 200 ms budget
  1. Tenant apps → Runs API5 ms
  2. Runs API → Run event log6 ms
  3. Runs API → Fair scheduler3 ms
  4. Response
Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
CREATE TABLE runs (
run_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
agent_version INT NOT NULL, -- the fixed settings the run is pinned to
status TEXT NOT NULL, -- queued, running, waiting, finished, failed, cancelled
lease_owner TEXT,
lease_expires_at TIMESTAMPTZ, -- 30 s, renewed every step
last_seq INT NOT NULL DEFAULT 0,
tokens_in BIGINT NOT NULL DEFAULT 0,
tokens_out BIGINT NOT NULL DEFAULT 0,
cost_micros BIGINT NOT NULL DEFAULT 0,
idempotency_key TEXT,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (tenant_id, idempotency_key)
);
CREATE TABLE run_events (
run_id TEXT NOT NULL,
seq INT NOT NULL, -- 1, 2, 3 ... with no gaps
type TEXT NOT NULL, -- RunCreated, ModelReplied, ToolCompleted, ...
data JSONB NOT NULL, -- up to 8 KB here. Larger outputs by reference
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (run_id, seq) -- the write that wins a slot owns the step
);

The primary key on (run_id, seq) is the most important line on this page. It is what stops two workers from splitting a run. Large outputs and sandbox files go to Amazon S3.

Why not a workflow engine like Temporal? The agent loop has one fixed shape: call the model, call tools, repeat. It needs a log and leases, not a general engine, and we want to schedule by tenant at every step. The cost is that leases and recovery are ours to build and test. A team that can’t own them should use Temporal.

Interviewerminute 16

The agent writes Python and wants to run it. Where does that happen?

What they are testing

Whether you treat model-written code as hostile and choose an isolation boundary on purpose. “In a Docker container on the worker”, with no word about the shared kernel, the network or reuse across tenants, is the red flag.

About 40% of runs execute code the model wrote, or drive a browser. That code may have been steered by a hidden instruction in a web page (a prompt injection). So treat it as untrusted code from the internet. It can’t run on a worker next to other tenants’ runs.

Each such run gets its own sandbox: a microVM, a tiny virtual machine with its own operating system kernel, such as Firecracker. It has 1 CPU, 2 GB, 60 seconds per execution, and no network unless the tenant allows specific hosts. It lives as long as the run, so files persist between steps. At the end it is destroyed, never reused.

Hosted agent platform · architecture

Stage 3: Sandboxes

Tenant appsRuns API2 servers, 4% busyRun queueleases, 30 s1% busyAgent workerslease a run, replay its log36 servers, 59% busyRun event logPostgres, (run, seq)15% busyModel providerone account255% busySandbox fleetone microVM per runnew55 servers, 59% busy
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasional
Stage

Stage 3 of 6

Sandboxes

Why this stage

About 40% of runs execute code the model wrote, or drive a browser.

That code is untrusted and must never run on a worker that holds other tenants’ runs.

What we add

A sandbox fleet: one microVM per run, with CPU, memory and time limits, no network by default, destroyed when the run ends.

What it costs

About 1,700 sandboxes in use at peak, and a cold start of about 2 s unless a warm pool hides it.

At 17 runs started per second, Model providers is 255% busy. Stage 4 is the fix.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one run looks like

  • A run lasts about 4 minutes. It makes 15 model calls and about 10 tool calls.
  • Each step writes about 3 events to the database.

What one machine can do

  • A worker holds about 200 runs at once, because it mostly waits on the model. So one worker takes about 0.8 new runs a second.
  • One Postgres primary takes about 5,000 small writes a second.
  • A sandbox host runs about 50 microVMs.

Outside limits

  • One model provider account allows about 100 calls a second at our prompt sizes. The stage 5 gateway pools about 10 accounts across providers.

Why sandboxes add up

  • About 40% of runs keep a sandbox for their whole life. That means about 96 sandboxes open for each run started per second.
SandboxPool.javasandbox_pool.pySandboxPool.cssandbox_pool.gosandbox-pool.ts
// Stage 3: warm microVMs for one image in one cell. A run takes one, owns it for its whole
// life, and it is destroyed afterwards. A sandbox never serves a second run, so nothing one
// tenant's code leaves behind can reach another tenant.
class SandboxPool {
private final Vmm vmm;
private final String image;
private final int target; // about 140 per cell: ten times the peak start rate × 2 s
private final ConcurrentLinkedDeque<Sandbox> warm = new ConcurrentLinkedDeque<>();
SandboxPool(Vmm vmm, String image, int targetWarm) {
this.vmm = vmm;
this.image = image;
this.target = targetWarm;
}
Sandbox acquire(String tenantId, String runId, List<String> egress) {
Sandbox sb = warm.pollFirst();
if (sb == null) {
// Pool empty during a burst: pay the cold start rather than fail the step.
sb = vmm.boot(image);
}
// Bind only after leaving the pool. Warm sandboxes belong to nobody: no tenant label,
// no files, no network. The tenant's egress allowlist is opened here, for this run.
sb.configure(
Map.of("tenant", tenantId, "run", runId),
egress, // empty by default: no internet, no internal network
1, 2048, Duration.ofSeconds(60));
return sb;
}
void release(Sandbox sb) {
// Destroy, never recycle. Cleaning a used VM well enough to trust it with another
// tenant costs more than restoring a fresh one from a snapshot.
vmm.destroy(sb);
}
// Runs every second in the background, so cold starts happen before anyone waits.
void refill() {
for (int missing = target - warm.size(); missing > 0; missing--) {
warm.addLast(vmm.boot(image)); // restored from a memory snapshot: runtime loaded
}
}
}

A cold microVM takes about 2 seconds to be ready. So each region keeps a pool of ready ones, started from a saved memory image. A ready sandbox belongs to nobody until a run takes it.

When a run waits a day for an approval, its sandbox’s files are saved to object storage, and the VM is destroyed. A fresh one gets the files when the run resumes.

Go deeperWhy not containers?2 min
Option Boundary Start Fits when
Container Shares the host’s kernel Under a second Code you trust, from one tenant
gVisor-style kernel A kernel written as a normal program About a second Untrusted code, if some speed can be given up
MicroVM (Firecracker) Hardware virtualization, its own kernel About 2 s with our image Untrusted code from many tenants: our case
WebAssembly or V8 isolates The language runtime Milliseconds Small pure functions, not “run Python with pip”

A container escape needs one bug in the kernel, and every tenant on the machine shares that kernel. A microVM escape needs a bug in a small virtual machine monitor, a much smaller target. Firecracker was built for exactly this: AWS Lambda’s functions from many customers.

Interviewerminute 19

The agent needs to call the customer’s billing system with the customer’s credentials. How?

What they are testing

Whether credentials stay out of the prompt and the sandbox, and whether the allowed tools are enforced in code, not by asking the model nicely. An API key in the system prompt is the red flag. So is letting the model decide when an approval is needed.

Tenants offer their systems as MCP servers. (MCP is a standard way for agents to call tools.) Every tool call from every run now goes through one service, the tool gateway. It runs five checks in order:

Five checks on every tool call

A tool call from the model

Check 1: on this agent's allowed list?

Check 2: needs a person's approval? Then wait

Check 3: add a short-lived credential from the vault

Check 4: attach the key from the log

Check 5: cap the output at 8 KB, and mark it as data

A tool call from the model

Check 1: on this agent's allowed list?

Check 2: needs a person's approval? Then wait

Check 3: add a short-lived credential from the vault

Check 4: attach the key from the log

Check 5: cap the output at 8 KB, and mark it as data

Scroll or pinch to zoom, drag to pan
  • Color key
  • From the model
  • A check our code runs
  1. The allowed list. The tool must be on this agent version’s list. Nothing in a prompt can change it.
  2. Approval. For tools the tenant marked, the worker saves the exact arguments and parks the run.
  3. Credentials. A short-lived token from the secrets vault, for this tenant and this server, goes into this one call. It never enters a prompt, the log or a sandbox. A model talked into leaking it has nothing to leak.
  4. The key. The key from the log goes with the request.
  5. The output. It is capped at 8 KB, and wrapped so the model can tell data from instructions.

Built-in tools (code, browser) go through the same gateway to the run’s sandbox. So one place enforces the rules for every tool.

Hosted agent platform · architecture

Stage 4: Tool gateway

Tenant appsRuns API2 servers, 4% busyRun queueleases, 30 s1% busyAgent workerslease a run, replay its log36 servers, 59% busyRun event logPostgres, (run, seq)15% busyModel providerone account255% busyTool gatewayMCP, policy, approvalsnew2 servers, 9% busyTenant MCP serversoutside our networknewSandbox fleetone microVM per run55 servers, 59% busySecrets vaultper-tenant tokensnew
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasional
Stage

Stage 4 of 6

Tool gateway

Why this stage

Agents call tenants’ own systems through MCP servers, with the tenant’s credentials.

Those secrets must never enter a prompt or a sandbox, and some calls need a person to approve them.

What we add

A tool gateway that every tool call passes through: allowlist per agent version, approval rules, credentials from a per-tenant vault, idempotency keys, audit.

What it costs

A hop on every tool call (about 5 ms) and one more critical service.

Approvals turn runs into things that wait for days.

At 17 runs started per second, Model providers is 255% busy. Stage 5 is the fix.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one run looks like

  • A run lasts about 4 minutes. It makes 15 model calls and about 10 tool calls.
  • Each step writes about 3 events to the database.

What one machine can do

  • A worker holds about 200 runs at once, because it mostly waits on the model. So one worker takes about 0.8 new runs a second.
  • One Postgres primary takes about 5,000 small writes a second.
  • A sandbox host runs about 50 microVMs.

Outside limits

  • One model provider account allows about 100 calls a second at our prompt sizes. The stage 5 gateway pools about 10 accounts across providers.

Why sandboxes add up

  • About 40% of runs keep a sandbox for their whole life. That means about 96 sandboxes open for each run started per second.
ToolGateway.javatool_gateway.pyToolGateway.cstool_gateway.gotool-gateway.ts
// Part of an agent version, fixed when the tenant publishes it. Nothing in a prompt or a
// tool result can change it, so an injected instruction cannot widen what the agent may do.
record ToolPolicy(Set<String> allowed, Set<String> needsApproval) {}
// Stage 4: every tool call from every run passes through here, and nowhere else.
class ToolGateway {
private final McpRegistry servers;
private final Vault vault;
private final Audit audit;
ToolGateway(McpRegistry servers, Vault vault, Audit audit) {
this.servers = servers;
this.vault = vault;
this.audit = audit;
}
ToolResult call(RunContext run, String name, Map<String, Object> args, String key) {
ToolPolicy policy = run.agentVersion().policy();
if (!policy.allowed().contains(name)) {
// Goes back to the model as a tool error it can recover from, not as a crash.
return ToolResult.error("Tool " + name + " is not enabled for this agent.");
}
if (policy.needsApproval().contains(name) && !run.isApproved(key)) {
// The worker records ApprovalRequested and parks the run. Nothing waits in memory,
// and the approver sees these exact arguments, not the model's summary of them.
return ToolResult.needsApproval(name, args);
}
McpServer server = servers.pinned(run.tenantId(), name); // the version the tenant reviewed
// A short-lived token for this tenant and this server only. It travels in the header
// of this one call: never in a prompt, the event log, or a sandbox.
String token = vault.token(run.tenantId(), server.id(), server.scopes());
audit.record(run.tenantId(), run.runId(), name, args, key);
// MCP has no idempotency field, so the key rides in the request's _meta and an HTTP
// header. Tenants' servers that change things must honor it (see deep dive 1).
McpResult result = server.callTool(name, args, token, Map.of("idempotencyKey", key),
Duration.ofSeconds(30));
// Tool output is untrusted text bound for the next prompt: cap it, and fence it so
// the model can tell data from the tenant's instructions.
String text = result.text().substring(0, Math.min(8192, result.text().length()));
return new ToolResult(Fence.wrap(text), result.isError());
}
}

Our lab showed why the allowed list must be code. Over 18 tries with a document that hid orders to file a refund ticket, no ticket was filed, but one answer told the user it had been. What made the call impossible was the allowed list on a read-only task, not the model’s judgment.

An approval turns a run into something that waits for days, at no cost. The run is just a row in a table until the approval arrives:

An approval that takes a day
Run queueTenant appEvent logTool gatewayWorkerRun queueTenant appEvent logTool gatewayWorkera team lead approves the next morningissue_refund order 5512, 89.00, key run_8f2:24:0needs approvalsave ApprovalRequested with the exact argumentssave sandbox files, release lease, status waitingevent approval_requested, and a webhookPOST approval, save ApprovalGrantedqueue run_8f2any worker leases run_8f2issue_refund, same key, approvedrefundedRun queueTenant appEvent logTool gatewayWorkerRun queueTenant appEvent logTool gatewayWorkera team lead approves the next morningissue_refund order 5512, 89.00, key run_8f2:24:0needs approvalsave ApprovalRequested with the exact argumentssave sandbox files, release lease, status waitingevent approval_requested, and a webhookPOST approval, save ApprovalGrantedqueue run_8f2any worker leases run_8f2issue_refund, same key, approvedrefunded
Scroll or pinch to zoom, drag to pan
  • Color key
  • Queue or stream
  • Service
  • Background worker

This is enough for a mid-level answer. Runs as saved logs on workers, sandboxes for code, and one gateway for tools with credentials kept out of prompts. The rest of the page is what makes it a senior answer: proving the platform works, sharing it fairly, and the hard failure cases.

Go deeperFollow one step, the API, and the classes4 min

Follow one step of a run with Next hop:

Hosted agent platform · architecture

Trace a request: One step of a run

Tenant appsRuns APIRun queueleases, 30 sAgent workerslease a run, replay its logRun event logPostgres, (run, seq)Model providerone accountTool gatewayMCP, policy, approvalsTenant MCP serversoutside our networkSandbox fleetone microVM per runSecrets vaultper-tenant tokens
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasionalHop in motionHops doneResponse

One step of a run: step 1 of 6

Agent workers to Model providers

The worker holds the run’s lease. It sends the system prompt, the tool list and the earlier steps, about 8,000 tokens. The first token comes after about 0.5 s; 400 tokens later the model asks to call run_python.

4500 ms of a 8000 ms budget
  1. Agent workers → Model providers4500 ms
  2. Agent workers → Run event log5 ms
  3. Agent workers → Tool gateway5 ms
  4. Tool gateway → Sandbox fleet1200 ms
  5. Agent workers → Run event log5 ms
  6. Response
Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.

The platform’s own work is about 15 ms of the 5.7 second step. The rest is the model and the tool. That is why this page spends its effort on scheduling, cost and failure, not on milliseconds.

The API. Agents are settings, and runs start asynchronously.

POST /v1/runs
Authorization: Bearer <tenant-api-key>
Idempotency-Key: 4b0c-...
{ "agentId": "agt_31", "version": 7, "input": "Refund order 5512, it arrived broken",
"webhook": "https://acme.example/hooks/agent" }
202 Accepted
{ "runId": "run_8f2", "status": "queued", "events": "/v1/runs/run_8f2/events" }

202 means the work hasn’t happened yet. A retry with the same key returns the same run. A tenant over its limit of runs at once gets queued, not rejected. Only a very long queue returns 429.

GET /v1/runs/run_8f2/events?after=23
Accept: text/event-stream
event: approval_requested
data: {"seq":26,"approvalId":"apr_9","tool":"issue_refund","args":{"order":5512,"amount":89.0}}

The events are the log itself, so after=23 resumes a dropped stream with no gap and no repeat. Each agent version is fixed once created. Editing an agent never changes a run in progress. Every endpoint is scoped by the API key’s tenant, so there is no way to name another tenant’s run.

The core service after step 4

drives

1

*

pinned

1

*

RunWorker

+work(Lease)

DurableRun

-runId

-List<RunEvent> events

+advance() : bool

«value»

RunEvent

+seq

+type: EventType

+data

«enumeration»

EventType

RunCreated

ModelReplied

ToolCompleted

ApprovalRequested

ApprovalGranted

RunFinished

«interface»

EventLog

+read(runId)

+append(runId, seq, type, data)

PostgresEventLog

+append() : fails if seq taken

«interface»

ModelClient

+complete(messages) : Reply

ToolGateway

+call(run, name, args, key) : ToolResult

«value»

AgentVersion

+model

+system prompt

+budget

«value»

ToolPolicy

+allowed

+needsApproval

«interface»

Vault

+token(tenant, server, scopes)

SandboxPool

+acquire(tenant, run, egress) : Sandbox

+release(Sandbox)

Sandbox

+configure(labels, egress, limits)

drives

1

*

pinned

1

*

RunWorker

+work(Lease)

DurableRun

-runId

-List<RunEvent> events

+advance() : bool

«value»

RunEvent

+seq

+type: EventType

+data

«enumeration»

EventType

RunCreated

ModelReplied

ToolCompleted

ApprovalRequested

ApprovalGranted

RunFinished

«interface»

EventLog

+read(runId)

+append(runId, seq, type, data)

PostgresEventLog

+append() : fails if seq taken

«interface»

ModelClient

+complete(messages) : Reply

ToolGateway

+call(run, name, args, key) : ToolResult

«value»

AgentVersion

+model

+system prompt

+budget

«value»

ToolPolicy

+allowed

+needsApproval

«interface»

Vault

+token(tenant, server, scopes)

SandboxPool

+acquire(tenant, run, egress) : Sandbox

+release(Sandbox)

Sandbox

+configure(labels, egress, limits)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

DurableRun holds nothing that isn’t in its events, so any worker can load it. AgentVersion and ToolPolicy are fixed values, so a run can’t change its own permissions. And ToolGateway is the only path from a run to the outside world.

Interviewerminute 23

Your customers’ agents give wrong answers sometimes. Is that your problem? How do you know your platform is good?

What they are testing

Whether you separate the platform’s quality (did we run the agent faithfully, safely and on time?) from the agent’s quality (was the answer right?), and give tenants tools for the second. Promising to judge every tenant’s answers yourself is the red flag.

There are two kinds of quality, with different owners. The tenant owns whether its agent’s answers are right. The platform owns whether it ran the agent faithfully: every step saved, none repeated, no tool outside the rules, no tenant slowed by another, every token billed correctly.

Replay is the strongest test the platform has. The log records every input the loop saw: model replies and tool results. Feed them back in place of the real model and tools, and a correct new version of our code makes exactly the same decisions. Before every release, we replay 10,000 saved runs. If even one run takes a different path, the release doesn’t ship.

We also kill workers in the middle of 1,000 test runs, with a fake tool that counts keys. Every run must finish, and no key may arrive twice. And attack documents try to make agents call tools off their allowed list. Zero such calls may get through.

For the tenant’s quality, the platform gives tools, not verdicts. Each agent can have a test set (see evaluation) that runs before a new version or a new model goes live, scored next to the current one. The tenant decides.

Go deeperAll the release metrics1 min
Metric How we measure it Target Blocks a release?
Replay differences 10,000 saved runs replayed on the new code 0 runs differ Yes
Resume after a worker kill Kill workers mid-step, 1,000 runs in staging 100% finish Yes
Repeated side effects The same test, with a fake tool that counts keys 0 Yes
Tool rule escapes Attack documents asking for tools off the list 0 calls made Yes
Sandbox escape and network Known escape attempts and network probes 0 succeed Yes
Runs failed by us Failed runs whose last error is ours Under 0.1% Canary rollback
Platform time per step Trace time minus model and tool time, 99th percentile Under 150 ms Canary rollback
Billing accuracy Our token counts against provider invoices, daily Within 1% Alert

The design works for one tenant at a time. Now we push it through four hard situations: one tenant’s burst, a refund that could happen twice, a tenant trying to read another’s data, and a model provider going down.

Interviewerminute 27

One customer just started 3,000 runs at once, and your model provider is returning 429s. What happens to everyone else?

What they are testing

Whether you schedule by tenant, not by arrival, and treat provider limits as a shared budget to divide. “Autoscale the workers” is the red flag. Workers aren’t the scarce resource. Model capacity and slots are.

Two shared things need dividing: run slots and model capacity. Nothing so far stops one tenant from filling every slot. And 260 model calls a second is more than one provider account allows.

Step 5: fair scheduling and a model gateway. The run queue becomes a fair scheduler, with one queue per tenant. Each tenant has a share, set by its plan. A free slot goes to the tenant that is using the smallest part of its share. A tenant with idle share lends it to whoever has work, and gets it back as its own runs arrive. Try it with a batch job:

ABCSlots in use each minute, over 90 minutes. The line is the cell's capacity.

With one shared queue, A’s batch of 3,000 makes B and C wait about 21 minutes, even though both are inside their plans. A hard cap protects them, but holds A to 100 slots while others sit idle. Fair share gives B and C a wait of 1 to 2 seconds, and A borrows every idle slot.

TokenBucket.javafair_scheduler.pyTokenBucket.csfair_scheduler.gofair-scheduler.ts
// Model tokens per minute for one tenant. It refills continuously, so a quiet tenant can
// burst, but never by more than one minute's worth.
class TokenBucket {
private final double ratePerNano, cap;
private double level;
private long at = System.nanoTime();
TokenBucket(double perMinute) {
this.ratePerNano = perMinute / 60e9;
this.cap = perMinute;
this.level = perMinute;
}
private void refill() {
long now = System.nanoTime();
level = Math.min(cap, level + (now - at) * ratePerNano);
at = now;
}
boolean has(double n) {
refill();
return level >= n;
}
// Charged after each model call with the real count. It may go below zero, and then the
// tenant's next run waits until the bucket refills: overspending costs only them.
void spend(double n) {
refill();
level -= n;
}
}
class Tenant {
final String id;
final double weight; // from the plan: the share of the cell this tenant is guaranteed
final int maxRunning; // a hard ceiling, so even borrowing has a limit
final TokenBucket tokens;
final Deque<String> queue = new ArrayDeque<>();
int running;
Tenant(String id, double weight, int maxRunning, TokenBucket tokens) {
this.id = id;
this.weight = weight;
this.maxRunning = maxRunning;
this.tokens = tokens;
}
}
record Admitted(String tenantId, String runId) {}
// Stage 5: which tenant's run gets the next free slot. Not first come, first served: the
// tenant using the smallest part of its weight goes first. Idle capacity is lent to whoever
// has work, and comes back as soon as the owner's runs show up. One scheduler per cell owns
// this state, so nothing here needs a lock.
class FairScheduler {
private final Map<String, Tenant> tenants = new HashMap<>();
private final double tokensPerRun; // about 126,000: 15 calls × 8,400 tokens
FairScheduler(List<Tenant> all, double tokensPerRun) {
for (Tenant t : all) tenants.put(t.id, t);
this.tokensPerRun = tokensPerRun;
}
void submit(String tenantId, String runId) {
tenants.get(tenantId).queue.addLast(runId);
}
Optional<Admitted> nextRun() {
Tenant best = null;
for (Tenant t : tenants.values()) {
if (t.queue.isEmpty() || t.running >= t.maxRunning) continue;
if (!t.tokens.has(tokensPerRun)) continue; // out of budget: only its runs wait
if (best == null || t.running / t.weight < best.running / best.weight) best = t;
}
if (best == null) return Optional.empty();
best.running++;
return Optional.of(new Admitted(best.id, best.queue.pollFirst()));
}
void finished(String tenantId) {
tenants.get(tenantId).running--;
}
}

Then tokens. Each tenant has a token budget per minute and a monthly cap. A tenant that overspends waits. Nobody else does.

Then model capacity. A model gateway sits between workers and providers. It holds about ten provider accounts, tracks how much room each has left, and sends each call to one with room. When all are busy, it queues calls in the same fair order. Steps of runs already in flight go before first steps of new runs, because a half-finished run is holding a slot and a sandbox.

Hosted agent platform · architecture

Stage 5: Fair scheduling and a model gateway

Tenant appsRuns API2 servers, 4% busyFair schedulera queue per tenant1% busyAgent workerslease a run, replay its log36 servers, 59% busyRun event logPostgres, (run, seq)15% busyModel gatewayrate limits, fallbacknew2 servers, 6% busyModel providersseveral accounts26% busyTool gatewayMCP, policy, approvals2 servers, 9% busyTenant MCP serversoutside our networkSandbox fleetone microVM per run55 servers, 59% busySecrets vaultper-tenant tokensUsage and tracestokens, cost, spansnew
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasional
Stage

Stage 5 of 6

Fair scheduling and a model gateway

Why this stage

At peak the platform makes about 260 model calls/s and one provider account allows about 100.

One tenant’s batch of 3,000 runs can fill every slot.

What we add

A fair scheduler with a queue per tenant, weights from each plan, and token budgets; a model gateway that spreads calls over provider accounts and providers; a usage meter.

What it costs

Admission is no longer first come, first served, and fallback to another model changes behavior, so it is opt-in per agent.

At 17 runs started per second, every part has headroom. Raise the traffic until something turns red.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one run looks like

  • A run lasts about 4 minutes. It makes 15 model calls and about 10 tool calls.
  • Each step writes about 3 events to the database.

What one machine can do

  • A worker holds about 200 runs at once, because it mostly waits on the model. So one worker takes about 0.8 new runs a second.
  • One Postgres primary takes about 5,000 small writes a second.
  • A sandbox host runs about 50 microVMs.

Outside limits

  • One model provider account allows about 100 calls a second at our prompt sizes. The stage 5 gateway pools about 10 accounts across providers.

Why sandboxes add up

  • About 40% of runs keep a sandbox for their whole life. That means about 96 sandboxes open for each run started per second.

Prompt reuse is the biggest cost lever. About 80% of each call’s input is the same start the provider saw seconds ago. Providers charge much less for that, as little as a tenth. A run drops from about 45 cents to roughly 20 to 25. That reuse lives with one provider and region, so the gateway keeps each run on the same account, and moves it only on failure.

Step 6: cells per region. At ten times the load, the event log takes about 7,700 writes a second, too many for one primary. And EU tenants need their data and model calls to stay in the EU. So we split into cells: complete copies of the stack, each sized for about 20,000 runs in flight. Each tenant has one home cell in its region, and a thin global layer routes by tenant. A bad deploy also reaches one cell first, which limits the damage. Large or regulated tenants get a cell of their own.

Hosted agent platform · architecture

Stage 6: Cells per region

One cell per region, many cellsEach cell runs this whole stack.A tenant has one home cell; EU dataand model calls stay in EU cells.Tenant appsRuns API2 servers, 4% busyFair schedulera queue per tenant<1% busyAgent workerslease a run, replay its log36 servers, 59% busyRun event logpartitioned by run id2% busyModel gatewayrate limits, fallback2 servers, 6% busyModel providersseveral accounts6% busyTool gatewayMCP, policy, approvals2 servers, 9% busyTenant MCP serversoutside our networkSandbox fleetone microVM per run55 servers, 59% busySecrets vaultper-tenant tokensUsage and tracestokens, cost, spans
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasional
Stage

Stage 6 of 6

Cells per region

Why this stage

At 10× (170 runs/s), the event log takes about 7,700 writes/s, past one primary.

EU tenants require their data and model calls to stay in the EU.

What we add

The whole stack copied into cells.

A tenant lives in one home cell in its region; the event log is partitioned by run id inside each cell.

What it costs

A thin global routing layer, cross-cell moves for tenants that grow, and more fleets to deploy, one cell at a time.

At 17 runs started per second, every part has headroom. Raise the traffic until something turns red.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one run looks like

  • A run lasts about 4 minutes. It makes 15 model calls and about 10 tool calls.
  • Each step writes about 3 events to the database.

What one machine can do

  • A worker holds about 200 runs at once, because it mostly waits on the model. So one worker takes about 0.8 new runs a second.
  • One Postgres primary takes about 5,000 small writes a second.
  • A sandbox host runs about 50 microVMs.

Outside limits

  • One model provider account allows about 100 calls a second at our prompt sizes. The stage 5 gateway pools about 10 accounts across providers.

Why sandboxes add up

  • About 40% of runs keep a sandbox for their whole life. That means about 96 sandboxes open for each run started per second.
Interviewerminute 32

Your worker crashed right after the refund API returned success. The resumed run calls it again. Does the customer get two refunds?

What they are testing

Whether you know that no amount of saving closes this gap on its own, and that the fix needs the tool’s help. Claiming “exactly once” without a key on the tool side is the red flag.

There is always a moment after a tool has acted, and before its result is saved. After a crash there, retrying risks a duplicate. Not retrying risks a lost action.

The fix needs the tool’s help. The key comes from the log (run_8f2:24:0), so the crashed worker and the new one compute the same key without talking to each other. The tool stores keys it has seen. When the same key arrives again, it returns the first result instead of paying again. (See idempotency.)

The crash window, and how the key closes it
Worker 2Tool's key storeRefund toolWorker 1Worker 2Tool's key storeRefund toolWorker 1crash before ToolCompleted is savedrefund 89.00, key run_8f2:24:0save key, then payok, refund rf_77refund 89.00, key run_8f2:24:0save key fails, key existsstored result rf_77ok, refund rf_77, nothing paid twiceWorker 2Tool's key storeRefund toolWorker 1Worker 2Tool's key storeRefund toolWorker 1crash before ToolCompleted is savedrefund 89.00, key run_8f2:24:0save key, then payok, refund rf_77refund 89.00, key run_8f2:24:0save key fails, key existsstored result rf_77ok, refund rf_77, nothing paid twice
Scroll or pinch to zoom, drag to pan
  • Color key
  • Background worker
  • Database
  • Service

Some tools can’t store keys. The tenant marks them as such. After a crash, the platform doesn’t retry them. It saves the outcome as “unknown”, and tells the model to check before trying again. An honest “unknown” beats a silent second refund.

Interviewerminute 35

A malicious tenant writes an agent whose only goal is to read another tenant’s data. Walk me through what stops it.

What they are testing

Whether isolation holds at every layer (data, secrets, compute, network, model context), and whether each layer holds even if the one above fails. One answer (“we check the tenant ID”) is the red flag.

No single wall is enough. Each layer assumes the one above it might fail. This is called defense in depth:

Layer The attack What stops it
API and data Ask for another tenant’s run ID The tenant comes from the API key, never the request. The database checks it again
Secrets Get the agent to print a credential Credentials exist only in the vault and the gateway
Compute Escape the sandbox to the machine One microVM per run, never reused
Network Reach our internal services from a sandbox All outbound traffic goes through a proxy that blocks everything not allowed
Model context Get another tenant’s data through shared prompt reuse No prompt ever contains two tenants’ data
Tool servers A third-party tool server slips instructions into its description Servers pinned to the version the tenant reviewed
Noisy neighbor Use up slots, tokens or a shared tool server Fair scheduler, token budgets, limits per tenant at the gateway

The database’s second check catches the query someone forgot to scope. The network proxy catches code that escaped the language but not the VM. No single bug crosses tenants.

Interviewerminute 38

Your main provider is down for 40 minutes. 3,000 runs are halfway through. What do you do?

What they are testing

Whether you know that switching models mid-run changes the agent’s behavior, and that saved runs make waiting cheap. “Fail over to another model automatically”, with no word about behavior or the tenant’s consent, is the red flag.

Try these in order:

  1. Retry, waiting a little longer each time.
  2. The same model, from another account or region. The run continues as before. Prompt reuse is lost, so the next steps cost about twice as much.
  3. The tenant’s chosen backup model, if it listed one.
  4. Otherwise, park the run. It releases its slot and resumes when the provider recovers. Parking costs only time. The run is a row in a table, and it resumes at the step it stopped on.

A circuit breaker per provider stops calls to a failing endpoint, and lets test calls through later (see resilience).

Why not switch models silently? Because the tenant tested its agent on one model. Another model may call tools in a different order, ignore an instruction, or format arguments differently. A silent switch turns our outage into the tenant’s quality problem. So backup models are part of the agent’s settings, chosen by the tenant, and shown in the run’s trace.

Interviewerminute 41

Pick any box and break it. What do your customers see?

What they are testing

Whether every part has a story, and whether AI failures (a runaway agent, an injection, a model that changed) are on the list beside crashed servers.

Every failure should end in one of three states. The run continues somewhere else, the run waits, or the model is told what failed. A run should never just vanish, and an action should never happen twice.

Click any box to break it and see what tenants would notice:

Hosted agent platform · architecture

Break it: what fails, who notices, what users see

One cell per region, many cellsEach cell runs this whole stack.A tenant has one home cell; EU dataand model calls stay in EU cells.Tenant appsRuns APIFair schedulera queue per tenantAgent workerslease a run, replay its logRun event logpartitioned by run idModel gatewayrate limits, fallbackModel providersseveral accountsTool gatewayMCP, policy, approvalsTenant MCP serversoutside our networkSandbox fleetone microVM per runSecrets vaultper-tenant tokensUsage and tracestokens, cost, spans
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasional
Break

Failure: click any box to break it

An agent worker goes down

What breaks

Its 200 runs stop mid-step.

Their leases stop being renewed.

How we notice

Missed heartbeats; leases expire after 30 s.

What users see

A pause of about 30 s in those runs, then they continue.

Nothing is lost.

What we do

Other workers take the expired leases, replay each run’s event log and continue from the last completed step.

Tool calls in flight repeat with the same idempotency key.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.

Three failures are special to agents:

  • A runaway agent loops and retries, burning the tenant’s budget. Budgets on steps, tokens and cost per run, and detecting repeats, stop it.
  • A hidden instruction in a tool’s output makes the agent try something the task shouldn’t. The allowed list, approvals for writes, and marked outputs stop it.
  • A provider changes its model overnight, and agents behave differently. We pin model versions, and upgrades are opt-in, with the tenant’s tests.
Go deeperEvery failure, and what we do2 min
Part or failure What tenants see What we do
A worker crashes Its 200 runs pause about 30 s Another worker reads each log and continues. Keys make repeats safe
Event log primary down Every run in the cell pauses A replica takes over in about 30 s. Workers stop, never guess
A model provider is down Runs on that model slow down or park Other accounts and regions, the tenant’s backup model, else park
A sandbox machine is lost About 50 runs lose their sandbox A new sandbox from the pool, files restored from the last save
A tenant’s tool server down That tenant’s tool calls fail A breaker per server. The model gets an error. Other tenants unaffected
A whole cell down Its tenants’ runs pause Runs resume when it recovers. Dedicated tenants can fail over

Under overload, shed load in this order: delay new runs first, then batch runs, then interactive ones. Steps of runs in flight slow down last, because each holds a slot and a sandbox.

Here is everything we built, in the order we built it. Use the buttons to step through it.

Hosted agent platform · architecture

The full design, stage by stage

One cell per region, many cellsEach cell runs this whole stack.A tenant has one home cell; EU dataand model calls stay in EU cells.Tenant appsRuns API2 servers, 4% busyFair schedulera queue per tenant<1% busyAgent workerslease a run, replay its log36 servers, 59% busyRun event logpartitioned by run id2% busyModel gatewayrate limits, fallback2 servers, 6% busyModel providersseveral accounts6% busyTool gatewayMCP, policy, approvals2 servers, 9% busyTenant MCP serversoutside our networkSandbox fleetone microVM per run55 servers, 59% busySecrets vaultper-tenant tokensUsage and tracestokens, cost, spans
Color keyServiceDatabaseQueue or streamBackground workerModelNew in this stageAsync or occasional
Stage

Stage 6 of 6

Cells per region

Why this stage

At 10× (170 runs/s), the event log takes about 7,700 writes/s, past one primary.

EU tenants require their data and model calls to stay in the EU.

What we add

The whole stack copied into cells.

A tenant lives in one home cell in its region; the event log is partitioned by run id inside each cell.

What it costs

A thin global routing layer, cross-cell moves for tenants that grow, and more fleets to deploy, one cell at a time.

At 17 runs started per second, every part has headroom. Raise the traffic until something turns red.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.The whole design. Step back through the stages to rebuild it from memory.
The numbers behind the busy bars

What one run looks like

  • A run lasts about 4 minutes. It makes 15 model calls and about 10 tool calls.
  • Each step writes about 3 events to the database.

What one machine can do

  • A worker holds about 200 runs at once, because it mostly waits on the model. So one worker takes about 0.8 new runs a second.
  • One Postgres primary takes about 5,000 small writes a second.
  • A sandbox host runs about 50 microVMs.

Outside limits

  • One model provider account allows about 100 calls a second at our prompt sizes. The stage 5 gateway pools about 10 accounts across providers.

Why sandboxes add up

  • About 40% of runs keep a sandbox for their whole life. That means about 96 sandboxes open for each run started per second.
Decision We chose We gave up We’d choose differently if
What we own The loop, state, sandboxes, tools, budgets Tenants’ freedom to bring any loop Tenants were mostly framework authors
Run state Our own event log with numbered slots A ready-made workflow engine The team couldn’t own leases and recovery (Temporal)
Side effects Keys from the log. “Unknown” for the rest Silent retries Every tool were ours and safe to repeat
Running code A microVM per run, never reused, a ready pool Container speed and density Only one trusted tenant ran code
Tool access One gateway with fixed rules and a vault A hop per call, and a critical service Agents had no outside tools
Scheduling Fair share, with borrowing First come, first served There were a handful of tenants with their own capacity
Model capacity A gateway over accounts, sticky routing, parking Silent switches to other models Tenants asked for “any model, cheapest first”
Layout Cells per region One global pool No residency rules, and under 20,000 runs in flight
  • 0 to 6. Who writes the loop, how long runs last, whether agents run code. Then the line between what we own and what the tenant owns.
  • 6 to 11. The numbers: 4,200 runs in flight, 260 model calls a second, 45 cents a run, 1,700 sandboxes.
  • 11 to 23. Build it: the loop, the event log with resume, sandboxes, the tool gateway. A mid-level answer can stop here.
  • 23 to 27. Our quality against the agent’s quality. Replay as the release test.
  • 27 to 41. Stress it: fair scheduling, the double refund, isolation, the provider outage.
  • 41 to 45. Failures, then your own questions.

Never skip the event log, the keys, or the tool gateway.

Interviewer asksA run has 2,000 steps and a 300,000-token conversation. What breaks?

The prompt. Every step resends the whole conversation, so cost per step grows with the run, and eventually passes the model’s limit. The tenant can turn on summarizing, where older steps become one summary event, saved in the log like any other, so replay still works. Budgets per run stop runs that would grow forever.

Go deeperMore follow-up questions3 min

You deploy new code. What happens to runs that started on the old one? Events carry a format version, and the replay test ran the new code on saved runs before it shipped. So old logs load in new code. For a change that can’t replay old runs, the old code stays deployed until its runs finish.

A tenant cancels a run while a tool call is in flight. Cancel is an event saved to the log. The worker sees it at the next step and stops. The tool call already sent finishes, and its result is saved, because a request that may have acted can’t be pulled back. The sandbox is destroyed.

A tenant brings its own model API key. The model gateway uses that key for that tenant’s calls, so its limits are the tenant’s own. Budgets still apply. The key goes in the vault, like tool credentials.

How do you bill accurately when provider usage arrives a day later? Every model reply event stores the token counts the provider returned. So the log is the billing record, written at the moment of use. A daily job compares our sums with the invoices, and alerts on more than 1% difference.

One tenant is 30% of all traffic. Fair share still works, but that tenant shouldn’t share a cell. Give it its own cell, with its own provider capacity.

The agent’s code tries to reach the cloud’s internal address for machine credentials. It fails. All outbound traffic from a sandbox goes through a proxy that blocks everything except the tenant’s allowed hosts, and internal addresses are blocked even if a tenant tries to allow them. The attempt is logged, and repeated attempts flag the agent for review.

How does an approver know what they’re approving? The approval shows the tool and the exact arguments that will be sent, not the model’s description of them. It is tied to the call’s key, so approving one refund can’t authorize a different one.

What changes at 100 times the runs? More cells, not bigger ones: about 25 to 30. Model capacity becomes the hard limit, negotiated per region with several providers.