Signals

The running jobjob_7Hq2 · working · $49.00 held

A signal is a named fact about the job. A predicate turns true, a silence runs its span, or your system says so; a signal can move the job, and never blocks it.

A signal answers one question: what has happened on this job that the work should act on?

services/watched-translation.ts
  signals: {
    long: job => job.input.pages > 30,
    source_changed: { inbound: true, goto: 'translate' },
    quiet: { silent: '3d', goto: 'nudge' },
  },

On the Service

KeyWrittenLeft outDoes
signals{ <name>: Signal }noneNamed facts that route the work: a predicate, silent for a span, with of for whose silence counts, or inbound; goto moves the job.

A named fact. A predicate is raised when it turns true; silent is raised when nothing happens on the job for that long, a span or a function of the job that returns one, and of: '<party input>' counts only that party's silence; inbound is raised by POST /jobs/:id/signals. goto interrupts the graph and moves the job to that step, and job.record.interrupted names the step it left. A signal never blocks: that's a guard's job.

A function of the job is raised when it turns true, and the record shows it from then on. A 48-page catalog is long from the moment it's ordered:

Call
→ POST /watched-translation {"document":"catalog.pdf","to":"de","pages":48,"max_usd":79,"key":"watched"}
magic values: none
← job_7Hq2 · waiting · on person · $79.00 held
  record  long · signal | translate · ai

{ inbound: true } is raised by your buyer's own system, through POST /jobs/:id/signals. Its goto moves the job to that step, and a step that already ran runs again:

Call
→ POST /jobs/job_7Hq2/signals {"signal":"source_changed"}
200 · job_7Hq2 · working · $79.00 held · magic values: none
  record  long · signal | translate · ai | source_changed · signal · goto translate | translate · ai · redo

Only inbound signals take a call. A predicate is raised by the work:

Call
→ POST /jobs/job_7Hq2/signals {"signal":"long"}
422 · magic values: none
  "code": "input_invalid",
  "reason": "long isn't an inbound signal of Watched Translation: it takes source_changed.",

{ silent: '3d' } is raised when nothing happens on the job for that long. Three quiet days on the proofreader raise quiet, whose goto runs nudge, and the job goes back to waiting on them:

Run
curl http://localhost:8787/owner/jobs/job_7Hq2   # 2026-10-28: three quiet days
magic values: none
← job_7Hq2 · waiting · on person · $79.00 held
  record  long · signal | translate · ai | source_changed · signal · goto translate | translate · ai · redo | quiet · signal · goto nudge | nudge · ai

of names whose silence counts. { silent: '14d', of: 'account' } counts only the account's: a member's own touches on the thread never hold it off, and only the party's reply resets it. Left out, anything on the job resets it. When silent reads a policy, a policy.set that changes it re-arms the timer from the last activity: api.forsale's gone_dark reads stall_days, and what re-arms a timer runs the change.

On localhost a test clock moves the time: services.dev tick 3d.

It can route the job with goto, to any step, a routed one included, and the step it interrupted stays on the record as job.record.interrupted, so a routed step can send the job back where it was: next: job => job.record.interrupted. Every predicate, when, next and guard reads raised signals as job.signals.

It also reaches the people on the job. Every Task the job posts carries its raised signals, and a Pool whose sees names them shows them in the teaser, before anyone claims. A rules row can raise one of its own with flags, such as a letter whose recipient is in another state:

Call
→ POST /reviews/tsk_7Hq2/attest {"checklist":["every claim is supported","no threat the client cannot carry out"]}   # as mbr_eligible
magic values: metadata.sandbox.propose = { settlement: [3800] }, member = mbr_eligible
← 200 tsk_7Hq2 · done · attorney · sign · US-NY · $75.00 · signals recipient_elsewhere · attested · 30 min · $150.00 an hour · due 2026-10-27 14:02
  14:02 · step.committed · sign · person · bar admission · US-NY · mbr_eligible
  14:02 · pay.accrued · mbr_eligible · attest · $75.00

A signal can also staff a step with escalate and price a job by tier.

A signal never blocks. Refusing an output is a guard's job.

OpAPICLIMCPSDKSiteDocsApp
job.signalPOST /jobs/:id/signalsjobs signal <job> <signal>send_job_signaljobs.signal()3 error pagescustomer portal

Events: signal.raised.

Operations has every column of every row.

CodeCategoryHTTPWhy
input_invalidinvalid_request422The order doesn't match the Service's input.
job_endedconflict409The job has ended, for good.

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

legal · services/demand-letter.ts · 26 lines
  signals: {
npx services.dev check · captured/legal-walk.txt
services/demand-letter.ts · 26 lines · $149 per job, within 48 hours, charged on send's receipt.
  steps   consent · client · consent → draft · ai → attack · ai · adversarial · 3 lenses → sign · attorney · client · stamped · 2-item checklist → send · the recipient → courier · a courier · routed → reply · ai → settle · client · decide

The legal example runs every one of its files, captured.

sales · services/deal.ts · 42 lines
  signals: {
    warm: job => job.record.replies > 0, committee: { inbound: true }, person: { inbound: true },
    gone_dark: { silent: job => `${job.policy.stall_days}d`, of: 'account', goto: 'nurture' } },
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.
  signals warm · a reply on the record → book escalates to the setter
          committee, person · inbound → meet escalates to the closer
          gone_dark · the account silent for stall_days → nurture, and a revived deal returns to the step it left

The sales example runs every one of its files.