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.

v3newest save — empathetic tone, cites the refund policyproduction
v2added a refund-window checkstaging
v1first version
Your app fetches 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   → unchanged

One 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:

versionAn explicit { version: 'a3f9c2' } (a version hash) pins that exact version. Wins over everything. Mutually exclusive with label.
labelAn explicit { label: 'staging' } resolves to whichever version that label currently sits on.
defaultNo version, no label → follow the production label. This is the deploy pointer, so your app tracks what you shipped, not the newest draft.
fallbackDefault, but there is no 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:

1 · FreshWithin the TTL (60s by default), get() returns the cached prompt with no network call — effectively free.
2 · StalePast the TTL, the stale copy is served instantly and refreshed in the background (stale-while-revalidate). Your request never waits on the network; the next fetch has the fresh version — this is how a label move propagates.
3 · FallbackNothing cached and the API is unreachable → the fallback prompt you passed to get() is used, flagged with isFallback.
4 · RaiseNo cache, no fallback, API down — only then does 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.

On this page