Skip to content
Warlock.js v4.7.0

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.

import { ai } from "@warlock.js/ai";
ai.systemPrompt(); // empty — chain blocks onto it
ai.systemPrompt("literal text"); // one-shot string form
ai.systemPrompt([block1, block2]); // array form — blocks render in declaration order
ai.persona("You are Alex."); // PersonaContract block
ai.instruction("Reply in Arabic."); // InstructionContract block
ai.agent({
model,
systemPrompt: "You are a concise senior TypeScript engineer.",
});

Fine for prototypes. Doesn’t scale to multiple agents that share a persona.

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.

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.

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(); // unchanged

Naming 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 labels

Merging 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(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 degraded

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.

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.

{{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.

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.

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.

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.

  • Prompt registryai.prompts, the process-wide name@version store a named prompt auto-registers into, plus validate / 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 agentsystemPrompt on the factory and per-call override.
  • Run workflow — per-step agents inherit their own system prompts.