Integration
Call other flows, run local commands, validate JWTs, and render template resources.
flow-ref
Invokes another flow by name. The target must be a flow with no source: (a source-less flow gets an implicit source and becomes callable by name). The called flow receives a fresh sub-message, a clone of the body and variables under a new event ID, so the sub-invocation correlates independently of the caller's own terminal event.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
flow | string | Yes | none | Name of the flow to invoke. |
oneWay | bool | No | false | Fire-and-forget when true; otherwise wait and fold the result back in. |
Synchronously (the default) the caller waits: the called flow's body replaces the caller's body and its variables are merged over the caller's key-by-key. If the called flow drops the message, the caller's message continues unchanged.
With oneWay: true the message is dispatched and the caller continues immediately, ignoring the result. Use it for audit trails and side effects.
Errors: a synchronous call propagates the called flow's error to the caller; a one-way call errors only if dispatch itself fails.
# Fire-and-forget audit; the request keeps moving immediately.
- type: flow-ref
name: audit-async
settings:
flow: audit
oneWay: true
# Synchronous delegation; enrich-order's body + variables fold back in.
- type: flow-ref
name: enrich-sync
settings:
flow: enrich-orderSee Composing flows for patterns.
cli-run
Runs a local program and carries on with how it ended. The events path watches
its output a line at a time while the command is still running, which is what
makes streaming a long command to a caller possible.
- type: cli-run
name: build
program: '"go"'
args: '["build", "./..."]'
workDir: /src
env: [PATH, HOME]
timeout: 5m
events:
process:
- type: sse-event
name: line
settings:
event: line
data: '{"stream": body.type, "text": body.text}'The block is synchronous: it returns once the command has exited. A block that resumed the flow once per line would end the caller's own chain, so an HTTP request would be answered and its connection torn down while the command was still running.
Settings
| Field | Meaning |
|---|---|
program | CEL expression naming the program: a bare name resolved through $PATH, or an absolute path. |
args | CEL expression yielding a list of strings, passed as argv. |
stdin | CEL expression written to the process's standard input, then closed. |
allow | Every program this block may run. Empty means no restriction (see below). |
allowInterpreters | Permit an interpreter on allow. Default false. |
env | Environment variables passed to the child, by name. Nothing else is passed. |
workDir | Directory the command runs in. |
timeout | How long it may run before it is killed. Default 30s. |
maxOutputBytes | Cap on captured output when nothing is streaming it. Default 1 MiB. |
onExit | fail (default) errors the block on a non-zero exit; continue carries on. |
events | Observer sub-flow, run once per event. Its result is discarded. |
emit | Which events reach it: stdout, stderr, exit. Empty emits all three. |
The allow list
allow is optional. A block without one may run anything that resolves,
which keeps cli-run pleasant for local, iterative work.
# Fine: the program is written right there.
program: '"go"'program is an expression, though, so which program runs can come from the
message. That is what lets an ai-agent tool branch hand
a model a set of commands and let it choose. There allow is the only thing
standing between the caller and arbitrary execution, and it is checked on every
message, before anything is spawned:
program: vars.program
allow: [ls, df, date]A block with no allow whose program comes from the message will run
whatever it is handed. That is what you want at a terminal, and it is remote
code execution if the flow is reachable by anyone else. Declare an allow list
for anything you deploy. The builder logs a warning at startup for that
combination: no list and a message-supplied program.
Rules worth knowing:
- An entry is a bare name resolved through
$PATH, or an absolute path used as it stands. Every entry is resolved at build time, so an entry naming a program that is not installed fails at startup rather than on the message that first reached for it. - Matching is on the resolved path, not the string you typed.
..segments are normalized, so/bin/../bin/shcannot dodge an entry for/bin/sh. Symlinks are deliberately not followed: on a busybox system every applet is a symlink to the same binary, so following them would make an allow list of["cat"]also admitsh. The cost is that on a usr-merged Linux/bin/lsand/usr/bin/lsdo not match each other, so write the list the way the flow names the program. - Interpreters are refused on an explicit list unless
allowInterpreters: true, because listingshorpythonallows everything it can reach. The check applies only to a list; a block with no list has already said "anything". It is a guardrail against an easy mistake, not a sandbox: the list can never be complete (gitruns a pager,tarhas--to-command).
What is not a risk
- No shell is ever involved. Arguments are passed as argv, so a semicolon, a pipe
or a
$(…)in an argument is just bytes to the callee. - The child inherits nothing. It sees exactly the variables named in
envand no others, so a command cannot read an API key the integration holds.
Output
The chain carries on with a body of {exitCode, stdout, stderr, lines}. The
output is captured whether or not an events path watched it go by.
maxOutputBytes caps what is kept, while lines always counts everything the
command produced, so a truncated capture is visible rather than silent.
Each event's body is {type, text, index} for stdout/stderr, and
{type: "exit", exitCode, lines} at the end. The exit event fires for every run,
even one that printed nothing. It still passes through emit, so a path that
wants it must not filter it out.
An events path that stops the flow (an sse-event whose caller hung up, with
ifClosed: stop) stops the command too, rather than letting it produce output
nobody will read.
Backpressure is free. The events path runs between reads of the process's pipes, so a slow sink blocks the child on write rather than buffering its output without bound.
See samples/cli-run.yaml, samples/cli-sse.yaml and samples/cli-agent.yaml.
jwt-validate
A filter block that authenticates HTTP requests: it verifies a bearer JWT against an OIDC provider and, on failure, stops the flow with a configurable response (401 by default) so the rest of the chain never runs. On success it injects the verified claims into a variable (default vars.jwt) and the token subject into vars.sub.
The token is read from a request-header variable (default Authorization, with a Bearer prefix stripped case-insensitively). The HTTP route source must copy that header into vars: set headers: [Authorization] on the source.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
mode | enum discover | jwks | inline | No | discover | How signing keys are resolved: OIDC discovery from issuer, direct JWKS fetch from jwksUrl, or an inline public key. |
issuer | string | For discover | none | Expected token issuer (iss). In discover mode it is also the discovery URL (<issuer>/.well-known/openid-configuration). For jwks/inline the iss check is applied when set and skipped when empty. |
audience | string | No | none | Expected token audience (aud). Empty skips the audience check. |
algorithms | list of strings | No | go-oidc defaults | Accepted signing algorithms, e.g. [RS256]. |
jwksUrl | string | For jwks | none | JWKS endpoint fetched directly (cached and refreshed). |
publicKey | string | For inline* | none | Literal PEM public key or certificate (RSA/ECDSA, PKIX or a certificate). |
publicKeyResource | string | For inline* | none | Resource id whose content is the PEM public key. *Set exactly one of publicKey / publicKeyResource. |
tokenHeader | string | No | Authorization | Variable holding the bearer token (a Bearer prefix is stripped). |
claimsVar | string | No | jwt | Variable the verified claims map is stored under on success (the subject is also set on vars.sub). |
rejectStatus | int | No | 401 | HTTP status of the built-in rejection response. |
resourceMetadataUrl | string | No | none | Protected-resource-metadata URL (RFC 9728) advertised as resource_metadata="…" in the WWW-Authenticate challenge; set it to make the endpoint an MCP-compliant protected resource. |
disableWwwAuthenticate | bool | No | false | By default, when an issuer is configured, a rejection sets a WWW-Authenticate: Bearer challenge in vars (list WWW-Authenticate in the route source's responseHeaders to emit it). Set true to opt out. |
Behavior notes:
- Key material for
discover/jwksis resolved lazily on the first request, so a briefly unreachable provider does not fail deployment;inlineneeds no network and fails fast on a bad key. - A rejection sets the body to
{"error": "unauthorized"}, setsvars.httpStatustorejectStatus, and requests the flow stop. A missing token yields a bare challenge; a present-but-invalid token addserror="invalid_token"(per RFC 6750). - A key-resolution failure (provider unreachable) is an operational error: it aborts the flow rather than rejecting the request.
flows:
- name: me
source:
connector: api
type: http
settings:
path: /me
headers: [Authorization] # forward the token into vars
responseHeaders: [WWW-Authenticate] # emit the challenge on rejection
process:
- type: jwt-validate
name: require-auth
settings:
mode: discover
issuer: ${OIDC_ISSUER}
audience: ${OIDC_AUDIENCE}
algorithms: [RS256]
# Only reached with a valid token.
- type: set-payload
settings:
value: '{"sub": vars.sub, "email": vars.jwt.email}'See Validation and auth and MCP auth for full walkthroughs.
template-resource
Renders a declared template resource against the current message. The template is declared under resources.templates in the flow file and embeds {{ CEL }} expressions over body, vars, env, and now. The rendered text replaces the message body by default, or is stored in a variable when target is set.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | none | The template to render: its resources.templates alias (the as), or its resource path when unaliased. |
target | string | No | none | Variable to store the rendered text in. Empty replaces the message body. |
rawBody | bool | No | false | Write the rendered text as a raw-content body {contentType, rawData} so a connector (e.g. the HTTP source) serves it verbatim. Body only: target must be empty. |
contentType | string | When rawBody | none | MIME type recorded on the raw body, e.g. text/html. |
Errors: an unknown template id or a render failure (a failing embedded expression) errors the block. Declared resources are loaded when the config loads, so a missing template file fails at deployment.
resources:
templates:
- resource: templates/welcome.tmpl
as: welcome
flows:
- name: greet
process:
- type: template-resource
settings:
id: welcome
target: rendered
- type: set-payload
settings:
value: '{"viaBlock": vars.rendered, "viaCel": templateResource("welcome")}'The templateResource(alias) CEL function renders the same template inline in an expression. See Resources for how templates are declared, and Serving HTML for the raw-body pattern.