Skip to content
agentic-pipelines

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.

Sequence of an agent step: the runner reads the agent and the step prompt from the data repo, interpolates the prompt, and opens a Claude Agent SDK session with closed tools, strictMcpConfig and no settings sources. The session starts the step's MCP servers; every tool call goes through the engine's PreToolUse hook, which allows writes only inside cwd. At the end the runner receives the structured output, cost and tokens, and writes the validated outputs, denials and redacted log to .runs/.
Anatomy of an agent step, from the engine repo.

The step

yaml
- 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: string
  • agent names agents/<agent>.md in the data repo: YAML front matter plus a Markdown prompt.
  • prompt is the path to this step's prompt, relative to the pipeline directory (by convention under steps/). It may contain {{...}} references to params, outputs of previous steps and, only here, secrets.
  • cwd is the sandbox's only write root. The engine doesn't create it: a previous step should.
  • outputs are validated against the contract exactly like a shell step's.
  • max_cost_usd caps the spend of this step (mapped to the SDK's maxBudgetUsd). 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's mcp_servers:, and that server must have a known kind: in the pipeline's registry. If the server is declared, the tool must also be in its allow.
  • Without tools:, the step gets the minimal default: Read, Grep and Glob.

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:

  • cwd is the only write root. Any attempt to write outside it, including a path with .. trying to escape, is denied.
  • additional_dirs widens 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).

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

Written from agentic-pipelines at b8b4db7: README.md, docs/reference.md; agentic-pipelines at dcea5ab: docs/diagramas/paso-agent.svg; pipelines-starter at 0b4f303: README.md.