Answers: OK, EMPTY, OFFER, BLOCKED

The running jobjob_7Hq2 · delivered · document.fr-CA.pdf · $49.00 charged

Every call on every surface answers in one of four envelopes, and every no has one shape. An agent branches on them, never on prose.

A buyer's agent shouldn't parse a sentence to know what happened, so each answer is one of four envelopes, the AXP standard's, the same on the API, the CLI, MCP and the SDK. A list, an offer and every no carry theirs in type.

EnvelopeHTTPIt holdsWhat to do
OK200, 201, 202the object: a job, a list, a watchread it; a job's next lists what you can call now
EMPTY200, 404a list with nothing in it, or an id nothing haschange the filter, or use an id an answer returned
OFFER402the price, the terms, the call that orderssend next unchanged, or don't
BLOCKED400 to 500a no, in the one shape belowsend next, hand it to a person, or stop

An OK is the object itself, with no wrapper. A job always carries next, the calls that would work now, so the caller never guesses what it may do. Here the buyer accepts a delivered job:

$ npx services.dev jobs accept job_7Hq2
job_7Hq2 · accepted · lease.fr-CA.pdf · $49.00 final
$ curl -X POST http://localhost:8787/jobs/job_7Hq2/accept -H 'content-type: application/json' -d '{}'
← 200 · job_7Hq2 · accepted · lease.fr-CA.pdf · $49.00 final
→ tools/call accept_job {"id":"job_7Hq2"}
← job_7Hq2 · accepted · lease.fr-CA.pdf · $49.00 final
await lingua.jobs.accept("job_7Hq2")
← job_7Hq2 · accepted · lease.fr-CA.pdf · $49.00 final
Output
magic values: none
same job, job_7Hq2, and the same body on all four surfaces, request ids aside

The tabs print each surface's summary line. The body itself, whole, is the job a buyer may read: its input, its price, each step with its hash, and the output. An accepted job's next is empty, because nothing is left to call.

Output
magic values: none

$ curl -X POST http://localhost:8787/jobs/job_7Hq2/accept -H 'content-type: application/json' -d '{}'
← 200 · {
  "id": "job_7Hq2",
  "object": "job",
  "created": "2026-10-25T14:02:11.000Z",
  "livemode": false,

Every field of the job, and every other object, is in Objects.

Nothing there is still an answer

A list with nothing in it is an OK with an empty data, never an error:

$ npx services.dev jobs list
{
  "object": "list",
  "data": [],
  "has_more": false,
  "url": "/jobs",
  "message": "No jobs yet."
}
$ curl 'http://localhost:8787/jobs?limit=10'
← 200 · {
  "object": "list",
  "data": [],
  "has_more": false,
  "url": "/jobs",
  "message": "No jobs yet."
}
→ tools/call list_jobs {}
← {
  "object": "list",
  "data": [],
  "has_more": false,
  "url": "/jobs",
  "message": "No jobs yet."
}
await lingua.jobs.list()
← {
  "object": "list",
  "data": [],
  "has_more": false,
  "url": "/jobs",
  "message": "No jobs yet."
}
Output
magic values: none
same list, and the same body on every surface that has the call, request ids aside

A record that isn't there is EMPTY, a 404 not_found, with param naming the id:

$ npx services.dev jobs get job_0000
No job job_0000.
  docs    http://localhost:8787/docs/errors/not_found
$ curl http://localhost:8787/jobs/job_0000
← 404 · {
  "type": "EMPTY",
  "category": "invalid_request",
  "code": "not_found",
  "reason": "No job job_0000.",
  "param": "id",
  "next": null,
  "doc_url": "http://localhost:8787/docs/errors/not_found",
  "request_id": "req_c7PJaH",
  "simulated": true
}
→ tools/call get_job {"id":"job_0000"}
← isError: true, structuredContent:
← {
  "type": "EMPTY",
  "category": "invalid_request",
  "code": "not_found",
  "reason": "No job job_0000.",
  "param": "id",
  "next": null,
  "doc_url": "http://localhost:8787/docs/errors/not_found",
  "request_id": "req_vzPAPG",
  "simulated": true
}
await lingua.jobs.get("job_0000")
× throws InvalidRequestError<'job.get'> · 404
← {
  "type": "EMPTY",
  "category": "invalid_request",
  "code": "not_found",
  "reason": "No job job_0000.",
  "param": "id",
  "next": null,
  "doc_url": "http://localhost:8787/docs/errors/not_found",
  "request_id": "req_c7PJaH",
  "simulated": true
}
Output
magic values: none
same refusal, not_found, and the same body on every surface that has the call, request ids aside

A list with nothing in it is EMPTY too, at 200, so an agent tells an empty answer from a full one by type alone.

An offer is a 402, not an error. Nothing is held until the buyer names the most they'll pay. An order without max_usd, or with less than the price, gets the offer: the price sentence, the terms written from the Service's steps, and the call to send. Its next.arguments are the order you sent, with max_usd filled in.

$ npx services.dev translation document=https://example.com/lease.pdf to=fr-CA --key lease-fr-CA
OFFER · $49 per job, within 48 hours, charged on delivery.
  next    npx services.dev translation document=lease.pdf to=fr-CA --max 49
Output
$ curl -X POST http://localhost:8787/jobs -H 'content-type: application/json' -d '{"service":"translation","document":{"url":"https://example.com/lease.pdf","name":"lease.pdf"},"to":"fr-CA","key":"lease-fr-CA"}'
← 402 · {
  "type": "OFFER",
  "object": "offer",
  "service": "translation",
  "title": "Translation",
  "price": {
    "usd": 49,
    "sentence": "$49 per job, within 48 hours, charged on delivery."
  },
Output
→ tools/call order_job {"service":"translation","document":{"url":"https://example.com/lease.pdf","name":"lease.pdf"},"to":"fr-CA","key":"lease-fr-CA"}
← {
  "type": "OFFER",
  "object": "offer",
  "service": "translation",
  "title": "Translation",
  "price": {
    "usd": 49,
    "sentence": "$49 per job, within 48 hours, charged on delivery."
  },
Output
await lingua.translation.order({"document":{"url":"https://example.com/lease.pdf","name":"lease.pdf"},"to":"fr-CA","key":"lease-fr-CA"})
← {
  "type": "OFFER",
  "object": "offer",
  "service": "translation",
  "title": "Translation",
  "price": {
    "usd": 49,
    "sentence": "$49 per job, within 48 hours, charged on delivery."
  },
Output
magic values: none
same offer, translation, and the same body on every surface that has the call, request ids aside

Past the buyer key's cap

Every buyer key has a cap: the most its live holds may add up to. An order that would take the key past it gets an offer too, but its next is a checkout, because only a person raises a cap. The agent hands next.url to that person; once they have, it sends next.then, the order unchanged:

Output
magic values: none

# this buyer key's cap is $500, and two Certified Translations already hold $378 on it
$ curl -X POST http://localhost:8787/jobs -H 'content-type: application/json' -d '{"service":"certified-translation","document":{"url":"https://example.com/lease.pdf","name":"lease.pdf"},"to":"fr","country":"FR","max_usd":189,"key":"lease-fr-3"}'
← 402 · {
  "type": "OFFER",
  "object": "offer",
  "service": "certified-translation",

/pricing publishes the cap as hardCeiling, so an agent can know it before it orders: Machine registers.

Here a buyer's agent tries to accept a job that waits on the client's signature. Only the client can sign, through their own link, so the answer hands the link back:

$ npx services.dev jobs accept job_7Hq3
sign waits on Ada Lovelace to sign. Only they can, through their own link. Hand them the link in next.
  next    hand http://localhost:8787/a/ask_7Hq2?t=cHGjprSn08ODyOp_8zLIVH to the person it asks
  docs    http://localhost:8787/docs/errors/needs_the_party
Output
$ curl -X POST http://localhost:8787/jobs/job_7Hq3/accept -H 'content-type: application/json' -d '{}'
← 403 · {
  "type": "BLOCKED",
  "category": "blocked",
  "code": "needs_the_party",
  "reason": "sign waits on Ada Lovelace to sign. Only they can, through their own link. Hand them the link in next.",
  "param": "ask_7Hq2",
  "next": {
    "do": "hand_to_person",
    "url": "http://localhost:8787/a/ask_7Hq2?t=cHGjprSn08ODyOp_8zLIVH",
Output
→ tools/call accept_job {"id":"job_7Hq3"}
← isError: true, structuredContent:
← {
  "type": "BLOCKED",
  "category": "blocked",
  "code": "needs_the_party",
  "reason": "sign waits on Ada Lovelace to sign. Only they can, through their own link. Hand them the link in next.",
  "param": "ask_7Hq2",
  "next": {
    "do": "hand_to_person",
Output
await lingua.jobs.accept("job_7Hq3")
× throws BlockedError<'job.accept'> · 403
← {
  "type": "BLOCKED",
  "category": "blocked",
  "code": "needs_the_party",
  "reason": "sign waits on Ada Lovelace to sign. Only they can, through their own link. Hand them the link in next.",
  "param": "ask_7Hq2",
  "next": {
    "do": "hand_to_person",
Output
magic values: none
same refusal, needs_the_party, and the same body on all four surfaces, request ids aside

Every no on every surface has these fields:

FieldHolds
typethe envelope: BLOCKED, or EMPTY for an id nothing has
categorythe kind of no, from the table below
codethe reason, as one word to branch on
reasonone sentence for a person, written for this job
paramthe field or id at fault, when there is one
nextthe call that would work; hand_to_person with the url, the party, what they're asked to do and expires_at; or null when nothing would
doc_urlthe code's page, on your domain
request_idthe id in the Request-Id header
simulatedtrue in the sandbox

Over MCP it's the same body, marked isError. The SDK throws BlockedError<'job.accept'> carrying it, typed with the codes job.accept can return. The CLI prints the reason and exits with the category's code; with --json it prints the body.

Whether to send it again

The category alone tells a caller whether the same call can ever work:

Output
magic values: none

category         HTTP         codes exit  send it again?
invalid_request  400 404 422  10    3     No: the same call gets the same answer. Send next.
conflict         409 410      22    6     No, not until the job's state changes. Read the job, then send next.
authentication   401          2     4     No: send the right key first.
blocked          403          10    5     No: only a person can take this step. Hand next to one.
guard            422          1     6     No: fix the value the guard names, then send it again.
unavailable      403          3     7     Not yet: nothing the caller sends changes it. Join the waitlist in next, and the same call works once it opens.
api_error        500          1     10    Yes: the same call with the same Idempotency-Key, which never runs it twice.

Every code and its fix: Errors.

When only a person can

needs_the_party and needs_a_person mean a key or a tool reached an act that only a person may take: signing, consenting, approving. No retry helps. The agent passes next.url to that person, then reads the job again. What an agent can never do lists those acts, and Asks covers the link the party opens.