Skip to content

Agent runtime

This page describes what happens inside the Pod once the controller has created a Job. The design goal is that Stations stay simple: they bring a base image, and the controller injects everything the agent needs.

flowchart TB
    subgraph POD["Agent Pod (restartPolicy: Never)"]
        direction TB
        INIT["initContainer: ai-agent-init"] -->|"clone repos + download files + install CLI + supervisor"| VOL[("emptyDir: /agent")]
        VOL --> MAIN["main container<br/>entrypoint = supervisor"]
        MAIN --> PROC["agent process"]
        CREDS[("credentials volume")] -.-> MAIN
    end
    INIT --> NOTIFY["init events → sinks"]
    PROC --> STDOUT["stdout → pod logs → status.output"]
    PROC --> HTTP["http sink (optional)"]
  1. Init container (ai-agent-init) prepares the shared emptyDir mounted at /agent: it clones the recipe’s repos into the workspace, downloads the Agent’s input files there, installs the agent CLI (Claude from the pinned copy baked into the agent image), and self-bootstraps any missing prerequisites. See The init container.
  2. Main container: the Station’s container, with its command overridden to run the supervisor from /agent. Because the runtime is glibc-linked, the Station base image must be glibc-based.
  3. Security context: the init container runs as root so it can install packages; the main container runs as a non-root user (runAsNonRoot, fixed UID/GID, fsGroup). Both share HOME inside /agent, and $HOME/.local/bin (where the CLI installer drops claude) is on the main container’s PATH.

What the controller injects into the container

Section titled “What the controller injects into the container”

The Job builder sets the container’s command to the supervisor followed by the agent argv, built by the agent adapter from the recipe (see Pluggable agents), and injects a few environment variables:

  • AGENT_SINKS: the recipe’s output.sinks as JSON (http + file destinations).
  • AGENT_NOTIFY_URL: shorthand for a single http sink.
  • AGENT_PARAMETERS: the run parameters as JSON, when present.
  • AGENT_REPOS: the recipe’s resources.repos as JSON, for the init container to clone.
  • WORKSPACE_DIR: where the init container clones repos (defaults to /workspace).
  • AGENT_FILES: the Agent’s spec.files as JSON — init container only, together with the headers_secret keys those entries name, so a download credential never reaches the agent.
  • AGENT_WATCH: the recipe’s output.watch as JSON (including any upload), for the supervisor.
  • TARGET_REPO / BRANCH_NAME: set when the Agent provides them.
  • AGENT_DELIVERY: the pull request delivery plan as JSON (repo, branch, base, rendered title, clone and outbox paths), when the recipe has delivery.pullRequest. It names the token’s variable but never holds the token.
  • AGENT_NAME / STATION_NAME / TASK_ID: the run’s identity, stamped onto every event.
  • POD_NAME / POD_NAMESPACE: the pod’s identity, from the downward API.
  • PATH / HOME: pointed at the injected bundle and home directory.

It also sets default resource requests/limits and an activeDeadlineSeconds derived from the Station’s deadlineMinutes.

ai-agent-init runs before the supervisor and provisions the environment from what the recipe declares, never from hardcoded policy. It runs a list of tools in order; each tool decides for itself whether the run needs it:

ToolActive whenDoes
supervisoralwayscopies the supervisor binary baked into the agent image into /agent/bin, so the main container can exec it as PID 1. Idempotent across init retries.
gitresources.repos is non-emptyclones each repo (full history) into WORKSPACE_DIR, checking out its ref (branch, tag, or SHA). Re-entrant across init retries. Private repos authenticate with token_secret (below).
filesthe Agent’s spec.files is non-emptydownloads each file with curl -fsSL (http/https only) straight to its path under WORKSPACE_DIR, creating parent directories. Runs right after git, because a clone replaces its destination and would delete a file written earlier, and before the workspace is handed to the agent’s uid, so the files are the agent’s. A path escaping the workspace (lexically, or through a symlink in a cloned repo) or a failed download fails the init, naming the path. Path, url and header ride as positional arguments and stdin, never shell text or argv.
the agent CLI (claude, codex, gemini, opencode, exec)always; which CLI comes from the recipe’s model (same routing as pluggable agents)installs the one CLI the run’s model routes to, via that vendor’s official installer or a pinned release package — e.g. Claude’s curl -fsSL https://claude.ai/install.sh | bash. Picking the installer from the same routing that picks the adapter means “install X” can never drift from “run X”. Claude is the exception to the download: the agent image bakes one pinned release (its CLAUDE_CLI_VERSION build arg, also the image label io.github.re-cinq.ai-agent.claude-cli.version), which the init copies into $HOME/.local/bin — no network fetch, and every run of one image runs the same CLI. An image without the baked copy falls back to the installer. Codex and Gemini are pinned too, but downloaded: Codex 0.142.5 (codexCliVersion) as the statically linked musl release package for the pod’s architecture (x86_64 or aarch64) from the openai/codex GitHub release, refused unless its SHA-256 matches the pinned one, and gemini-cli 0.62.0 (geminiCliVersion) as its npm tarball. The init then emits an installed lifecycle event carrying the CLI’s version and its origin (baked, downloaded, or present when the image already had it on PATH).
skillsalways (the repo’s own .claude/skills); the registry half when resources.skills_source is setstages skills into the run’s $HOME/.claude so headless claude --print auto-loads them user-scope: the cloned repo’s own .claude/skills, then the registry’s settings.json (session hooks), then each name in resources.skills fetched as <source>/<name>.tar.gz. Best-effort — an unreachable registry never fails the run.
hooksresources.skills_source is setfetches <source>/hooks/<vendor>.tar.gz for the vendor the model routes to and extracts it relative to $HOME. Hooks are vendor-native config (Claude Code .claude/settings.json, Codex .codex/, Gemini .gemini/settings.json, OpenCode its plugin dir), so nothing is translated: one mechanism per vendor, content owned by the registry. After skills, so a Claude bundle’s settings.json wins over the flat one. Best-effort — no bundle for this vendor means no org hooks, not a failed run.
conversationresources.conversation names both a source and an id, and the vendor has a state directoryrestores the prior run’s state archive into the vendor’s own state dir under $HOME, so the agent resumes that conversation instead of starting fresh. Best-effort: a missing archive leaves the run with a fresh conversation. See continuing a conversation.
mcpthe model routes to a vendor whose CLI reads MCP servers from a settings file rather than a flag (Gemini, OpenCode)merges resources.mcp_servers into the CLI’s user settings under the vendor’s key, replacing that key whole and leaving every other key alone. For Gemini that is mcpServers in /agent/.gemini/settings.json, with httpUrl for http, url for sse and command + args for stdio; for OpenCode it is mcp in /agent/.config/opencode/opencode.json, with remote (url, headers) for http and sse and local (command argv) for stdio. The step re-enters the init binary (ai-agent-init mcp-settings <file> <key> <servers>), since the slim init image has nothing that can merge JSON. The user scope is the one gemini-cli loads without its root-ownership check, which its system scope (GEMINI_CLI_SYSTEM_SETTINGS_PATH, what v0.11.2 used) applies and a uid-1000 pod cannot pass. The tool runs last, after the hook bundle and a restored conversation have landed in .gemini, so a bundle’s hooks in that file survive and a server a previous run declared is replaced — with an empty mcpServers when this recipe declares none, so it never outlives its secret; OpenCode’s mcp key is reset on every run the same way. Each headers entry becomes a header whose value references the pod env var the controller injects (MCP_<SERVER>_<NAME>), and a legacy headers_secret becomes an Authorization header referencing the env var it has always used (the secret key upper-cased, non-alphanumerics folded to _, e.g. tools-mcp-auth → TOOLS_MCP_AUTH); either is the same variable Claude’s --mcp-config uses (${NAME} for Gemini, {env:NAME} for OpenCode, which also gets oauth: false); the CLI expands it from the pod env when it loads the file, so the credential never reaches disk. A stdio server with env entries is launched, for every vendor, as sh -c 'export NAME="$MCP_<SERVER>_<NAME>" …; exec "$0" "$@"' <command> <args>, so it sees each secret under the name it expects (Codex additionally whitelists the variables with env_vars). Claude and Codex need no step: their adapters pass the servers on the command line (--mcp-config, and -c mcp_servers.<name>.… overrides with env_http_headers naming the variable). A headless Gemini run can only call the tools under permission_mode: bypass (--yolo plus the bypass policy below), since the Gemini adapter maps no allowed-tools list. A failed write fails the init.

Gemini request timeout. gemini-cli gives each request 60 seconds to start answering, and a thinking model on a large context can take longer; the request then fails with fetch failed and the CLI resends the same context after a backoff. The one setting that changes that limit is an experiment flag (DEFAULT_REQUEST_TIMEOUT, in seconds), which the CLI reads from a local file when GEMINI_EXP names one. So the mcp init tool writes /agent/.gemini/experiments.json with that flag at 600 s, and the Gemini agent container gets GEMINI_EXP pointing at it. To use your own file or value, set GEMINI_EXP in the definition’s resources.env; that entry replaces the default.

Gemini under bypass. --yolo alone does not grant every tool: gemini-cli’s built-in rules still decide whatever yolo does not cover. A headless run denies every shell command whenever the approval mode is not yolo, and plan mode, which the model can enter by itself, denies everything. The call then fails with Tool execution denied by policy.. So the mcp init tool also writes /agent/.gemini/bypass-policy.toml, a policy that allows every tool in every approval mode (pipes and redirections included) and denies only ask_user, since nobody answers a headless run. A bypass run’s command loads it with --policy, at user tier, above every built-in rule. --policy replaces the user policies directory, so the command names /agent/.gemini/policies too: a hook bundle can still ship its own policy files there, and a deny rule in one still applies. An auto run loads neither, and the CLI’s own rules apply.

A repo’s token_secret names the environment variable holding its access token (the controller populates it from the secret store, the same way secrets become env vars). The clone authenticates through a git credential helper that reads that variable by name at clone time, so the token value never appears in an argv or a log line. The env-var name is validated before use, and the repo url is never passed through a shell.

The helper persisted into the clone’s config additionally falls back to a token file at <clone>/.git/agent-token (written by the init, 0600 before a byte lands) when the variable is unset or empty: some agent CLIs spawn their shell-tool children with a scrubbed environment, in which the env-only helper answered an empty password and every push died on “terminal prompts disabled” with the round complete. The environment stays the primary path; the file lives inside .git/ so it can never be committed, and is removed with the clone.

For an https origin the token is persisted a third way, as an http.<origin>.extraheader (AUTHORIZATION: basic base64(x-access-token:<token>), the mechanism actions/checkout uses), scoped to the clone’s own scheme and host, so a remote on any other host never receives it. The Gemini CLI runs every shell command with GIT_CONFIG_* environment entries that empty credential.helper (and point global and system config at /dev/null). Environment config outranks the repo’s, so the helper above is wiped before git ever asks it, and the token file cannot help. Gemini overrides nothing under http.* and repo-local config still loads, so the header authenticates the push anyway. Git sends the header on every request to that origin, so it is what authenticates for every vendor; the helper keeps answering for an origin that gets no header. The header lives in <clone>/.git/agent-auth.gitconfig (0600), which the clone’s config pulls in with include.path; the shell builds it from the variable’s value, so the token is never in an argv, and .git/config itself still only names where the secret lives. An unset variable writes no header.

A git-credential broker replaces the launch-time token when the run has one. An Agent whose parameters carry git_credential (a run credential) and git_credential_url (the broker) gets them as AGENT_GIT_CREDENTIAL and AGENT_GIT_CREDENTIAL_URL — controller-owned names a recipe cannot override. The clone’s credential helper then POSTs {"repo":"<owner>/<name>"} to the broker under Authorization: Bearer $AGENT_GIT_CREDENTIAL and answers git with the username/password it returns, so the token is minted at the moment git authenticates, scoped to that one repo, and never outlives the run. The same helper is persisted into the clone’s config, so a push hours later asks again rather than presenting a token that has since expired. Only the variable names and the repo’s owner/name are in the helper script; a repo whose owner/name holds anything but GitHub’s name characters is not spliced in at all, and clones the way it would without a broker. The broker is expected to verify the run credential and grant only the repo it names, only while its run is open.

None of that push auth exists when the recipe sets delivery.pullRequest. The init still clones with the token, but persists no helper, token file or header into the clone, and the agent container gets neither the repo’s token variable nor GH_TOKEN nor the broker variables. A courier container pushes the work instead.

Before running the tools it self-bootstraps prerequisites: any executable a tool needs (git, bash, curl, sha256sum, base64) that isn’t on PATH is installed using the package manager detected from the distro (apt/dnf/apk); curl is installed together with ca-certificates, so its https downloads verify on a bare base image too. The staging tools add sh, curl, and tar when they are active. On a base image that already ships these, nothing is installed.

Throughout, the init reports its own lifecycle (started, per-tool running, succeeded, failed) to the same output.sinks as the agent (AGENT_SINKS + AGENT_NOTIFY_URL), using the same {"source": {…}, "event": …} envelope, so init progress is observable on the same channel and correlates with the agent’s events. A non-zero exit fails the Pod before the supervisor starts.

Adding a tool is one new Tool implementation plus a registry entry; adding a distro is one new PackageManager; nothing else changes.

The supervisor is the Pod’s entrypoint (PID 1). It:

  • Spawns the agent argv it was handed (built by the controller from the recipe).
  • Reads the agent’s stdout line by line, wraps each event in a {"source": {…}, "event": …} envelope stamped with the run’s identity (agent, station, task, pod, namespace) so it stays traceable through a workflow, echoes the enriched event to its own stdout (captured in the pod logs, and therefore in status.output), and fans it out to every configured sink: http (POST) and file (append).
  • Captures the agent’s stderr and forwards it to the pod log tagged [agent], rather than letting it inherit the supervisor’s. It is where a CLI prints auth failures, rate limits and stack traces; inherited raw it interleaved with the JSONL event stream the controller caps into status.output, where a consumer could not tell a crash trace from a malformed event.
  • Reads back any file the recipe declared under output.watch once the agent exits, raising each as a named {"kind": "file"} event on the same sinks — before the terminal lifecycle event, so a consumer treating that as end-of-stream still receives it. A watch with upload has its bytes POSTed to the upload URL instead, and its event carries the size and SHA-256 rather than the content. See the Notification API.
  • Enforces its own run deadline (AGENT_DEADLINE_MS, injected by the controller a fixed margin inside the Job’s activeDeadlineSeconds). An agent that neither exits nor emits a terminal event would otherwise leave the supervisor waiting until the kubelet killed the pod — taking the terminal event with it, so a wedged run simply stopped. Instead the supervisor forces the agent down (SIGTERM, then SIGKILL), still reports, and exits 124.
  • Forwards SIGTERM/SIGINT to the agent for graceful shutdown, and ignores SIGPIPE so a broken sink can’t kill it.
  • Exits with the agent’s exit code.
  • Archives this run’s conversation state and POSTs it back when the recipe set resources.conversation.pin, so a later run can continue it. Best-effort: a failed save costs the next run its continuity, never this one its result.

It runs on vibe’s event loop and uses vibe’s HTTP client for http sinks.

For a realtime session the controller sets AGENT_REALTIME_STYLE, and the supervisor runs a session instead of a single run:

  • It serves POST /v1/prompt and POST /v1/close on port 8090. It accepts only the run’s token (AGENT_REALTIME_TOKEN), which it removes from its environment before starting the agent, so the agent cannot prompt its own session.
  • It runs prompts one turn at a time, in arrival order, starting with AGENT_REALTIME_PROMPT when it is set. With style stdin (Claude) one long-lived process gets each prompt as a stream-json line on stdin, and each result ends a turn. With style turn each prompt spawns its own process, and once a session id is known the supervisor uses AGENT_REALTIME_NEXT_ARGV, which resumes it.
  • It raises a turn event when a turn starts and ends, and a session event when the session id is known.
  • It ends the session on a close (once queued turns ran), after AGENT_REALTIME_IDLE_MS with no turn, or on SIGTERM, which in this mode it handles itself instead of forwarding. It then saves the conversation under the session id (when the definition sets resources.conversation.source and the vendor keeps state) and exits 0. The run deadline still applies and exits 124, and a Claude CLI that exits by itself ends the session with the CLI’s own exit code.

The agent CLI is not hardcoded. agentcore.agent.Agent is a small interface (name() and command(recipe, renderedPrompt)) that each provider implements, mapping the AgentDefinition recipe (model, tools, permission mode, max turns) to the provider’s argv. The controller’s job-builder picks the adapter from the recipe’s model and bakes the resulting command into the Job; the supervisor just runs it.

ProviderModelsAdapterCommand
Claude Codeclaude-*, and anything unrecognized (the default)ClaudeAgentclaude --print --output-format stream-json …
OpenAI Codexgpt-*, o1*/o3*/o4*, *codex*CodexAgentcodex exec --json …
Google Geminigemini-*GeminiAgentgemini --prompt … --output-format stream-json
OpenCodeany id containing opencode, e.g. opencode/anthropic/claude-sonnet-4-6OpenCodeAgentopencode run --format json …
Command runnerthe literal id execExecAgentthe argv from the recipe’s tool_config.command, with the rendered prompt appended

Routing is checked in that order, so opencode/openai/gpt-4.1 reaches OpenCode rather than matching the Codex gpt rule. The exec adapter is the non-LLM escape hatch: it spawns a deterministic command that must honour the same NDJSON stdout protocol (ending with a {"type":"result", …} line), so a scripted step runs on the same rails as a model.

They all emit newline-delimited JSON, so the supervisor streams them identically. Adding a provider is one new Agent implementation plus a model match; nothing else changes.

Because the adapter owns the argv, tool permissions map differently per vendor: OpenCode governs tool access through its own permission model, so a recipe’s allowed_tools / disallowed_tools do not become flags there.

By default every run is a fresh conversation. resources.conversation makes a run continue a previous one: {source, id} points at the archive of the earlier run’s state, the init restores it into the vendor’s own state directory under $HOME, and the adapter resumes it.

The seam is vendor-neutral because the CLIs disagree on both halves. Claude appends --resume <id> before the -- prompt terminator; Codex makes resume a subcommand taking the prompt as a positional (codex exec resume <id> <prompt>). Claude stores state at .claude/projects/<cwd-slug>/<id>.jsonl while Codex uses .codex/sessions/<YYYY>/<MM>/<DD>/rollout-<timestamp>-<id>.jsonl — a path that cannot be derived from the id. So state is addressed as a directory (Agent.stateDir()) and moved as an archive, which handles multi-file formats for free.

ProviderState directoryCan pin a chosen id
Claude Code.claude/projectsyes (--session-id)
OpenAI Codex.codex/sessionsno — the CLI assigns its own id
Google Gemini.geminino — the CLI assigns its own id
OpenCode, exec—no

Gemini resumes by position rather than by id: gemini-cli only accepts a session id it issued itself, and it has no pin flag to make the caller’s id one, so --resume <caller id> exited FATAL_INPUT_ERROR (42) before the first turn. The adapter passes --resume latest instead. That is exact here because the restored archive holds only the conversation being continued, and when nothing was restored latest starts a fresh session with a warning — which keeps the restore best-effort rather than turning a missing archive into a failed run.

pin is the id this run saves its own state as, which makes each run a fork: the run it continued is left intact and independently resumable, so a caller can go back to an earlier run rather than only ever the latest. A vendor whose CLI cannot accept a chosen id reports that by returning no arguments rather than appearing to support it.

Everything on this path is best-effort. A missing or unreachable archive leaves the run with a fresh conversation, and a failed save costs the next run its continuity — never this one its result.

resources.skills names the skills a run gets and resources.skills_source is the registry base URL they come from. The init’s skills tool stages three things into $HOME/.claude (HOME is /agent, which is where headless claude --print auto-loads user scope — project scope under cwd / never fires): the cloned repo’s own .claude/skills, the registry’s settings.json, and each named skill fetched as <source>/<name>.tar.gz.

Skills are fetched, never baked. Nothing org-specific lives in the agent image, so the subsystem stays consumer-agnostic: the recipe declares intent and hands over a URL, and the subsystem knows nothing of the registry beyond it. The URL is read from $AGENT_SKILLS_SOURCE at run time so a recipe-supplied string never enters a shell command, and skill names are validated against injection before use.

A hook is how an org puts a guard between the agent and its tools — refuse a command, audit a call — and every CLI reads hooks in its own format from its own place under $HOME: Claude Code from .claude/settings.json (also passed as --settings), Codex from .codex/, Gemini from .gemini/settings.json, OpenCode from its plugin directory. The subsystem translates none of those. Instead the registry publishes one bundle per vendor at <skills_source>/hooks/<vendor>.tar.gz, laid out relative to $HOME in that vendor’s native format, and the init’s hooks tool extracts it there for whichever vendor the run’s model routes to — the same routing that picks the CLI to install and the adapter to run. A Claude bundle therefore carries .claude/settings.json plus whatever scripts its hook commands name; a Codex bundle carries .codex/…. Adding a vendor to the registry is adding a directory; the subsystem does not change.

The vendor name comes from the adapter, never from recipe input, and the URL is the same $AGENT_SKILLS_SOURCE env reference skills use. The tool runs after skills, so a Claude bundle’s settings.json wins over the flat <source>/settings.json the skills tool stages (kept for registries that predate bundles). Like skills it is best-effort: a registry with no bundle for this vendor leaves the run without org hooks and never fails it.

The bundle is extracted at the root of $HOME, not under .claude, so whoever controls the skills_source a recipe names can place any dotfile the run’s CLI or tools will read. That is the same trust the skills tool already extends to the source (a recipe is org-authored), and tar strips leading slashes and refuses .. members, so a bundle cannot write outside $HOME.

  • Output is emitted as one self-identifying JSON event per line: {"source": {"agent","station","task","pod","namespace"}, "event": <the agent's JSON>}, so any consumer in a workflow / assembly line can correlate it back to its run. It always goes to stdout (pod logs), which the controller reads back on a terminal transition into the Agent’s status.output (alongside the agent container’s real status.exitCode), capped to the last MAX_OUTPUT_BYTES (default 256 KiB, tail-preserving) so the Agent object stays under etcd’s ~1.5 MB per-object limit. If the pod was already garbage-collected by the time the controller reads back, the Agent still reaches a terminal phase but status.failureReason records why the output is missing (see the controller lifecycle) rather than leaving it silently empty. It also goes to every sink the recipe declares: http (POST per event) and file (append per event). When output.select is set, sink delivery is filtered to the listed event types; each provider event is normalized to the tool_call/message/tool_result/result/usage vocabulary and matched against the selectors (with optional tool/contains narrowing); stdout still receives every event type, so status.output is never select-filtered, only size-capped to the tail.

  • Credentials are the agent’s own concern: the controller injects the provider’s API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, …) as an environment variable from a Kubernetes Secret (AgentDefinition.spec.resources.secrets). Each secrets entry {name, ref} becomes a container env var name sourced via secretKeyRef from a single namespace Secret named agent-secrets, with ref as the key inside it (the operator creates that Secret out of band). Plain resources.env entries are injected as literal env vars. The supervisor inherits this environment and passes it to the agent CLI; it stages nothing itself.

    Codex keys. codex exec reads its key only from CODEX_API_KEY and ignores OPENAI_API_KEY. Either name works in resources.secrets or resources.env: for a Codex run with an OPENAI_API_KEY entry and no CODEX_API_KEY of its own (in secrets or env), the controller also injects CODEX_API_KEY with whatever OPENAI_API_KEY resolves to in the pod. A secret becomes a second secretKeyRef to the same agent-secrets key, so the key still never reaches argv or disk; a literal env value is copied as is. Other vendors are unaffected.

    Batch exception. For a definition with execution: batch there is no run pod: the controller itself reads the key that a resources.secrets entry points at from agent-secrets and sends it to the vendor’s batch API. The entry’s name depends on the vendor the model routes to: ANTHROPIC_API_KEY for Claude, GEMINI_API_KEY for Gemini and OPENAI_API_KEY for Codex. This is the one case where a provider key leaves the run pod’s environment, and it needs the opt-in components/batch kustomize component; see RBAC & network.

A definition with execution: batch skips everything above. No Job, init container or supervisor runs. The controller renders the prompt from the Agent’s parameters, submits it as a one-request batch to the batch API of the model’s vendor (the request’s custom id is the Agent’s uid), and polls it from its reconcile loop:

VendorBatch APILines written to status.output
ClaudeAnthropic Message BatchesClaude stream-json: system init, assistant with the text blocks only, then result with usage, the batch-discounted total_cost_usd and session_id
GeminiGemini Batch APIGemini stream-json: init, the assistant’s text as one message, then result with stats
CodexOpenAI Batch APICodex exec --json: thread.started, turn.started, the text as one agent_message item, then turn.completed with the usage

These are the lines the vendor’s CLI prints for a single-turn, no-tool run, so a parser that reads a CLI run’s output reads a batch run’s output the same way.

Because it is a single model call, a batch run has no tools, turns, repositories, MCP servers, skills, conversation resume, watched files or per-run input files; definitions (or Agents, for files) that set them are rejected. The controller also delivers the lines, through output.select, to the definition’s output.sinks, followed by the run’s terminal lifecycle event. If the batch times out with onTimeout: fallback, the controller starts an ordinary Job for the same definition, and the rest of this page applies to it. See batch runs.