Trill

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:

VariableDescription
CIAlways "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_IDUnique identifier for the current workflow run
CI_JOB_NAMEName of the currently executing job
CI_STEP_NAMEName of the currently executing step
CI_WORKSPACEAbsolute path to the job’s workspace directory
IDEMPOTENCY_KEYStable key for {run, job, step} — safe as a dedup key for external API calls (retries of the same step reuse it)
STEP_OUTPUT_FILEPath to write structured JSON outputs (see Step Outputs)
STEP_EXTEND_FILEPath to write extension YAML for dynamic extension

Set in agent mode only (empty strings in local mode):

VariableDescription
CI_SERVER_URLURL of the Trill service the agent is connected to
CI_PROJECT_IDStable ID of the project this run belongs to
CI_PROJECT_SLUGHuman-readable slug of the project
CI_RUN_URLDirect URL to the run page ($CI_SERVER_URL/runs/$CI_RUN_ID)
TRILL_SERVERMirrors CI_SERVER_URL. Read by nested trill CLI invocations as the default for --server
TRILL_PROJECTMirrors 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:

TypeDescriptionOutput type
textSingle-line text inputstring
textareaMulti-line text inputstring
selectChoose from the options liststring
checkboxLabelled boolean toggleboolean
numberNumeric input with optional min / max boundsnumber

Field properties:

PropertyDefaultDescription
namerequiredField identifier, used as output key
typerequiredOne of the types above
options[]Choices for select fields
requiredtrueWhether input is mandatory
defaultnoneDefault value if input is empty
hintnoneHelp text shown to the user
minnoneLower bound for number fields
maxnoneUpper 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:

TemplateRenderedStored as
"42"42number 42
"true"trueboolean true
"hello"hellostring "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

PropertyDefaultDescription
urlrequiredRequest URL (template-rendered)
methodGETHTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
headersnoneHeader map (values are template-rendered)
bodynoneRequest 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:

OutputTypeDescription
status_codenumberHTTP status code (200, 404, etc.)
bodyvariesResponse body (auto-parsed as JSON when possible)
headersobjectResponse 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

PropertyCommandApprovalWaitExpressionHTTP
runrequired
promptrequired
fieldsoptional
durationoptional*
signaloptional*
expressionsrequired
urlrequired
methodoptional
headersoptional
bodyoptional
envoptionalignoredignoredignoredignored
ifoptionaloptionaloptionaloptionaloptional
allow_failureoptionaloptionaloptionaloptionaloptional
timeoutoptionaloptionaloptionaloptional
typecommandapprovalwaitexpressionhttp

*Wait steps require at least one of duration or signal. — = not allowed for this step type.

Next Steps