Skip to main content
An agent’s system prompt can be a single string (prompt), or an ordered list of blocks (instructions). Blocks let you reuse the same paragraph across many agents, fill in values per request, and include a section only when a condition holds. Blocks are opt-in per agent. An agent with no instructions keeps using its prompt string exactly as before.

The three block types

instructions is a JSON array. Each entry has a type:
At request time the included blocks are rendered and joined with a blank line, in order, into a single system message. Platform guidance (tool conventions, approval rules, workflow calling conventions) stays in its own separate messages after yours, so your prompt is never concatenated with ours.
When instructions is set it is authoritative — the prompt column is not read at all, not even as a fallback. Set one or the other, not both.

Variables

Any block’s content — text, prompt_block, or the content of a shared block — can contain placeholders. An unresolved placeholder with no default is left visible ({{userName}}) rather than blanked, so a missing value is obvious in the output instead of silently gutting a sentence. Values come from the chat request body:
Rules for variables:
  • A dot path can be sent either way — pick whichever your client produces:
    A dotted key is expanded into the nested form, so both fill {{user.language}}, and a payload may mix them ({ "user": { "language": "FR" }, "user.country": "MX" } gives you both). If the two spellings genuinely conflict — "user" as a string and "user.language" — the first one in the payload wins and the other is reported as dropped.
  • Every dot-separated segment must be a plain identifier: letters, digits and underscores, starting with a letter or underscore.
  • Values are a string, number, boolean, an array of those, or an object. Paths go up to 3 segments deep — {{a.b.c}} yes, {{a.b.c.d}} no — however you spell them.
  • null means “I have no value for this” and is ignored, exactly as if you had left the key out: the placeholder falls back to its own || 'default', or stays visible. That is what lets you take the object the discovery endpoints return, fill in the leaves you know, and send it back untouched.
  • An array is kept or dropped whole, and renders as JSON if you interpolate it directly — its real use is contains / in display conditions.
  • Up to 64 values (each leaf counts, however deeply nested), 2 KB per value, 16 KB total.
  • Anything that breaks those rules is dropped, not rejected — a display-only field must never fail a chat turn. Use the preview endpoints below to see what was dropped while you are authoring; they report the full path of the value that failed (user.language, not user).

Built-in variables

One namespace is filled in by the server. You do not pass it, and you cannot override it — "now" and "now.date" are both dropped from variables: All are UTC, and all are computed when the request is served — not when the agent was built or published — so they are correct on every turn. Use it straight in any block:
A chat request that sends no variables at all still renders it:
This is the main reason to reach for a builtin rather than a variable of your own: a model has no reliable sense of the current date, and a value you pass from a client can be wrong or skewed. Sending "now": "yesterday" in variables is ignored — the builtin always wins. Everything else is yours to pass. Your application already knows the user’s name, plan or locale — send those as variables rather than expecting the platform to infer them.

Discovering which variables to send

You do not have to read anyone’s prompt to find out what a network expects. GET /v1/copilots/{id} returns variables alongside the agents — the same nested shape you send back to chat, with every path the network’s blocks read:
A leaf is what the prompt renders when you omit that variable — the 'free' in {{plan || 'free'}} — so you see the consequence rather than infer it. null means there is none and a literal {{userName}} ends up in the system prompt, which makes the nulls the ones you actually need to fill in. So the round trip is: read it once when your client boots, fill in the leaves your application knows, and post the object as variables on each chat request. Anything you left null is ignored, so a partly-filled object is always safe to send:
A variable is reported as null if any agent in the network uses it bare, even when another agent supplies a default — that other agent still renders the placeholder. When every use has a default but they differ between agents, the first is shown; each agent still renders its own. What it scans, and why:
  • Each agent’s active version — the one chat actually runs. An agent with no published version yet contributes nothing, because chat can’t reach it.
  • Only agents composed of blocks. An agent still on the legacy single prompt string is never template-rendered, so listing placeholders found in it would promise substitution that does not happen.
  • Server builtins are excluded. now.* is filled in for you and cannot be supplied.
GET /v1/agents/{id} returns the same object for a single agent, covering that agent and every subagent it can delegate to — delegation reuses your variables, so they are part of the same requirement. Use this one when you are targeting an agent directly, as /v1/experiments does. One shape can’t be expressed: a path read both wholesale ({{user}}) and by sub-path ({{user.name}}) comes back only as the namespace, { "user": { "name": null } }. Nothing you could send would satisfy both spellings separately anyway — an object supplied at user renders as JSON for the wholesale use.
Variables are display-only. They are rendered into the system prompt and read by nothing else — no authorization, identity, billing or routing decision uses them. Identity comes from the session token.They are also untrusted text: a value that came from an end user lands verbatim in a system message. Have the block delimit it — write User's stated name: "{{userName}}" rather than dropping it into a sentence where it reads as an instruction.

Variables in experiments

POST /v1/experiments takes the same variables map, applied to every item in the dataset:
That is also the cleanest way to A/B a prompt: run the same dataset twice with different values and compare the two summaries. To vary values per row, put them on the dataset item’s own requestContext.variables. They are merged over the run-level ones leaf by leaf, so an item overrides only what it varies:
With the run above, that item renders userName: Maya and account.tier: enterprise. The one behaviour that differs from chat is what happens when a value is missing. A chat turn degrades quietly — a display-only field must never fail a live request. An experiment instead refuses to start (400) when a placeholder with no default has no value for some item, because otherwise it bakes a literal {{userName}} into the system prompt of every affected item and bills you for the whole run:
Pass "strictVariables": false to run anyway. Either way the response carries that variables report — unused lists values you sent that no block reads, which is usually a typo in a key and never blocks the run.

Display conditions

A prompt_block (inline) or a shared block can carry rules — a group that decides whether the block is included at all. Rules are evaluated against the same values the templates see.
Groups use AND or OR and may nest up to three levels. A block with no rules is always included. A rule that cannot be evaluated — an unknown operator, a malformed group — makes the whole group false, so the block is omitted. A condition you cannot evaluate must never ship conditional content by accident.

Shared blocks and versioning

A shared block lives at /v1/prompt-blocks and follows the same draft/publish/ activate model as agents and workflows:
  • version 0 is the mutable draft;
  • publish inserts a new immutable numbered version;
  • activate points the block’s live pointer at a published version.

Pinning: the part worth understanding

When an agent publishes, every prompt_block_ref in its instructions is frozen to the block version it resolved to at that moment. The stored reference gains a versionId:
That means:
  • A published agent version renders exactly the same prompt forever. Publishing or activating a new version of a shared block does not change what any live agent says.
  • Rolling an agent back to an earlier version restores the prompt text that version shipped with.
  • An agent’s draft is different: its refs have no pin, so they always resolve the block’s current active version. That is what makes editing a block and previewing an agent feel live.
versionId is server-owned. Sending it yourself on a draft save is rejected with a 400.
A block that has never been published can be referenced from an agent draft (blocks and agents are often written side by side), and its draft content is what previews render. Publishing the agent then fails with a 422 until the block has a published version — there is nothing stable to pin to.

Rolling an update out

After you publish and activate a new version of a shared block, agents keep their old pin until you republish them. Two endpoints support that: GET /v1/prompt-blocks/{id}/references lists every agent referencing the block, what its active version is pinned to, and whether that is behind. POST /v1/prompt-blocks/{id}/rollout republishes and reactivates those agents:
An agent is skipped when its draft differs from its active version. Publishing an agent ships its whole draft, so a prompt rollout must never quietly release someone’s half-finished model or tool edits. Finish or discard those changes and run the rollout again. Pass agentIds to limit the scope, or activate: false to publish the new versions without moving the live pointers. Partial success is a 200 with the breakdown above — read skipped and failed, do not assume everything moved. The agent detail response also carries this signal:
Note that blocksOutdated is independent of dirty. An agent can be perfectly clean and still be serving an older copy of a shared block.

Walkthrough

1

Create a shared block

The response carries the block and its automatically-created draft — that draft is what you write to next.
2

Write the draft

Only the fields you send are written, and the save always applies — the last write to a draft wins. The rev on the draft is a server-owned change counter you can read to tell whether someone else has saved since; you never send it.
3

Publish and activate it

4

Reference it from an agent draft

Unknown or foreign block ids are rejected here with a 400, rather than quietly turning into a paragraph that never appears.
5

Preview the composed prompt

Pass version to preview a published version instead of the draft ("draft", a version number, or a version id). Broken references are a 422 here rather than being skipped, so problems surface before you publish.
6

Publish the agent

This is where each reference is pinned. Activate the new version as usual to put it live.
To iterate on wording without saving anything, POST /v1/prompt-blocks/preview renders arbitrary content against sample variables and tells you which variables the template uses, which were missing, whether the rules include it, and which variables were dropped.

The retired prompt field

Agents used to carry their system prompt as a single prompt string. instructions replaced it: every agent’s prompt was converted to a single text block holding exactly what prompt contained,
and the field is now gone. It is no longer returned, and sending it to PATCH /v1/agents/{id}/draft is ignored rather than rejected. Anything in your own tooling that displayed prompt should read instructions instead, or call the preview endpoint for the rendered result. Two small differences came with the conversion: leading and trailing whitespace is trimmed from each block, and a prompt containing {{something}} is now treated as a placeholder — it renders unchanged unless a variable of that name is supplied.

Deleting a shared block

DELETE /v1/prompt-blocks/{id} is a soft delete and is refused with a 409 while any agent draft or active version still references the block. Pass ?force=true to override. Version rows are never deleted. Agent versions published against a block stay pinned to the exact version they froze, and keep rendering it even after the block is deleted.

Reference