Customize the system prompt
Prompt customization changes the instructions sent to the model; it does not bypass protocol schemas, layout validation, healing, or retry behavior. Treat it as a behavior layer on top of the same structured-output contract.
The reference LLM planner (llmPlanner from @maelstrom-co/planner-llm) builds
its prompt from named sections. You can override any
section by name without touching the others — the prompt-builder owns
joining, ordering, and template-variable substitution.
This page covers the section catalogue, the four customizer forms
(replace, prepend, append, transform), and when each one runs.
Before you start
Section titled “Before you start”- A wired engine (see Use a custom LLM provider).
- An installed
@maelstrom-co/planner-llmpackage. - Familiarity with the engine’s job — see The Engine.
The sections
Section titled “The sections”llmPlanner builds two halves of every prompt from a set of named sections.
Customizations target a key by name. The current set is:
| Half | Key | What it contains |
|---|---|---|
system |
identity |
Who the assistant is. Reads vars.assistantName. |
system |
triggers |
Update-trigger types (user intent, chart resize, vessel modified) and how to handle each. |
system |
layout |
Layout-mode-specific rules (grid bounds, tiling tree shape). |
system |
actions |
How to emit an execution plan. |
system |
rules |
Catch-all behavioral rules. |
user |
vessels |
The vessel + variant catalogue for the current request. |
user |
chartState |
The current chart state (instances, layout snapshot). |
user |
updateTriggers |
Why this turn is happening (user intent, action results, etc.). |
SystemSection and UserSection are exported from @maelstrom-co/planner-llm if
you want the string constants instead of typing the keys — useful when you
want autocomplete on section names:
import { SystemSection } from '@maelstrom-co/planner-llm';
prompt: { system: { [SystemSection.RULES]: { append: '...' } } };The four customizer forms
Section titled “The four customizer forms”A customizer is one of four shapes. Pick the smallest form that does the job:
| Form | Type | Use when |
|---|---|---|
| Replace | string |
The default content isn’t useful for your app. |
| Prepend/append | { prepend?, append? } |
You like the defaults and want to layer on top. |
| Transform | (defaults: string) => string |
You need to rewrite the default content programmatically. |
| Remove | null |
You want the section gone entirely. |
The transform form requires an existing default for the key. For new
section keys (anything outside the table above), only string and
{ prepend?, append? } are valid — there are no defaults to transform.
-
Pass a
promptblock tollmPlanner. Customizations are keyed by half (system/user) and then by section name.src/server/engine.ts import { createEngine } from '@maelstrom-co/engine';import { llmPlanner } from '@maelstrom-co/planner-llm';import { geminiText } from '@tanstack/ai-gemini';export const engine = createEngine({planner: llmPlanner({adapter: geminiText('<your-model-id>'),prompt: {vars: { assistantName: 'Aria' },system: {rules: { append: 'Always reply in the same language the user wrote.' },},},}),}); -
Pick the customizer form for each section. Mix forms freely across sections — they don’t have to agree.
prompt: {system: {// Replace — drop the default identity entirely.identity: 'You are a healthcare scheduling assistant.',// Append — keep the default rules, add a domain-specific one.rules: { append: 'Never speculate about diagnoses.' },// Transform — wrap the default with a stricter wrapper.triggers: (defaults) => `${defaults}\n\nRespond only to scheduling intents.`,// Remove — drop the actions section entirely (rare; see below).// actions: null,},}
End-to-end example
Section titled “End-to-end example”A healthcare scheduling app might keep the default layout and action rules, but add domain constraints to both prompt halves:
export const engine = createEngine({ planner: llmPlanner({ adapter: geminiText('<your-model-id>'), prompt: { vars: { assistantName: 'Aria' }, system: { identity: { append: 'You help clinic staff arrange scheduling tools.' }, rules: { append: 'Never speculate about diagnoses or treatment.' }, }, user: { updateTriggers: { append: 'Prefer the calendar and patient-search vessels for appointment requests.', }, }, }, }),});The system additions are stable policy. The user addition is request-time context that can depend on the current chart, tenant, or workflow.
Variations
Section titled “Variations”Add a new section
Section titled “Add a new section”The shape allows arbitrary keys, not just the defaults. Use this to inject a domain block the catalogue doesn’t cover:
prompt: { system: { domainContext: 'The user is operating a flight-booking app...', },}New keys accept only string or { prepend?, append? } — there is no
default content to transform.
Substitute template variables
Section titled “Substitute template variables”prompt.vars substitutes {{key}} placeholders inside any section default.
The reserved one is assistantName, which the default identity section
reads:
prompt: { vars: { assistantName: 'Aria' },}Custom sections you write can reference your own variables; they’re a plain key/value map of strings.
Customize the user half
Section titled “Customize the user half”User sections (vessels, chartState, updateTriggers) follow the same
four-form rule. The most common case is appending domain context to
updateTriggers:
prompt: { user: { updateTriggers: { append: 'Treat all medical terms as case-sensitive when matching vessels.', }, },}When each customizer runs
Section titled “When each customizer runs”This is the part that catches people out:
- System prompt is built once.
llmPlannerprecomputes the full system prompt at construction — one string perLayoutMode— and the stage indexes byrequest.context.layoutModeper turn. Anysystemcustomizer, in any form (string,{ prepend, append }, or function), is resolved whenllmPlanner(...)is called. The result is frozen. - User prompt is built per request. The user half depends on the current
vessels, chart state, and update reasons, so it cannot be precomputed.
Any
usercustomizer is invoked on everychart_request.
To see the difference, use the function form on both sides:
const engine = createEngine({ planner: llmPlanner({ adapter: /* ... */, prompt: { system: { // Called once during llmPlanner(...); the timestamp is frozen for this planner's lifetime. rules: (defaults) => `${defaults}\n\nEngine booted: ${new Date().toISOString()}`, }, user: { // Called per chart_request; the timestamp is fresh each turn. updateTriggers: (defaults) => `${defaults}\n\nNow: ${new Date().toISOString()}`, }, }, }),});Two practical consequences:
- A
systemcustomizer that closes over a value captured at app startup sees that value forever, even if it changes later. If you need per-request behavior, the customization belongs inuser, notsystem. - A misconfigured
systemcustomizer (function targeting a new key, or a function that throws) surfaces as a synchronous throw fromllmPlanner(...)—new Error('Invalid LlmPlannerOptions: prompt.system: …'). A misconfiguredusercustomizer first surfaces on the firstchart_requestasValidationErrorwithfield: 'prompt'.
Verify
Section titled “Verify”The fastest check is a log line on first call. @maelstrom-co/planner-llm exposes
buildPrompt, buildSystemPrompt, and buildUserPrompt for tests and dry runs:
import { buildSystemPrompt } from '@maelstrom-co/planner-llm';
const systemPrompt = buildSystemPrompt('grid', { vars: { assistantName: 'Aria' }, system: { rules: { append: 'Always reply in English.' } },});
console.log(systemPrompt);If the prompt prints with your override in place, the customization is wired.
Next steps
Section titled “Next steps”- Use a custom LLM provider — pair a customized prompt with the model that fits your tone.
- Conversation history — feed prior turns alongside the customized prompt.
- The Engine — how the engine validates and heals the model’s response after the prompt goes out.