ADR-0037 — Compound prompts: every prompt field is string | string[]
- Status: Accepted
- Date: 2026-06-12
- Constrained by: ADR-0006, ADR-0011, ADR-0015
- Amends: ADR-0019 (prompt composition section)
Context
Prompt content gets reused. Commit rules, tone guidance, project conventions — the same chunk wants to live in the primary agent prompt, in a workflow step, in a subagent. Until now the only options were copy-paste (drift, the worst outcome) or the implicit composition ADR-0019 defined for workflows: unconditionally prefixing the project primary prompt onto the workflow fragment.
The implicit prefix turned out to be the wrong call. The primary prompt is written for an open-ended chat agent; most of it is noise or contradiction for a constrained recipe. A workflow is the narrowing primitive — prefixing it with the widest prompt in the project works against its purpose, dilutes the workflow's instructions, and invites off-topic behavior. In practice the full primary prompt is never what a workflow wants; specific sections of it are.
We explored two designs for explicit reuse before landing here:
- Inline interpolation — a top-level
fragments:DSL block (name → path) and a{fragment:name}token substituted inside prompt files. - Path arrays — every prompt field accepts
string | string[]; array entries are prompt file paths; the harness loads each file and concatenates.
We chose path arrays.
Decision
Every prompt-bearing field in the project DSL — project primary system_prompt, workflow step prompt, subagent system_prompt, and any future prompt field — accepts string | string[].
A
stringis a single project-relative prompt file path (current behavior, unchanged).A
string[]is an ordered list of prompt file paths. The harness loads each file, trims it, and joins with a blank line:contents.map(s => s.trim()).join("\n\n")Paths only. Array entries are file paths, never inline prompt text. Prompts live in files: hot-reloadable, editor-managed, version-controlled (per ADR-0013's editor-as-prompt-manager posture).
Order is concatenation order. What you read in the YAML is what the model gets, top to bottom.
Validation: every entry is checked for file existence at project load, exactly like single prompt paths today. The existing check generalizes; nothing new at session start.
Federated override is set, not merge. Per ADR-0015, a prompt field in
project.local.yamlreplaces the portable value entirely — string over array, array over string, whatever is supplied wins. Arrays do not deep-merge, append, or splice. Surgical local changes are done by pointing at a local prompt file.Subproject boundary: paths resolve against the owning project's root via the existing URI prefixes (ADR-0011). A subproject's arrays cannot reach parent files; a parent cannot inject entries into a subproject agent's arrays. Included subprojects know nothing of the outside world. This is the existing portability rule applied, not a new rule.
Composition is visible. The debug view and audit log show the fully composed prompt — the exact text the model received — same as today. The YAML additionally shows the recipe at a glance.
There is no fragments: block and no name indirection. A reusable chunk is a file; reuse is listing its path in more than one array.
Why not interpolation
The {fragment:name} design was workable but every one of its problems is structural, and the array design deletes them rather than mitigating them:
- Placeholder grammar. Bare braces collide with legitimate prompt content (JSON examples, code, tool schemas), forcing either silent pass-through of typos (confabulation fuel) or false-positive errors. A namespaced token reduces but doesn't eliminate this, and adds escaping rules.
- A template language is a slippery slope. A working include mechanism invites parameters, conditionals, filters — Jinja-in-prompts. The array design forecloses this structurally: there is nothing to grow syntax onto. (Note:
{{ }}interpolation in DSLwith:strings is a separate, deliberately fenced mechanism — DSL strings only, never prompt file content.) - Composition as data beats composition as prose. With arrays, what a prompt is made of is readable from the YAML without opening any file. With interpolation, it's buried mid-file.
- Mid-line insertion was never needed. Reused chunks are block-level (rules, conventions, sections). Appending whole blocks with
\n\ncovers the real use case with zero parsing.
The cost: no single point of indirection. Locally swapping one shared file means restating each array that references it in project.local.yaml, where a fragments: block would have been one override. Accepted — it's rare, and the restated array is at least honest about exactly what that host runs.
Consequences
What this commits us to
- DSL schema change: prompt fields are
z.union([z.string(), z.array(z.string()).nonempty()])everywhere. - Harness: one shared compose step — load, trim, join — applied uniformly to every prompt field it resolves (primary, step, subagent). One function, no per-surface variants, no drift.
- Spec amendments:
specs/project-dsl.md(field types),specs/workflows.md(composition section),specs/agent.md(compose path). JSON Schema atkaged.dev/schema/v1.jsonupdated. - ADR-0019's prompt composition section is superseded: workflows no longer receive an implicit primary-prompt prefix. A workflow step's prompt is exactly its array, nothing prepended. (Workflow structural changes are ADR-0038.)
What this forecloses
- No template syntax inside prompt files. Prompt files are static text, full stop.
- No inline prompt strings in arrays. Paths only.
- No array merge semantics in federated config. Set wins; the question of append-vs-replace never has to be answered.
- No fragment nesting. There are no fragments — a file cannot "include" another file. Composition has exactly one level: the array.
- No implicit prompt inheritance anywhere. If content should appear in two places, both places list the file. The principle, stated once: scalars inherit, content composes explicitly.
What becomes easier
- Reusing a chunk across primary / workflow steps / subagents without copy-paste drift.
- Reading what a prompt is composed of: it's right there in the YAML.
- Editing shared content: one file, hot-reload, every referencing prompt picks it up on next compose.
What becomes harder
- Local override of a widely-shared file touches every array that lists it (see cost above).
- Authoring gains a micro-decision: one file or several? Guidance: split when a chunk is reused, not before.
Alternatives considered
Alternative A — Implicit primary-prompt prefix (status quo, ADR-0019)
Why tempting: Zero authoring effort; project context comes along for free.
Why rejected: The full primary prompt is never what a constrained run wants. Dilutes workflow instructions, invites drift off-task, and the operator must read two sources to understand one run. Invisible composition is the disease, not the cure.
Alternative B — fragments: block + {fragment:name} interpolation
Why tempting: Named indirection; positional insertion; single override point in federated config.
Why rejected: See "Why not interpolation." Placeholder grammar, escaping, collision handling, and a structural invitation to grow a template language — all to support mid-line insertion nobody needs.
Alternative C — Full template language in prompt files (Jinja-class)
Why tempting: Maximum flexibility; conditionals and parameters solve hypothetical future needs.
Why rejected: Violates "prompt is everything" — the prompt file stops being the prompt and becomes a program that emits one. Audit story degrades, hot-reload semantics complicate, and the flexibility serves no current need. If we do something twice it should be a variable — a variable, not a function.