Concepts
Everything conceptual in one place: prompts, versions, labels, tags, variables, placeholders, the get/compile split, the exact data shapes, version resolution, and how caching keeps a fetch off your critical path.
Prompt management has a small vocabulary and a small set of data structures. Learn them once here — the feature pages are then just how-to.
The object model
Three nouns for shipping, plus tags for finding.
Prompt
A named container your app fetches by (refund-agent). Stable identity, unique per team. Carries tags. A / in the name renders as a folder.
Version
An immutable snapshot of the content — messages, model, tools, variables, output format — identified by a content-addressed hash (git-style). You never edit a version; you save a new one, and full history is kept.
Label
A movable pointer (production, staging) that names exactly one version. Deploying is moving the label.
Tag
Per-prompt taxonomy (support, billing), shared across every version. For finding prompts, not deploying them.
Versions and labels, visually
Each save cuts a new immutable version. A label is a chip you move onto whichever version is live — deploying and rolling back are the same one gesture.
refund-agent → follows production → gets the deployed version. Shipping an earlier one instead is dragging the production chip down to it — no redeploy. Rolling back drags it up. Full history is kept — every version stays, identified by its own hash, so any of them is one label-move away. (Versions are shown by hash in the app; the numbers here are just for the picture.)Labels vs tags — the one distinction to internalise. A label is a per-version deploy pointer; moving it is a deployment. A tag is per-prompt taxonomy shared across every version; it's for organisation. They never touch the same field. Deployment mechanics live in Version control.
What a version contains
Unlike a plain-string registry, a version stores everything needed to make the call — so a model swap or a temperature change ships as a version, not a code change:
- messages — a chat array with real roles, not one blob of text.
- model — provider, model, temperature, max tokens, and the rest of the generation params.
- tools — MCP servers and function tools attached by reference.
- variables — declared, each with an optional default.
- response format — optional JSON mode or JSON schema.
get gives you the prompt; compile fills it in
Two calls, and it's worth being exact about which does what.
get(name) → the raw prompt
Returns a ManagedPrompt: all the content pieces (messages still hold {{vars}}) plus the variable declarations and metadata. Over the network, cached. Read-only from the SDK — you author it in the app.
compile(values) → messages filled in
Returns a CompiledPrompt. Renders the messages with your values and carries model / tools / response_format through untouched. Local, no network. The only step that transforms anything.
get(name) hands you the whole prompt. The messages are still raw — {{company}} is right there in the text, unfilled:
const prompt = await trodo.prompts.get('refund-agent');
// {
// messages, // RAW — still contains {{company}}, {{question}}
// model, // the generation config
// tools,
// response_format,
// variables, // the DECLARATIONS: name + optional default
// name, version, labels, tags, …
// compile() // bound method, below
// }compile(values) does one job: render the messages. You hand it a value for each variable; it fills the {{tokens}} and expands any placeholders. The other three pieces — model, tools, response_format — are static, so they pass straight through, unchanged:
const { messages, model, tools, response_format } = prompt.compile({
company: 'Acme',
question: 'where is my order?',
});
// messages → RENDERED: {{company}} became "Acme"
// model → the same config, passed through
// tools → unchanged
// response_format → unchangedOne name — model. The generation config is called model on the fetched ManagedPrompt and model on the CompiledPrompt — the same object, same name, everywhere (SDK, wire, dashboard). compile() never touches it; it only exists on the result for convenience so you have one payload to send.
Variables are a plain key → value map. The key is the exact name you declared on the prompt in the app; the value is what you supply at that call site. A variable you leave out falls back to its default (or empty) — never an error. Passing a key the prompt doesn't declare throws, before any model call — see Variables & templating.
The shapes in detail
get resolves to a ManagedPrompt; compile produces a CompiledPrompt. These are the two shapes your code touches.
Messages
A message has a role and a content array of typed blocks. Storing the system prompt as a message (rather than a side field) is what lets Trodo place it correctly for each provider — Anthropic takes it as a top-level param, Gemini as systemInstruction.
{
"role": "system",
"content": [{ "type": "text", "text": "You are a support agent for {{company}}." }]
}Roles are system, user, assistant, and tool — any of them, any number of times, in any order (multiple system messages are allowed). Content blocks are typically text, but the array also carries image and tool blocks.
Placeholders vs variables
Two different substitution concepts, easy to confuse:
- A variable fills text —
{{company}}becomes a string. - A placeholder injects a list of messages at a point in the conversation — chat history, most often. It's a
messages-typed variable, rendered as a placeholder node:
{ "type": "placeholder", "name": "chat_history" }At compile time you pass a string for a variable and an array of messages for a placeholder. Injected messages pass through untouched — their text is never re-rendered, so history can safely contain {{braces}}. Details in Variables & templating.
Variable declarations
Every variable is declared — a name and an optional default. Knowing the declared set is what lets compile() catch a value passed for a variable that doesn't exist (a typo), instead of silently rendering the wrong prompt:
[
{ "name": "company", "default": "your company" },
{ "name": "question" },
{ "name": "history", "type": "messages" }
]The only meaningful type is messages — a placeholder. Scalar variables are just text, so they carry no type. Nothing is required: a missing value renders the default, or empty.
model
{ "provider": "openai", "model": "gpt-4o", "temperature": 0.2, "max_tokens": 1024 }A versioned recommendation — the SDK hands it back to you on prompt.model (and on the compiled result); it doesn't make the call. provider and model are both optional. Full field list and per-provider rules in Model config.
tools
A prompt stores a reference to a tool — an MCP server (by credential, schemas resolved live at run time) or a self-contained function — never a secret. See Tools and MCP servers.
{ "kind": "mcp_tool", "config": { "credential_id": "…", "tool_names": ["lookup_order"] } }CompiledPrompt
compile() fills the variables and returns the ready-to-send payload:
{
"messages": [ /* rendered, provider-ready */ ],
"model": { "provider": "openai", "model": "gpt-4o", "temperature": 0.2 },
"tools": [ /* references */ ],
"response_format": null
}The messages array is already in the shape providers expect — you pass it straight to your client.
How a version is resolved
get chooses which version to return by a strict precedence:
{ version: 'a3f9c2' } (a version hash) pins that exact version. Wins over everything. Mutually exclusive with label.{ label: 'staging' } resolves to whichever version that label currently sits on.production label. This is the deploy pointer, so your app tracks what you shipped, not the newest draft.production label yet → the latest version, so a prompt you never deployed still resolves instead of erroring.The label is resolved server-side on every fetch, so moving production reaches a running app on its next refresh. More in Version control.
Caching & availability
get is on your hot path, so it must never become a hard dependency. Every fetch caches under the selector you asked for (a given label or version caches separately), and reads degrade down a ladder rather than failing:
get() returns the cached prompt with no network call — effectively free.fallback prompt you passed to get() is used, flagged with isFallback.get() throw. The rung you never reach in practice.Set cacheTtlSeconds: 0 to disable caching (every call hits the network); raise it to cut fetches. Full detail in Caching & availability.
Two verbs, one habit
- fetch (
get) — resolve a prompt by name to a version, over the network, cached. - compile (
compile) — fill the variables and produce the payload, locally, no network.
You fetch once (and cache), then compile per request with that request's values.
Why it's shaped this way
- Immutability + movable labels = deploy and roll back by moving a pointer, with the live version always preserved.
- Structured messages = the Playground and Experiments score the right thing, and multimodal/tools land without a schema change.
- Declared variables = a typo (a value for a variable that does not exist) is caught at compile time, not sent to the model.
- Local compile + caching = a prompt fetch on your hot path never adds a hard dependency.