Agent in the sandbox¶
Since 0.8.0 (What's new in 0.8.0). More model providers and the timeout that stops the whole process tree, since 0.10.0 (What's new in 0.10.0).
sbx.agent runs a coding agent (OpenCode)
inside the sandbox itself. The agent sees the same filesystem, the same
commands and the same egress as any other code you run with
commands.run, and calls its model only through the
secrets gateway: the
agent uses the model credential, but can never read it.
Prefer to describe the agent in a file?
Since 0.14.0, Deploy your agent builds the image,
runs the agent and serves it as an HTTP service from a rayito-agent.toml,
with Strands, Hermes, LangGraph, deepagents, OpenCode or your own
framework. This page remains the reference for sbx.agent, the engine they
use underneath.
The normal way to use it is two calls: Sandbox.create(...) with the
model's gateway and sbx.agent.run(...) (section 2).
No pool and no warm-up is needed: the
fast startup (prefetch, pause between turns, pool with
warmup) is optional, and Which one do I use? says when each
option pays off. If the conversation can sit idle for more than 8 h, the
option is persist=.
Before you start (once per account and region, ≈ 20 min)
- Set up AWS done (≈ 15 min)
and
rayito-base-capspublished (caps variant). - The
rayito-agentimage:rayito agent template build --bucket <your-bucket>(≈ 5 min, ≈ $0.056/week per version; Agent templates). Whoever builds it needsRayitoTemplateBuilder(rayito stack deploy templates) or to be an administrator. - The model key in Secrets Manager (≈ $0.40/month) and
RayitoSecretsReaderfor whoever calls the SDK (rayito stack deploy secrets-access). - Access to the model at your provider (Agent providers).
The sandbox needs an image with the runtime installed (OpenCode in
/opt/agents). If opencode is not on the PATH, the run fails with
reason="runtime_missing". AgentTemplate builds that image (OpenCode,
ripgrep and, if you want, deepagents) on top of rayito-base-caps, the
only base that enforces closed egress: see
Agent templates. The
examples on this page use the rayito-agent image it builds.
1. The secret and the gateway¶
The model credential lives in Secrets Manager. One preset per provider
builds the SecretGateway with allow= already restricted:
| Preset (Python / TypeScript) | Upstream | Header | Allowed paths |
|---|---|---|---|
bedrock_gateway(secret, *, region, models, rate_per_minute=0) / bedrockGateway(secret, { region, models, ratePerMinute }) |
https://bedrock-runtime.<region>.amazonaws.com |
authorization |
POST /model/<id>/converse-stream and /converse, only for the given models |
anthropic_gateway(secret, *, rate_per_minute=0) / anthropicGateway(secret, { ratePerMinute }) |
https://api.anthropic.com |
x-api-key |
POST /v1/messages |
openai_compatible_gateway(secret, *, upstream, base_path="", rate_per_minute=0) / openaiCompatibleGateway(secret, { upstream, basePath, ratePerMinute }) |
the upstream you pass |
authorization |
POST <base_path>/chat/completions |
OpenAI, Gemini, Azure OpenAI, OpenRouter, Groq, Mistral, DeepSeek, xAI and a LiteLLM proxy have their own preset since 0.10.0: see Agent providers, which also says which one to choose, which have been tested with a real model and which subscriptions are not supported.
secret is the name of a secret (or a SecretRef). bedrock_gateway
never opens /model/*: that would let code inside the sandbox call any
model in the account, outside the SDK's token budget. It does not accept
ARNs (the gateway rejects an encoded / in the path): use the model or
inference profile id.
For Bedrock, the secret holds Bearer <Bedrock API key>. Use a short-lived
key (≤ 12 h) and rotate it with sbx.gateways.refresh(). For an
OpenAI-compatible API, also Bearer <key>; for Anthropic, the key as is.
rate_per_minute / ratePerMinute (0 by default, no cap) limits the calls
per minute the gateway lets through, including those the sandbox code makes
on its own outside the SDK's token budget (see
Security).
2. Normal startup: create the sandbox and run¶
This is all you need for most cases: a sandbox with closed egress, the
model's gateway and run().
from rayito import AgentModel, AgentSpec, Sandbox, SecretStore, bedrock_gateway
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
SecretStore().create("bedrock-key", "Bearer <short-lived Bedrock key>")
spec = AgentSpec(
model=AgentModel(
provider="bedrock",
id=MODEL_ID,
gateway="bedrock",
region="us-east-1",
),
instructions="Answer in English and do not modify files outside /home/user.",
)
with Sandbox.create(
template="rayito-agent",
allow_internet_access=False,
gateways={
"bedrock": bedrock_gateway("bedrock-key", region="us-east-1", models=[MODEL_ID], rate_per_minute=60),
},
) as sbx:
result = sbx.agent.run("List the files in /home/user and summarize what is there.", spec=spec)
print(result.text, result.steps, result.usage.total)
from rayito import AgentModel, AgentSpec, AsyncSandbox, bedrock_gateway
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
async def main() -> None:
spec = AgentSpec(
model=AgentModel(provider="bedrock", id=MODEL_ID, gateway="bedrock", region="us-east-1"),
)
async with await AsyncSandbox.create(
template="rayito-agent",
allow_internet_access=False,
gateways={
"bedrock": bedrock_gateway("bedrock-key", region="us-east-1", models=[MODEL_ID], rate_per_minute=60),
},
) as sbx:
result = await sbx.agent.run("Summarize the README.", spec=spec)
print(result.text)
import { AgentModel, AgentSpec, Sandbox, bedrockGateway } from "rayito";
const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
const spec = new AgentSpec({
model: new AgentModel({
provider: "bedrock",
id: MODEL_ID,
gateway: "bedrock",
region: "us-east-1",
}),
});
await using sbx = await Sandbox.create({
template: "rayito-agent",
allowInternetAccess: false,
gateways: {
bedrock: bedrockGateway("bedrock-key", { region: "us-east-1", models: [MODEL_ID], ratePerMinute: 60 }),
},
});
const result = await sbx.agent.run("Summarize the README.", { spec });
console.log(result.text, result.usage.total);
The first run() on a new VM takes a few seconds longer than the following
ones (it reads the runtime binary from the freshly restored disk); later
turns on the same VM no longer pay for it. Only if that matters to you, see
Fast startup.
AgentModel.gateway must name an entry of gateways=; if it does not
exist, run() raises InvalidArgumentException/InvalidArgumentError
before any call. The gateway lives on the handle that created it:
Sandbox.connect(sandbox_id) from another process does not recover it, so
agent.run() on that handle fails the same way. For several turns use the
same handle (sbx.pause() / sbx.connect(), option B) or,
beyond 8 h, persist=.
run() and stream() options¶
| Python | TypeScript | Default | What it does |
|---|---|---|---|
spec |
spec |
required | the AgentSpec (below) |
runtime |
runtime |
"opencode" |
"opencode", "deepagents" or a runtime object such as DeepAgents(...) |
session_id |
sessionId |
none (new session) | continues a session: the session_id of an earlier AgentResult |
model |
model |
spec.model.id |
another model id for this run, from the same provider and gateway (with bedrock_gateway, it has to be in its models) |
limits |
limits |
AgentLimits() |
step, token, time and output limits |
workdir |
workdir |
/home/user |
the agent's working directory |
reasoning |
reasoning |
False |
also emits Reasoning events |
| — | signal |
none | an AbortSignal that aborts the run |
AgentSpec¶
| Field (Python / TypeScript) | Default | What it does |
|---|---|---|
model |
required | AgentModel(provider, id, gateway, region=None, base_path="", prompt_caching=True) (TS: new AgentModel({ provider, id, gateway, region, basePath, promptCaching })). provider is bedrock, anthropic, openai (also xAI), google (Gemini), azure (Azure OpenAI) or openai-compatible; region is required on Bedrock; base_path only with openai-compatible |
small_model / smallModel |
the same model.id |
model for the runtime's auxiliary tasks (titles, summaries) |
instructions |
none | the agent's system instructions |
permissions |
AgentPermissions(default="allow") |
see Permissions |
agents |
{} |
subagents by name: SubAgent(description, instructions, model=None, permissions=None). build is reserved |
mcp |
{} |
MCP servers by name: McpLocal(command, envs={}, timeout_seconds=None) (TS: timeoutMs; a process inside the sandbox) or McpRemote(gateway, path="/") (always through a gateway). OpenCode only |
raw_config / rawConfig |
none | merged into OpenCode's configuration. The keys the SDK manages (provider, autoupdate, share, enabled_providers, model, small_model, mcp, agent, permission, instructions) raise InvalidArgumentException/InvalidArgumentError. OpenCode only |
runtime_version / runtimeVersion |
none | today it is only validated as text: it is not compared with the image version and never produces runtime_version_mismatch |
The names in agents and mcp are 1 to 64 characters [a-z0-9_-].
AgentSpec has no field for keys: the credential only arrives through the
gateway.
Other providers: change the preset and AgentModel.provider (bedrock,
anthropic, openai, google, azure or openai-compatible). The full
table, with each one's secret and AgentModel, is in
Agent providers.
If you do not know which one to use, Which one do I choose?.
With OpenAI-style APIs the gateway cannot limit the model: set the spending
cap at the provider
(Provider-side limits).
from rayito import AgentModel, anthropic_gateway, openai_compatible_gateway
anthropic = anthropic_gateway("anthropic-key")
anthropic_model = AgentModel(provider="anthropic", id="claude-haiku-4-5", gateway="anthropic")
openai = openai_compatible_gateway("openai-key", upstream="https://llm.example.com", base_path="/v1")
openai_model = AgentModel(
provider="openai-compatible", id="my-model", gateway="openai", base_path="/v1"
)
import { AgentModel, anthropicGateway, openaiCompatibleGateway } from "rayito";
const anthropic = anthropicGateway("anthropic-key");
const anthropicModel = new AgentModel({ provider: "anthropic", id: "claude-haiku-4-5", gateway: "anthropic" });
const openai = openaiCompatibleGateway("openai-key", { upstream: "https://llm.example.com", basePath: "/v1" });
const openaiModel = new AgentModel({
provider: "openai-compatible",
id: "my-model",
gateway: "openai",
basePath: "/v1",
});
console.log(anthropic, anthropicModel, openai, openaiModel);
Subagents, MCP servers and permissions¶
A SubAgent is an agent the main one can invoke; a local MCP server runs
as a child process inside the sandbox (argument list, no shell) and a
remote one is reached only through a gateway, which also has to be in
gateways=. McpLocal.envs is written into the runtime's configuration,
readable by the sandbox user: never put a credential there.
from rayito import AgentModel, AgentPermissions, AgentSpec, McpLocal, McpRemote, SubAgent
spec = AgentSpec(
model=AgentModel(
provider="bedrock",
id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
gateway="bedrock",
region="us-east-1",
),
agents={
"reviewer": SubAgent(
description="Reviews a diff and points out errors.",
instructions="Read only: do not edit files.",
permissions=AgentPermissions(tools={"edit": "deny"}),
),
},
mcp={
"fs": McpLocal(command=["node", "/opt/mcp/fs/index.js"], timeout_seconds=30),
"docs": McpRemote(gateway="docs-mcp", path="/mcp"),
},
permissions=AgentPermissions(tools={"bash": {"git *": "allow", "*": "deny"}}),
)
import { AgentModel, AgentPermissions, AgentSpec, McpLocal, McpRemote, SubAgent } from "rayito";
const spec = new AgentSpec({
model: new AgentModel({
provider: "bedrock",
id: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
gateway: "bedrock",
region: "us-east-1",
}),
agents: {
reviewer: new SubAgent({
description: "Reviews a diff and points out errors.",
instructions: "Read only: do not edit files.",
permissions: new AgentPermissions({ tools: { edit: "deny" } }),
}),
},
mcp: {
fs: new McpLocal({ command: ["node", "/opt/mcp/fs/index.js"], timeoutMs: 30_000 }),
docs: new McpRemote({ gateway: "docs-mcp", path: "/mcp" }),
},
permissions: new AgentPermissions({ tools: { bash: { "git *": "allow", "*": "deny" } } }),
});
console.log(spec.gatewayNames());
With this spec, the sandbox needs two gateways: bedrock and
docs-mcp (spec.gateway_names() / spec.gatewayNames() lists them). A
generic gateway is created with SecretGateway (see
Secrets gateway).
Cost and activation
- Activates: calling
sbx.agent.run(),stream()orprepare().sbx.agentis lazy: touching it makes no call. - AWS resources and calls: nothing new from Rayito. The runtime
calls the model (Bedrock
Converse/ConverseStream, or the provider's API) through the gateway. Each run makes 2-3 RPCs to the sandbox: write the configuration (skipped if its sha did not change),Startand the stdin with the prompt. - Approximate cost: the model dominates it, not the VM: in the reference example (10 steps of Haiku 4.5), the model costs between ≈ 7× and ≈ 21× the VM. Figures and formula in Pricing: cost of an agent.
- IAM: that of the gateway (
secretsmanager:GetSecretValueon the secret). - How to turn it off: do not call
sbx.agent. The rest of the sandbox does not change.
3. stream, sessions, abort and limits¶
run() returns an AgentResult (text, the assistant's last text;
session_id/sessionId, steps, usage (a TokenUsage with input,
output, reasoning, cache_read, cache_write and total),
exit_code/exitCode, tool_calls/toolCalls and
dropped_lines/droppedLines, the runtime lines that were discarded) or
raises AgentException/AgentError. stream() never raises because
of an agent failure: the last event is Done or AgentFailed. Sandbox or
transport errors do propagate. usage_reported/usageReported (also on
Done) is False if the agent did not report tokens (an app, a command or a
function of your own that returns no usage): usage is then zero because it
is unknown, not because nothing was spent.
Events (event.type): step_started (StepStarted), text (Text, the
full text of a part; OpenCode does not emit deltas), text_delta
(TextDelta, chunks of text in progress; deepagents only), reasoning
(Reasoning, only with reasoning=True), tool_call (ToolCall: name,
status, input and a trimmed output), step_finished (StepFinished,
with usage), agent_failed (AgentFailed, with reason) and done
(Done).
The AgentStream (AsyncAgentStream in async) has result(), which
consumes what is left and returns the AgentResult (or raises), abort(),
session_id/sessionId and dropped_lines/droppedLines; it is closed
with with/async with, close()/aclose() (Python) or close()
(TypeScript).
from rayito import AgentLimits, AgentSpec, Sandbox
def fix_tests(sbx: Sandbox, spec: AgentSpec) -> None:
with sbx.agent.stream(
"Fix the failing test.",
spec=spec,
limits=AgentLimits(max_steps=20, timeout_seconds=300),
) as s:
for event in s:
if event.type == "tool_call":
print(event.name, event.status)
result = s.result()
sbx.agent.run("Add a regression test.", spec=spec, session_id=result.session_id)
from rayito import AgentSpec, AsyncSandbox
async def fix_tests(sbx: AsyncSandbox, spec: AgentSpec) -> None:
async with await sbx.agent.stream("Fix the failing test.", spec=spec) as s:
async for event in s:
if event.type == "tool_call":
print(event.name, event.status)
result = await s.result()
print(result.session_id)
import { AgentLimits, type AgentSpec, type Sandbox } from "rayito";
export async function fixTests(sbx: Sandbox, spec: AgentSpec): Promise<void> {
const stream = await sbx.agent.stream("Fix the failing test.", {
spec,
limits: new AgentLimits({ maxSteps: 20, timeoutMs: 300_000 }),
});
for await (const event of stream) {
if (event.type === "tool_call") console.log(event.name, event.status);
}
const result = await stream.result();
const controller = new AbortController();
setTimeout(() => controller.abort(), 60_000);
await sbx.agent.run("Add a regression test.", {
spec,
sessionId: result.sessionId,
signal: controller.signal,
});
}
abort() (Python, also from another thread), cancelling the asyncio
task or an AbortSignal kill the runtime process and everything it
launched. The result is reason="aborted".
A sandbox runs a single agent execution at a time: a second run() while
another is still in progress ends with reason="busy".
| Limit (Python / TypeScript) | Default | What it does |
|---|---|---|
timeout_seconds / timeoutMs |
600 s | deadline of the runtime process; required because, on a model 5xx, OpenCode retries forever. When it expires: timeout |
max_steps / maxSteps |
50 | the stream ends with max_steps when the next step starts |
max_total_tokens / maxTotalTokens |
1,000,000 (None/null turns it off) |
checked after each StepFinished (it can overshoot by up to one step); the stream ends with token_budget |
max_output_bytes / maxOutputBytes |
16 MiB | cap on the runtime output the SDK keeps; exceeding it does not cut the run or produce output_limit (that reason exists, but is not emitted today) |
When max_steps or max_total_tokens is exceeded the SDK stops the
runtime just like abort() and waits for it to finish: the stream ends
with that reason and the next run() does not find it busy (busy).
What the timeout and abort() guarantee¶
Since 0.10.0, with a rayd 0.10.0 image.
OpenCode and deepagents run each command of their shell tool in a session
of its own (setsid), outside the runtime process's group, and a command
can also daemonize itself (a dev server, a nohup ... &). That is why each
run starts as a command with kill_tree
(Commands): rayd makes the runtime a child
subreaper of everything it launches, so no descendant escapes it even if
its parent exits.
- Timeout (
timeout_seconds/timeoutMs):raydsendsSIGTERMto the runtime and all its descendants; if any is left after 5 s, it freezes (SIGSTOP) the whole tree and kills it (SIGKILL). The stream ends withreason="timeout"only when none is left, so the nextrun()does not find the agentbusy. abort()and the SDK limits (max_steps,max_total_tokens):raydfreezes the tree, kills the descendants and then the runtime, with no grace period.- Only processes in that run's tree are signaled: nothing from the rest of
the sandbox (other commands, the PTY, the
run_codekernel). - A run that ends by itself (
Doneor a runtime failure) does not kill what it left running: if the agent started a server on purpose, it stays alive. - With an image whose
raydis older than 0.10.0, the timeout andabort()only reach the runtime's group: what the shell tool launched in its own session survives and the nextrun()can find the agentbusy. Rebuild the image withAgentTemplatefrom your SDK's version;rayito doctorgives FAIL oncompatibilityif the image'sraydis older than the SDK's series.
The possible reason values are in
Errors. No message
carries the provider's text, the prompt or sandbox content.
Use the agent from another process¶
One process can create the sandbox with gateways= and another (a worker,
an API, a scheduled task) drive the agent with Sandbox.connect(), without
recreating it. That other process needs the sandbox's access token: the
sbx.access_token (sbx.accessToken in TypeScript) saved by whoever
created it, passed as access_token= (accessToken) or in
RAYITO_ACCESS_TOKEN; without it, connect() raises
AuthenticationException (AuthenticationError). connect() asks rayd
for a read-only ConfigureStatus and rebuilds sbx.gateways: the name,
port and last error of each route. No secret leaves rayd: not the
upstream, not the headers, not their values.
import os
from rayito import AgentModel, AgentSpec, Sandbox
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
spec = AgentSpec(
model=AgentModel(provider="bedrock", id=MODEL_ID, gateway="bedrock", region="us-east-1"),
)
sbx = Sandbox.connect(
"microvm-00000000-0000-0000-0000-000000000001",
access_token=os.environ["RAYITO_ACCESS_TOKEN"],
)
print(sbx.gateways["bedrock"].url)
result = sbx.agent.run("Summarize the README.", spec=spec)
import os
from rayito import AgentModel, AgentSpec, AsyncSandbox
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
async def main() -> None:
spec = AgentSpec(
model=AgentModel(provider="bedrock", id=MODEL_ID, gateway="bedrock", region="us-east-1"),
)
sbx = await AsyncSandbox.connect(
"microvm-00000000-0000-0000-0000-000000000001",
access_token=os.environ["RAYITO_ACCESS_TOKEN"],
)
result = await sbx.agent.run("Summarize the README.", spec=spec)
print(result.text)
import { AgentModel, AgentSpec, Sandbox } from "rayito";
const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
const spec = new AgentSpec({
model: new AgentModel({ provider: "bedrock", id: MODEL_ID, gateway: "bedrock", region: "us-east-1" }),
});
const accessToken = process.env.RAYITO_ACCESS_TOKEN;
if (accessToken === undefined) {
throw new Error("RAYITO_ACCESS_TOKEN is missing");
}
const sbx = await Sandbox.connect("microvm-00000000-0000-0000-0000-000000000001", {
accessToken,
});
console.log(sbx.gateways.get("bedrock")?.url);
const result = await sbx.agent.run("Summarize the README.", { spec });
Without the gateway definition there is nothing to rotate: on a recovered
handle, sbx.gateways.refresh() only rereads the state (ports and errors).
Rotating the key, with refresh() or with reincarnate(), is only
possible from the handle that called create(gateways=): on a recovered
handle, reincarnate() raises because it does not know the original
definition. The handle that created the sandbox keeps its own even if it
calls connect() again; a recovered one rereads the state on every
connect(), with the same request_timeout (requestTimeoutMs in
TypeScript) as that call. On an agent without the secret_gateway
feature, sbx.gateways stays empty and connect() makes no extra call. If
rayd rejects the token on that read, connect() does not fail:
sbx.gateways stays empty and the error shows up on the first
authenticated call, as with any other connect().
Permissions: they are not a security boundary¶
AgentPermissions(default="allow"|"deny", tools={...}) decides which tools
the agent can use. A tools value is an action or a map of patterns (for
example, {"bash": {"git *": "allow", "*": "deny"}}). "ask" is not
valid: a run without a terminal cannot answer. By default question,
webfetch and websearch are denied; an entry of yours in tools wins.
With deepagents only the tools read, edit, list, glob, grep,
bash, task and todowrite exist, and only bash accepts patterns.
But the runtime runs whatever the model asks within what is allowed,
without asking. The real boundary is the MicroVM plus closed egress
(allow_internet_access=False), just as for any code in the sandbox.
Sandbox.create leaves egress open by default and the SDK does not
warn if the agent runs like that: pass allow_internet_access=False
yourself. Risks and mitigations in
Security.
Fast startup¶
Everything in this section is optional. A freshly restored VM reads
each page of code slowly the first time, during a window that ranges from
about 10 to over 20 s depending on the batch (Q142, Q155): OpenCode's first
exec after create() pays for that read, and a new VM's first token
arrives in ≈ 12-24 s median depending on the batch (tables below). For
most cases no pool is needed: that cost is paid once per conversation,
and the following turns run on the same VM.
Which one do I use?¶
- Does the conversation last less than 8 h from
create()? (the platform cap counts launched time plus suspended time) → one VM per conversation, paused between turns (B). Create once, pause at the end of each turn and resume withconnect()(≈ 3 s to the first token, Q149). It only costs one suspend/resume cycle per pause and the snapshot storage while paused (Pricing). With no code to write: the default auto-suspend (IdlePolicy(max_idle_seconds=300, auto_resume=True)) already suspends it after 300 s without traffic, and the SDK's next call (agent.runincluded) wakes it on its own. You only have to create it with atimeoutthat covers the conversation (3600 s by default, cap 8 h). - Can it sit idle for more than 8 h? → save its
HOMEto S3 with persistence (persist=) and, when the user comes back, create another VM with the samepersist=: it restores theHOME, and with it the OpenCode sessions, sosession_idis still valid. You pay for the cold firstexecagain (example). - Do many new conversations arrive and does their first message have
to be fast? → a pool with
warmup(C): the first token arrives ≈ 5 s after the take, in exchange for a fixed cost per idle slot per month (Pricing; example). - None of the above? → no pool:
Sandbox.create(...)andsbx.agent.run(...), the normal startup.
A multi-turn conversation on the same VM (B):
from rayito import AgentModel, AgentSpec, Sandbox, bedrock_gateway
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
spec = AgentSpec(model=AgentModel(provider="bedrock", id=MODEL_ID, gateway="bedrock", region="us-east-1"))
sbx = Sandbox.create(
template="rayito-agent",
timeout=8 * 3600, # (1)!
allow_internet_access=False,
gateways={"bedrock": bedrock_gateway("bedrock-key", region="us-east-1", models=[MODEL_ID])},
)
try:
first = sbx.agent.run("Read the README and summarize the project.", spec=spec)
sbx.pause() # (2)!
sbx.connect() # (3)!
second = sbx.agent.run("Now suggest three improvements.", spec=spec, session_id=first.session_id)
print(second.text)
finally:
sbx.kill()
- The cap: 8 h launched + suspended.
- Between turns; without this line, auto-suspend does it after 300 s without traffic.
- ≈ 3 s to the next turn's first token. It resumes this handle,
which keeps the gateway; a
Sandbox.connect(sandbox_id)from another process does not recover it andagent.run()would fail.
import { AgentModel, AgentSpec, Sandbox, bedrockGateway } from "rayito";
const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
const spec = new AgentSpec({
model: new AgentModel({ provider: "bedrock", id: MODEL_ID, gateway: "bedrock", region: "us-east-1" }),
});
const sbx = await Sandbox.create({
template: "rayito-agent",
timeoutMs: 8 * 3600 * 1000, // the cap: 8 h launched + suspended
allowInternetAccess: false,
gateways: { bedrock: bedrockGateway("bedrock-key", { region: "us-east-1", models: [MODEL_ID] }) },
});
try {
const first = await sbx.agent.run("Read the README and summarize the project.", { spec });
await sbx.pause(); // without this line, auto-suspend does it after 300 s without traffic
await sbx.connect(); // ≈ 3 s to the first token; resumes this handle, which keeps the gateway
const second = await sbx.agent.run("Now suggest three improvements.", { spec, sessionId: first.sessionId });
console.log(second.text);
} finally {
await sbx.kill();
}
More than 8 h idle: persist=¶
The HOME (with the agent's sessions) goes to S3 and comes back in the
next VM. Each turn creates a new VM with the same persist= and the
gateway, and saves the HOME before releasing it:
import os
from rayito import AgentSpec, S3Prefix, Sandbox, bedrock_gateway
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
role = os.environ["RAYITO_EXECUTION_ROLE_ARN"]
home = S3Prefix("amzn-s3-demo-bucket", prefix="rayito-home", name="conversation-42")
def turn(spec: AgentSpec, prompt: str, session_id: str | None) -> str:
with Sandbox.create( # (1)!
"rayito-agent",
execution_role_arn=role,
persist=home,
allow_internet_access=False,
gateways={"bedrock": bedrock_gateway("bedrock-key", region="us-east-1", models=[MODEL_ID])},
) as sbx:
result = sbx.agent.run(prompt, spec=spec, session_id=session_id)
sbx.checkpoint_files() # (2)!
return result.session_id
- Restores the saved
HOME, if there is one. The image needs the persistence capabilities (AgentTemplatestarts fromrayito-base-caps) and an execution role with access to the prefix. s3://amzn-s3-demo-bucket/rayito-home/conversation-42/home.tar.gz.
import { type AgentSpec, S3Prefix, Sandbox, bedrockGateway } from "rayito";
const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
const executionRoleArn = process.env.RAYITO_EXECUTION_ROLE_ARN ?? "";
const persist = new S3Prefix({ bucket: "amzn-s3-demo-bucket", prefix: "rayito-home", name: "conversation-42" });
export async function turn(spec: AgentSpec, prompt: string, sessionId?: string): Promise<string> {
await using sbx = await Sandbox.create({
template: "rayito-agent",
executionRoleArn,
persist,
allowInternetAccess: false,
gateways: { bedrock: bedrockGateway("bedrock-key", { region: "us-east-1", models: [MODEL_ID] }) },
});
const result = await sbx.agent.run(prompt, { spec, sessionId });
await sbx.checkpointFiles(); // the HOME, with the agent's sessions, to S3
return result.sessionId;
}
Agent pool (C)¶
Only if many new conversations arrive whose first message has to be
fast. The slot is parked without a gateway (warm slots never carry
secrets): the gateway is opened when it is taken, with
pool.take(gateways=...). Sandbox.create(pool=...) does not accept
gateways=, so in an agent pool take() is used directly.
from rayito import AgentModel, AgentSpec, PoolConfig, SandboxPool, agent_pool_warmup, bedrock_gateway
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
spec = AgentSpec(model=AgentModel(provider="bedrock", id=MODEL_ID, gateway="bedrock", region="us-east-1"))
config = PoolConfig(
size=2,
template="rayito-agent",
allow_internet_access=False,
warmup=agent_pool_warmup("opencode"),
)
with SandboxPool(config) as pool:
gateway = bedrock_gateway("bedrock-key", region="us-east-1", models=[MODEL_ID])
with pool.take(gateways={"bedrock": gateway}) as sbx:
print(sbx.agent.run("Summarize the README.", spec=spec).text)
import { AgentModel, AgentSpec, SandboxPool, agentPoolWarmup, bedrockGateway } from "rayito";
const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
const spec = new AgentSpec({
model: new AgentModel({ provider: "bedrock", id: MODEL_ID, gateway: "bedrock", region: "us-east-1" }),
});
await using pool = await new SandboxPool({
size: 2,
template: "rayito-agent",
allowInternetAccess: false,
warmup: agentPoolWarmup("opencode"),
}).start();
await using sbx = await pool.take({
gateways: { bedrock: bedrockGateway("bedrock-key", { region: "us-east-1", models: [MODEL_ID] }) },
});
console.log((await sbx.agent.run("Summarize the README.", { spec })).text);
Details of the warm-up and its per-slot cost in Pool: warm-up.
The options¶
They can be combined (a pool with warmup on an image with prefetch, for
example). The amounts for each are in
Pricing: VM cost, with fast-start.
| Option | What it does | When to use it | Cost above the VM |
|---|---|---|---|
| A. Prefetch | AgentTemplate(prefetch=True) (the default) bakes OpenCode's binary into the memory snapshot and a daemon that, after each restore, brings it into the page cache; in addition, the template does not warm the run_code kernel (kernel_warmup=False). On any image, sbx.agent.prepare() does the warm-up by hand |
whenever you build the image with AgentTemplate; it shortens the first turn of each new VM |
≈ $0: the memory snapshot ends up smaller than before |
B. pause() between turns |
the VM is suspended with everything in memory and connect() (or any call, with auto-resume) resumes it |
the general case: a conversation of less than 8 h from create() |
one suspend/resume cycle per pause + storage while paused |
C. Pool with warmup |
PoolConfig(warmup=agent_pool_warmup("opencode")) runs the binary in each slot before parking it |
only if many new conversations arrive and the first message has to be fast | a bit more than a base pool slot, per idle slot per month |
| No pool | Sandbox.create(...) and sbx.agent.run(...), no warm-up |
new conversations whose first message can take as long as a new VM | nothing besides the VM |
Each run, in addition, costs its VM (launch plus the seconds of compute until the answer) and its model; the breakdown per scenario is in Pricing.
Measured on real AWS (2026-10-07, us-east-1, Claude Haiku 4.5, n=5)
Time to first token of a short answer, with egress closed and the model
through bedrock_gateway (AWS_API_NOTES.md Q146-Q150, and Q153 for
the row after A's fix):
| Scenario | create() / take p50 |
First token p50 | First token p95 |
|---|---|---|---|
create() without prefetch |
8.7 s | 28.3 s | 41.1 s |
| A, before the fix (the daemon read during startup) | 16.6 s | 20.5 s | 37.0 s |
| without prefetch, in the same batch as the next row | 8.8 s | 13.3 s | 19.5 s |
| A, with the daemon that waits for the guest to settle | 9.2 s | 14.7 s | 15.5 s |
B: connect() after pause() |
0.55 s | 3.1 s | 3.2 s |
C: pool.take() with warmup |
0.95 s | 5.1 s | 6.7 s |
- A: the daemon cuts the stretch after
create()from 19.6 s to 4.7 s (−76 %), but in its first version it read right after restore and competed with the startup thatcreate()waits for, socreate()took ≈ 8 s more and end to end only 28 % was gained. Now it waits for the guest to have 1 s without I/O in progress before reading, andcreate()no longer grows (9.2 s versus 8.8 s without prefetch in the same batch). The stretch aftercreate()drops from 5.2 s to 3.8 s, but end to end the difference (14.7 s versus 13.3 s) falls within the noise of n=5: the no-prefetch control came out much faster than in the first measurement (13.3 s versus 28.3 s), so today A's gain is small. It stays on because it no longer costs anything. - deepagents is not cached with a short prompt: its one-word prompt
is ≈ 3,503 input tokens, below the 4,096 that Haiku requires for a
cache point, so each turn pays for all of them (≈ 4 times what
OpenCode's turn costs, which reads 7,596 from cache;
Pricing). After
create(), its first token arrives in 7.5 s median versus 5.0 s for OpenCode on the same image.
Measured on real AWS (2026-10-09, us-east-1, Claude Haiku 4.5, n=6)
The same test, with each phase timed (AWS_API_NOTES.md Q155 and Q156;
images interleaved, after two disposable launches per version). With
n=6, the last column is the maximum:
| Batch | AgentTemplate image |
create() p50 |
First token p50 | First token max. | Memory snapshot |
|---|---|---|---|---|---|
| 1 | the previous one (warmed kernel) | 12.0 s | 16.4 s | 23.1 s | 920 MB |
| 1 | without warming the kernel | 7.65 s | 16.3 s | 28.8 s | 671 MB |
| 2 | without warming the kernel | 8.8 s | 15.6 s | 21.2 s | 671 MB |
| 2 | without warming the kernel, with the binary and deepagents baked in (trial) | 6.3 s | 12.4 s | 13.6 s | 1,030 MB |
| 3 | the previous one (warmed kernel; n=4) | 19.1 s | 23.7 s | 27.0 s | 920 MB |
| 3 | the current one: without warming the kernel, with the binary baked in | 6.4 s | 20.9 s | 34.9 s | 858 MB |
- Where the time goes: on the client, resolving the ARN of an image
name ≈ 1 s (only the process's first
create(); pass the ARN to save it),run-microvm≈ 0.5 s, the gateway secret ≈ 0.35 s,Configure≈ 0.7 s and writing OpenCode's configuration ≈ 0.2 s. OpenCode starts in ≈ 1.3 s with a warm cache, and a second turn on the same VM gives the first token in ≈ 3 s. The rest is the slow window after restore. - Removing the kernel warm-up brings
create()forward (4.4 s median in batch 1, 12.7 s in batch 3) but, by itself, not the first token: OpenCode's firstexecpays the window the kernel used to pay. - Baking the binary into the snapshot brought the median first token
forward against the control of its own batch (−3.2 s in 2, −2.8 s in
3), but the dispersion is large and the slow window changes a lot from
one batch to another (the previous image went from 16.4 s to 23.7 s):
with n=6 it is not a guaranteed improvement. What is certain is that
create()returns sooner and that the memory snapshot weighs 62 MB less than before. - Reading right after restore, without waiting for the guest to
settle, still does not work:
create()15-26 s.
The first launches of a new version are slow
Right after publishing an image version, its first launches take much
longer: in Q146 the first 3 of version 1 took 48-65 s in create(),
and the first 5 of version 2, 26-63 s, versus 6.9-12.5 s for the
following ones (without prefetch). Before measuring or sending it
traffic, warm it with a few disposable launches or with a pool.
Without the daemon (for example, on an image of your own without
AgentTemplate), prepare() triggers the same warm-up by hand:
prepare(runtime="opencode") (TS: prepare({ runtime })) launches the
warm-up steps in the background and returns right away, without waiting
for any of them.
deepagents¶
The default runtime is OpenCode. With runtime="deepagents" (or
runtime=DeepAgents(...)) the same run/stream runs a
deepagents (LangChain) graph
in a runner isolated from the image's venv (AgentTemplate with
deepagents): the same events (plus TextDelta), the same SDK limits and
the model only through the gateway. Without entrypoint, the runner
assembles create_deep_agent with the AgentSpec's model, instructions
and subagents; with entrypoint="package.module:build" it imports your
function from the working directory and passes it a RunnerContext (model,
instructions, subagents, backend, permissions middleware, working
directory) so it returns your compiled graph. mcp and raw_config do not
exist in this runtime and fail before any call. Its session_id values
have the form rda_<32 hex>.
import { type AgentSpec, DeepAgents, type Sandbox } from "rayito";
export async function runDeepAgents(sbx: Sandbox, spec: AgentSpec): Promise<string> {
const result = await sbx.agent.run("Summarize the README.", {
spec,
runtime: new DeepAgents({ entrypoint: "my_agent.graph:build" }),
});
return result.text;
}
deepagents supports the same providers as OpenCode. Since 0.10.0 the agent
image ships socksio (without it, any model other than Bedrock failed at
startup with AgentFailed and ImportError) and, with
provider="openai-compatible", the runner requests token usage in
streaming, so result.usage and max_total_tokens work there too. Both
things need an image built with AgentTemplate from 0.10.0.
If your build does not pass ctx.middleware to the graph, the
AgentSpec permissions are lost. With a short prompt, deepagents does not
reach Haiku's prompt-cache minimum (see Fast startup).
Build the image from the CLI¶
The CLI does not run the agent or manage pools (that is the SDK's job); it does build the image:
Options (--name, --base, --memory-mb, --no-deepagents,
--no-prefetch, --force, --timeout) in
Agent templates.
To describe the agent in a file and build, run or deploy it from the CLI,
see rayito agent init, build, run, deploy, invoke, status, stop
and export in Deploy your agent.
The E2B shim¶
rayito.e2b.Sandbox has no .agent, because E2B does not define this
surface, and it does not accept gateways=. To use the agent, create the
sandbox with the native SDK (rayito.Sandbox.create(..., gateways=...), as
in section 2). The native
handle of a shim sandbox (sbx.native.agent) does not work: it has no
gateway, so agent.run() fails with InvalidArgumentException before any
call.