Vercel Logo

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

  1. Create src/system.ts with a PromptContext interface and a buildSystemPrompt(ctx) function
  2. Compose the prompt from sections, including optional sections like gitBranch and projectContext
  3. Call buildSystemPrompt(...) in index.ts and pass the result to instructions

Hands-on Exercise 3.2

Extract the prompt into a typed builder.

Requirements:

  1. Define PromptContext with workingDirectory, sandboxType, toolNames, optional gitBranch, optional projectContext
  2. Write buildSystemPrompt(ctx: PromptContext): string that returns the sectioned prompt
  3. Make gitBranch and projectContext conditional, including their section only when set
  4. In index.ts, build the context object and call buildSystemPrompt(ctx) for the instructions field

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 buildSystemPrompt pure. 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:

src/system.ts
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

src/system.ts
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:

index.ts
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.

Why a function, not a 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:

Terminal
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:

index.ts (temporary)
console.log(instructions);

You should see the full Agency and Guardrails sections, with the working directory and tool names interpolated in.

Terminal
npx tsc --noEmit

Commit

git add src/system.ts index.ts
git commit -m "refactor(prompt): extract buildSystemPrompt with runtime context"

Done-When

  • src/system.ts exports PromptContext and buildSystemPrompt
  • buildSystemPrompt returns the same prompt content as the previous lesson when given the same context
  • gitBranch and projectContext are optional and only included when provided
  • index.ts calls buildSystemPrompt(...) instead of using an inline string
  • npx tsc --noEmit passes
Write a test for the prompt

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

src/system.ts
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");
}
index.ts
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?

supported.