Skip to main content
Every session is an agent sandbox that already has a shell, files, git and a real browser the agent can open pages in, click through and screenshot. Nothing here is needed for those. This page is about the rest: what the agent may reach beyond its sandbox, and which of the built-in network tools it gets. Three kinds, and they differ in where the code runs. Everything else the agent can do — shell, reading and writing files, starting services, publishing a preview, committing to a repository — is always present and is not configured here. You are choosing what it can reach outside the workspace, not what it can do inside one. Declare them in agent.tools, on POST /v1/sessions. The agent has them from its first turn, which is what every example below does. PUT /v1/sessions/{session_id}/tools can change the configuration of a session that already exists — see Does PUT reach a running session? before you rely on it for anything the current turn needs, because the answer is no. At most 32 tools per session; each name is 1–64 characters.

Built-in tools

The sandbox already has these. Naming one is the whole request — they take no other field:
Naming any of them narrows the session to exactly those. Say nothing and the session gets whatever this deployment allows, which is what every session got before you could choose. Say "tools": [] and it gets none of them — no way out to the network at all, which is the shape for running code you have not read. You can only narrow. A tool the deployment has turned off does not come back by being named in a request body. If it did, the operator switch would be decoration, and the one thing it exists for — running untrusted work with no route to the internet — is exactly what it could no longer guarantee. So the list you get is the intersection, and reading the configuration back tells you what you actually have rather than what you asked for.

Function tools

You declare the shape; the agent calls it; the turn stops until you answer.
When the agent calls it, the session reports a required action and waits. Answering is required-actions.md; the loop is written for you in run-session.ts. A turn waiting on you is still in flight. The workspace is not reclaimed out from under it, however long you take. There is no deadline on the answer unless you set one — timeout_seconds on the tool, see required-actions.md — and the only ceiling either way is the workspace’s two hours, in limits.md.

MCP servers

Exactly one of url or command:
  • url — a server already running, which the sandbox connects out to.
  • command — a process started inside the sandbox, spoken to over stdio.
The connection is made from the sandbox, not from us. Your tool traffic does not pass through our control plane — which is what lets an MCP server on your private network be reachable at all, and keeps our egress out of your blast radius. A server reachable only from your VPC works if the sandbox is in it; one behind your office firewall does not.

What the sandbox can run

If you use command, this is what is already there to start: So the two commonest MCP distribution shapes — npx -y <package> and uvx <package> — work with nothing installed first.

A stdio server has no way to receive a secret, yet

Every MCP client in the ecosystem hands a stdio server its credentials through the environment. This one cannot: the tool object has no env field, and there is nowhere else to put a value a command process would see as a variable.
That starts — the binaries above are all there — but it starts with no credentials, and a server that requires one over the environment cannot be configured through /v1 at all yet. Until env exists, a server of that shape needs a remote HTTP endpoint that accepts a header instead, so its secret goes in authorization or headers on a url entry rather than command.

When a server cannot be reached

By default it is skipped, and the agent simply has fewer tools. Nothing fails. The agent will usually say it cannot do the thing you asked — which looks like the model being unhelpful, when the real cause is a server that never answered. That default is deliberate: one optional server being down should not destroy a session that could still do most of its work. But it is only right when the server really is optional. You will be told, though. A skipped server produces an mcp_unavailable item in the session’s transcript and an mcp.unavailable event on the stream:
So when an agent says it could not do something, check the items before you conclude the model was weak — that is the mistake this item exists to prevent. If the session is pointless without that server, say so:
With required: true the session fails loudly instead of running crippled, and you get an error rather than a confused answer. It arrives on the first call that needs the workspace — sending a message, writing a file — and it names the server and what went wrong:
invalid_request rather than a retryable code, deliberately: waiting will not make the server reachable. This is a configuration to change, not a state to wait out.
Choose deliberately. required: false and a broken URL is the one combination that produces no signal anywhere a caller can see it. If you are not sure, required: true is the safer default for anything the task actually depends on.
required must be true or false. A string — "required": "yes" — is refused rather than read as false, because false is a real choice here and not one you would have made by writing the word required.

Confirm the configuration took

POST /v1/sessions answers 201, and its agent object does not include tools at all — so a 201 is not evidence your server was configured, only that the request was accepted. Read it back:
The shape, and what redacted means, is in Reading the configuration back below.

Confirm it was actually used

A completed turn is not a tool call, and a model that cannot reach a server still produces a plausible-looking answer — it knows what that answer is supposed to look like, and says it anyway. Check rather than trust:
Look for tool_call entries naming your server — mcp_s_<name>_<tool> — and then, the first time you wire a new server up, check one fact the agent reported against the upstream system yourself, through a different path than the agent used. Matching answers mean the agent actually reached it. An agent’s own account of its own tool use is the weakest evidence available.

Does PUT reach a running session?

PUT /v1/sessions/{id}/tools answers 200, and GET /v1/sessions/{id}/tools reads back exactly what you sent. Neither means the agent already running has it. Verified against production: a session was created with one function tool, allowed to complete its first turn, then given a second tool with PUT. GET read both tools back correctly. Asked on its next turn to call the second one by name, the agent answered: “There’s no tool named tool_b in my available set — only tool_a exists.” The new tool reached the control plane’s record of the session and nothing else. The tool list is fixed when the session’s workspace is built, the same as instructions — a PUT changes what a new workspace for this session will start with, not what the current one is running. Paused-and-woken does not rebuild it; a session that is torn down and recreated does. If a session needs a new tool immediately, the reliable way is to give it one from the start, or to accept that the change reaches the next workspace rather than the current turn.

Reading the configuration back

The answer is in the same vocabulary you sent: one tools array, each entry carrying its type. The same shape goes back to PUT. Secrets are not returned. authorization and every header value are held back, and redacted names what was withheld — so a server with credentials configured is distinguishable from one without, which an omission alone would not tell you. redacted is not a request field. If you read the configuration, change something and PUT it back, you get a 400 naming redacted rather than a success that quietly replaced your credentials with nothing. Re-send the secrets, or build the body from your own source.

Demos

Every field above is real; these are the three shapes worth actually running, in order of how often you’ll reach for them. Each does the whole loop this page just walked through: create with the tool attached, read the configuration back, then confirm a real call happened rather than trusting the agent’s account of it.

A remote server, bearer-authenticated — Linear

Verified end to end against production: a personal API key, sent straight as Authorization: Bearer, no OAuth flow. Two endpoints — https://mcp.linear.app/mcp/readonly for reads, https://mcp.linear.app/mcp for reads and writes — which makes “the agent cannot modify my tracker” a property of the URL you chose rather than a sentence in a prompt.
The full run this was verified against — the exact team id it returned, a real issue it later created on the read-write endpoint, and what an unreachable server looks like when required: true catches it — is in Give an agent your tracker.

A local stdio server — npx, no network account needed

No vendor, no key — the server ships in the package, and the sandbox already has npx. Good for trying the whole loop once before you point it at anything real.
Verified against production: the tool call lands as mcp_s_local_echo, with args: {"message":"gobare-docs-check"} on the way in and summary: "Echo: gobare-docs-check" on the way back — the same fact the tool_call items page is checked against, independent of what the agent’s own final message claims.

A hosted server from a second vendor — GitHub

The same shape as the Linear demo above, against a different provider, is worth seeing once precisely because the config barely changes:
https://api.githubcopilot.com/mcp/ answers a plain bearer token with a working MCP handshake — no OAuth dance, same as Linear. A token scoped to read-only is enough for get_me; narrow allowed_tools to what the task actually needs before you hand over anything broader. Verified against production, with a token holding no scopes at all: the tool_call item came back as mcp_s_github_get_me, and the login and id in its result matched a direct, independent GET https://api.github.com/user with the same token — not the agent’s own summary of what it saw.

Refusals

Every refusal carries { error: { code, message, request_id } } — see errors.md.

Next