Format-Aware Prompt Builder

Agentic AI Systems intermediate Building with AI ~25 min read

Overview

Sending well-formed prompts to large language model APIs is deceptively complex. A raw string passed to the Anthropic Messages API may be syntactically valid yet fail to reflect house style, miss format-specific constraints, or omit the few-shot examples that steer model output into the right shape. The Format-Aware Prompt Builder solves this by assembling every prompt from composable, versioned sources: a master style guide that defines voice and persona, a set of per-format rules that constrain structure (markdown tables, JSON envelopes, MDX front-matter), a curated pool of numbered examples from which the newest N are selected at build time, and metadata bullet lists that inject run-time context like author, date, and target audience.

The assembly process is not simple concatenation. Each source has a priority, and when two sources produce conflicting instructions the builder resolves them deterministically rather than silently overwriting. The style guide provides defaults; format-specific rules override relevant defaults; metadata values are interpolated into placeholders in both. Example blocks are appended last and trimmed to fit within a configurable token budget, ensuring that the final prompt never exceeds the window that downstream caching or context-compression pipelines expect. The result is a prompt that is simultaneously short (no redundant boilerplate), consistent (same brand voice every call), and contextually accurate (current metadata, freshest examples).

This topic covers the design of the prompt assembly pipeline, the rule priority resolution algorithm, the newest-N example selection strategy, and the TypeScript patterns that implement a reusable PromptBuilder class. Understanding format-aware construction is essential for teams that generate many prompt variants — documentation generators, content pipelines, multi-tenant SaaS products — where manually authoring each prompt would be impractical and error-prone.

Core Concepts

Style Guide Merging

The master style guide is a structured document that defines global defaults: tone (formal, conversational), persona (assistant name, role description), prohibited phrases, required sign-offs, and output language. During prompt assembly the builder loads this guide as a base layer. Every other source — format rules, metadata — is merged on top using a shallow-merge strategy: keys present in a later source replace the corresponding defaults from the style guide, while keys absent from later sources fall through to the style guide value. This ensures every generated prompt inherits brand voice without the caller having to repeat global instructions.

Format-Specific Rules

Different output formats impose different structural requirements. A prompt requesting a markdown table must instruct the model to emit a pipe-delimited header row; a prompt requesting a JSON envelope must constrain the model to valid JSON with a specific schema; a prompt requesting MDX front-matter must enforce YAML key ordering. Format-specific rule sets live in separate files keyed by format name ("markdown-table", "json-envelope", "mdx-frontmatter"). The builder loads the rule set matching the requested output format and merges it over the style guide layer, overriding only the fields that are format-relevant while leaving global voice settings intact.

Example Selection (Newest-N)

Few-shot examples dramatically improve model output quality but consume tokens. The builder maintains a numbered example pool — each example tagged with a sequence number and a format label — and selects the N most recently added examples for the matching format. “Most recently added” is defined by sequence number rather than wall-clock time, making the selection deterministic and reproducible. Selected examples are appended to the prompt in ascending sequence order (oldest first, newest last) so the model sees the most current stylistic conventions immediately before it must produce output. The value of N is capped by the remaining token budget after the instruction layers have been assembled.

Metadata Injection

Run-time metadata — author name, document title, target audience, publication date, tenant ID — must appear in the prompt but differs for every call. Rather than string-concatenating metadata into a hardcoded template, the builder uses a placeholder syntax ({{author}}, {{date}}) in the style guide and format rule strings. At assembly time a single interpolation pass replaces all placeholders with values from the metadata map passed by the caller. Unresolved placeholders are either replaced with a configurable default or cause the builder to throw, depending on the strict-mode flag. This keeps template files readable and decouples the prompt shape from the runtime data.

Token Budget Management

LLM APIs charge per token and impose context window limits. Prompt caching on the Anthropic API requires the cached prefix to be stable across calls — inserting variable-length content early in the prompt defeats caching. The builder tracks a token budget and allocates it in fixed priority order: style guide instructions consume the first slice, format rules consume the second, metadata the third, and examples fill whatever remains up to a configured maximum. Each layer is tokenized using a lightweight approximation (characters divided by a words-per-token ratio) before a final check against the hard limit. If examples must be truncated, the builder removes from the oldest end first, preserving the newest examples that are most representative of current style.

How It Works

The prompt assembly pipeline processes a build request through four sequential stages: source loading, rule merging, metadata interpolation, and example selection with token budgeting.

Prompt Assembly Pipeline

A build request carries three inputs: the desired output format (e.g., "markdown-table"), a metadata map of key-value pairs, and an optional override map that the caller can use to pin specific prompt fields for a single call. The pipeline loads the style guide from a shared config store (typically a JSON or YAML file checked into the repo). It then loads the format rule set corresponding to the requested format. Both sources are passed to the merge stage. After merging, the interpolation stage walks every string value in the merged document and expands {{key}} placeholders using the metadata map. The example selector then queries the example pool for entries matching the format, sorts by sequence number descending, takes the top candidates that fit the remaining token budget, and appends them as an ordered few-shot block. The final assembled prompt is returned as a plain string ready for the messages array of an Anthropic API request.

Rule Priority Resolution

Merging two rule sets that may define the same key requires a deterministic resolution strategy. The builder uses a three-level priority stack: (1) caller overrides are highest priority and always win, (2) format-specific rules override style guide defaults for any key they define, and (3) the style guide provides the fallback for any key not covered by format rules or caller overrides. Merging is shallow by default — nested objects in format rules replace the entire corresponding nested object in the style guide rather than being recursively merged. This predictable behavior prevents subtle bugs where a format rule partially overrides a nested style guide object and leaves unexpected keys from the guide mixed in. When deep merge is needed for a specific key, the caller can annotate it with a __merge: "deep" directive in the format rule file.

Example Selection Strategy

The example pool is an append-only log of numbered entries. Each entry carries a sequence number (auto-incremented integer), the output format it demonstrates, the input prompt, and the expected model output. Selection proceeds as follows: first, the pool is filtered to entries whose format label matches the requested format; second, entries are sorted by sequence number in descending order; third, entries are added to the selection greedily from the top of the sorted list until the token budget for examples would be exceeded; fourth, the selected entries are reversed back to ascending order for inclusion in the prompt. This newest-first selection ensures that recent stylistic revisions (a new table header format, an updated JSON schema) are always represented, while the ascending output order presents examples in the natural progression a model finds easiest to follow.

Implementation Patterns

PromptBuilder Class

A TypeScript class that encapsulates style guide loading, rule merging, metadata interpolation, and example selection behind a single build() method.

import Anthropic from "@anthropic-ai/sdk";
import * as fs from "fs";

interface StyleGuide {
  persona: string;
  tone: string;
  prohibitedPhrases: string[];
  signOff: string;
  [key: string]: unknown;
}

interface FormatRules {
  format: string;
  structureInstructions: string;
  outputConstraints: string;
  [key: string]: unknown;
}

interface ExampleEntry {
  seqNum: number;
  format: string;
  input: string;
  output: string;
}

interface BuildRequest {
  format: string;
  metadata: Record<string, string>;
  overrides?: Record<string, unknown>;
  maxExamples?: number;
  tokenBudget?: number;
}

export class PromptBuilder {
  private styleGuide: StyleGuide;
  private formatRulesDir: string;
  private examplePool: ExampleEntry[];
  private client: Anthropic;

  constructor(
    styleGuidePath: string,
    formatRulesDir: string,
    examplePoolPath: string
  ) {
    this.styleGuide = JSON.parse(fs.readFileSync(styleGuidePath, "utf8"));
    this.formatRulesDir = formatRulesDir;
    this.examplePool = JSON.parse(fs.readFileSync(examplePoolPath, "utf8"));
    this.client = new Anthropic();
  }

  build(request: BuildRequest): string {
    const { format, metadata, overrides = {}, maxExamples = 3, tokenBudget = 2000 } = request;

    // Stage 1: load format rules
    const rulesPath = `${this.formatRulesDir}/${format}.json`;
    const formatRules: FormatRules = fs.existsSync(rulesPath)
      ? JSON.parse(fs.readFileSync(rulesPath, "utf8"))
      : { format, structureInstructions: "", outputConstraints: "" };

    // Stage 2: merge layers (style guide < format rules < caller overrides)
    const merged = this.mergeLayers(this.styleGuide, formatRules, overrides);

    // Stage 3: interpolate metadata placeholders
    const interpolated = this.interpolate(merged, metadata);

    // Stage 4: build instruction block and measure tokens
    const instructionBlock = this.buildInstructionBlock(interpolated);
    const instructionTokens = this.estimateTokens(instructionBlock);
    const remainingBudget = tokenBudget - instructionTokens;

    // Stage 5: select and append examples
    const examples = this.selectExamples(format, maxExamples, remainingBudget);
    const exampleBlock = this.buildExampleBlock(examples);

    return [instructionBlock, exampleBlock].filter(Boolean).join("\n\n");
  }

  private mergeLayers(
    guide: StyleGuide,
    rules: FormatRules,
    overrides: Record<string, unknown>
  ): Record<string, unknown> {
    return { ...guide, ...rules, ...overrides };
  }

  private interpolate(
    doc: Record<string, unknown>,
    metadata: Record<string, string>
  ): Record<string, unknown> {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(doc)) {
      if (typeof value === "string") {
        result[key] = value.replace(/\{\{(\w+)\}\}/g, (_, k) => metadata[k] ?? `{{${k}}}`);
      } else {
        result[key] = value;
      }
    }
    return result;
  }

  private buildInstructionBlock(doc: Record<string, unknown>): string {
    const lines: string[] = [];
    if (doc.persona) lines.push(`You are ${doc.persona}.`);
    if (doc.tone) lines.push(`Tone: ${doc.tone}.`);
    if (doc.structureInstructions) lines.push(String(doc.structureInstructions));
    if (doc.outputConstraints) lines.push(String(doc.outputConstraints));
    if (doc.signOff) lines.push(String(doc.signOff));
    return lines.join("\n");
  }

  private estimateTokens(text: string): number {
    // Approximate: ~4 characters per token
    return Math.ceil(text.length / 4);
  }

  private selectExamples(
    format: string,
    maxN: number,
    tokenBudget: number
  ): ExampleEntry[] {
    const candidates = this.examplePool
      .filter((e) => e.format === format)
      .sort((a, b) => b.seqNum - a.seqNum); // newest first

    const selected: ExampleEntry[] = [];
    let usedTokens = 0;

    for (const entry of candidates) {
      if (selected.length >= maxN) break;
      const entryTokens = this.estimateTokens(entry.input + entry.output);
      if (usedTokens + entryTokens > tokenBudget) break;
      selected.push(entry);
      usedTokens += entryTokens;
    }

    // Return in ascending order (oldest first) for natural few-shot presentation
    return selected.sort((a, b) => a.seqNum - b.seqNum);
  }

  private buildExampleBlock(examples: ExampleEntry[]): string {
    if (examples.length === 0) return "";
    const blocks = examples.map(
      (e, i) => `Example ${i + 1}:\nInput: ${e.input}\nOutput: ${e.output}`
    );
    return "--- Examples ---\n" + blocks.join("\n\n");
  }

  async send(systemPrompt: string, userMessage: string): Promise<string> {
    const response = await this.client.messages.create({
      model: "claude-opus-4-5",
      max_tokens: 1024,
      system: systemPrompt,
      messages: [{ role: "user", content: userMessage }],
    });
    const block = response.content[0];
    return block.type === "text" ? block.text : "";
  }
}

Template Merging Logic with Deep Merge Support

Utility functions for shallow and deep merging of rule layers, including support for the __merge: "deep" directive in format rule files.

type MergeDirective = "shallow" | "deep";

interface MergeableValue {
  __merge?: MergeDirective;
  [key: string]: unknown;
}

/**
 * Merges two plain objects. For nested objects, the merge mode is determined
 * by a __merge directive on the overlay value. Defaults to shallow.
 */
export function mergeRuleLayers(
  base: Record<string, unknown>,
  overlay: Record<string, unknown>
): Record<string, unknown> {
  const result: Record<string, unknown> = { ...base };

  for (const [key, overlayValue] of Object.entries(overlay)) {
    if (key === "__merge") continue; // directive key, not a real field

    const baseValue = base[key];

    if (
      overlayValue !== null &&
      typeof overlayValue === "object" &&
      !Array.isArray(overlayValue) &&
      (overlayValue as MergeableValue).__merge === "deep" &&
      baseValue !== null &&
      typeof baseValue === "object" &&
      !Array.isArray(baseValue)
    ) {
      // Deep merge: strip the directive key and recurse
      const { __merge, ...overlayRest } = overlayValue as MergeableValue;
      result[key] = mergeRuleLayers(
        baseValue as Record<string, unknown>,
        overlayRest
      );
    } else {
      // Shallow merge: overlay wins entirely
      result[key] = overlayValue;
    }
  }

  return result;
}

/**
 * Resolves the final merged rule set for a given format, applying
 * caller overrides last. Returns an immutable snapshot.
 */
export function resolveRules(
  styleGuide: Record<string, unknown>,
  formatRules: Record<string, unknown>,
  callerOverrides: Record<string, unknown> = {}
): Readonly<Record<string, unknown>> {
  const afterFormat = mergeRuleLayers(styleGuide, formatRules);
  const afterOverrides = mergeRuleLayers(afterFormat, callerOverrides);
  return Object.freeze(afterOverrides);
}

/**
 * Validates that all placeholders in a rule set have corresponding
 * metadata keys. Returns an array of unresolved placeholder names.
 */
export function findUnresolvedPlaceholders(
  rules: Record<string, unknown>,
  metadata: Record<string, string>
): string[] {
  const placeholderPattern = /\{\{(\w+)\}\}/g;
  const unresolved = new Set<string>();

  function scan(value: unknown): void {
    if (typeof value === "string") {
      let match: RegExpExecArray | null;
      while ((match = placeholderPattern.exec(value)) !== null) {
        const key = match[1];
        if (!(key in metadata)) {
          unresolved.add(key);
        }
      }
    } else if (Array.isArray(value)) {
      value.forEach(scan);
    } else if (value !== null && typeof value === "object") {
      Object.values(value as Record<string, unknown>).forEach(scan);
    }
  }

  Object.values(rules).forEach(scan);
  return Array.from(unresolved);
}

// Example usage
const styleGuide = {
  persona: "a technical writing assistant for {{author}}",
  tone: "precise and concise",
  signOff: "Generated on {{date}}.",
};

const mdxRules = {
  format: "mdx-frontmatter",
  structureInstructions:
    "Begin every document with YAML front-matter: title, description, tags.",
  outputConstraints: "Emit only valid MDX. No raw HTML outside of JSX components.",
  tone: { __merge: "deep", override: "formal" }, // deep merge demo
};

const metadata = { author: "Esniff", date: "2026-04-11" };

const resolved = resolveRules(styleGuide, mdxRules, {});
const missing = findUnresolvedPlaceholders(resolved, metadata);
// missing === [] — all placeholders have values

Common Pitfalls

Further Reading