ADR-0018: prompt_file — an external prompt source for agent one-shots
Context and Problem Statement
📝 ADR-0011 gave a harness a declarative prompt: set it, and the supervisor
synthesizes the whole agent argv at spawn (args stay empty) via the adapter's
PromptCommand. The value is stored verbatim, never placeholder-expanded, and
lands as the final argv element — claude -p --verbose --output-format stream-json "<prompt>".
That is right for a sentence. It is wrong for the prompts people actually schedule. A cron one-shot's instructions are a specification: what to gather, what to write, what it may not touch, and — for anything reading issues, logs or web pages — a paragraph of prompt-injection hardening. TOML basic strings cannot contain a raw newline, so such a prompt becomes one unreadable line, and every edit to it is a config edit that trips the reload path.
The workaround already in the wild is to make the prompt a pointer:
prompt = "Read ~/.config/dotfiles/blog-sweep.prompt.md and execute its instructions verbatim — that file IS the complete specification for this run."
It works because agents can read files, not because Harness supports it, and it carries three real costs:
- The path is unvalidated. Nothing checks it at load, and nothing checks it at spawn. A renamed or unrendered file does not fail — the agent is simply handed an instruction to read something that is not there and left to improvise. For a scheduled run with no human attached, that reads as a silent no-op.
- Preamble competes with instructions. Every such prompt spends its first sentence explaining its own indirection, and that sentence sits in the same channel as the work.
- It is invisible to Harness.
harness list, the TUI "what" column, and the wire all show the pointer sentence. Nothing knows a file is involved, so nothing can report that it is missing.
So: how does a harness name a prompt that lives in a file, without the daemon learning to read the file's contents as configuration?
Decision Drivers
- Config truth is the file (📝 ADR-0006).
harness.tomlis hand-authored and round-trips through the TUI edit form. Anything that makes the form re-persist content it did not author is a defect, not a cosmetic issue. - A failure must be loud (📝 ADR-0013). SPEC-0008 already validates the cron expression eagerly so a typo fails the load rather than silently never firing. A prompt source deserves the identical treatment: a scheduled run whose instructions are missing must fail visibly, not run empty.
- Prompts change more often than configuration. The indirection is desirable precisely because editing a prompt should not require a daemon reload.
- Precedent exists, twice.
env_fileis already a config key resolved at parse and read at spawn;[server]already pairsauthorized_keyswithauthorized_keys_file. Neither needed new vocabulary. - One field, one type.
promptis displayed in the TUI dashboard, theharness listdetail table, the control-plane wire, andpersisted.json. A field whose value is sometimes an instruction and sometimes a path forces every one of those consumers to learn the difference.
Considered Options
- Option 1 — an
@sigil onprompt.prompt = "@/path/to/file.md", borrowing the file-reference convention several agent CLIs use. - Option 2 — implicit path sniffing. Treat
promptas a path when it expands to an existing readable file, and as literal text otherwise. - Option 3 — a separate
prompt_filekey, mutually exclusive withprompt.
Decision Outcome
Chosen option: Option 3 — a separate prompt_file key, because it adds no
grammar to an existing field, needs no escape hatch, and reuses the env_file
shape the parser and supervisor already implement.
[harness.blog-sweep]
harness = "claude-code"
prompt_file = "~/.config/dotfiles/blog-sweep.prompt.md"
model = "claude-opus-5"
schedule = "0 9 * * 1"
prompt and prompt_file are mutually exclusive, and either satisfies the
"requires a prompt" predicate that model, auto_accept, max_turns, quiet
and schedule are already validated against. Path resolution is the existing
one: a leading ~ expands, and a relative path resolves against the directory
holding the config file (against the project root in a project file).
The file is read at spawn, never at parse
This is the load-bearing half of the decision. prompt_file stores a path
on core.Harness; the supervisor reads it immediately before exec and passes
the contents to PromptCommand as the prompt. Contents are never folded into
Harness.Prompt at load time, because doing so breaks three things at once:
- The TOML round-trip. The TUI edit form re-emits
prompt = <quoted>from the in-memory harness. If the field held file contents, opening the form on a scheduled harness and saving an unrelated change would rewrite the entire prompt body intoharness.tomlas one quoted literal. This is the same failure 📝 ADR-0011 already avoids by refusing to desugarmodelintoargs. - Every display surface. The TUI dashboard's "what" column,
harness list, the control-plane wire andpersisted.jsonwould each carry the whole document where they carry a summary today. - The reason for the indirection. Parse-time reads make prompt edits require a daemon reload, which is most of what the pointer idiom bought.
Reading at spawn also means the prompt that runs is the prompt on disk at firing time — the intended behavior for a cron one-shot whose specification is maintained separately from the schedule that fires it.
Validation is eager anyway, and stricter than env_file
Config load SHALL check that prompt_file resolves to a readable, non-empty
file, and fail with the same located error every sibling field produces.
Deferring the only check to spawn would reproduce the silent-no-op the pointer
idiom already suffers from.
This deliberately diverges from env_file, where a missing file is
tolerated (a harness with no extra environment still runs correctly). A harness
with no prompt has nothing to run at all, so the file is required at parse and
re-checked at spawn — the file can be deleted between the two, and a spawn-time
read failure must fail the start with an attributable error rather than
launching an agent with an empty instruction.
Relative paths anchor on the declaring file, not the process cwd
prompt_file resolves against the directory holding the file that declared it
(the project root in a project harness.toml), which is the rule harness_d
already uses — and deliberately not the treatment workdir and env_file
get in the global config, where a relative value is stored raw and only ~ is
expanded at spawn.
The divergence is forced by eager validation. config.Load runs in two
processes with different working directories: the CLI, and a daemon that
systemd or launchd starts with an arbitrary cwd. A cwd-relative prompt path
would therefore validate in one and fail in the other, turning a config error
into a "works when I check it, fails when it fires" report. Anchoring on the
declaring file makes the path mean exactly one thing wherever it is read.
No environment-variable expansion
prompt_file gets ~ expansion and the config-relative resolution above, and
nothing else. $HOME/$VAR
interpolation appears nowhere in the schema today, and ~/… already covers the
motivating case. Introducing shell-style interpolation on the one value that
becomes agent instructions is a wider decision than this ADR needs, and its
absence is easy to reverse later.
Consequences
- Good, because the pointer preamble disappears: the
.mdfile is the prompt, not an instruction to go find the prompt. - Good, because a missing or unreadable prompt file now fails the config load with a located error instead of producing a run that does nothing.
- Good, because prompt edits stay reload-free — the spawn-time read picks up the current file on the next firing.
- Good, because the display surfaces get shorter and more useful: a path and a summary line beat a wall of instruction text in a table cell.
- Bad, because config truth is now split across two files.
harness.tomlno longer tells you what a scheduled harness will do, only where to look — and the referenced file is outside the config's own reload watch. - Bad, because a file readable at parse can be unreadable at spawn, so the same failure must be handled in two places with two different error paths.
- Neutral, because the schema grows one key.
promptis unchanged, and every existing config keeps working — including the pointer idiom, which remains valid, merely unnecessary.
Confirmation
- SPEC-0006 gains a Prompt Source requirement formalizing
promptandprompt_file, their exclusivity, path resolution, parse-time validation, and the spawn-time read. - Acceptance tests: a harness with
prompt_filespawns with the file's contents as the agent's final argv element;promptandprompt_filetogether fail the load with a located error; aprompt_filenaming a missing, unreadable, or empty file fails the load;schedule/model/auto_accept/max_turnsare accepted alongsideprompt_filealone; a config edited through the TUI form round-tripsprompt_fileas a path and never inlines its contents; a file deleted between load and spawn fails the start with an error naming the harness and the path; editing the file's contents changes the next run without a reload.
Pros and Cons of the Options
Option 1 — an @ sigil on prompt
- Good, because it needs no new key, and
@fileis a convention agent CLI users already recognize. - Good, because it is explicit — unlike sniffing, the intent is written down.
- Bad, because it needs a permanent escape rule (
@@) for a prompt that legitimately begins with@, which is not exotic:@joestump-agent,@claude, and any prompt opening with a mention hit it. - Bad, because it overloads one field with two types, so the TUI dashboard,
harness list, the wire, andpersisted.jsonmust each learn the convention or display@/path/…where they display an instruction. - Bad, because the sigil has to survive the TUI form's quote-and-rewrite round-trip as a sigil rather than as text.
Option 2 — implicit path sniffing
- Good, because it requires no syntax at all and reads naturally.
- Bad, because the same TOML means different things on different machines: the interpretation depends on filesystem state at load time.
- Bad, because deleting or renaming the file does not fail — it silently reinterprets the config, handing the agent a bare path as its instruction. This is exactly the failure mode the feature exists to remove.
- Bad, because it cannot produce a useful error. "This looked like a path but did not resolve" is indistinguishable from a short literal prompt.
- Bad, because a prompt that happens to be a bare path becomes unwritable.
Option 3 — a separate prompt_file key
- Good, because there is no grammar to learn, escape, or round-trip.
- Good, because it is self-documenting in the file and in
--helpoutput. - Good, because it mirrors
env_fileandauthorized_keys_file, so the parser, the resolver, and the spawn-time read are all existing shapes. - Good, because validation can say precisely what is wrong and where.
- Neutral, because it adds a key to a schema that already has several.
- Bad, because two keys can express one concept, so the parser owes an exclusivity check and an error that explains it.
Architecture Diagram
More Information
- Extends ADR-0006 — adds
prompt_fileto the harness schema; the file remains hand-authored and the source of truth, and the new key holds a path so it stays one. - Extends ADR-0011 —
prompt_fileis an alternate source for the prompt 📝 ADR-0011 synthesizes into the agent argv;PromptCommandand the adapter interface are unchanged. - Related ADR-0008 —
prompt_filefollowsenv_filein keeping content out of the config, though a prompt is not a secret and the file is read into argv, which is world-visible in a process listing. Secrets still belong inenv_file. - Related ADR-0009 —
a relative
prompt_filein a projectharness.tomlresolves against the project root, matchingworkdirandenv_file. - Related ADR-0013 — scheduled one-shots are the motivating case, and the eager-validation rule follows the cron expression's precedent.
- Governs SPEC-0006 REQ "Prompt Source".
Related Artifacts
Direct relationships declared in YAML frontmatter (per ADR-0023 / SPEC-0018). Run /sdd:graph chain ADR-0018 for the transitive view.