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:
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:
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. -
nullmeans “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/indisplay 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, notuser).
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:
variables at all still renders it:
"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:
'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:
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
promptstring 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 in experiments
POST /v1/experiments takes the same variables map, applied to every item in
the dataset:
requestContext.variables. They are merged over the run-level ones leaf by
leaf, so an item overrides only what it varies:
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:
"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
Aprompt_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.
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, everyprompt_block_ref in its instructions is
frozen to the block version it resolved to at that moment. The stored reference
gains a versionId:
- 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:
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:
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
2
Write the draft
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
400, rather than
quietly turning into a paragraph that never appears.5
Preview the composed prompt
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
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,
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.