Defining Steps
Steps are the sequential units within a job. Each step runs after the previous one completes. Trill supports five step types: command, approval, wait, expression, and http.
Command Steps
The default step type. Runs a shell command:
steps:
- name: build
run: cargo build --release
Multi-line commands work with YAML block scalars:
steps:
- name: setup
run: |
echo "Installing dependencies..."
npm install
echo "Done"
Each step runs in a PTY (pseudo-terminal), so you get colors and interactive output just like a real terminal.
Step Environment
Trill injects these variables into every command step. Values marked
“agent mode only” are empty strings in local mode, so portable workflows
can branch on [[ -n "$CI_SERVER_URL" ]] without errors.
Always set:
| Variable | Description |
|---|---|
CI | Always "true" when running under trill |
CI_LOCAL | "true" in local mode, "false" in agent mode |
CI_SERVER | "true" in agent mode, "false" in local mode |
CI_RUN_ID | Unique identifier for the current workflow run |
CI_JOB_NAME | Name of the currently executing job |
CI_STEP_NAME | Name of the currently executing step |
CI_WORKSPACE | Absolute path to the job’s workspace directory |
IDEMPOTENCY_KEY | Stable key for {run, job, step} — safe as a dedup key for external API calls (retries of the same step reuse it) |
STEP_OUTPUT_FILE | Path to write structured JSON outputs (see Step Outputs) |
STEP_EXTEND_FILE | Path to write extension YAML for dynamic extension |
Set in agent mode only (empty strings in local mode):
| Variable | Description |
|---|---|
CI_SERVER_URL | URL of the Trill service the agent is connected to |
CI_PROJECT_ID | Stable ID of the project this run belongs to |
CI_PROJECT_SLUG | Human-readable slug of the project |
CI_RUN_URL | Direct URL to the run page ($CI_SERVER_URL/runs/$CI_RUN_ID) |
TRILL_SERVER | Mirrors CI_SERVER_URL. Read by nested trill CLI invocations as the default for --server |
TRILL_PROJECT | Mirrors CI_PROJECT_SLUG. Read by nested trill CLI invocations as the default for --project |
TRILL_SOURCE_* | One variable per key in the run’s source metadata (e.g. TRILL_SOURCE_COMMIT_SHA, TRILL_SOURCE_BRANCH). Keys are uppercased, hyphens become underscores |
The TRILL_SERVER / TRILL_PROJECT mirrors mean a step can shell out to
trill signal <run-id> --name go or trill run followup.yaml --token trl_... without re-specifying the server or project — they’re picked up
from the environment.
Steps can override or add variables via the step’s env:
steps:
- name: build
run: cargo build
env:
RUSTFLAGS: "-C target-cpu=native"
CARGO_INCREMENTAL: "0"
Step-level env is only used by command steps. The field is accepted on
other step types for forward compatibility but has no effect there.
Step Timeout
Steps can have a timeout that cancels execution if it takes too long:
steps:
- name: build
run: cargo build --release
timeout: 10m
Values use human-readable duration format: 5s, 30m, 1h30m, 2h.
A timed-out step is marked as failed and subsequent steps are skipped
(unless allow_failure: true).
Step Outputs
Steps produce outputs by writing JSON to $STEP_OUTPUT_FILE:
steps:
- name: version
run: |
VERSION=$(git describe --tags)
echo "{\"version\": \"$VERSION\"}" > "$STEP_OUTPUT_FILE"
- name: tag
run: echo "Version is {{ steps.version.outputs.version }}"
The JSON file must contain an object. Values can be any JSON type — strings, numbers, booleans, arrays, and nested objects are all preserved:
steps:
- name: discover
run: |
echo '{"count": 3, "services": ["api", "web"], "config": {"port": 8080}}' \
> "$STEP_OUTPUT_FILE"
- name: use
run: |
echo "Found {{ steps.discover.outputs.count }} services"
echo "Port: {{ steps.discover.outputs.config.port }}"
After a step completes, trill reads this file and makes the values
available via {{ steps.<name>.outputs.<key> }} in subsequent steps.
Step Conditions
Steps can have their own if conditions:
steps:
- name: deploy
run: ./deploy.sh
if: "not local"
Approval Steps
Approval steps pause the workflow for a human (or automated) decision.
They’re the first non-command step type — set type: approval:
steps:
- name: approve
type: approval
prompt: "Deploy to production?"
When an approval step runs, trill displays the prompt and waits for a response. The user can approve or reject. Rejection fails the step (and skips subsequent steps, same as a command failure).
Fields
Approval steps can collect structured input via fields:
steps:
- name: approve
type: approval
prompt: "Deploy to production?"
fields:
- name: environment
type: select
options: [staging, production]
- name: reason
type: text
required: false
hint: "Why are we deploying?"
Five field types are supported:
| Type | Description | Output type |
|---|---|---|
text | Single-line text input | string |
textarea | Multi-line text input | string |
select | Choose from the options list | string |
checkbox | Labelled boolean toggle | boolean |
number | Numeric input with optional min / max bounds | number |
Field properties:
| Property | Default | Description |
|---|---|---|
name | required | Field identifier, used as output key |
type | required | One of the types above |
options | [] | Choices for select fields |
required | true | Whether input is mandatory |
default | none | Default value if input is empty |
hint | none | Help text shown to the user |
min | none | Lower bound for number fields |
max | none | Upper bound for number fields |
steps:
- name: approve
type: approval
prompt: "Ship release?"
fields:
- name: environment
type: select
options: [staging, production]
- name: replicas
type: number
min: 1
max: 50
default: "3"
- name: notify_team
type: checkbox
default: "true"
- name: notes
type: textarea
required: false
hint: "Release notes (markdown ok)"
Output types are preserved — checkbox outputs are JSON booleans and
number outputs are JSON numbers, so
{{ steps.approve.outputs.replicas + 1 }} works as expected.
Approval Outputs
Field values become step outputs, available to subsequent steps:
steps:
- name: approve
type: approval
prompt: "Deploy to production?"
fields:
- name: environment
type: select
options: [staging, production]
- name: reason
type: text
required: false
- name: deploy
run: |
echo "Deploying to {{ steps.approve.outputs.environment }}"
echo "Reason: {{ steps.approve.outputs.reason }}"
This is the same output mechanism as command steps — approval
outputs flow through {{ steps.<name>.outputs.<key> }}.
Timeout
Approval steps can have a timeout that auto-rejects if no decision
is made in time:
steps:
- name: approve
type: approval
prompt: "Deploy to production?"
timeout: 30m
When the timeout expires, the step is marked as failed (same as a manual rejection) and downstream steps are skipped. When running against trill.build, the timer service checks for expired approvals every 2 seconds.
Console Interaction
In a terminal, approval looks like this:
══════════════════════════════════
⏸ Approval: approve (deploy)
Deploy to production?
environment [staging/production]: production
reason (optional): quarterly release
[a]pprove [r]eject
> a
✓ approve (approved)
JSON Protocol
With --debug --json, approvals use structured JSON. Trill emits:
{
"event": "approval_required",
"job": "deploy",
"step": "approve",
"prompt": "Deploy to production?",
"fields": [
{"name": "environment", "type": "select", "options": ["staging", "production"]},
{"name": "reason", "type": "text", "required": false}
]
}
The required field is only present when false. If absent, the
field is required (the default).
Respond on stdin with:
{"action": "approve", "outputs": {"environment": "production", "reason": "quarterly release"}}
Or reject:
{"action": "reject"}
See Advanced Concepts for using this with LLM agents.
Wait Steps
Wait steps pause the workflow for a fixed duration, an external signal,
or both. They are the durable counterpart to approval steps — where
approvals need a human decision, waits need time to pass or an external
system to respond. Set type: wait:
Duration Waits
The simplest form: pause for a fixed amount of time.
steps:
- name: cooldown
type: wait
duration: 15m
The step blocks for the specified duration, then execution continues
with the next step. Duration values use human-readable format:
5s, 30m, 1h30m, 2h.
This is useful for cooling periods, rate-limit backoff, or giving an external deployment time to stabilize before running health checks.
Signal Waits
Wait for an external system or operator to send a named signal:
steps:
- name: gate
type: wait
signal: deploy-approved
timeout: 24h
The step blocks until a signal with the matching name is delivered.
Signals are sent via the trill signal CLI command (see
Advanced Concepts).
The timeout field is optional but recommended for signal waits.
If the signal is not received before the timeout expires, the step
fails and subsequent steps are skipped. Without a timeout, a signal
wait blocks indefinitely.
Combined Duration and Signal Waits
Both duration and signal can be set on the same step. The duration
runs first, then the signal wait begins:
steps:
- name: deploy-gate
type: wait
duration: 5m
signal: manual-override
This is useful when you want a minimum wait period followed by an explicit go-ahead. For example: wait 5 minutes for a canary deployment to soak, then wait for the on-call engineer to confirm.
Signal Data as Outputs
When a signal is delivered, any JSON data included with the signal becomes the step’s outputs. Subsequent steps can reference these values:
steps:
- name: gate
type: wait
signal: deploy-ready
- name: deploy
run: |
echo "Deploying to {{ steps.gate.outputs.env }}"
The signal sender provides the data:
trill signal <run-id> --name deploy-ready --data '{"env": "production"}'
This is the same output mechanism as command and approval steps —
signal data flows through {{ steps.<name>.outputs.<key> }}.
Expression Steps
Expression steps evaluate MiniJinja templates in-process and store the
results as step outputs. No shell command is spawned — this is useful for
computing derived values, transforming outputs between jobs, and avoiding
the shell for pure data tasks. Set type: expression:
steps:
- name: derive
type: expression
expressions:
tag: "v{{ jobs.build.outputs.version }}"
short_sha: "{{ jobs.build.outputs.sha[:7] }}"
env: "{{ 'production' if not local else 'staging' }}"
Each key in expressions becomes an output key. Templates have access to
the same expression context as command step templates — jobs, steps,
local, and env are all available.
Auto-Parsing
Rendered values are automatically parsed as JSON when possible. If the rendered string is valid JSON, the typed value is stored (number, boolean, array, object). Otherwise, the raw string is kept:
| Template | Rendered | Stored as |
|---|---|---|
"42" | 42 | number 42 |
"true" | true | boolean true |
"hello" | hello | string "hello" |
'[1,2]' | [1,2] | array [1,2] |
Expression Outputs
Expression outputs work exactly like command and approval outputs.
Subsequent steps reference them via {{ steps.<name>.outputs.<key> }}:
steps:
- name: derive
type: expression
expressions:
tag: "v{{ jobs.build.outputs.version }}"
- name: push
run: docker push myapp:{{ steps.derive.outputs.tag }}
If any template fails to render (e.g. referencing an undefined variable), the step fails and subsequent steps are skipped.
HTTP Steps
HTTP steps make HTTP requests with templated URLs, headers, and bodies.
Useful for webhooks, API calls, and service integration without writing
shell curl commands. Set type: http:
steps:
- name: health
type: http
url: "https://api.example.com/health"
Request Configuration
| Property | Default | Description |
|---|---|---|
url | required | Request URL (template-rendered) |
method | GET | HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS |
headers | none | Header map (values are template-rendered) |
body | none | Request body (template-rendered) |
steps:
- name: notify
type: http
url: "https://hooks.slack.com/services/XXX"
method: POST
headers:
Content-Type: application/json
Authorization: "Bearer {{ steps.auth.outputs.token }}"
body: '{"text": "Deploy complete: {{ jobs.build.outputs.version }}"}'
HTTP Outputs
Every HTTP step produces three outputs:
| Output | Type | Description |
|---|---|---|
status_code | number | HTTP status code (200, 404, etc.) |
body | varies | Response body (auto-parsed as JSON when possible) |
headers | object | Response headers as key-value pairs |
steps:
- name: check
type: http
url: "https://api.example.com/status"
- name: verify
run: |
echo "Status: {{ steps.check.outputs.status_code }}"
echo "Body: {{ steps.check.outputs.body }}"
Error Handling
HTTP responses with status codes 400+ are treated as failures. The step
is marked failed and subsequent steps are skipped (unless
allow_failure: true). Outputs are still populated on failure, so you
can inspect the response body when using allow_failure:
steps:
- name: flaky_api
type: http
url: "https://api.example.com/unstable"
allow_failure: true
- name: check
run: |
echo "Got status {{ steps.flaky_api.outputs.status_code }}"
Connection errors and timeouts also fail the step. Use timeout to set
a request deadline:
steps:
- name: slow_api
type: http
url: "https://api.example.com/slow"
timeout: 30s
Step Type Summary
| Property | Command | Approval | Wait | Expression | HTTP |
|---|---|---|---|---|---|
run | required | — | — | — | — |
prompt | — | required | — | — | — |
fields | — | optional | — | — | — |
duration | — | — | optional* | — | — |
signal | — | — | optional* | — | — |
expressions | — | — | — | required | — |
url | — | — | — | — | required |
method | — | — | — | — | optional |
headers | — | — | — | — | optional |
body | — | — | — | — | optional |
env | optional | ignored | ignored | ignored | ignored |
if | optional | optional | optional | optional | optional |
allow_failure | optional | optional | optional | optional | optional |
timeout | optional | optional | optional | — | optional |
type | command | approval | wait | expression | http |
*Wait steps require at least one of duration or signal.
— = not allowed for this step type.
Next Steps
- Debugging — Step through steps interactively
- Advanced Concepts — Data flow, signals, dynamic extension, LLM integration