Skip to content

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.

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: '...' } } };

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.

  1. Pass a prompt block to llmPlanner. 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.' },
    },
    },
    }),
    });
  2. 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,
    },
    }

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.

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.

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.

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.',
},
},
}

This is the part that catches people out:

  • System prompt is built once. llmPlanner precomputes the full system prompt at construction — one string per LayoutMode — and the stage indexes by request.context.layoutMode per turn. Any system customizer, in any form (string, { prepend, append }, or function), is resolved when llmPlanner(...) 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 user customizer is invoked on every chart_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:

  1. A system customizer 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 in user, not system.
  2. A misconfigured system customizer (function targeting a new key, or a function that throws) surfaces as a synchronous throw from llmPlanner(...) — new Error('Invalid LlmPlannerOptions: prompt.system: …'). A misconfigured user customizer first surfaces on the first chart_request as ValidationError with field: 'prompt'.

The fastest check is a log line on first call. @maelstrom-co/planner-llm exposes buildPrompt, buildSystemPrompt, and buildUserPrompt for tests and dry runs:

scripts/preview-prompt.ts
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.