A routine definition is YAML or JSON. Publishing takes either; send YAML with Content-Type: application/yaml. To find mistakes before you publish, check the definition first.

Check a definition

POST /v1/routines/validate compiles a definition, sent as JSON, without publishing it. It always answers 200: valid says whether it would publish, and diagnostics lists every problem with a JSON pointer into your definition.

bash
curl -s https://beta-api.b3os.org/v1/routines/validate \ -H "Authorization: Bearer $B3OS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "check-status", "steps": [{"id": "fetch", "uses": "core/http@1", "with": {"method": "GET", "url": "https://status.example.com"}}]}'

The call needs routines:write, like publishing.

Top level

FieldMeaning
nameRequired. Lowercase slug, unique in the project, up to 64 characters. runs, hooks, validate and import are reserved.
descriptionWhat the routine does, shown wherever the routine is listed.
inputsA JSON Schema object for the run's input. Runs are validated against it and get its defaults.
outputAny JSON value; strings in it are templates. It becomes the run's result.
envStrings evaluated once when the run starts, exposed as env.<NAME>. Not passed to sandboxes on their own; put what a step needs in its with.
varsConstants exposed as vars.<name>. Their strings are never evaluated.
triggersUp to 20 triggers: cron, interval, webhook or action.
defaultsdurability, timeout_ms (30 seconds by default), retry and concurrency for the routine.
stepsRequired. Up to 500 steps.

Steps

Each step does exactly one of: call something (uses with with), fan out over a list (map), wait for a signal or a time (wait), or pause (sleep_ms).

yaml
steps: - id: fetch uses: vision/scrape@1 with: url: ${{ inputs.url }} - id: notify uses: core/http@1 if: contains(steps.fetch.outputs.markdown, 'outage') with: method: POST url: https://hooks.example.com/ops body: text: "Outage mentioned on ${{ inputs.url }}" retry: max_attempts: 3

core/http@1 sends one request through the egress guard, with no credentials; call a marketplace action when the request needs one. Its outputs are status, headers and body, and a status of 400 or more fails the step with upstream_error unless fail_on_error_status is false.

FieldMeaning
idRequired. A CEL identifier (fetch_page, not fetch-page), unique across the routine
nameA display label
uses, withThe callable and its input. Product callables take their API request body as with.
needsEarlier steps this one depends on. Without it, a step depends on the step before it; needs: [] starts it at once.
ifA condition; the step is skipped when it is false
retrymax_attempts, initial_ms (200), factor (2), max_ms (30000), jitter (on)
timeout_msOverrides defaults.timeout_ms
continue_on_errorLet the run go on when this step fails
connectionsBinds a pack action's connection keys to vault connections (conn_). A key left out uses the most recent connection for that pack the run may use.

Steps run in array order unless needs says otherwise, so a list of steps reads top to bottom and needs creates fan-out and joins. A run succeeds when no step concluded failure.

Expressions

Expressions are CEL inside ${{ }}. A string that is exactly one expression keeps the expression's type, so ${{ inputs.count }} stays a number and ${{ steps.page.outputs.links }} stays a list. A string with other text around the expression splices its text value in. An expression's result is never scanned for ${{ again, so input data cannot inject expressions.

What each place can read:

WhereVariables
env valuesinputs, vars, trigger, run
Step with, connections, map.over, wait.until, wait.ms, sleep_msinputs, vars, env, trigger, run, steps (this step's ancestors), and item and index inside a map
Step ifThe same, plus success(), failure() and always()
outputinputs, vars, env, trigger, run, and every top-level step
Trigger with, if and dedupetrigger, vars

For an ancestor step s: steps.s.outputs is the callee's whole output ({} if it did not succeed), steps.s.outcome is success, failure, skipped or canceled, steps.s.conclusion is the same except success for a failure with continue_on_error, and steps.s.error is null or {code, detail, status, retryable}. run holds id, routine_id, version_id, started_at, durability and idempotency_key.

Besides the CEL standard functions and macros (size, has, matches, map, filter, exists and the rest), these GitHub-style functions are available: contains, startsWith and endsWith (case-insensitive), format, join, toJSON, fromJSON, and in if only, success(), failure() and always(). An if without a status function means success() && (<your condition>).

Two CEL rules catch people out. A missing field is an error, not null: guard optional data with has(x.field) or 'key' in map. And int and double do not mix in arithmetic: write double(inputs.count) + 1.0. Expressions are checked for syntax and names when you publish; type errors and missing fields at run time fail the step with expression_error. There is no now(); use run.started_at or trigger.fired_at.

Map

yaml
- id: pages map: over: ${{ inputs.urls }} concurrency: 8 steps: - id: scrape_one uses: vision/scrape@1 with: url: ${{ item }}

map runs its steps once per element of over (up to 10,000 elements), concurrency at a time (8 by default, 256 at most). Inside, item is the element and index its position. The map step's outputs are items (the last body step's outputs per element, null for failed or skipped ones) and outcomes, both in input order. The first failed item cancels the rest and fails the map unless the body steps continue on error. Maps nest up to 3 deep.

Wait and sleep

yaml
- id: approval wait: signal: approval ms: 86400000 # give up after a day- id: ship if: steps.approval.outputs.payload.approved uses: core/http@1 with: { method: POST, url: "https://deploy.example.com/hooks/release" }

A wait step holds no resources while it waits. {signal} waits for POST /v1/routines/runs/{run_id}/signal with that name; add ms or until for a deadline. Its outputs are signal, payload, received_at and timed_out; a deadline is not a failure, timed_out is just true. {until} waits for an RFC 3339 time. sleep_ms pauses for a fixed time.

Retries

Attempt n starts min(max_ms, initial_ms * factor^(n-2)) milliseconds after the previous one, and with jitter a random amount between half that and all of it, from a generator seeded by the run, step, item and attempt, so a replay makes the same choices. Only failures the callee marks retryable are retried, plus timeouts of pure and read steps. Every attempt of a step carries the same idempotency key.

defaults.retry applies only to pure and read steps. A write step retries only with its own retry, and an irreversible one cannot retry.

Concurrency

yaml
defaults: concurrency: limit: 1 group: ${{ inputs.customer_id }} on_limit: queue

At most limit runs are active per group. A new run in a full group waits (queue, the default), ends canceled (skip), or cancels the oldest active run first (cancel_running).

Differences from GitHub Actions

  • One list of steps per routine; no jobs or runners. There is no run: shell step: run commands with sandboxes/exec@1.
  • Steps take needs like GitHub jobs, and needs must name earlier steps.
  • Expressions are CEL: missing fields are errors, int and double are distinct, strings may use ' or ", there are no object filters such as steps.*.outputs (use .map() and .filter()), and toJSON is compact with sorted keys.
  • Status functions look at the step's ancestors, not at every earlier step.
  • Keys are snake_case and times are in milliseconds: continue_on_error, timeout_ms.
  • with values are any JSON, and a step's outputs are the callee's whole output; the routine has one output.
  • Contexts are inputs, steps, env, vars, trigger, run, and item and index in maps. There is no secrets context: credentials are bound through connections and injected at the egress proxy.
  • triggers is a list with IDs instead of on:. Cron has six fields with seconds first and requires a catch-up policy.
  • map replaces strategy.matrix; wait, sleep_ms and retry have no GitHub equivalent.
Ask a question... ⌘I