Expressions & data flow

Expressions & data flow

Any string field in a job — a command, a url, header values, a body, instructions, and env values — can contain {{ }} expressions. They’re evaluated at run time, just before the job executes, so they always see fresh values: the current time, the latest parent output, the resolved secrets.

url: 'https://api.example.com/stats?key={{ $secrets.api_key }}&day={{ $now.date }}'

In the editor, every expression-aware field shows a small {} badge — click it to insert a secret, variable, or one of the macros below at the cursor. A field can mix literal text with several {{ }} segments; each result is stringified and spliced into the surrounding text.

What you can reference

ExpressionWhat it is
$varsGlobal non-secret variables ({{ $vars.region }})
$secretsResolved secret values for this job ({{ $secrets.api_key }}) — masked in logs
$credentialsReusable credentials by name ({{ $credentials.myapi.token }}) — secret fields masked
$envThe daemon’s environment minus its own secrets ({{ $env.HOME }})
$nowThe current time, with date helpers ({{ $now.date }})
$parentThe upstream (parent) run’s output — how data flows between jobs (below)
$parentsEach parent’s latest output by id ({{ $parents['fetch-stats'].json.total }})

Data flow: $parent

When a job runs after a parent (an afterParent schedule point, or as an onFailure handler), it receives the parent run’s output as $parent — this is how one job hands data to the next:

FieldWhat it holds
{{ $parent.text }}the parent’s captured text output
{{ $parent.json }}the parent’s output parsed as JSON ($parent.json.total)
{{ $parent.status }}an http parent’s response status code
{{ $parent.error }}the failure message when the parent failed — see Failure handling
{{ $parent.name }} / {{ $parent.jobId }}the parent job’s name / id

$parent is populated even for a manual or scheduled run of a child: it falls back to the parent’s latest run matching the point’s required outcome (default succeeded), so “Run now” on a chained job still sees its parent’s last output. Operators like switch and wait pass their own parent’s output straight through, so a fetch → wait 2d → child still reads the fetch data.

A worked example

# jobs/reports/fetch-stats.yaml — the parent: fetch JSON on a schedule
id: fetch-stats
name: Fetch stats
group: reports
type: http
method: GET
url: 'https://api.example.com/stats?key={{ $secrets.api_key }}'
schedule:
  - { kind: daily, time: '07:00' }
# jobs/reports/post-summary.yaml — the child: runs after the parent succeeds, uses its JSON
id: post-summary
name: Post summary
group: reports
type: slack
credential: slack-bot
channel: '#reports'
message: 'Today: {{ $parent.json.total }} events (fetched {{ $now.date }})'
schedule:
  - { kind: afterParent, parent: fetch-stats, on: succeeded }

Secrets vs. $env

{{ }} is a templating convenience, not a secret store. $env deliberately drops the daemon’s own secrets — anything named CRONABLE_* or ANTHROPIC_*, and any key containing KEY, TOKEN, SECRET, PASSWORD, or CREDENTIAL — so {{ $env.CRONABLE_ENCRYPTION_KEY }} renders empty, never the value. For any secret an expression genuinely needs, use {{ $secrets.X }} (encrypted at rest, masked everywhere) or a credential. See Security.

The code job type shares this sandbox

Expressions run in a node:vm context seeded with only the helpers above, with a source denylist (rejecting constructor, process, require, eval, Function, …), code generation disabled, and a per-expression timeout. Each segment’s result is capped at 256 KB so a runaway expression can’t clog a command, log, or the live stream. A code job’s script runs through the same guard over the same context — it just returns a value that becomes the run’s structured output for the next job.

This is a best-effort guard for a trusted, single-operator tool, not a hardened security boundary — it runs JavaScript you wrote, in the same trust boundary as the commands the daemon already executes on your behalf. Never feed attacker-controlled strings into an expression field.