> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sidenet.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Prompt blocks & variables

> Compose an agent's system prompt from reusable, versioned blocks with runtime variables and display conditions.

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

| `type`             | Fields             | What it is                                                       |
| ------------------ | ------------------ | ---------------------------------------------------------------- |
| `text`             | `content`          | Inline text. Always included. Lives only on this agent.          |
| `prompt_block`     | `content`, `rules` | Inline text with a display condition.                            |
| `prompt_block_ref` | `id`               | A reference to a shared block managed under `/v1/prompt-blocks`. |

```json theme={null}
{
  "instructions": [
    { "type": "text", "content": "You are Acme support. Be concise." },
    { "type": "prompt_block_ref", "id": "b1f2c3d4-..." },
    { "type": "text", "content": "Address the user as {{userName || 'there'}}." },
    {
      "type": "prompt_block",
      "content": "Escalate refunds over $500 to the billing team.",
      "rules": {
        "operator": "AND",
        "conditions": [{ "field": "plan", "operator": "equals", "value": "internal" }]
      }
    }
  ]
}
```

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.

<Note>
  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.
</Note>

## Variables

Any block's content — `text`, `prompt_block`, or the content of a shared block —
can contain placeholders.

| Syntax                      | Behaviour                                                                    |
| --------------------------- | ---------------------------------------------------------------------------- |
| `{{userName}}`              | Replaced with the value.                                                     |
| `{{user.name}}`             | Dot path — send `"user": { "name": "Maya" }` or `"user.name": "Maya"`.       |
| `{{userName \|\| 'there'}}` | Default rendered when the value is missing or null. Single or double quotes. |

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:

```bash theme={null}
curl https://api.sidenet.ai/v1/chat \
  -H "Authorization: Bearer SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "AGENT_ID",
    "messages": [{ "role": "user", "content": "where is my order?" }],
    "variables": {
      "userName": "Maya",
      "plan": "pro",
      "seats": 12,
      "user": { "language": "FR" }
    }
  }'
```

Rules for `variables`:

* A dot path can be sent either way — pick whichever your client produces:

  ```json theme={null}
  { "user": { "language": "FR" } }
  { "user.language": "FR" }
  ```

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

| Variable           | Example                    | Use it for                                                                                         |
| ------------------ | -------------------------- | -------------------------------------------------------------------------------------------------- |
| `{{now.datetime}}` | `2026-08-11 14:20 UTC`     | Prose. States its own zone, so you never append "(UTC)" by hand.                                   |
| `{{now.date}}`     | `2026-08-11`               | A bare date you pass on — a tool argument, a filter — where a zone suffix would corrupt the value. |
| `{{now.iso}}`      | `2026-08-11T14:20:33.167Z` | Machine-readable timestamps. The trailing `Z` is the zone.                                         |
| `{{now.timezone}}` | `UTC`                      | Stating the zone separately.                                                                       |

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:

```json theme={null}
{
  "type": "text",
  "content": "The current date and time is {{now.datetime}}. Resolve relative dates such as \"last quarter\" against it, and never state a date you cannot derive from it."
}
```

A chat request that sends no `variables` at all still renders it:

```bash theme={null}
curl https://api.sidenet.ai/v1/chat \
  -H "Authorization: Bearer SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "AGENT_ID",
    "messages": [{ "role": "user", "content": "how did we do last quarter?" }]
  }'
```

```text theme={null}
The current date and time is 2026-08-11 14:20 UTC. Resolve relative dates such
as "last quarter" against it, and never state a date you cannot derive from 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:

```json theme={null}
{
  "id": "NETWORK_ID",
  "agents": [{ "agentId": "ag-1", "name": "Support", "description": "..." }],
  "variables": {
    "instance": { "store_count": null, "today_local": null },
    "plan": "free",
    "userName": null
  }
}
```

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:

```json theme={null}
{
  "agentId": "AGENT_ID",
  "messages": [{ "role": "user", "content": "how did we do last week?" }],
  "variables": {
    "instance": { "store_count": 42, "today_local": "2026-08-12" },
    "userName": "Maya"
  }
}
```

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.

<Warning>
  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.
</Warning>

### Variables in experiments

`POST /v1/experiments` takes the same `variables` map, applied to every item in
the dataset:

```bash theme={null}
curl https://api.sidenet.ai/v1/experiments \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "AGENT_ID",
    "datasetId": "DATASET_ID",
    "userId": "END_USER_ID",
    "variables": { "userName": "Maya", "account": { "tier": "pro" } }
  }'
```

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:

```json theme={null}
{
  "input": "where is my order?",
  "requestContext": { "variables": { "account.tier": "enterprise" } }
}
```

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:

```json theme={null}
{
  "error": "Bad request",
  "details": "Missing value(s) for required prompt variable(s): userName. …",
  "variables": {
    "supplied": ["account.tier"],
    "required": ["userName"],
    "missing": ["userName"],
    "dropped": [],
    "unused": [],
    "itemsAffected": 2,
    "items": [{ "itemId": "item-2", "missing": ["userName"], "dropped": [] }]
  }
}
```

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.

```json theme={null}
{
  "operator": "AND",
  "conditions": [
    { "field": "plan", "operator": "in", "value": ["pro", "enterprise"] },
    {
      "operator": "OR",
      "conditions": [
        { "field": "seats", "operator": "greater_than", "value": 10 },
        { "field": "region", "operator": "equals", "value": "EU" }
      ]
    }
  ]
}
```

Groups use `AND` or `OR` and may nest up to three levels. A block with no rules
is always included.

| Operator                                                                   | Notes                                                   |
| -------------------------------------------------------------------------- | ------------------------------------------------------- |
| `equals`, `not_equals`                                                     | Compares loosely, so `12` and `"12"` match.             |
| `contains`, `not_contains`                                                 | Substring for strings, membership for arrays.           |
| `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal` | Numeric. Non-numeric operands make the condition false. |
| `in`, `not_in`                                                             | `value` must be an array.                               |
| `exists`, `not_exists`                                                     | `null` counts as absent.                                |

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

```json theme={null}
{ "type": "prompt_block_ref", "id": "b1f2c3d4-...", "versionId": "9ac3f0e1-..." }
```

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.

<Note>
  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.
</Note>

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

```bash theme={null}
curl -X POST https://api.sidenet.ai/v1/prompt-blocks/BLOCK_ID/rollout \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json theme={null}
{
  "rolledOut": [{ "agentId": "a1", "versionNumber": 8 }],
  "skipped":   [{ "agentId": "a2", "reason": "draft has unpublished changes" }],
  "failed":    []
}
```

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:

```json theme={null}
{
  "blocksOutdated": true,
  "referencedBlocks": [
    {
      "id": "b1f2c3d4-...",
      "name": "Brand voice",
      "pinnedVersionNumber": 3,
      "activeVersionNumber": 4,
      "outdated": true
    }
  ]
}
```

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

<Steps>
  <Step title="Create a shared block">
    ```bash theme={null}
    curl -X POST https://api.sidenet.ai/v1/prompt-blocks \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "name": "Brand voice", "description": "Tone and style" }'
    ```

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

  <Step title="Write the draft">
    ```bash theme={null}
    curl -X PATCH https://api.sidenet.ai/v1/prompt-blocks/BLOCK_ID/draft \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "content": "Write in a friendly, concise tone. Address the user as {{userName || \"there\"}}."
      }'
    ```

    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.
  </Step>

  <Step title="Publish and activate it">
    ```bash theme={null}
    curl -X POST https://api.sidenet.ai/v1/prompt-blocks/BLOCK_ID/publish \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "change_message": "initial tone guidelines" }'

    curl -X POST https://api.sidenet.ai/v1/prompt-blocks/BLOCK_ID/activate \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "version_number": 1 }'
    ```
  </Step>

  <Step title="Reference it from an agent draft">
    ```bash theme={null}
    curl -X PATCH https://api.sidenet.ai/v1/agents/AGENT_ID/draft \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "instructions": [
          { "type": "text", "content": "You are Acme support. Be concise." },
          { "type": "prompt_block_ref", "id": "BLOCK_ID" }
        ]
      }'
    ```

    Unknown or foreign block ids are rejected here with a `400`, rather than
    quietly turning into a paragraph that never appears.
  </Step>

  <Step title="Preview the composed prompt">
    ```bash theme={null}
    curl -X POST https://api.sidenet.ai/v1/agents/AGENT_ID/instructions/preview \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "variables": { "userName": "Maya" } }'
    ```

    ```json theme={null}
    {
      "authoredPrompt": "You are Acme support. Be concise.\n\nWrite in a friendly, concise tone. Address the user as Maya.",
      "usesBlocks": true,
      "blocks": [
        { "name": null, "included": true, "rendered": "You are Acme support. Be concise.", "missingVariables": [] },
        { "name": "Brand voice", "included": true, "rendered": "Write in a friendly, concise tone. Address the user as Maya.", "missingVariables": [] }
      ],
      "droppedVariables": []
    }
    ```

    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.
  </Step>

  <Step title="Publish the agent">
    ```bash theme={null}
    curl -X POST https://api.sidenet.ai/v1/agents/AGENT_ID/publish \
      -H "Authorization: Bearer YOUR_API_KEY"
    ```

    This is where each reference is pinned. Activate the new version as usual to
    put it live.
  </Step>
</Steps>

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,

```json theme={null}
{
  "instructions": [{ "type": "text", "content": "<the former prompt string>" }]
}
```

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

| Endpoint                                    | Purpose                                                          |
| ------------------------------------------- | ---------------------------------------------------------------- |
| `POST /v1/prompt-blocks`                    | Create a block (its draft is created for you)                    |
| `GET /v1/prompt-blocks`                     | List the org's blocks                                            |
| `GET /v1/prompt-blocks/{id}`                | Block, active version, draft summary + `dirty`                   |
| `PATCH /v1/prompt-blocks/{id}/draft`        | Save `content`, `rules`, `name`, `description`                   |
| `POST /v1/prompt-blocks/{id}/publish`       | Freeze the draft as a new version                                |
| `POST /v1/prompt-blocks/{id}/activate`      | Point the block at a published version                           |
| `GET /v1/prompt-blocks/{id}/versions`       | Published versions, newest first                                 |
| `GET /v1/prompt-blocks/{id}/references`     | Which agents use this block                                      |
| `POST /v1/prompt-blocks/{id}/rollout`       | Republish agents onto the latest version                         |
| `POST /v1/prompt-blocks/preview`            | Render content without saving                                    |
| `DELETE /v1/prompt-blocks/{id}`             | Soft delete (guarded)                                            |
| `POST /v1/agents/{id}/instructions/preview` | Render an agent's composed prompt                                |
| `GET /v1/agents/{id}`                       | Includes `variables` — what this agent and its subagents consume |
| `GET /v1/copilots/{id}`                     | Includes `variables` for a whole network                         |
| `POST /v1/experiments`                      | Takes `variables` + `strictVariables`; returns a variable report |
