Dynamic Prompt Construction
The sectioned prompt from the last lesson is hardcoded. That's fine when there's one project, one sandbox, and one fixed toolset. The minute any of those move, the prompt has to move with them.
Different working directory. Different sandbox backend. A subagent that only gets read and grep. The hardcoded string can't carry any of that. A function can.
Outcome
A buildSystemPrompt(context) function in src/system.ts returns the system prompt string from a typed PromptContext. The agent's instructions are now derived from runtime state instead of pasted in.
Fast Track
- Create
src/system.tswith aPromptContextinterface and abuildSystemPrompt(ctx)function - Compose the prompt from sections, including optional sections like
gitBranchandprojectContext - Call
buildSystemPrompt(...)inindex.tsand pass the result toinstructions
Hands-on Exercise 3.2
Extract the prompt into a typed builder.
Requirements:
- Define
PromptContextwithworkingDirectory,sandboxType,toolNames, optionalgitBranch, optionalprojectContext - Write
buildSystemPrompt(ctx: PromptContext): stringthat returns the sectioned prompt - Make
gitBranchandprojectContextconditional, including their section only when set - In
index.ts, build the context object and callbuildSystemPrompt(ctx)for theinstructionsfield
Implementation hints:
- Push sections into an array and
join("\n")at the end. Plain string concat, no template engine - Conditional sections use a simple
if (ctx.foo) sections.push(...). Don't reach for fancier patterns - Keep
buildSystemPromptpure. Same context in, same prompt out, no side effects. That makes it unit-testable
The shape of context
The prompt depends on runtime state. Bottle that state into one object:
export interface PromptContext {
workingDirectory: string;
sandboxType: string;
toolNames: string[];
gitBranch?: string;
projectContext?: string;
}workingDirectory and sandboxType always apply. toolNames lets the prompt list the tools that are actually wired up (which matters when you give a subagent a subset). gitBranch and projectContext are optional because they're not always knowable.
The builder
export function buildSystemPrompt(ctx: PromptContext): string {
const sections: string[] = [];
sections.push(`You are a coding agent working in: ${ctx.workingDirectory}`);
sections.push(`Sandbox: ${ctx.sandboxType}`);
sections.push(`
# Agency
- USE your tools. Read files, search code, run commands, then answer.
- Do NOT explain what you WOULD do. Actually do it.
- Available tools: ${ctx.toolNames.join(", ")}`);
if (ctx.gitBranch) {
sections.push(`- Current branch: ${ctx.gitBranch}`);
}
sections.push(`
# Guardrails
- Prefer simple, minimal changes
- Search before creating, and reuse existing patterns
- No new dependencies without asking`);
if (ctx.projectContext) {
sections.push(`
# Project Instructions (from AGENTS.md)
${ctx.projectContext}`);
}
return sections.join("\n");
}There's no template engine here. There's no DSL. There's an array, a few push calls, and a join. That's deliberate. The prompt is a string. Building it should look like building a string.
Wire it in
In index.ts, replace the inline instructions literal with a call to the builder:
import { buildSystemPrompt } from "./src/system";
const instructions = buildSystemPrompt({
workingDirectory: cwd,
sandboxType: "local",
toolNames: Object.keys({ read, grep, bash }),
});
const agent = new ToolLoopAgent({
model: "anthropic/claude-haiku-4-5",
instructions,
tools: { read, grep, bash },
stopWhen: stepCountIs(10),
});The agent's behavior on a single task may look the same as before. The win is structural. Adding a git context line, swapping sandbox types, or stripping a section for a subagent now requires editing one focused function instead of finding and replacing inside a multiline string.
The prompt is the most important configuration the harness has. Making it a function means it's testable (assert what the output looks like for a given context), composable (add sections without touching others), replaceable (users can provide their own builder), and deterministic (same context, same prompt, every time). The cost is one file. The benefit shows up the third time you add a section.
Try It
Run any prompt you've used before:
bun run index.ts . "Find all TODO comments in this project"The output should be the same as the last lesson, because the prompt content is the same. The change is internal. Confirm the agent still has the tools it expects by logging the prompt itself once:
console.log(instructions);You should see the full Agency and Guardrails sections, with the working directory and tool names interpolated in.
npx tsc --noEmitCommit
git add src/system.ts index.ts
git commit -m "refactor(prompt): extract buildSystemPrompt with runtime context"Done-When
src/system.tsexportsPromptContextandbuildSystemPromptbuildSystemPromptreturns the same prompt content as the previous lesson when given the same contextgitBranchandprojectContextare optional and only included when providedindex.tscallsbuildSystemPrompt(...)instead of using an inline stringnpx tsc --noEmitpasses
Add a quick assertion: build the prompt with gitBranch: "main" and confirm the output contains "Current branch: main". Build it without gitBranch and confirm the line is absent. This is the smallest possible unit test for a prompt, and it catches the kind of bug that's almost impossible to spot by reading the model's output.
Solution
export interface PromptContext {
workingDirectory: string;
sandboxType: string;
toolNames: string[];
gitBranch?: string;
projectContext?: string;
}
export function buildSystemPrompt(ctx: PromptContext): string {
const sections: string[] = [];
sections.push(`You are a coding agent working in: ${ctx.workingDirectory}`);
sections.push(`Sandbox: ${ctx.sandboxType}`);
sections.push(`
# Agency
- USE your tools. Read files, search code, run commands, then answer.
- Do NOT explain what you WOULD do. Actually do it.
- Available tools: ${ctx.toolNames.join(", ")}`);
if (ctx.gitBranch) {
sections.push(`- Current branch: ${ctx.gitBranch}`);
}
sections.push(`
# Guardrails
- Prefer simple, minimal changes
- Search before creating, and reuse existing patterns
- No new dependencies without asking`);
if (ctx.projectContext) {
sections.push(`
# Project Instructions (from AGENTS.md)
${ctx.projectContext}`);
}
return sections.join("\n");
}import { buildSystemPrompt } from "./src/system";
const tools = { read, grep, bash };
const agent = new ToolLoopAgent({
model: "anthropic/claude-haiku-4-5",
instructions: buildSystemPrompt({
workingDirectory: cwd,
sandboxType: "local",
toolNames: Object.keys(tools),
}),
tools,
stopWhen: stepCountIs(10),
});Was this helpful?