System Prompts & Agent Config Files
Bunsen does not have a systemPrompt field on agent.yaml. System-prompt wiring varies too much per agent — CLAUDE.md, AGENTS.md, GEMINI.md, --append-system-prompt, SDK options — and folding any of it into the platform schema would force Bunsen to learn one contract per agent. Bunsen keeps the platform thin and lets the agent author wire their own prompt, the same way the rest of the agent contract works.
This page is the cookbook. Agent authors compose system prompts themselves using two small primitives:
writeFile:step — a peer ofrun:insideinstall.configure(andworkspace.setup). Drops a file at a known path inside the container. Source is either an inlinecontent:literal or afrom: <path>file alongsideagent.yaml. No heredoc shell-quoting risk; base64 underneath.- Variant
install.configurewithmergeMode: append— variant adds one extra step on top of base configure without redeclaring the base.
Both primitives are generic. System prompts are the motivating use case, not the only one — config files, ruleset drops, license text, fixture data all use the same shape.
Same philosophy as the asymmetric composition model in The Environment Model: Bunsen carries less, the agent author wires their own way.
The recommended pattern (Pattern A: drop a config file)
The recommended approach — Pattern A — is to drop a config file the agent reads at startup via a
writeFile: step in install.configure:
# agent.yaml
install:
source: { type: local }
configure:
- run: |
# base configure (whatever the agent needs)
...
variants:
cautious:
install:
configure:
mergeMode: append # add to base, don't replace
items:
- writeFile: $BUNSEN_AGENT_HOME/.claude/CLAUDE.md
from: prompts/cautious.md
yolo:
install:
configure:
mergeMode: append
items:
- writeFile: $BUNSEN_AGENT_HOME/.claude/CLAUDE.md
from: prompts/yolo.mdLayout in the agent directory:
agents/my-agent/
├── agent.yaml
└── prompts/
├── cautious.md
└── yolo.mdRun it:
bn run experiments/my-experiment --agent claude-code:cautiousPattern details
writeFile: step shape
The full step schema is defined alongside the rest of the agent config — see
agent.yaml Reference and The Environment Model for the
authoritative writeFile/run step shape (and the hosted agent.v1.json
schema). The summary below covers the fields you need for prompt wiring.
- writeFile: <target-path-inside-container>
from: <path-relative-to-agent.yaml> # OR
content: |
inline UTF-8 content here
as: root # optional, default 'user'
timeout: 30s # optional- Exactly one of
from/contentis set per step. - The target path supports
$VARshell expansion at execution time —$BUNSEN_AGENT_HOMEis the recommended way to write into the agent's home regardless of root vs non-root execution.BUNSEN_AGENT_HOMEresolves to/home/bunsenfor non-root execution and/rootfor root experiments. - Inline
content:is treated as a literal byte stream. No env interpolation — secrets in env vars never reach the manifest. If you need a secret in file content, userun:plusenvsubst(the existing pattern). - Parent directories auto-created; existing files silently overwritten; file mode
644. If you need executable mode, add a follow-uprun: chmod +x .... from:paths are resolved relative to the directory containingagent.yaml; a path-safety check rejectsfrom: ../../etc/passwd.as:follows the phase default (install.configure→root;workspace.setup→useri.e. the non-rootbunsenuser when one exists). Inworkspace.setup, dropping a file viawriteFilelandsbunsen-owned so the agent can modify it. Setas: rootto escalate explicitly.
Shell state does not persist across a
writeFileboundary. Consecutiverun:steps with the sameas:are batched into one shell invocation (soexport FOO=barin step 1 is visible to step 3). AwriteFile:step — or a switch to a differentas:— ends the batch. So this does not work:- run: export FOO=bar # exported in one shell - writeFile: /tmp/x # batch break content: ... - run: echo "$FOO" # new shell — $FOO is empty hereIf you need an env var across a writeFile, put the export inside the run step that uses it (or use a real env var via
defaults.envso the container env carries it).
Root-mode caveat for
$VARindefaults.envvalues. A$BUNSEN_AGENT_HOME(or any$VAR) inside adefaults.envvalue is expanded by the non-root container entrypoint's shell. Under root-mode execution there is no such shell — the agent is invoked with the env value passed literally, so the$BUNSEN_AGENT_HOME/...string arrives unexpanded. For root experiments, hardcode the path (e.g./root/prompts/cautious.md) or set it from arun:step. ThewriteFile:target path is unaffected — it expands regardless of execution user. See Running as Root (environment.user).
Variant mergeMode
A variant's install.configure accepts either the raw step array (which replaces the base list) or the wrapped form:
install:
configure:
mergeMode: append | replace # defaults to 'replace' when omitted
items:
- <step>
- <step>mergeMode: append concatenates the variant's items: onto the base configure list. replace (and the raw-array shorthand) replaces wholesale.
Worked examples for every shipped agent
The pattern works for any agent — the only difference is the target path the agent reads at startup. Here's the wiring for each agent that ships in this repo.
claude-code (CLI)
Claude Code reads ~/.claude/CLAUDE.md at startup. With Bunsen's BUNSEN_AGENT_HOME reserved env (set to /home/bunsen for non-root, /root for root), the same wiring works regardless of execution user.
variants:
cautious:
install:
configure:
mergeMode: append
items:
- writeFile: $BUNSEN_AGENT_HOME/.claude/CLAUDE.md
from: prompts/cautious.mdThis is shipped in this repo. Try:
bn run <any-experiment> --agent claude-code:cautiousAlternative: --append-system-prompt wrapper (Pattern B)
If you specifically need flag-based injection (e.g. the prompt source isn't a file the agent can read, or you want CLI-visible provenance), build a wrapper script during install.build and point entrypoint at it:
install:
build:
image: ubuntu:22.04
cacheSalt: claude-code-wrapped-v1
run:
- |
# ... fetch the claude binary as usual into /output/bin/claude ...
cat > /output/bin/claude-wrapped <<'EOF'
#!/bin/sh
set -e
if [ -n "$AGENT_SYSTEM_PROMPT_FILE" ] && [ -f "$AGENT_SYSTEM_PROMPT_FILE" ]; then
exec claude --append-system-prompt "$(cat "$AGENT_SYSTEM_PROMPT_FILE")" "$@"
fi
exec claude "$@"
EOF
chmod +x /output/bin/claude-wrapped
entrypoint:
command: claude-wrapped
args:
- --dangerously-skip-permissions
variants:
cautious:
install:
configure:
mergeMode: append
items:
- writeFile: $BUNSEN_AGENT_HOME/.prompts/cautious.md
from: prompts/cautious.md
defaults:
env:
AGENT_SYSTEM_PROMPT_FILE: $BUNSEN_AGENT_HOME/.prompts/cautious.mdWhen to reach for this over Pattern A: the wrapper makes the prompt visible in ps/process trees and survives if the agent ignores ~/.claude/CLAUDE.md for any reason. Costs more — every variant change means rebuilding the artifact via install.build (whereas Pattern A only re-runs install.configure, which is cheap). Default to Pattern A unless you have a specific reason.
Root-mode: the
AGENT_SYSTEM_PROMPT_FILE: $BUNSEN_AGENT_HOME/.prompts/cautious.mdenv value uses$VARexpansion — under root-mode execution, hardcode/root/.prompts/cautious.mdinstead. See the root-mode caveat for$VARindefaults.envvalues above.
claude-sdk-agent (TypeScript)
The SDK agent reads its system prompt from the SDK options.systemPrompt field, not from a file. That field's type is string | { type: 'preset'; preset: 'claude_code'; append?: string }, and the agent's default prompt is already a preset object ({ type: 'preset', preset: 'claude_code', append: '...' }) — so the cleanest wiring keeps the Claude Code preset and appends your file's contents via the append sub-field rather than replacing the prompt wholesale. Patch the agent to read an env var and build the systemPrompt it passes to the SDK options:
import { readFileSync, existsSync } from 'node:fs';
const promptFile = process.env.AGENT_SYSTEM_PROMPT_FILE;
const systemPrompt: AgentConfig['systemPrompt'] = promptFile && existsSync(promptFile)
? { type: 'preset', preset: 'claude_code', append: readFileSync(promptFile, 'utf-8') }
: DEFAULT_SYSTEM_PROMPT;
// ... pass `systemPrompt` to the SDK options.Then in agent.yaml:
variants:
cautious:
defaults:
env:
AGENT_SYSTEM_PROMPT_FILE: $BUNSEN_AGENT_HOME/prompts/cautious.md
install:
configure:
mergeMode: append
items:
- writeFile: $BUNSEN_AGENT_HOME/prompts/cautious.md
from: prompts/cautious.mdRoot-mode: the
AGENT_SYSTEM_PROMPT_FILE: $BUNSEN_AGENT_HOME/prompts/cautious.mdenv value uses$VARexpansion — under root-mode execution, hardcode/root/prompts/cautious.mdinstead. See the root-mode caveat for$VARindefaults.envvalues above.
codex-cli (OpenAI's CLI)
Codex reads ~/.codex/AGENTS.md at startup.
variants:
cautious:
install:
configure:
mergeMode: append
items:
- writeFile: $BUNSEN_AGENT_HOME/.codex/AGENTS.md
from: prompts/cautious.mdThe base install.configure already creates $BUNSEN_AGENT_HOME/.codex/ (when writing config.toml), so the append step just adds the file. If you ever need the prompt drop without the base directory being created, write a mkdir -p run: step first.
gemini-cli (Google's CLI)
Gemini reads ~/.gemini/GEMINI.md and also accepts a systemInstruction field in ~/.gemini/settings.json. The file-drop is the simpler of the two:
variants:
cautious:
install:
configure:
mergeMode: append
items:
- writeFile: $BUNSEN_AGENT_HOME/.gemini/GEMINI.md
from: prompts/cautious.mdbasic-coding-agent (Python, hand-rolled)
This agent hardcodes its system prompt in its Python source. To make it configurable, replace the hardcoded string with an env-var lookup that falls back to the embedded default:
import os
DEFAULT_SYSTEM = """You are a skilled coding agent. ..."""
system = os.environ.get("AGENT_SYSTEM_PROMPT", DEFAULT_SYSTEM)Then in agent.yaml:
variants:
cautious:
defaults:
env:
AGENT_SYSTEM_PROMPT_FILE: $BUNSEN_AGENT_HOME/prompts/cautious.md
install:
configure:
mergeMode: append
items:
- writeFile: $BUNSEN_AGENT_HOME/prompts/cautious.md
from: prompts/cautious.mdRoot-mode: the
AGENT_SYSTEM_PROMPT_FILE: $BUNSEN_AGENT_HOME/prompts/cautious.mdenv value uses$VARexpansion — under root-mode execution, hardcode/root/prompts/cautious.mdinstead. See the root-mode caveat for$VARindefaults.envvalues above. The inlineAGENT_SYSTEM_PROMPTform below has no$VARto expand and is safe either way.
Or use content: inline if the prompt is short:
variants:
cautious:
defaults:
env:
AGENT_SYSTEM_PROMPT: |
Be cautious. Confirm before any destructive action.(The content: writeFile form is also fine; defaults.env works when the prompt is short.)
Why no systemPrompt field on agent.yaml?
Adding it would force Bunsen to know one wiring per agent — --append-system-prompt, CLAUDE.md,
AGENTS.md, GEMINI.md, SDK options. Bunsen keeps the platform thin and lets each agent wire its own
prompt, so the platform never has to shape itself around any one agent's contract.
Experiment-side steering
If you want to steer the agent with task-specific context (not agent-wide), put it in
experiment.task.prompt. Reaching across the experiment-agent boundary to write into agent-internal paths
is a coupling smell.
That said, writeFile: is also available in workspace.setup (it shares the same step shape). Use it to drop test fixtures, sample data, or seed files into the workspace — not to write into agent-internal paths.
Verifying the prompt took effect
To confirm the file actually landed, inspect the container during a run (bn runs open <run-id> opens
the local web viewer at localhost:3456) or check the agent's startup behavior in the captured traces.
Each run's manifest also records the resolved variant and configure steps — see
Run Manifest & Events.
See also
- The Environment Model — asymmetric composition and the sealed-closure agent model.
- agent.yaml Reference — the full
writeFile/runstep schema and variant rules. - Running as Root (environment.user) — root vs non-root execution and the
$VARcaveat. - Agent Skills — cross-agent
SKILL.mdauthoring, another way to shape agent behavior.