Write system prompts
@warlock.js/ai ships a tiny prompt-composition surface. Three factories — ai.systemPrompt(), ai.persona(), ai.instruction() — compose into the systemPrompt option accepted by every agent and workflow step.
The whole point: keep prompts versioned, forkable, and free of brittle string concatenation.
The three factories
Section titled “The three factories”import { ai } from "@warlock.js/ai";
ai.systemPrompt(); // empty — chain blocks onto itai.systemPrompt("literal text"); // one-shot string formai.systemPrompt([block1, block2]); // array form — blocks render in declaration order
ai.persona("You are Alex."); // PersonaContract blockai.instruction("Reply in Arabic."); // InstructionContract blockThree usage shapes
Section titled “Three usage shapes”String form — one-shot
Section titled “String form — one-shot”ai.agent({ model, systemPrompt: "You are a concise senior TypeScript engineer.",});Fine for prototypes. Doesn’t scale to multiple agents that share a persona.
Builder form — composable
Section titled “Builder form — composable”const prompt = ai.systemPrompt() .persona("You are Alex, a senior TypeScript engineer.") .instruction("Explain things assuming the reader is a Go developer.") .instruction("Always cite the relevant TypeScript handbook section.");
const myAgent = ai.agent({ model, systemPrompt: prompt });Each call returns a new SystemPrompt. Chain to add blocks; the original is never mutated.
Array form — explicit order
Section titled “Array form — explicit order”ai.systemPrompt([ ai.persona("You are Alex, a TypeScript expert."), ai.instruction("Respond in {{language|English}}."),]);Useful when you have prebuilt persona / instruction blocks reused across many prompts.
Merge predefined blocks
Section titled “Merge predefined blocks”base.merge(...blocks) folds N pre-built blocks into a builder in one call. Each block is folded in order through the same per-type rules as the chained methods: a persona block sets or replaces the single, leading persona; every other block (e.g. an instruction) is appended in the order given. The builder is immutable — the original is untouched — and merge is exactly equivalent to chaining the same blocks.
const reviewer = ai.persona("You are a meticulous code reviewer.");const style = ai.instruction("Prefer small, focused diffs.");const lang = ai.instruction("Respond in {{language|English}}.");
const base = ai.systemPrompt().instruction("Cite the line numbers you reference.");
const prompt = base.merge(reviewer, style, lang);// equivalent to:// base.persona(reviewer).instruction(style).instruction(lang)So base.merge(reviewer, style, lang) keeps base’s existing instruction, prepends reviewer as the leading persona, then appends style and lang in order. Compose reusable persona / instruction blocks once and merge the relevant set per agent.
Identity — .meta() and auto-registration
Section titled “Identity — .meta() and auto-registration”A prompt carries optional identity + provenance metadata: name, version, description, required (placeholder keys callers must supply), and composedFrom (provenance — see below). Attach it at build time or with the immutable .meta() updater:
// At build time — the third-form factory takes a meta object.const support = ai.systemPrompt("You are senior support for {{product}}.", { name: "support", version: "1", description: "Tier-1 support persona.", required: ["product"],});
// Or update an existing builder — returns a NEW builder, original untouched.const renamed = base.meta({ name: "support", version: "2" });
renamed.meta(); // { name: "support", version: "2", composedFrom?: [...] }base.meta(); // unchangedNaming a prompt auto-registers it in ai.prompts under name@version (version defaults to the next integer). An anonymous prompt — no name — is never registered and exists only for inline composition. Read the snapshot with the no-argument prompt.meta(); it’s undefined for a bare anonymous prompt.
Merge another prompt — by value or by name
Section titled “Merge another prompt — by value or by name”Beyond merging loose blocks, .merge folds in a whole prompt — its persona replaces, its instructions append — and records deterministic provenance in the result’s meta.composedFrom (e.g. ["base@2", "global-instructions@1"], no random suffixes):
// Fold in a prompt instance by value.const composed = ai.systemPrompt().instruction("Cite line numbers.").merge(reviewerPrompt);
// Fold in a prompt registered in `ai.prompts` by name — latest version by default.const fromRegistry = ai.systemPrompt().merge("global-instructions");
// Pin the version to fold in.const pinned = ai.systemPrompt().merge("global-instructions", { fromVersion: "1" });
composed.meta()?.composedFrom; // deterministic source labelsMerging by name resolves through ai.prompts; an unknown name (or requested version) throws InvalidRequestError. All three forms are immutable — the original builder is never touched.
Validate this prompt
Section titled “Validate this prompt”.validate(options?) is sugar over ai.prompts.validate(this, options). It always runs the deterministic missing-placeholder check, and — when options.judge is a model — the Nova-safe LLM-as-judge quality pass:
const report = await support.validate({ placeholders: { product: "Warlock" } });report.ok; // true when no required {{key}} is missing (deterministic)report.missing; // []
// Add an optional judge for a quality score — it never flips `ok`.const graded = await support.validate({ placeholders: { product: "Warlock" }, judge: openai.model({ name: "gpt-4o-mini" }),});graded.score; // 0..1, or undefined if the judge degradedHow blocks order
Section titled “How blocks order”SystemPrompt stores blocks: readonly SystemPromptBlockContract[] — not separate persona + instructions fields. Rendering honors insertion order.
- Chained
.persona(x)— replaces the existing persona in place, or prepends when none exists. Default persona-first layout. - Chained
.instruction(y)— appends. - Array form — verbatim.
Immutability — safe forking
Section titled “Immutability — safe forking”Every mutation returns a new SystemPrompt. The original is never touched:
const base = ai.systemPrompt().persona(alex).instruction(cite);const arabic = base.instruction("Prefer Arabic comments");
// `base` still has 2 blocks. `arabic` has 3. Neither affects the other.Persona and Instruction blocks follow the same rule — their text is readonly. Safe to share base prompts across multiple agents; safe to fork; nothing reaches back to mutate state you didn’t intend.
Mustache placeholders
Section titled “Mustache placeholders”{{key}} and {{key|defaultValue}} substitute at render time:
const prompt = ai.systemPrompt() .persona("You are Alex, a TypeScript expert.") .instruction("Respond in {{language|English}}.");
await myAgent.execute("Why use generics?", { placeholders: { language: "Arabic" },});Or set defaults on the agent — per-call values override them:
ai.agent({ model, systemPrompt: prompt, placeholders: { language: "Arabic" },});Substitution works on the rendered concatenation of every block, so {{key}} inside a persona and inside an instruction both resolve against the same placeholder bag.
Per-call overrides
Section titled “Per-call overrides”Replace the agent’s system prompt for a single run:
await myAgent.execute(input, { systemPrompt: alternativePrompt });Useful for A/B testing, request-scoped personalization, turn-by-turn prompt variation.
Forking a base prompt
Section titled “Forking a base prompt”const base = ai.systemPrompt() .persona("You are a support agent for Acme Corp.") .instruction("Cite policy §{{policy}} when denying a refund.");
const enterprise = base.instruction("Escalate immediately for Enterprise customers.");const trial = base.instruction("Offer a 14-day extension before closing the ticket.");Three distinct prompts, one common foundation. Base is immutable — safe to share across modules.
Why tagged discriminator, not instanceof
Section titled “Why tagged discriminator, not instanceof”All blocks implement SystemPromptBlockContract { readonly type: string; readonly text; resolve() }. Runtime discrimination uses the string type tag ("persona", "instruction") — NOT instanceof.
Why: instanceof breaks across duplicate package copies (different node_modules trees), across realms, across bundler scopes. Tagged unions don’t.
Related
Section titled “Related”- Prompt registry —
ai.prompts, the process-widename@versionstore a named prompt auto-registers into, plusvalidate/diff/export. - Refine prompts —
.refined({ model, criteria, store }), the prompt compiler: lazily rewrite this builder into a model-optimized version, pinned like a lockfile. - Run agent —
systemPrompton the factory and per-call override. - Run workflow — per-step agents inherit their own system prompts.