Open sourcePosternDrafted in the open
Specification v0.1 · draft Apache-2.0

Postern

The open execution and entitlement protocol for packaged AI agents.

A small HTTP contract for running one as a local process, and for checking that whoever is running it is allowed to. Postern fixes the interface, not the agent — anything that can call HTTP can be a client, and anything that can serve HTTP can be an agent, in any language.

A postern is the small gate in a fortification that authorised people pass through. Present proof, pass through, then run.
Version
0.1 · draft
License
Apache-2.0
Verbs
4 · a ceiling
Reference implementation
Sigrix · Level 3

This is a draft, and nothing in it is stable yet.

Version 0.1 is drafted in the open, deliberately: a specification benefits most from being read while changing it is still free. It is not released — there is no tag to pin to, and until there is, changes land under Unreleased rather than moving the version. Breaking changes will happen before 1.0, and VERSIONING.md says which of them move what. If you are building against it, please open an issue — knowing who depends on what is what lets us avoid breaking you.

The contract

Four verbs

An agent that speaks Postern can say what it takes and produce what it returns, over four HTTP endpoints. No SDK is required, and none is planned.

Specification ↗
describe

What inputs does this take, what does it return, which credentials does it need, which of its tools cost money.

GET /postern/v0/describe
run

Run it, give me the result.

POST /postern/v0/run
stream

Run it, show me as it goes.

POST /postern/v0/stream
status

Is it healthy, am I still allowed to run it.

GET /postern/v0/status

Four verbs is a ceiling, not a starting point. Every published verb is a permanent obligation, and a contract that stays compatible for a decade is worth more than a complete one that fractures in two years. Extending within the four — new optional describe fields, new stream event types — is ordinary work and is welcome.

Get it

Run it locally

A container image and a Python package. Both are conveniences around the specification — nothing in Postern requires either, and neither is the protocol.

Docker image Available

Sigrix's own runner — the reference implementation. It serves the four verbs on port 8787 for one agent, which it fetches at boot from the distributor that licensed it. One image for every listing; there is no per-listing build.

docker pull sigrix/runner:1.6.0
docker run --rm -p 8787:8787 -e SIGRIX_TOKEN=… sigrix/runner:1.6.0 owner/listing
the argument is the listing, as {seller-handle}/{listing-id}; both it and the token are on your account's plugin-feed card.
an agent you unzipped yourself needs no image — python -m sigrix_runtime.postern inside the bundle is the same runner.
Conformance checker Available

Points at a running runner and reports which of the three levels it actually meets, and which MUST rules it breaks getting there. It checks over the wire, so it reads a Go or Rust runner as well as a Python one — a test suite rather than an SDK.

pip install postern-conformance
postern-conformance http://127.0.0.1:8787
it never runs your agent unless asked — a run can spend money, so --execute is opt-in and the rest is checked through refusals that execute nothing.
the package is postern-conformance; postern on PyPI is an unrelated project. Source ↗
Health

Status

What Sigrix serves as a distributor, and whether it is answering. The schema row is the one worth watching: publishing an $id on sigrix.io is a maintenance obligation, not a label. Your own runner is not on this list — it runs on your machine, so nothing here can attest it.

Full status page ↗
All systems operational sigrix.io/status/postern
Entitlement check
Checks are being answered from a live read of the entitlement record.
Operational
Bundle retrieval
Bundles are being served to entitled buyers.
Operational
Version check
Boot-time update checks are being answered from a live read of the listing.
Operational
Schema hosting
All 8 schema documents for Postern 0.1 are resolving.
Operational
checked at 2026-09-11T07:03:34Z What your runner does when this is not green ↗
In the repository

Specification, schemas, examples

Everything normative lives in one document. Everything machine-readable sits beside it.

sigrix-io/postern ↗
Appendix A

Changes

Corrections made before the first tagged release. Amending the document in this window is not a version event — there is nothing pinned to protect yet — so they belong to the version being prepared.

All entries ↗
Unreleased 0.1 in preparation 55 corrections so far — no tagged release yet, nothing to pin to.
  • §4.1, §4.1.2, §4.2, §4.4, §2.3 2026-09-06 idempotent_retry moves from describe.capabilities to status. §4.1 opens "capabilities describes the agent", and the field falsified it: whether a repeat executes the agent a second time is a fact about the runner serving it, like level and limits. It is moved rather than withdrawn, which the capabilities.streaming withdrawal above it makes easy to conflate — that rested on two arguments and only one transfers. streaming was also a second vocabulary for a fact §3 already stated, so a client acting on it broke a MUST; nothing else states whether a runner honours the header, limits.idempotency_retention_seconds bounds how long the promise lasts rather than whether it is made, and a client reading this one broke no rule. It sits beside limits and not inside it, limits carrying bounds and a boolean being none. §4.1.2 keeps its pairing with write_tools — what an agent does that nothing can undo, and whether asking twice does it twice — and now says the two are read from two documents, both answerable at Level 1. A runner still emitting the field under capabilities validates, that object being open, and means nothing by it; a client MUST read the answer from status.
  • §4.2, §4.4, §4.5 2026-09-06 §4.2 says what an Idempotency-Key does before the first execution is answered. The replay promise was keyed on a key already *answered*, so a repeat arriving mid-execution fell outside every sentence in the section — and that is the window a client retries in, since §4.2's own reasoning is that a connection dropping mid-run looks the same whether the agent was halfway through or writing its last byte. A runner MAY hold the second request and answer it with the first execution's result, bounded by limits.max_run_seconds; a client MUST NOT assume it will, the posture this section already takes toward the retention window; and §4.5's capacity refusal needed nothing added, being the one 503 whose retry lands on an answered key. It also states that one keyspace spans both verbs, which the replay rule assumed where it says what a replayed result looks like on stream.
  • §2, §2.1 2026-09-06 §2.1's envelope binds "a path this specification defines" rather than every non-2xx response. A runner binds a port, and §2 gives Postern's own paths the /postern/v0/… prefix without scoping §2.1 to them — so read literally the rule reached every other response that port gives, and a request for a favicon was a conformance failure. It binds every request under the prefix, an unimplemented path among them, and nothing outside it, which is what the conformance checker already assumed.
  • §2.3 2026-09-06 Answering a preflight is responding to it, not admitting the origin. §2.3 pairs a MUST to answer OPTIONS with a SHOULD to answer a refused origin 204 rather than an error status, and left open whether a refusal answers the MUST — a runner could read it as requiring the origin be allowed. What the MUST forbids is leaving the method unhandled: a reset connection and the 405 a framework gives an unregistered method both reach the browser as a failed preflight, with nothing to tell the page a refusal from a runner that is not running.
  • §2.1, §4.4, §4.6 2026-09-06 A 424 missing_credential SHOULD carry error.detail.missing, an array of the environment variable names describe declares and the runner's environment does not hold. §4.6 makes step 5 a MUST on the grounds that "a 424 names the variable to set; the 500 agent_error that follows a run started without it names nothing" — and until now only message did, which §2.1 makes prose a client shows a user rather than acts on. So the argument for the MUST was sound and the field it turns on did not exist. It is an array rather than one name because a runner reading its own environment against describe's list meets every unset one at once, and because that is the cardinality status.credentials.missing already publishes — a scalar here would be one fact in two shapes. §4.5's error.detail.max_run_seconds is already defined as "the same integer status declares", so echoing status is the established shape for a detail member rather than a new one. examples/error.json carried this member as detail.env, a spelling nothing in this document defined.
  • §4.4 2026-09-06 status.credentials is defined, which §4.4 showed in its own example and never said. satisfied is true when every environment variable describe declares (§4.1.3) is set, and missing names those that are not; the block is OPTIONAL. §4.6 already relied on it — it distinguishes publishing the satisfied set from performing the step-5 check, and cites §4.4 for an optionality §4.4 never stated — so this is the definition that citation pointed at. Every other member §4.4 carries it names: limits, update and each of entitlement's. credentials was the one it drew and left unlabelled.
  • §4.3, §4.5 2026-09-05 §4.3 states that done repeats start's run_id, and that a runner MUST NOT report two identifiers for one run. The rule was asserted in stream-event.schema.json — whose start description has always said it carries *"the run_id that the done payload repeats"* — and demonstrated in examples/stream.txt, where both name 01JD8XW2Q9. The prose said only that start carries run_id, so the correlation was documented everywhere except the document that binds it. It spans two events, which is why neither schema can reach it and why it is asserted the way §4.3's delta concatenation rule already is.
  • §1.5, §2.2, §4.1.4, §4.4, §5.1 2026-09-05 status.agent, status.entitlement and describe.output are REQUIRED, which the sections reasoning about them assumed and the schemas did not carry. Every top-level member §4.4 marks, it marks OPTIONALlimits, update — and the two it marks REQUIRED are conditional members inside entitlement, so agent and entitlement themselves were unmarked rather than decided, and status.agent required nothing at all inside it, admitting {"agent": {}} while describe required id, name and version. Each omission cost something stated elsewhere: §5.1 MUSTs a runner with no distributor to report entitlement.state as not_required, and saying nothing is indistinguishable from a runner that has not implemented entitlement; §1.5's "one agent, one identifier, spelled one way in all four places" is false of a status that names none, and §2.2's identity rule cannot be checked against a runner that omits it — such a runner is not caught disagreeing with its own describe, it cannot be asked; run and stream both MUST carry an output with a type, and §4.1's describe is the only place a client can learn that type before it asks. Only agent.id is required within status.agent. No example, fenced block or reference-runner path in this repository emitted a document any of this now refuses.
  • §1.5, §2.2 2026-09-05 §2.2 says no runner *path* carries an agent identifier, where it used to say none of the four verbs did. The narrower claim was false as written — describe carries agent.id and §1.5 says so — and only the addressing reading supports the conclusion drawn from it, that a client MAY treat a runner's port as its agent's address. The next sentence, contrasting with distributor paths that *address* an agent by identifier, always meant the same thing.
  • §4.1.1, §4.2 2026-09-05 describe.schema.json enforces the pairing §4.1.1 already stated between an input's type and its default. The sentence fixing the value space — *text and select carry a string, number carries a number* — was prose only, so {"type": "number", "default": "not a number"} validated cleanly, as did a numeric default on a text input. Nothing in the repository violated it, which is why it went unseen. null stays legal under every type, since it is what says no value was supplied. The two new branches are keyed positively on the types they constrain, never negatively on the ones they do not: this section anticipates a fourth type, and a branch reading *anything that is not number* would constrain it the moment it arrived. run-request.schema.json deliberately does not follow — the values there sit in a different document from the declarations that type them, so it admits the union of the three value spaces and §4.2 leaves the per-input check to the runner.
  • §1.5, §4.1.1, §4.2, §5.3 2026-09-05 §4.1.1 states the grammar an input key has to satisfy. describe.schema.json has enforced [A-Za-z0-9_.-]+ from the first commit and §4.1.1 said only that a declaration MUST carry the member, so a key with a space in it was refused with no sentence to cite — and nothing asserted the pattern either way, which by validate.py's own doctrine made it a rule deletable without anything going red. The reason is now stated with it: a client renders a key as the *name* of a thing and label carries the human-facing name, so the key does not have to, and excluding whitespace and non-ASCII keeps two keys that look alike alike. Its length stays unbounded, unlike an agent identifier's, because a key crosses no trust boundary — it travels between one runner and a client already talking to it, never to a distributor. The grammar binds the declaration and reaches run through §4.2's "map keyed by describe's input keys" rather than being restated there.
  • §2.1, §4.1.3 2026-09-05 §4.1.3 states what a credentials[] entry carries, and what its closure is for. describe.schema.json has always closed that object, and it was the only statement of the entry's shape anywhere — §4.1.3 described the rule and never the record. The closure is not a list of today's members: §4.1.3's own claim that there is *nowhere in the protocol for a secret to travel* is true only while the object is closed, since an open one would make value beside env schema-valid, along with every other spelling of the leak the section forbids. It catches the structural half and not the whole of it — a value written into purpose is a conforming shape carrying a nonconformant string, which only reading the field finds. The cost is named too: a member added here later is additive for runners and for any client that does not use an emit-side schema as an acceptance filter, and breaks only one that does. It is the only closed object in these schemas outside error.schema.json's root, which schemas/README.md now records as a fourth place the emit/accept difference is load-bearing rather than as three.
  • §4.1.3, §4.4, §4.6 2026-09-05 A runner MUST perform the credential check, not merely order it. §4.6 placed the environment check last and gave missing_credential its producing rule, but bound only the sequence — so a runner that never inspected its environment never reached the condition, started the agent, and answered agent_error. Two conforming runners therefore answered one request differently, a 424 naming the variable to set against a 500 naming nothing, and a client could not tell which it held. It is a MUST rather than a SHOULD because every runner can perform it: describe declares credentials by environment variable name (§4.1.3) and every runner answers describe (§4.1), so the list is already in hand. status.credentials staying OPTIONAL is not the same obligation — it governs publishing the satisfied set, not checking one before a run.
  • §2.1, §2.3, §5.5, §7 2026-09-02 Added unauthorized (401), the answer a runner gives when it requires inbound authentication of its own and a request does not satisfy it (§2.1, §7). §7 has always obliged a runner binding a non-loopback interface to authenticate its callers while specifying no scheme for it, which left the *refusal* undefined too — so such a runner had to answer either a code meaning something else or one outside §2.1's table, and a client met a second error shape at exactly the deployment where it had least context. The scheme stays unspecified; only the refusal is fixed. §5.5's "Postern defines no 401" is narrowed to the distributor it was always about: its reasoning is that a 401 confirms a token was once real and so sorts guesses into piles, which needs a catalogue to enumerate, and a runner serving one agent behind an identifier-free path space (§2.2) has none. §2.3 gains Authorization in the preflight's Access-Control-Allow-Headers for such a runner — a browser cannot send a header its preflight did not admit, so without it a page is the one client kind that could never authenticate — and a runner requiring nothing SHOULD NOT name it, since the header is off the safelist and admitting it preflights a describe that would otherwise go without one.
  • §2.1, §4.2, §4.6 2026-09-01 A runner's refusals are ordered: it decides what the request says before it inspects what it holds, so a run that both omits a required input and meets a runner missing a credential is answered bad_request rather than missing_credential (§4.6). Both were correct under the previous text and nothing chose between them, which left §4.2's MUST untestable on any runner whose environment was incomplete — the ordinary state of one being brought up — and made a malformed request's answer depend on the operator's deployment. missing_credential gains the producing rule it never had: it was the only code in §2.1's table that no section required, defined and never asked for. §4.5's capacity refusal is deliberately left unordered.
  • §2.1 2026-08-16 A client MUST tolerate an error code it does not recognise, so that adding a code stays an additive change.
  • §2.1, §4.3 2026-08-16 Added not_implemented (501). A Level 2 runner answers stream with it rather than with unavailable, which is now 503 only.
  • §2.1, §5.6 2026-08-16 Added withdrawn (410), so the withdrawn-agent response in §5.6 has a code and can be constructed at all.
  • §5.3, §5.4 2026-08-16 The entitlement check response now carries checked_at, defined as the moment the distributor last consulted the authority rather than the moment it answered. A runner propagates it unchanged and MUST NOT re-stamp it, so the distributor's cache and the runner's cache share one deadline instead of stacking.
  • §4.4 2026-08-16 entitlement.checked_at is now REQUIRED in status when the entitlement state is active or revoked.
  • §4.3 2026-08-16 The delta reconstruction rule applies only when a delta is emitted, so a Level 3 runner that cannot produce incremental text stays conformant by emitting none.
  • §2.1, §2.2 2026-08-18 A runner serves exactly one agent, stated normatively rather than left to be inferred from the absence of an identifier in its paths. not_found's "no such agent" meaning is distributor-side only; on a runner the code can only mean an unimplemented path. Each code in the §2.1 table now says which side emits it.
  • §2.1 2026-08-18 The error envelope's root is closed by design — nothing sits beside error, so the envelope has one extension point rather than two. The schema already asserted this; §2.1 now states it, with the reason and with the fact that it constrains what an implementation emits rather than licensing a client to reject what it receives.
  • §5.6 2026-08-18 The §5.6 410 body carries the date access ends as error.detail.access_ends_at. The closed root leaves detail as the only place it can go, and the specification previously left it unplaced.
  • §2 2026-08-16 The subprocess discovery line is POSTERN_PORT=<port>, replacing the mixed-case form.
  • §8 2026-08-16 Removed verification from the org.sigrix member list.
  • §3, §4.2, §4.3 2026-08-19 A runner answers *any* verb above its declared level with 501 and not_implemented. The rule was previously stated only for a Level 2 runner asked to stream, leaving a Level 1 runner asked to run with no defined answer; it now sits in §3, so it also covers any level added later.
  • §4.1.1 2026-08-19 Narrowed input values to what the three declared types can produce. run's inputs map and an input's default no longer admit a boolean, which none of text, number or select yields. Adding a fourth type later is additive; withdrawing a value shape a runner had relied on would not be.
  • §4.2 2026-08-19 Removed status from the run response. Its only legal value was ok, because §2.1 routes every failure through a non-2xx error envelope, and the partial-result case it might have grown into cannot be carried by a value an older client would read as a complete result.
  • §2.1, §5.3, §5.5, §7 2026-08-19 §5.5's indistinguishability rule covers token state, not only agents. An unknown, revoked, or superseded token answers 404 with not_found, the same as a valid token presented for an agent the buyer may not have, and Postern defines no 401 — a status meaning "authenticate and try again" would confirm the token was once real. §5.3's success rule gains the failure branch it presupposed, and §7's "stop resolving" now names the answer it stops with.
  • §4.4, §5.4 2026-08-19 entitlement.stale_after_seconds is now REQUIRED for revoked as well as active, matching checked_at: it is required wherever a check actually happened. Without it a runner held a timestamp and no deadline, so §5.4's re-check rule could not be evaluated for a revoked answer and the restoration §5.4 obliges a distributor to support could never be observed.
  • §4.1, §4.4 2026-08-19 agent_id has a grammar: two parts of lowercase ASCII alphanumerics, - and ., joined by one /, bounded at 128 characters and compared octet-for-octet with no folding or normalisation of any kind (§1.5). It was previously only a non-empty string, and the canonical acme/market-research-crew did not fit the single path segment the distributor endpoints gave it. It now occupies two segments and is never percent-encoded — the grammar admits no character a path requires encoding for — and a string that fills two segments without matching the grammar is answered 400, an answer a distributor MUST be able to produce without consulting its catalogue, which is why it does not weaken §5.5 (§5.3.1, §5.6). The identifier carries no listing type, so a distributor dispatching on one resolves it itself and MUST test each branch out of band: §5.5 makes a missing branch indistinguishable from a correct refusal, so no client will ever report it (§5.5). agent.id carries the pattern and the bound in describe.schema.json and status.schema.json.
  • §5.3 2026-08-19 The entitlement check has a schema, and its response carries postern like every other success payload in the protocol. It was the only one without a version marker, and a distributor's version is inferable from nothing else — VERSIONING.md forbids reading it off the path prefix. Freezing the shape settled two things the prose had left loose: stale_after_seconds is sent whether or not the distributor caches, because §5.4's re-check deadline and §4.4's status report both need it and neither is conditional on a cache existing; and agent_id echoes the identifier the request addressed, octet-for-octet, so a mismatch is a failed check rather than something to reconcile. The validate.py skip over the §5.3 block is gone with it — that payload was checked by nothing until now.
  • §5.6 2026-08-19 Digest: sha-256=<base64> becomes Repr-Digest: sha-256=:<base64>: on a bundle response. RFC 3230 was obsoleted by RFC 9530 before this specification shipped, and the replacement is a structured field, so the colons are syntax rather than decoration. Repr-Digest rather than Content-Digest because a client verifies the bundle it keeps, not the bytes of one hop.
  • §4.4, §5.3, §5.4, §5.7, §8 2026-08-19 A runner has defined behaviour when the distributor cannot be reached (§5.7). The check response declares grace_seconds beside stale_after_seconds, and a runner whose answer has expired with nothing answering keeps running until checked_at + stale_after_seconds + grace_seconds, reporting unknown — the state §4.4 has always listed and nothing in §5 produced. The honest upper bound in §5.4 is now the sum of the two rather than the first alone, and is stated as such; both terms are the distributor's own, so it can evaluate the sum before publishing either. 0 is a valid grace and means *stop at the window*, so strictness is declared rather than inferred from an absent field. §8 puts Sigrix's at 86400.
  • §5.4, §5.7 2026-08-19 §5.4's rule against persisting an active answer across a restart is replaced. A runner MAY persist an answer, provided it persists checked_at with it and evaluates the deadlines against that value on load; a restart yields no fresh window. The old rule was written when the check returned no timestamp at all, so a persisted answer had no trustworthy expiry and discarding it was the only bound available. With the anchor returned and propagated unchanged (§5.3), discarding shortens nothing — a runner that can reach the distributor re-checks anyway — and costs the case §5.7 exists for, where a machine reboots with no network and cannot tell an entitlement it held five minutes ago from one it never had.
  • §5.7 2026-08-19 A 404 from the check is an answer rather than an outage: no grace applies, the runner stops at once, reports revoked, and answers run and stream with 403 not_entitled. It reports revoked even though §5.5 stops it distinguishing a withdrawn entitlement from one that never existed or a token that no longer resolves — what the three have in common is all a client can act on. A runner that has never completed a check does not run at all, reports unknown with no checked_at, and answers 503 unavailable. The rule under both: unreachable answers unavailable, refused answers not_entitled.
  • §2.3, §7 2026-08-22 Browser clients have a defined answer: a runner MUST answer the OPTIONS preflight on run and stream, and the origin policy behind it is the operator's, defaulting to refusal rather than to Access-Control-Allow-Origin: *. The specification named a web UI as a client kind and said nothing about CORS, so a fully conforming runner could be unreachable from one — while the obvious remedy, a wildcard, would hand run and its write_tools to every page the user visits. A runner MUST now also reject a run or stream body whose Content-Type is not application/json: application/json is what makes the request preflight at all, and a runner accepting text/plain executes the agent for any origin without one, which is the whole of the preceding rule undone.
  • §4.2, §4.3, §4.5 2026-08-22 A run in flight has a defined life (§4.5). A runner SHOULD abort the agent when the client disconnects, on run and stream alike, and MUST NOT deliver an abandoned run's output anywhere else — there being no callback and no verb that takes a run_id, which is also why an abort cannot be reported and a reopened stream is a new run rather than a resumption. An abort is not a rollback: §4.1.2's write_tools name things that may already have happened, and a retry without an Idempotency-Key buys the work twice. Previously nothing said whether closing a laptop lid stopped an agent from spending money.
  • §2.1, §4.4, §4.5 2026-08-22 Added run_timeout (504), and with it a runner's right to impose a maximum run duration. agent_error and unavailable both nearly fit and both mislead — one reports a working agent as broken, the other invites a retry into the same deadline. A runner imposing a limit MUST declare it as status.limits.max_run_seconds and MUST NOT declare one longer than it can enforce, and the refusal SHOULD carry it as error.detail.max_run_seconds.
  • §4.4, §4.5 2026-08-22 Concurrency is the runner's to decide and discoverable rather than assumed: it MAY refuse an overlapping run with 503 unavailable, needing no new code because the client's move is the one that code already asks for, and SHOULD declare status.limits.max_concurrent_runs. status.state: "running" observes that a run is in flight and promises nothing about admission — a client MUST be ready for 503 whatever status last said, since the slot can go elsewhere between the two calls.
  • §2.1, §4.1.1, §4.1.4 2026-08-22 output has a section of its own (§4.1.4). text is the v0 output type by decision rather than by accident of the examples, matching what §4.1.1 already said for inputs — and an unrecognised output.type now has a receive-side rule, which is the part that changes the contract rather than recording it. It is deliberately not the rule the other four extensible surfaces use: an error code, a stream event name, an input type and a validation member are all things a client may ignore, and output.type is what says how to read value, so ignoring it misreads a known rather than tolerating an unknown. A client MUST NOT present value as text, MUST NOT report the run as failed — a 200 it cannot render is a run that succeeded — and SHOULD name the type it was given. The rule has to exist before a second output type can, or the addition breaks every client written against the closed set: the ordering the error-code enum already paid for.
  • §4.3 2026-08-22 stream's event payloads have schemas, and its rules about them are stated rather than implied by a table cell. A step carries at least name and status; latency_ms is an elapsed time, so it is reported on finished and a runner MUST NOT emit it on a started step, where there is nothing yet to measure — a client receiving one anyway ignores it rather than rejecting the event. stream-event.schema.json covers the three payloads this specification defines itself; done and error carry bodies §4.2 and §2.1 already define, and the SSE framing spans events, so neither is expressible there.
  • §7 2026-08-22 The plaintext-token prohibition has a loopback exception, on both halves: a distributor reachable only on loopback may serve plaintext, and a runner may send its token when the peer address is loopback — the one case where the network the TLS rule exists to protect is not there. The condition is the address connected to rather than the hostname configured, because a name is resolved by something the runner does not control, and resolving before connecting leaves a gap between the two answers. A runner SHOULD say when it takes the exception, to its operator rather than in status.
  • §4.1.4, §4.3, §4.4 2026-08-23 output.type gains bytes, for an agent whose result is a file rather than prose. value carries the artifact base64-encoded and stays a JSON string, so no envelope changes shape; media_type is REQUIRED beside it and is an open RFC 6838 string, because an enum would need a specification revision per format. A bytes run emits no delta — §4.3's invariant is text-shaped, and base64 fragments would satisfy it while giving a client nothing but the encoding to print — so it reports progress with step instead. describe.output.example stays text-only, and a runner bounding what it returns declares limits.max_output_bytes in status, measured before base64. Additive: a client written against §4.1.4's receive-side rule survives it.
  • §5.7.3, §5.7.4 2026-08-23 A runner whose first-ever check answers 404 reports revoked with its own re-check cadence as stale_after_seconds. That fallback was already the rule, but reachable only by reading "its own re-check cadence" as the field. The clause that did name the field attached a SHOULD — reuse the distributor's last value — which a first check cannot satisfy, so the one case a plain misconfiguration produces was the one left unstated, and the conformant reading was the harder of the two to find. §5.7.4 also now answers whether a client can tell a runner-supplied number from a distributor's: it cannot, and does not need to, because the bound protects the runner's own operator under revoked where it protects the distributor under active, and only the second party gains by overstating it. §5.7.3 says that a 404 is a completed check and so not its case, and status.schema.json's two descriptions carry the same exception.
  • §4.3 2026-08-29 The delta concatenation invariant gains the receive-side rule it was missing: where the accumulated deltas and done's output.value disagree, a client SHOULD prefer done, and MUST NOT report the run as having failed on that ground. It was the one place a client could be surprised with nothing stated for it — every other one has a rule, and §4.1.4's is the near neighbour, separating a run that succeeded from a client that rendered it wrongly. A SHOULD rather than a MUST because a client writing deltas to standard output has already emitted them. The invariant itself is unchanged and still binds the runner.
  • §2.3, §4.1.2, §4.2, §4.5 2026-08-29 A runner that honours an Idempotency-Key declares it, as capabilities.idempotent_retry in describe. §2.3 already varied a browser client's preflight by whether the runner honours the header, so the one answer was being advertised in a CORS header to a client with no protocol-level way to ask for it, and §4.5 had since made being charged twice a documented outcome of an ordinary disconnect. Declaring it forced fixing what honouring means, which the specification had never said: a repeat key MUST NOT execute the agent again and MUST be answered with the result of the first execution, a produced error included, while a request refused before the agent ran binds no key. Absent and false read identically, so a client written before the field is right about every runner that had not made the promise. §4.5 stops naming the key as the remedy for the disconnect it can do least about — the run it would deduplicate against was aborted and its output discarded — and says that answering a retry carrying that run's key is not the late delivery its discard rule forbids. A runner declaring the field MUST admit the header in its preflight, or the promise holds for every client kind except the browser.
  • §4.2 2026-08-31 run_id is unique per execution rather than per response, so a replayed idempotent answer carries the run_id of the execution it replays. The uniqueness MUST predates the replay rule by some distance and the two were never read together: a strict reader of the older sentence is pushed toward minting a fresh identifier for the replay, which names an execution that never ran and so has no line in any log — defeating the correlation the same sentence's SHOULD exists for. Nothing an implementer builds under either reading fails, which is why this needed saying rather than leaving to sense.
  • §2.1, §4.2, §4.4 2026-08-31 An Idempotency-Key identifies a request rather than a caller, and idempotency_conflict (409) is what a runner answers when one is presented with different inputs. #89 keyed the replay rule on the header alone, which answers the second request with a result computed for inputs the caller never sent — at 200, in a valid envelope, undetectable on either side. That is the failure §4.1.4 argues hardest against, reached through a header a client adds in order to be careful, and worst on exactly the agent §4.1.2 warns about. bad_request nearly fits and misleads: nothing in the body needs fixing, and 400 already answers a failed validation, so a client could not tell "your inputs are wrong" from "that key is spoken for" — refusals whose remedies are opposite. Inputs are compared by value on the decoded map, so re-serialising a request cannot manufacture a conflict, and a 409 executes nothing and so binds no key. Retention is the runner's to choose and a client MUST NOT assume a window, since a runner forgetting after a second satisfies every word of the replay rule; a runner that can state one SHOULD declare status.limits.idempotency_retention_seconds, the one member of limits bounding a promise rather than a run.
  • §3, §4.1 2026-08-31 capabilities.streaming is withdrawn. It appeared in §4.1's example and in describe.schema.json — the one property there carrying no description — and no prose ever defined it, which left capabilities documenting one of its two booleans once #89 gave idempotent_retry a treatment. Defining it was the alternative and would have frozen a contradiction: §3 makes level in status the authority and forbids assuming a level read from anywhere else, so a client acting on streaming breaks a MUST, and a client that may not act on a field has decoration. Nothing bound the two, so {"level": 2} beside {"streaming": true} was a payload no rule refused. This is the same refusal of a second vocabulary that removed run's status field and kept 0 out of max_concurrent_runs, and pre-1.0 is the only cheap moment for it — VERSIONING.md's additive-only rule binds after. It breaks no runner: capabilities is open, so one still emitting the field validates unchanged and merely means nothing by it. §4.1 now says capabilities describes the agent and level the deployment, which is the line that keeps the field from being re-proposed. The conformance checker's streaming/level agreement warning goes with it — that rule was the tool's own inference from §3, with no sentence to cite.
  • §4.4, §8 2026-09-02 status gains an OPTIONAL update block, reporting what a runner learned when it asked its distributor whether a newer version of the agent exists: a state of not_required, unreachable, current or update_available, with the running version as current and the reported one as latest. It is present only where a check ran, so a runner configured for none omits it — a different fact from a check that ran and found no distributor. An unreachable check is explicitly not a failure: a runner MUST NOT refuse to start or to run because it could not tell, which is §5.7's posture for an entitlement it cannot re-check, and a client reads unreachable as *not known* rather than as *out of date*. It sits in status rather than describe for the reason limits does — the version a runner happens to be running, against a distributor it happens to be configured for, is a fact about the deployment. No distributor path is added: §5 fixes the two a runner must call to serve its agent at all, a version answer is neither, and a runner that never asks conforms fully — so how latest is obtained is the distributor's to publish, and §8 records Sigrix's, unauthenticated because it names no buyer.
  • §4.1.4 2026-09-02 output.media_type is bounded by the grammar it always claimed. Both schemas carried a pattern that was wrong in each direction at once: it refused every experimental type, x-custom/foo among them, because the type half admitted no -, while accepting a subtype beginning !, which §4.2 of RFC 6838 forbids. Both halves are that RFC's restricted-name now. §4.1.4 also states what the pattern used to imply by accident — a runner emits the field in lower case, so two runners naming one format agree octet-for-octet, and a client MUST NOT reject a response over its case.
  • §4.6, §5.7.4 2026-09-02 §4.6 places the entitlement refusals, which it previously left out of its sequence entirely. They are step 2 — behind the level check, ahead of the media type, the inputs and the environment — so a runner that has been told no answers that rather than a 400 naming something the caller could fix, which §5.7.4 already forbids it to imply. The general sentence is narrowed to the steps it was always about: *what the request says before what the runner holds* governs steps 3 to 5, and an entitlement is neither. Both orders conformed before, so a conformance checker could assert neither.
  • §4.4, §8 2026-09-02 version.schema.json fixes the shape of a version answer — postern, the agent_id echoed octet-for-octet, and a version string compared for equality only, with no ordering implied. It is the source of §4.4's status.update.latest, and the first schema here whose *path* this specification does not define: §5 fixes the two distributor paths a runner must call to serve its agent at all, a version answer is neither, and §8 records where Sigrix serves it. Fixing the shape without fixing the path is the point — a second distributor offering the same answer answers it the same way, and a runner reads both with one parser. It joins entitlement.schema.json as a distributor payload the conformance checker does not bundle, since a runner never emits one.
Every breaking change is listed with a migration note. Read Appendix A in full ↗
Contributing

Read it while changing it is still free.

Issues and pull requests are welcome. CONTRIBUTING.md is worth reading first — in particular the part about how quickly you can expect an answer, which is written to be accurate rather than flattering. If you are implementing Postern in another language, say so on an issue; that is the most useful thing anyone can tell us right now.