Agent steps and the sandbox
An agent step hands the work to an AI agent through the Claude Agent SDK. It runs with nobody around to approve permissions, so the SDK runs in bypassPermissions mode and asks nothing. The real control belongs to the engine, not the SDK.
The step
- id: screenshots agent: docs-writer prompt: steps/screenshots.md cwd: "{{params.docs_repo}}" additional_dirs: ["{{params.admin_repo}}"] when: "{{detect-target.capture_target_slug}}" mcp_servers: [playwright] tools: - Read - Write - Edit - mcp__playwright__browser_navigate - mcp__playwright__browser_take_screenshot outputs: summary: stringagentnamesagents/<agent>.mdin the data repo: YAML front matter plus a Markdown prompt.promptis the path to this step's prompt, relative to the pipeline directory (by convention understeps/). It may contain{{...}}references to params, outputs of previous steps and, only here, secrets.cwdis the sandbox's only write root. The engine doesn't create it: a previous step should.outputsare validated against the contract exactly like ashellstep's.max_cost_usdcaps the spend of this step (mapped to the SDK'smaxBudgetUsd). No cap if omitted.
All the fields are in the pipeline.yaml reference.
The four controls
1. A closed list of tools
tools: is deny by default, and it's checked by pipelines validate, not just at runtime:
- Every native tool must be in the engine's internal table:
Read,Write,Edit,NotebookEdit,Glob,Grep,StructuredOutput. - Every
mcp__<server>__<tool>must belong to a server enabled in the step'smcp_servers:, and that server must have a knownkind:in the pipeline's registry. If the server isdeclared, the tool must also be in itsallow. - Without
tools:, the step gets the minimal default:Read,GrepandGlob.
Bash and any other tool that runs arbitrary code (REPL, Workflow, Agent, EnterWorktree, ExitWorktree, browser_evaluate, browser_run_code_unsafe) are always forbidden. If a step needs to run commands, it should be a shell step.
2. The engine's own hook on every call
Before every tool call, the engine's PreToolUse hook (the engine's, not the SDK's) decides to allow or deny:
cwdis the only write root. Any attempt to write outside it, including a path with..trying to escape, is denied.additional_dirswidens the read roots, never the write ones: a step can read a source-of-truth repo without being able to touch it. They can only be interpolated with{{params.*}}, never secrets.- A tool without an entry in the internal table is denied by default, with the reason
unknown tool for the engine: "<name>". It fails closed in the face of the unknown, not open. - The hook never throws: any internal error becomes an explicit denial.
A declared server is different: the engine doesn't know its tools' fields, so it applies no filesystem profiles to them, only the allow list. A tool of that server that isn't in allow, or that the step doesn't grant in its tools:, is denied at runtime. Whoever writes the pipeline vouches that no tool in allow runs arbitrary code or writes where it shouldn't.
3. No ambient MCP servers
The engine always starts the session with strictMcpConfig: true: no MCP server already configured on the machine (plugins, another project's .mcp.json) joins the session. Only the mcp_servers the step declares. Today only stdio servers are supported, with kind: playwright (the engine knows its tools) or kind: declared (a server of your own, whose tools you list in allow).
mcp_servers: playwright: command: npx args: ["-y", "@playwright/mcp@0.0.79", "--headless", "--no-sandbox"] kind: playwright my-device: command: my-device-mcp args: ["--stdio"] kind: declared allow: [screenshot, tap]allow is the server's ceiling; each step still grants itself what it uses, for example tools: [mcp__my-device__screenshot] with mcp_servers: [my-device]. validate rejects the always-forbidden execution tools by name in allow; anything else is on the pipeline's author. Because doctor and every run start the server to list its tools, a declared server must answer tools/list without external resources, without secrets and with its {{params.*}} arguments unresolved.
4. No ambient settings
settingSources: [], always: the session loads no user or project settings.json, so there are no hooks foreign to the engine that could hang or alter an unattended run.
Every denial is recorded
Every denial is persisted in the step's record, denials, not just kept in memory during the run. You see them with pipelines show <name> <run-id> <step> and on the run page of the dashboard, with the path and the reason.
If a step invokes a tool the engine doesn't govern, even if the step ends successfully, pipelines run and resume print a warning naming the tool and the step, so it gets added to the engine's table before it turns into a confusing failure in another run.
pipelines doctor adds one more layer: for each MCP server with a known kind:, it starts the real server and reads its tools/list live, to catch tools added, renamed or removed in an MCP package update before a real run does. With playwright it compares each tool's fields against the engine's table; with declared it only checks that every tool granted to the steps still exists in the real server. That check (mcp-schema) never blocks run, but it does make doctor fail.
Secrets and the API key
agent steps authenticate with ANTHROPIC_API_KEY, read from the data repo's .env or from the environment (the .env value wins). Only agent steps receive it.
A {{secrets.NAME}} reference, declared in requires.env with a literal name (a name built from params can't be referenced this way), may appear only in the prompt of an agent step. Anywhere else (cwd, run, shell: guards, additional_dirs) it's a validation error, so a secret never ends up in a shell command where it could leak into a log. Step logs are written with every secret value already replaced by [redacted].
Cost
Each agent step reports its cost and tokens in its record (costUsd, inputTokens, outputTokens), and run.json adds them up in totalCostUsd. To make an agent cheaper, the starter suggests switching it to model: haiku in its agents/ file. At API prices with Sonnet, the starter's test runs cost about $0.25 per weekly briefing, $0.15 per news digest and $0.20–0.35 per docs review.
What the agent claims, a shell step verifies
An agent saying it saved a screenshot doesn't prove the file exists: follow the agent step with a shell step that checks it. More conventions like this in Surviving the night.