Wire protocol
Every message a process adapter and the runner exchange, its direction, when it's sent, and its fields, generated from spec/schemas/msg-*.schema.json
What you'll learn
You're writing a process adapter, or reading one's output, and need each message's direction and fields.
- every message type and which side sends it
- the fields each message carries and which are required
- what a recorded exchange looks like, line by line
The 13 messages a process adapter and the runner exchange, one JSON object per line, from spec/schemas/msg-*.schema.json. Every message carries type, its name, so the required fields listed leave it out. A dotted key sits inside the key before the dot, and an upper-case part such as FIELD stands for a name you choose.
| Message | Direction | When it's sent | Required fields |
|---|---|---|---|
OPEN | runner → adapter | First message of every adapter session (SPEC.md §5). | none |
RECORD | runner → adapter | One record handed to a non-source adapter (SPEC.md §5). | key |
END | both directions | Terminates a stream (SPEC.md §5). | none |
SCHEMA | adapter → runner | An adapter's first message: the JSON Schema for the fields it will provide (SPEC.md §5). | provides |
RECORD | adapter → runner | One fact-bearing record from an adapter (SPEC.md §5). | none |
VERDICT | adapter → runner | A filter step's decision about one record (SPEC.md §5, §7). | key, pass |
ATTEST | adapter → runner | A deliver adapter's three-way verdict on one delivery after re-reading what the target stored (SPEC.md §5, §6, §8; ADR-036). | key, status |
PENDING | adapter → runner | A step ended its session with work in flight: every dispatched record the session did not answer is pending under token, which the runner stores opaquely and hands back on a later OPEN (pending.token) so the adapter can collect. | token |
PREFLIGHT | adapter → runner | A deliver adapter's answer, in a preflight session (OPEN with preflight: true, no records), to whether the live target is fit to send to: ok; blocked (a readable fact says sends would be meaningless or wrong — the runner fails the step before any record is dispatched); inconclusive (the target could not be read — reported ok with a warning). | status |
COST | adapter → runner | What a record (or, with key null/absent, a whole step) cost (SPEC.md §5). | provider |
STATE | adapter → runner | A resumable source's cursor (SPEC.md §5), so a re-run can pick up where the last one stopped. | cursor |
LOG | adapter → runner | Human-facing noise from an adapter (SPEC.md §5). | level, msg |
ERROR | adapter → runner | What an error means for one record, or for the whole step (SPEC.md §5, §8 "A step that stops", ADR-065). | verdict, reason |
OPEN (runner → adapter)
First message of every adapter session (SPEC.md §5). Derived from internal/protocol.Message: every field but type carries omitempty, so an OPEN with an empty config omits the key entirely.
Schema: spec/schemas/msg-open.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always OPEN | yes | — |
step_id | string | no | The pipeline step id this session serves. |
run_id | string | no | ULID of the run. |
config | object | no | The step's resolved with: block, validated against the manifest's config_schema before it is sent. |
pending | object | no | ADR-038. Present only when the runner is collecting: the token the adapter emitted in a PENDING message. The adapter MUST NOT dispatch new work; it collects results for the records that follow, or emits PENDING again if they are not ready. |
pending.token | string | yes | — |
accepts | array of string | no | ADR-065. The optional adapter → runner messages this runner acts on, such as ERROR. An adapter relies on one only when it is listed; a runner that predates the field sends none. |
preflight | boolean | no | ADR-040. Present (true) only in a preflight session: no records follow, and the adapter answers PREFLIGHT then END after checking the live target against this step's config (its variables: targets ride in config as ever). |
RECORD (runner → adapter)
One record handed to a non-source adapter (SPEC.md §5). fields is exactly the projection of the adapter's needs (or of the step's uses:, ADR-004) — nothing more. The runner always canonicalizes and sends key; sources never receive this message.
Schema: spec/schemas/msg-record-in.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always RECORD | yes | — |
key | object | yes | — |
key.entity_type | string | yes | 'person' | 'company' (extensible), SPEC.md §4. |
key.identity_key | string | yes | Canonical identity key, SPEC.md §4. |
fields | object | no | Projection of the step's needs/uses. Omitted when the projection is empty. |
END (both directions)
Terminates a stream (SPEC.md §5). Runner → adapter it means 'no more input'; adapter → runner it means 'no more output'. It carries nothing else.
Schema: spec/schemas/msg-end.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always END | yes | — |
SCHEMA (adapter → runner)
An adapter's first message: the JSON Schema for the fields it will provide (SPEC.md §5). A deliver adapter that learns nothing still sends one, with an empty properties object.
Schema: spec/schemas/msg-schema.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always SCHEMA | yes | — |
provides | object | yes | A JSON Schema document describing the adapter's output fields. |
RECORD (adapter → runner)
One fact-bearing record from an adapter (SPEC.md §5). key is REQUIRED for every role but source: a source emits raw fields and the runner derives the identity key from them (SPEC.md §4, emits_key_fields), so a source's RECORD legitimately has no key.
Schema: spec/schemas/msg-record-out.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always RECORD | yes | — |
key | object | no | — |
key.entity_type | string | yes | — |
key.identity_key | string | yes | — |
fields | object | no | Fields learned. Validated against the manifest's provides before the ledger write. |
confidence | map of number | no | Per-field confidence, OPTIONAL, default 1.0 (SPEC.md §5). |
payload | object | no | OPTIONAL raw vendor response (or per-record slice) this record was extracted from (SPEC.md §5, ADR-030). The runner decides retention from the adapter's declaration; old readers ignore this field. |
payload.content_type | string | no | — |
payload.body | string | yes | — |
VERDICT (adapter → runner)
A filter step's decision about one record (SPEC.md §5, §7). Stored in run_records.verdicts; pass=false freezes the record's state at this step. pass is a *bool in Go, so an explicit false is always on the wire; a VERDICT with no pass at all is treated as a fail.
Schema: spec/schemas/msg-verdict.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always VERDICT | yes | — |
key | object | yes | — |
key.entity_type | string | yes | — |
key.identity_key | string | yes | — |
pass | boolean | yes | — |
reason | string | no | Why. Omitted when empty. |
ATTEST (adapter → runner)
A deliver adapter's three-way verdict on one delivery after re-reading what the target stored (SPEC.md §5, §6, §8; ADR-036). Emitted only by adapters declaring attests in their manifest, after the RECORD that acknowledges the delivery. confirmed: every non-blank field sent is present in what is stored. contradicted: a readable value says it did not persist — the record fails, the deliveries row is kept with that status (the record exists at the target; re-sending would duplicate it). inconclusive: the re-read failed or the shape was unrecognised — the record advances, accepted, with a receipt warning. An attesting adapter that says nothing about a record is inconclusive. Runners that predate this message ignore it (§5 forward compatibility).
Schema: spec/schemas/msg-attest.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always ATTEST | yes | — |
key | object | yes | — |
key.entity_type | string | yes | — |
key.identity_key | string | yes | — |
status | one of confirmed, contradicted, inconclusive | yes | — |
reason | string | no | What was compared, or why nothing could be. Omitted when empty. |
PENDING (adapter → runner)
ADR-038. A step ended its session with work in flight: every dispatched record the session did not answer is pending under token, which the runner stores opaquely and hands back on a later OPEN (pending.token) so the adapter can collect. Step-level — one per session, emitted after any records the session could answer and before END. Runners that predate this message ignore it (§5), and then fail the unanswered records as they would any unanswered record.
Schema: spec/schemas/msg-pending.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always PENDING | yes | — |
token | string | yes | Provider-opaque handle for the in-flight work (such as a Message Batches id). Never interpreted by the runner. |
detail | object | no | Free-form, for the receipt, for example {"provider":"anthropic","records":25,"expires_at":"..."}. |
PREFLIGHT (adapter → runner)
ADR-040. A deliver adapter's answer, in a preflight session (OPEN with preflight: true, no records), to whether the live target is fit to send to: ok; blocked (a readable fact says sends would be meaningless or wrong — the runner fails the step before any record is dispatched); inconclusive (the target could not be read — reported ok with a warning). checks is what was examined, for the receipt.
Schema: spec/schemas/msg-preflight.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always PREFLIGHT | yes | — |
status | one of ok, blocked, inconclusive | yes | — |
checks | array of object | no | — |
checks[].name | string | yes | — |
checks[].ok | boolean | yes | — |
checks[].detail | string | no | — |
reason | string | no | Why blocked or inconclusive. Omitted when empty. |
destination | string | no | ADR-062, optional: a display label for what the step delivers to (a campaign's name and id), printed on the receipt's preflight line. Nothing keys on it; the dedupe scope is the config's stable identifier. |
COST (adapter → runner)
What a record (or, with key null/absent, a whole step) cost (SPEC.md §5). Best-effort, but every v0 built-in that spends money or tokens MUST emit it. amount_usd is NOT required: Go's omitempty on the float64 field drops a zero amount from the wire, so a free adapter written in Go emits COST with no amount_usd at all. Absent means 0. key may be JSON null for a step-level cost.
Schema: spec/schemas/msg-cost.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always COST | yes | — |
key | object or null | no | — |
key.entity_type | string | yes | — |
key.identity_key | string | yes | — |
provider | string | yes | — |
amount_usd | number | no | 0 allowed (unknown/free). Absent means 0. |
basis | one of measured, estimated | no | ADR-046: measured is reserved for an amount derived from vendor-reported cost metadata in the response; an amount multiplied out from a config or manifest rate is estimated even when the unit count is exact. Absent means estimated. |
detail | object | no | Credits, tokens, whatever the provider reported. |
STATE (adapter → runner)
A resumable source's cursor (SPEC.md §5), so a re-run can pick up where the last one stopped.
Schema: spec/schemas/msg-state.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always STATE | yes | — |
cursor | object | yes | Opaque to the runner; meaningful only to the adapter that wrote it. |
LOG (adapter → runner)
Human-facing noise from an adapter (SPEC.md §5). The runner relays it to stderr; stdout is reserved for data (SPEC.md §8).
Schema: spec/schemas/msg-log.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always LOG | yes | — |
level | one of info, warn, error | yes | — |
msg | string | yes | — |
ERROR (adapter → runner)
What an error means for one record, or for the whole step (SPEC.md §5, §8 "A step that stops", ADR-065). verdict is the binding tier's error vocabulary (§10a): fail_record and retry fail the keyed record (retry: the adapter's own attempts ran out), skip counts it skipped, fail_run stops the step. key is required for fail_record, skip and retry and optional (or null) for fail_run. A runner acts on ERROR only when it listed it in OPEN's accepts; an adapter not told so fails the session by exiting non-zero instead.
Schema: spec/schemas/msg-error.schema.json.
| Key | Type | Required | Description |
|---|---|---|---|
type | always ERROR | yes | — |
key | object or null | no | — |
key.entity_type | string | yes | — |
key.identity_key | string | yes | — |
verdict | one of fail_record, fail_run, skip, retry | yes | — |
reason | string | yes | What the receipt and the ledger print. |
Example
spec/wire/basic-run.ndjson, recorded from real adapters: the source session with csv/source and the enrich session with mock-enrich-py. Each line wraps the message in msg; stream, dir, and adapter say which session it belongs to, which way it went, and the adapter on the other end (on GitHub).
{"stream":"source","dir":"runner->adapter","adapter":"csv/source","msg":{"type":"OPEN","step_id":"source","run_id":"01J8Z6QG4M0000000000000001","config":{"path":"internal/adapters/csvsource/fixtures/people.csv"}}}
{"stream":"source","dir":"runner->adapter","adapter":"csv/source","msg":{"type":"END"}}
{"stream":"source","dir":"adapter->runner","adapter":"csv/source","msg":{"type":"SCHEMA","provides":{"additionalProperties":false,"properties":{"column_6":{"type":"string"},"company_domain":{"type":"string"},"email":{"type":"string"},"full_name":{"type":"string"},"linkedin_url":{"type":"string"},"title":{"type":"string"}},"type":"object"}}}
{"stream":"source","dir":"adapter->runner","adapter":"csv/source","msg":{"type":"RECORD","fields":{"company_domain":"acme.com","email":"Jane.Doe@Acme.com","full_name":"Jane Doe","linkedin_url":"https://www.linkedin.com/in/jane-doe/","title":"VP Marketing"}}}
{"stream":"source","dir":"adapter->runner","adapter":"csv/source","msg":{"type":"RECORD","fields":{"company_domain":"https://www.globex.io","email":"bob@globex.io","full_name":"Bob Stone","linkedin_url":"linkedin.com/in/bob-stone","title":"Head of Growth"}}}
{"stream":"source","dir":"adapter->runner","adapter":"csv/source","msg":{"type":"RECORD","fields":{"company_domain":"initech.dev","email":"carol@initech.dev","full_name":"Carol Ray"}}}
{"stream":"source","dir":"adapter->runner","adapter":"csv/source","msg":{"type":"LOG","level":"info","msg":"read 3 rows from internal/adapters/csvsource/fixtures/people.csv"}}
{"stream":"source","dir":"adapter->runner","adapter":"csv/source","msg":{"type":"END"}}
{"stream":"enrich","dir":"runner->adapter","adapter":"mock-enrich-py","msg":{"type":"OPEN","step_id":"mock","run_id":"01J8Z6QG4M0000000000000001","config":{"note":"seen by the external adapter"}}}
{"stream":"enrich","dir":"runner->adapter","adapter":"mock-enrich-py","msg":{"type":"RECORD","key":{"entity_type":"person","identity_key":"jane.doe@acme.com"},"fields":{"email":"Jane.Doe@Acme.com","full_name":"Jane Doe"}}}
{"stream":"enrich","dir":"runner->adapter","adapter":"mock-enrich-py","msg":{"type":"RECORD","key":{"entity_type":"person","identity_key":"bob@globex.io"},"fields":{"email":"bob@globex.io","full_name":"Bob Stone"}}}
{"stream":"enrich","dir":"runner->adapter","adapter":"mock-enrich-py","msg":{"type":"RECORD","key":{"entity_type":"person","identity_key":"carol@initech.dev"},"fields":{"email":"carol@initech.dev","full_name":"Carol Ray"}}}
{"stream":"enrich","dir":"runner->adapter","adapter":"mock-enrich-py","msg":{"type":"END"}}
{"stream":"enrich","dir":"adapter->runner","adapter":"mock-enrich-py","msg":{"type": "SCHEMA", "provides": {"type": "object", "additionalProperties": false, "properties": {"mock.score": {"type": "integer", "minimum": 0, "maximum": 100}, "mock.note": {"type": "string"}}}}}
{"stream":"enrich","dir":"adapter->runner","adapter":"mock-enrich-py","msg":{"type": "RECORD", "key": {"entity_type": "person", "identity_key": "jane.doe@acme.com"}, "fields": {"mock.score": 21, "mock.note": "seen by the external adapter"}}}
{"stream":"enrich","dir":"adapter->runner","adapter":"mock-enrich-py","msg":{"type": "COST", "key": {"entity_type": "person", "identity_key": "jane.doe@acme.com"}, "provider": "mock", "amount_usd": 0, "detail": {"credits": 0}}}
{"stream":"enrich","dir":"adapter->runner","adapter":"mock-enrich-py","msg":{"type": "RECORD", "key": {"entity_type": "person", "identity_key": "bob@globex.io"}, "fields": {"mock.score": 30, "mock.note": "seen by the external adapter"}}}
{"stream":"enrich","dir":"adapter->runner","adapter":"mock-enrich-py","msg":{"type": "COST", "key": {"entity_type": "person", "identity_key": "bob@globex.io"}, "provider": "mock", "amount_usd": 0, "detail": {"credits": 0}}}
{"stream":"enrich","dir":"adapter->runner","adapter":"mock-enrich-py","msg":{"type": "RECORD", "key": {"entity_type": "person", "identity_key": "carol@initech.dev"}, "fields": {"mock.score": 68, "mock.note": "seen by the external adapter"}}}
{"stream":"enrich","dir":"adapter->runner","adapter":"mock-enrich-py","msg":{"type": "COST", "key": {"entity_type": "person", "identity_key": "carol@initech.dev"}, "provider": "mock", "amount_usd": 0, "detail": {"credits": 0}}}
{"stream":"enrich","dir":"adapter->runner","adapter":"mock-enrich-py","msg":{"type": "LOG", "level": "info", "msg": "enriched 3 records"}}
{"stream":"enrich","dir":"adapter->runner","adapter":"mock-enrich-py","msg":{"type": "END"}}
See also
Used in
- Facts have provenance
- Two adapter tiers: Every NDJSON message a process adapter reads and writes