Routine definitions
Every field of a routine definition, how steps are ordered and connected, the CEL expressions between them, and how B3OS differs from GitHub Actions.
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.
bashcurl -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"}}]}'
tsimport { B3OS } from "@b3os/sdk";const b3os = new B3OS();const check = await b3os.routines.validateRoutine({ name: "check-status", steps: [{ id: "fetch", uses: "core/http@1", with: { method: "GET", url: "https://status.example.com" } }],});for (const d of check.diagnostics) console.log(d.severity, d.pointer, d.detail);
pythonfrom b3os import B3OSb3os = B3OS()check = b3os.routines.validate_routine({ "name": "check-status", "steps": [{"id": "fetch", "uses": "core/http@1", "with": {"method": "GET", "url": "https://status.example.com"}}],})for d in check["diagnostics"]: print(d["severity"], d.get("pointer"), d["detail"])
The MCP server has no tool that validates or publishes routines; agents run published routines with routine_run. From a terminal, b3os routine deploy check-status.yaml publishes a file; when the publish is refused it prints each problem with its JSON pointer.
The call needs routines:write, like publishing.
Top level
| Field | Meaning |
|---|---|
name | Required. Lowercase slug, unique in the project, up to 64 characters. runs, hooks, validate and import are reserved. |
description | What the routine does, shown wherever the routine is listed. |
inputs | A JSON Schema object for the run's input. Runs are validated against it and get its defaults. |
output | Any JSON value; strings in it are templates. It becomes the run's result. |
env | Strings 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. |
vars | Constants exposed as vars.<name>. Their strings are never evaluated. |
triggers | Up to 20 triggers: cron, interval, webhook or action. |
defaults | durability, timeout_ms (30 seconds by default), retry and concurrency for the routine. |
steps | Required. 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).
yamlsteps: - 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.
| Field | Meaning |
|---|---|
id | Required. A CEL identifier (fetch_page, not fetch-page), unique across the routine |
name | A display label |
uses, with | The callable and its input. Product callables take their API request body as with. |
needs | Earlier steps this one depends on. Without it, a step depends on the step before it; needs: [] starts it at once. |
if | A condition; the step is skipped when it is false |
retry | max_attempts, initial_ms (200), factor (2), max_ms (30000), jitter (on) |
timeout_ms | Overrides defaults.timeout_ms |
continue_on_error | Let the run go on when this step fails |
connections | Binds 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:
| Where | Variables |
|---|---|
env values | inputs, vars, trigger, run |
Step with, connections, map.over, wait.until, wait.ms, sleep_ms | inputs, vars, env, trigger, run, steps (this step's ancestors), and item and index inside a map |
Step if | The same, plus success(), failure() and always() |
output | inputs, vars, env, trigger, run, and every top-level step |
Trigger with, if and dedupe | trigger, 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
yamldefaults: 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 withsandboxes/exec@1. - Steps take
needslike GitHub jobs, andneedsmust name earlier steps. - Expressions are CEL: missing fields are errors,
intanddoubleare distinct, strings may use'or", there are no object filters such assteps.*.outputs(use.map()and.filter()), andtoJSONis 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. withvalues are any JSON, and a step's outputs are the callee's whole output; the routine has oneoutput.- Contexts are
inputs,steps,env,vars,trigger,run, anditemandindexin maps. There is nosecretscontext: credentials are bound throughconnectionsand injected at the egress proxy. triggersis a list with IDs instead ofon:. Cron has six fields with seconds first and requires a catch-up policy.mapreplacesstrategy.matrix;wait,sleep_msandretryhave no GitHub equivalent.