Skip to main content

Expressions & Templating

Every place SOAT lets you map, transform, or interpolate values uses one of six pattern families. Each family has a distinct syntax because each resolves at a different time — that is what lets them compose in a single string without escaping rules. This page is the complete reference.

Quick Reference

PatternSyntaxWhere it is validResolves at
JSON Logic{"var": "input.x"}, {"cat": [...]}, {"if": [...]}, …Orchestration input_mapping, state_mapping, expression, exit_condition; pipeline step input and pipeline output; tool output_mappingRun / call time
Dotted pathsstate.a.b, text, MySecret.valueOrchestration state_mapping keys, loop collection, and the nodes.<id> namespace; output_path on tool_output message content; formation ref_attrRun / call / apply time
{param}/users/{user_id}execute.url of http toolsCall time
Discussion tokens{topic}, {transcript}, {steps.<name>}, {steps.<name>.last}Discussion step promptsTurn time
{{secret:...}}{{secret:sec_01HXYZ}}execute.url, execute.headers, mcp.url, mcp.headersCall time
${...}${ParamName}, ${LogicalId}, ${body.field}Formation sub expressions; execute.urlApply time (${Name}) / call time (${body.x})
Formation objects{"ref": ...}, {"param": ...}, {"sub": ...}Formation template resource propertiesApply time

JSON Logic

JSON Logic is the platform's single expression language for structured data mapping. One shared evaluator handles every surface, so operators behave identically everywhere.

An expression is a single-key object whose key is a registered operatorvar, cat, if, comparison and arithmetic operators, map, and so on. Anything else is a literal: multi-key objects, arrays, and primitives are recursed into, so expressions can be nested at any depth inside literal structure.

{
"prompt": { "cat": ["Summarize: ", { "var": "input.text" }] },
"isLong": { ">": [{ "var": "input.word_count" }, 500] },
"data": { "title": { "var": "input.title" }, "static": "literal string" }
}

Where each surface points var

The syntax is identical everywhere; only the context root differs:

SurfaceFieldvar reads from
Orchestration nodeinput_mappingRun state. Run input is seeded under the input namespace only — read it with {"var": "input.key"} (a flat {"var": "key"} is never satisfied by run input). Every upstream node's raw artifact is also available under nodes.<nodeId> (see Dotted paths).
Orchestration transform / conditionexpressionRun state (same as above)
Orchestration pollexit_conditionRun state plus response (latest tool result) and attempt
Orchestration nodestate_mapping{ "output": <the node's own artifact>, "state": <run state> } — note the different context root from every other orchestration surface
Pipeline tool stepinputinput.* (tool call arguments) and steps.<id>.* (earlier step results)
Pipeline tooloutputSame as pipeline step input
Any tool (http, mcp, pipeline)output_mappingoutput.* (the tool's raw result)

Passing logic-shaped data as a literal

To pass an object that looks like an expression — for example, the literal payload {"var": "x"} — wrap it in preserve, which returns its argument unevaluated:

{ "payload": { "preserve": { "var": "x" } } }

Dotted paths

Plain dotted strings (no delimiters) appear where a value is an address, not an expression:

  • Orchestration state_mapping keys{"state.summary": {"var": "output.content"}} writes the node artifact's content field to state.summary, building nested objects along the way (state.a.b is readable back as {"var": "a.b"}). The state. prefix is optional. Keys are state write paths; values are JSON Logic (see JSON Logic) — the reverse-of-input_mapping shape (there, keys are input-parameter names and values point at the read source).
  • The nodes.<id> namespace — every completed orchestration node's full artifact is recorded at state.nodes.<nodeId>, whether or not that node declares a state_mapping. A downstream node reads it with {"var": "nodes.<nodeId>.<field>"}, giving orchestrations the same read-any-upstream-result ergonomics as a pipeline's steps.<id> without explicit wiring. nodes is a reserved state key: a state_mapping write targeting it is rejected. (An input_schema property named nodes is fine — run input lives under state.input, so it cannot collide.)
  • Loop node collectionstate.items.pending names the state array to iterate.
  • output_path on tool_output message content — extracts a field from a tool result before it enters a conversation ("text", "data.0.url"; numeric segments index arrays).
  • Formation ref_attr"MySecret.value" reads an attribute of another resource: everything before the first dot is the logical ID, the rest is the attribute name.

:::note Mapping keys are stored verbatim

The keys of an input_mapping, state_mapping, or approval-node arguments are names you author, and they leave SOAT exactly as written — an input_mapping key becomes a sub-tool's request-body key, an emit_event node's data key, or a line in the prompt an agent node builds. They are never case-converted, so a key written cost_center arrives as cost_center, and any {"var": ...} that reads it keeps the same spelling.

:::

Dotted paths also appear inside JSON Logic var strings ({"var": "steps.call.text"}, {"var": "nodes.fetch.result"}) — that is JSON Logic's addressing, not a separate mechanism.

Single curly ({param})

execute.url on http tools supports {paramName} placeholders, replaced at call time with the URL-encoded tool argument of the same name. Matched arguments are removed from the query/body; the rest pass through normally.

{ "execute": { "url": "https://api.example.com/users/{user_id}/posts/{post_id}", "method": "DELETE" } }

This is the canonical URL placeholder syntax and intentionally matches OpenAPI path templating.

:::warning Single braces, not double {{city}} is not a supported placeholder — double braces are reserved for secret references. Creating or updating a tool (directly, or via a formation) with any other {{...}} token in execute/mcp fields is rejected with 400 INVALID_TEMPLATE_TOKEN. Always write {city}. :::

Discussion prompt tokens

A discussion always compiles to at most two engine steps: the fixed deliberation step (one branch per participant) and an optional synthesis step. Participant and synthesis prompts support a fixed allowlist of {token} substitutions, resolved before each turn:

TokenReplaced with
{topic}The discussion run's topic
{transcript}Prior turns within the current step (enables the shared sequential transcript)
{steps.deliberation}Concatenated output of the deliberation step (only meaningful from synthesis)
{steps.deliberation.last}Only the deliberation step's final turn

Unknown tokens are left untouched (safe for literal braces in a prompt), but a {token} outside this allowlist — e.g. {steps.synthesis}, a self-reference, or a typo — is surfaced as a non-blocking entry in the discussion's template_warnings field, returned on every create, update, and read.

Secret references ({{secret:...}})

The only valid double-curly syntax. A {{secret:sec_...}} token embeds a Secret by public ID inside execute.url, execute.headers, mcp.url, or mcp.headers:

{ "execute": { "headers": { "Authorization": "Bearer {{secret:sec_01HXYZ}}" } } }

The referenced secret must exist in the same project (validated at tool create/update; 400 SECRET_NOT_FOUND otherwise). The stored tool — and every GET/LIST response — keeps the token; the decrypted value is substituted server-side only at the moment of the outbound request and is never echoed back.

Dollar curly (formations and body params)

${Name} in formation sub

Inside a formation template, {"sub": "..."} interpolates ${Name} tokens at apply time. A token names either a template parameter or a resource logical ID (resolved to its physical ID):

{ "url": { "sub": "${AppUrl}/webhooks/${MyTrigger}" } }

${body.field} in tool URLs

${body.fieldName} in execute.url is replaced at call time with the URL-encoded tool argument, exactly like {param}. It exists because {param}-style tokens cannot pass through a formation sub (the sub resolver owns ${...}, and skips body.* tokens on purpose):

{ "url": { "sub": "${AppUrl}/expenses/${body.expense_id}" } }

Prefer {param} when defining tools directly via the API or CLI; use ${body.x} when the URL is built by a formation sub.

Formation object expressions

Formation resource properties support three single-key object forms, deliberately mirroring CloudFormation:

FormMeaning
{"ref": "LogicalId"}The physical ID of another resource in the template
{"param": "Name"}A template parameter value
{"sub": "...${Name}..."}String interpolation of parameters and logical IDs

See Formations for the full model.

Composition — resolution phases in one string

Because each family has its own delimiter and resolution phase, they nest without escaping. A formation can produce a tool whose header carries a secret reference:

{ "headers": { "Authorization": { "sub": "Bearer {{secret:${ApiSecret}}}" } } }
  1. Apply timesub resolves ${ApiSecret} to the physical ID: the stored header becomes Bearer {{secret:sec_01HXYZ}}.
  2. Call time — the secret token resolves to the decrypted value, only inside the outbound request.

The same phase rule explains ${body.x}: sub leaves it alone at apply time so the tool resolver can fill it at call time.

Not templating: request context

Session and actor context does not reach a tool through any of the families above. There is no {{context:...}} form — writing one is rejected with 400 INVALID_TEMPLATE_TOKEN like any other non-secret double-curly token.

Instead, the server injects the generation's tool_context as X-Soat-Context-* request headers on every http and mcp tool call, alongside the tool's own execute.headers. If you are looking for how to get the actor, session or caller identity into an outbound call, that is the mechanism — see the Tool Context reference.

Common mistakes

  • {{param}} in a tool URL — double braces are secrets-only; use {param}. Rejected at write time with 400 INVALID_TEMPLATE_TOKEN.
  • A {{context:...}} token to read session context — context is not templating; it arrives as X-Soat-Context-* headers. See Tool Context.
  • camelCase var paths for run input — orchestration run-input keys round-trip verbatim: an input sent as cycle_task is read as {"var": "input.cycle_task"}, not cycleTask.
  • Bare string as a state read — in an input_mapping, a bare string is a literal. "state.key" does not read state; use {"var": "key"}.
  • Forward references — a pipeline step may only read steps.<id> of an earlier step; formations reject circular ref/sub dependencies.
  • Expecting { var: ... } to survive as data — wrap logic-shaped literals in preserve.