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
| Pattern | Syntax | Where it is valid | Resolves at |
|---|---|---|---|
| JSON Logic | {"var": "input.x"}, {"cat": [...]}, {"if": [...]}, … | Orchestration input_mapping, state_mapping, expression, exit_condition; pipeline step input and pipeline output; tool output_mapping | Run / call time |
| Dotted paths | state.a.b, text, MySecret.value | Orchestration state_mapping keys, loop collection, and the nodes.<id> namespace; output_path on tool_output message content; formation ref_attr | Run / call / apply time |
{param} | /users/{user_id} | execute.url of http tools | Call time |
{{secret:...}} | {{secret:sec_01HXYZ}} | execute.url, execute.headers, mcp.url, mcp.headers | Call time |
{{context:...}} | {{context:ocaToken}} | execute.headers, mcp.headers, preset_parameters | Call time |
${...} | ${ParamName}, ${LogicalId}, ${body.field} | Formation sub expressions; execute.url | Apply time (${Name}) / call time (${body.x}) |
| Formation objects | {"ref": ...}, {"param": ...}, {"sub": ...} | Formation template resource properties | Apply 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 operator — var, 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:
| Surface | Field | var reads from |
|---|---|---|
| Orchestration node | input_mapping | Run 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 / condition | expression | Run state (same as above) |
Orchestration poll | exit_condition | Run state plus response (latest tool result) and attempt |
| Orchestration node | state_mapping | { "output": <the node's own artifact>, "state": <run state> } — note the different context root from every other orchestration surface |
| Pipeline tool step | input | input.* (tool call arguments) and steps.<id>.* (earlier step results) |
| Pipeline tool | output | Same as pipeline step input |
Any tool (http, mcp, pipeline) | output_mapping | output.* (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_mappingkeys —{"state.summary": {"var": "output.content"}}writes the node artifact'scontentfield tostate.summary, building nested objects along the way (state.a.bis readable back as{"var": "a.b"}). Thestate.prefix is optional. Keys are state write paths; values are JSON Logic (see JSON Logic) — the reverse-of-input_mappingshape (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 atstate.nodes.<nodeId>, whether or not that node declares astate_mapping. A downstream node reads it with{"var": "nodes.<nodeId>.<field>"}, giving orchestrations the same read-any-upstream-result ergonomics as a pipeline'ssteps.<id>without explicit wiring.nodesis a reserved state key: astate_mappingwrite targeting it is rejected. (Aninput_schemaproperty namednodesis fine — run input lives understate.input, so it cannot collide.) - Loop node
collection—state.items.pendingnames the state array to iterate. output_pathontool_outputmessage 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.
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.
Secret references ({{secret:...}})
One of the two valid double-curly forms. 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.
Context references ({{context:...}})
The other valid double-curly form. A {{context:<key>}} token reads one key of the caller's tool_context for this call and substitutes it into a tool header, or into a pinned parameter:
{ "mcp": { "headers": { "Authorization": "Bearer {{context:ocaToken}}" } } }
{ "preset_parameters": { "adAccountId": "{{context:ocaAdAccountId}}" } }
It exists because tool_context on its own can only produce headers under the deployment's context prefix (X-Soat-Context- by default) — a security invariant, since a caller-named header could otherwise overwrite the tool's own credential. The token moves the header naming to the party that knows the header shape: the tool declares where the value goes, the caller supplies the value.
Unlike {{secret:...}}, it is valid in execute.headers, mcp.headers and preset_parameters only — never execute.url, mcp.url, execute.auth, or a model-supplied argument. A context value is caller-supplied, and a URL it could steer is a request to a host the tool's author never configured. A token anywhere else is rejected at write time with 400 INVALID_TEMPLATE_TOKEN.
A preset is the one place the token reaches a value rather than a header, and it is safe for the same reason a header is: the tool's author chose the parameter, and the pin wins over anything the model supplies. Since context values are strings, a resolved preset is retyped to the parameter's declared schema type. {{secret:...}} is deliberately not resolved in a preset — it stays literal, keeping secrets to headers.
A key that is not present in the tool_context at call time fails the tool call with 400 MISSING_TOOL_CONTEXT_KEY, naming the key and the header (or the preset parameter). Sending Authorization: Bearer instead would surface as an opaque upstream 401, far from the real mistake. An empty-string value is a value, not a missing key.
Both forms may appear in one header value and are substituted in a single pass, so no substituted value is re-read as template source. Full rules, including which calling paths carry no context at all, are in the Tool Context reference.
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:
| Form | Meaning |
|---|---|
{"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}}}" } } }
- Apply time —
subresolves${ApiSecret}to the physical ID: the stored header becomesBearer {{secret:sec_01HXYZ}}. - 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.
Mostly not templating: request context
Session and actor context is not a general expression family: no tool_context value is ever interpolated into a URL, a body, or a JSON Logic expression. 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. That is the mechanism for getting the actor, session or caller identity into an outbound call — see the Tool Context reference.
The exceptions are both declared by the tool itself: {{context:<key>}} in its own headers, so a credential lands in the header the target expects, and in its preset_parameters, so the scope that credential is confined to can vary per run.
Common mistakes
{{param}}in a tool URL — double braces are secrets-only; use{param}. Rejected at write time with400 INVALID_TEMPLATE_TOKEN.- A
{{context:...}}token outsideheadersorpreset_parameters— in a URL or anexecute.authblock it is rejected with400 INVALID_TEMPLATE_TOKEN. Context also always arrives asX-Soat-Context-*headers regardless. See Tool Context. - camelCase
varpaths for run input — orchestration run-input keys round-trip verbatim: an input sent ascycle_taskis read as{"var": "input.cycle_task"}, notcycleTask. - 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 circularref/subdependencies. - Expecting
{ var: ... }to survive as data — wrap logic-shaped literals inpreserve.