Guides
Prompts and prompt files
getPrompt() is called before every model invocation, including after each tool response. This is how to build one from state, load it from a file, and let a transition override it.
Every step supplies its own system prompt. There is no global prompt, no template engine, and no prompt registry — just a method that returns a string. The interesting parts are when it is called, how a transition can override it, and where prompt text stops being trustworthy.
The getPrompt() contract
public getPrompt(): string | null
It is called immediately before every model invocation for that step. In a tool loop that means once per model turn:
getPrompt() -> model call -> tool call -> handler -> stay()
getPrompt() -> model call -> tool call -> handler -> go(Next)
Next.getPrompt() -> model call -> ...
Two consequences:
- the prompt can reflect state saved by the tool handler that just ran, which is how
ExploreStepre-injects an updated hotel list on the next turn; - it must be cheap. Do not call a network service from
getPrompt().
The returned string becomes the system message at index 0 of the step’s memory namespace,
replacing whatever was there. Returning null yields an empty system message.
It may read this step’s state, another step’s state through the flow, session context, static
files, and the environment. ExploreStep reads all four:
public getPrompt(): string {
const hotelJson = JSON.parse(HotelJSON);
const currentDate =
process.env.HOTEL_FLOW_CURRENT_DATE ?? moment().utc().format();
set(hotelJson, "currentDate", currentDate);
const hotelFound = this.getState("hotelFound");
if (hotelFound) {
set(hotelJson, "hotelFound", hotelFound);
}
return Prompt.replace(ExplorePrompt, {
HOTEL_JSON: JSON.stringify(hotelJson),
});
}
withPrompt() and the _prompt key
A transition can supply the destination’s prompt:
return go(TerminateSessionStep).withPrompt(DemoPrompt.AbruptEnd);
PicoFlow saves that text as _prompt in the destination step’s state. The base
getPrompt() returns it:
public getPrompt(): string | null {
const p = this.getState("_prompt");
return p ? p.toString() : null;
}
A subclass that overrides getPrompt() therefore hides it — unless it checks the base value
first. That is what the ?? idiom is for:
public getPrompt(): string {
return super.getPrompt() ?? "The normal prompt for this step.";
}
TerminateSessionStep uses exactly this pattern, which is why withPrompt(...) on a
transition into it changes the closing message.
_prompt is written with saveState(), so it is persisted in the session document and survives later turns. A step that keeps super.getPrompt() ?? fallback will keep returning the prompt from an old transition until another transition overwrites it. Call removeState("_prompt") when a one-shot prompt should not persist.
Use withPrompt(...) when the caller knows something about how the destination should
behave — “confirm the booking with this confirmation number”, “explain that the user asked to
exit”. Use a normal getPrompt() when the destination’s behaviour is intrinsic.
Prompt.file and prompt assets
Prompt.file(relativePath) reads a file synchronously, resolving the path relative to the
directory of the module that called it, and caches the contents by absolute path for the
lifetime of the process.
const PROMPT = Prompt.file("prompt/favorites.md");
const SCHEMA = Prompt.file("prompt/favorites.json");
Call it at module scope, not inside getPrompt(). The cache makes repeated calls cheap, but
module scope makes the dependency obvious and fails fast at import time if the file is
missing.
Because the path is resolved against the calling module, the files must be shipped next to
the compiled JavaScript. The demo does this with a postbuild step:
"postbuild": "copyfiles -u 1 'src/**/*.{json,md,png,pdf}' dist/"
A missing prompt file surfaces as an ENOENT at import time, which is much easier to debug
than a silently empty prompt.
Templates: replace, replace2 and set
Prompt files use a double-brace placeholder syntax.
Today is {{CurrentDate}}.
Available hotels:
{{HOTEL_JSON}}
Three helpers fill them in:
| Helper | Behaviour |
|---|---|
Prompt.replace(template, values) |
Replaces every {{key}}; a key with no value keeps the placeholder |
Prompt.replace2(template, values) |
Trims whitespace inside the braces and JSON.stringifys non-string values |
Prompt.set(prompt, key, value) |
Replaces only the first occurrence of one placeholder |
const template = `
Ask the user to provide the date of birth for {{UserName}}.
`;
const name = this.flow.getStepState<string>(NameStep, "name");
const prompt = Prompt.replace(template, { UserName: name });
replace2 is the one to reach for when values are objects or when the template has spaces
inside the braces:
const prompt = Prompt.replace2(PromptTemplate, {
internal_address: {
street: "123 Main St",
city: "Beaverton",
state: "OR",
zip: randomZip(),
},
});
Prompt.replace substitutes with values[key] || match. An empty string, 0 or false therefore leaves the raw placeholder visible in the prompt. Use replace2, which only skips undefined, or normalise the value before substituting.
Composing a prompt from parts
The demo flows compose a role file, a task partial, and the framework’s shared end-of-conversation instructions at module scope:
const ExplorePartial = Prompt.file("prompt/explore.md");
const ExplorePrompt = `
${HotelPrompt.Role}
${ExplorePartial}
${FlowPrompt.EndChat}
`;
FlowPrompt.EndChat is PicoFlow’s built-in text describing how and when to call
terminate_session. Including it is what makes “I’m done” work consistently across steps.
Keep the split along these lines:
- role file — identity and tone, shared by every step in the flow;
- task partial — what this stage must accomplish, and which tool to call when;
- example or schema file — a JSON example is often clearer than prose;
- runtime values — injected through
Prompt.replace, never hard-coded.
Prompt text is not a security boundary
This is the rule that matters most.
A prompt is a request to a probabilistic system. It can be misread, ignored, or overridden by user text that ends up in the same context window. So:
- validation belongs in the tool handler, not in the prompt —
AddressSteptells the model to send the raw text and letsValidateAddressdecide; - authorisation belongs in your application code, before the side effect;
- limits belong in the Zod schema and then again in the handler;
- secrets do not belong in prompt files at all — they ship as plain assets in
dist/; - anything a user typed that you interpolate into a prompt is untrusted input.
Write prompts that describe conversational behaviour: what to ask, when to call a tool, how to explain a rejection. Let deterministic code own the decision.
Failure modes
| Symptom | Cause |
|---|---|
| Placeholder text visible to the user | Key missing from the values object, or a falsy value with Prompt.replace |
ENOENT at startup |
Prompt.file path is wrong, or assets were not copied into dist/ |
| A stale prompt reappears on a later turn | _prompt is durable state; clear it with removeState("_prompt") |
withPrompt(...) has no effect |
The destination overrides getPrompt() without calling super.getPrompt() |
| The model ignores an instruction | The instruction is doing work that belongs in a handler or schema |
| Prompt changes never take effect | Prompt.file caches by absolute path for the process lifetime; restart |
Related: Prompt files and templates, Big prompts as spec files, and Authoring a step.