> ## Documentation Index
> Fetch the complete documentation index at: https://docs.beta.b3os.org/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](#check-a-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.

**curl**

```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"}}]}'
    ```
  **TypeScript**

```ts
    import { 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);
    ```
  **Python**

```python
    from b3os import B3OS

    b3os = 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"])
    ```
  **MCP**

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](/routines/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`).

```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](/actions) 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](https://cel.dev) 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`](/errors#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`](/routines/runs#signals) 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.