---
title: "The step graph"
url: https://services.dev/docs/service/graph
captured: [graph.txt, legal-walk.txt, sales-check.txt, tax-check.txt]
---
# The step graph

URL: https://services.dev/docs/service/graph

> Steps run in the order you write them. after, when and next add branches, loops, joins and parallel steps, and the step that last committed is the job's stage.

The graph answers one question: which step runs next.

## Smallest form

A branch: `next` picks a path, and the other is skipped.

```ts title="services/contract-translation.ts"
    triage: { by: 'ai', next: job => (job.input.pages > 30 ? 'clauses' : 'whole') },
    clauses: { by: 'ai', after: 'triage', stage: 'translating' },
    whole: { by: 'ai', after: 'triage', stage: 'translating' },
    // A join: it runs after whichever path ran.
    read: { by: 'ai', after: ['clauses', 'whole'], stage: 'reading' },
```

## Every form

**On any step**

| Key | Written | Left out | Does |
|---|---|---|---|
| `after` | `After` | the step written before | Runs after these. `[]` starts at the top; several join; two steps with the same `after` run in parallel. |
| `when` | `(job) => boolean` | none | Skipped unless true. |
| `next` | `(job) => <step> \| End \| <end>` | the next written step | Where the job goes next: a step (a branch, or a loop back) or an end. |
| `stage` | `string` | the step name | The stage buyers see on their timeline. |
| `routed` | `true` | none | Runs only when a `next` or a signal's `goto` sends the job here, never in written order. |

## Branch

A long contract goes clause by clause, a short one whole:

```txt
→ POST /contract-translation {"contract":"lease.pdf","to":"de","pages":48,"max_usd":60,"key":"long"}
```

```txt title="magic values: none"
← job_7Hq2 · delivered · lease.de.pdf · $60.00 charged
  record  triage · ai | clauses · ai | read · ai | review · ai · pass
  stage   review
  steps   stp_1 triage committed · stp_2 clauses committed · stp_3 whole skipped · stp_4 read committed · stp_5 review committed

→ POST /contract-translation {"contract":"nda.pdf","to":"de","pages":6,"max_usd":60,"key":"short"}
← job_7Hq3 · delivered · nda.de.pdf · $60.00 charged
  record  triage · ai | whole · ai | read · ai | review · ai · pass
  stage   review
  steps   stp_1 triage committed · stp_2 clauses skipped · stp_3 whole committed · stp_4 read committed · stp_5 review committed
```

The record holds what ran. `steps` holds every step, the skipped one too, so a timeline can show the path the job didn't take. The review at the end is the one every AI job gets: you didn't write it.

## Loop

`next` can send the job back. Revision runs draft and revise once per pass ordered, and ends at `'accepted'`:

```ts title="services/revision.ts"
    draft: 'ai',
    // A loop back: until the passes are done, the job goes back to draft, and draft and revise run again.
    revise: { by: 'ai', next: job => (job.record.filter(e => e.type === 'step.committed' && e.step === 'revise').length + 1 < job.input.passes ? 'draft' : 'accepted') },
```

```txt
→ POST /revision {"document":"report.pdf","to":"de","passes":2,"max_usd":45,"key":"two"}
```

```txt title="magic values: none"
← job_7Hq4 · delivered · report.de.pdf · $45.00 charged
  record  draft · ai | revise · ai | draft · ai · redo | revise · ai | review · ai · pass
```

A step that runs again prints `redo`, and a loop spends from the same AI budget as everything else on the job, so it can't run away.

## Skip, run in parallel, join

`when` skips a step. Two steps with the same `after` run at once, and a step whose `after` names both waits for both:

```ts title="services/localization.ts"
    terms: { by: 'ai', when: job => job.input.glossary === undefined },
    // Both run after terms, in parallel.
    strings: { by: 'ai', after: 'terms' },
    listing: { by: 'ai', after: 'terms' },
    // Joins both, then three AI reviewers attack the strings at once.
    qa: { review: 'adversarial', after: ['strings', 'listing'], of: 'strings', lenses: ['placeholders', 'length', 'tone'] },
```

```txt
→ POST /localization {"strings":"strings.json","listing":"listing.txt","to":"de","glossary":"glossary.csv","max_usd":49,"key":"glossed"}
```

```txt title="magic values: none"
← job_7Hv5 · delivered · strings.de.json · listing.de.txt · $49.00 charged
  record  terms · skipped | strings · ai | listing · ai | qa · ai · adversarial · 3 lenses · pass
```

The order brought a glossary, so `terms` was skipped, and `strings` and `listing`, which follow it, ran anyway: a skipped step counts as done for the steps after it.

## Routed steps

A step marked `routed: true` never runs in written order. It runs only when something sends the job there: a `next`, a submit's `rejected` or `fallback`, or a [signal's](https://services.dev/docs/service/signals.md) `goto`. A signal's `goto` keeps the step it interrupted as `job.record.interrupted`, so the routed step's `next` can return the job there and the graph runs on from it. `check` fails `step_unreachable` on one nothing can reach.

## The stage

The stage is the `stage` of the step that last committed, or its name. Buyers see it on their timeline. In the branch above, `read` set `reading` and the review after it set `review`, so both jobs ended at `stage review`. You never declare a list of stages or a state machine: the graph is both.

## Seven surfaces

No call of its own: it changes what the job carries, on every surface that reads one.

[Operations](https://services.dev/docs/reference/operations.md) has every column of every row.

## Refusals

| `check` gate | Why |
|---|---|
| [`step_unreachable`](https://services.dev/docs/reference/check/step_unreachable.md) | A step no edge reaches never runs. |

| Written | What TypeScript says |
|---|---|
| `deliver: { draft: 'ai', examine: { review: 'adversarial', after: 'drat' } }, price: 49` | `Type '"drat"' is not assignable to type 'After<"draft" \| "examine"> \| undefined'. Did you mean '"draft"'?` |
| `deliver: { draft: 'ai', examine: { review: 'adversarial', next: () => 'fiel' }, file: 'ai' }, price: 49` | `Type '"fiel"' is not assignable to type '"draft" \| "examine" \| "file" \| End'.` |

## In the example brands

The lines that use it in the example brands, as their files write them, and what `check` read aloud for each file.

```ts title="legal · services/docket-watch.ts · 10 lines"
  deliver: { check: 'ai', ack: { by: 'watcher', when: job => job.clocks.inside('7d') } },
```

```txt title="npx services.dev check · captured/legal-walk.txt"
services/docket-watch.ts · 10 lines · $12 per month, charged at the start of each month.
  steps   check · ai → ack · patent watcher → review · ai · adversarial
```

[The legal example](https://services.dev/docs/examples/legal.md) runs every one of its files, captured.

```ts title="tax · services/estimate.ts · 17 lines"
      submit: 'https://treasury.example.test/payments', to: 'Treasury and states', receipt: 'receipt', each: { of: job => Object.keys(job.output.amount) }, when: job => decided(job) === 'pay it',
    record: { by: async (_, job) => ({ paid: job.output.amount }), when: job => decided(job) === 'pay it' },
```

```txt title="npx services.dev check · captured/tax-check.txt"
services/estimate.ts · 17 lines · $79 per job, within 48 hours, charged on delivery.
  steps   compute · code · cites prior-year safe harbor, cumulative by quarter → approve · owner · decide → review · ai · adversarial → pay · Treasury and states · one for each jurisdiction with an installment → record · code
```

[The tax example](https://services.dev/docs/examples/tax.md) runs every one of its files, captured.

```ts title="sales · services/deal.ts · 42 lines"
    qualify: { by: 'ai', next: job => (job.output.fit ? 'book' : 'lost') },
    close: { by: 'ai', next: job => (job.output.closed_value ? 'won' : 'lost'),
      guard: { closed_value: (v, job) => v >= job.input.value * job.policy.floor || 'below the floor you set' } },
    nurture: { by: 'nurturer', routed: true, next: job => (job.output.revived ? job.record.interrupted : 'lost') },
```

```txt title="npx services.dev check · captured/sales-check.txt"
services/deal.ts · 42 lines · nothing on effort; 5% of closed_value at won, proven by the account's payment, held 60 days by bank, 120 by card.
  steps   source · ai → qualify · ai → book · ai · setter when warm → meet · ai · closer from humans_from, committee or person · skipped for B2A · guard held: the account attends 10 minutes → propose · ai · the closer's span → negotiate · ai · the closer's span → close · ai · the closer's span · guard closed_value at least value × floor → nurture · nurturer · routed
```

[The sales example](https://services.dev/docs/examples/sales.md) runs every one of its files.

---

- Up: [Service()](https://services.dev/docs/service.md)
- Previous: [deliver](https://services.dev/docs/service/deliver.md)
- Next: [AI steps and briefs](https://services.dev/docs/service/ai.md)
