The step graph

The running jobjob_7Hq2 · working · $49.00 held

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.

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

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' },

On any step

KeyWrittenLeft outDoes
afterAfterthe step written beforeRuns after these. [] starts at the top; several join; two steps with the same after run in parallel.
when(job) => booleannoneSkipped unless true.
next(job) => <step> | End | <end>the next written stepWhere the job goes next: a step (a branch, or a loop back) or an end.
stagestringthe step nameThe stage buyers see on their timeline.
routedtruenoneRuns only when a next or a signal's goto sends the job here, never in written order.

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

Call
→ POST /contract-translation {"contract":"lease.pdf","to":"de","pages":48,"max_usd":60,"key":"long"}
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.

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

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') },
Call
→ POST /revision {"document":"report.pdf","to":"de","passes":2,"max_usd":45,"key":"two"}
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.

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

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'] },
Call
→ POST /localization {"strings":"strings.json","listing":"listing.txt","to":"de","glossary":"glossary.csv","max_usd":49,"key":"glossed"}
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.

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 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 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.

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

Operations has every column of every row.

check gateWhy
step_unreachableA step no edge reaches never runs.
WrittenWhat TypeScript says
deliver: { draft: 'ai', examine: { review: 'adversarial', after: 'drat' } }, price: 49Type '"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: 49Type '"fiel"' is not assignable to type '"draft" | "examine" | "file" | End'.

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

legal · services/docket-watch.ts · 10 lines
  deliver: { check: 'ai', ack: { by: 'watcher', when: job => job.clocks.inside('7d') } },
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 runs every one of its files, captured.

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' },
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 runs every one of its files, captured.

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') },
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 runs every one of its files.