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.

MessageDirectionWhen it's sentRequired fields
OPENrunner → adapterFirst message of every adapter session (SPEC.md §5).none
RECORDrunner → adapterOne record handed to a non-source adapter (SPEC.md §5).key
ENDboth directionsTerminates a stream (SPEC.md §5).none
SCHEMAadapter → runnerAn adapter's first message: the JSON Schema for the fields it will provide (SPEC.md §5).provides
RECORDadapter → runnerOne fact-bearing record from an adapter (SPEC.md §5).none
VERDICTadapter → runnerA filter step's decision about one record (SPEC.md §5, §7).key, pass
ATTESTadapter → runnerA 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
PENDINGadapter → runnerA 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
PREFLIGHTadapter → runnerA 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
COSTadapter → runnerWhat a record (or, with key null/absent, a whole step) cost (SPEC.md §5).provider
STATEadapter → runnerA resumable source's cursor (SPEC.md §5), so a re-run can pick up where the last one stopped.cursor
LOGadapter → runnerHuman-facing noise from an adapter (SPEC.md §5).level, msg
ERRORadapter → runnerWhat 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.

KeyTypeRequiredDescription
typealways OPENyes—
step_idstringnoThe pipeline step id this session serves.
run_idstringnoULID of the run.
configobjectnoThe step's resolved with: block, validated against the manifest's config_schema before it is sent.
pendingobjectnoADR-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.tokenstringyes—
acceptsarray of stringnoADR-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.
preflightbooleannoADR-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.

KeyTypeRequiredDescription
typealways RECORDyes—
keyobjectyes—
key.entity_typestringyes'person' | 'company' (extensible), SPEC.md §4.
key.identity_keystringyesCanonical identity key, SPEC.md §4.
fieldsobjectnoProjection 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.

KeyTypeRequiredDescription
typealways ENDyes—

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.

KeyTypeRequiredDescription
typealways SCHEMAyes—
providesobjectyesA 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.

KeyTypeRequiredDescription
typealways RECORDyes—
keyobjectno—
key.entity_typestringyes—
key.identity_keystringyes—
fieldsobjectnoFields learned. Validated against the manifest's provides before the ledger write.
confidencemap of numbernoPer-field confidence, OPTIONAL, default 1.0 (SPEC.md §5).
payloadobjectnoOPTIONAL 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_typestringno—
payload.bodystringyes—

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.

KeyTypeRequiredDescription
typealways VERDICTyes—
keyobjectyes—
key.entity_typestringyes—
key.identity_keystringyes—
passbooleanyes—
reasonstringnoWhy. 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.

KeyTypeRequiredDescription
typealways ATTESTyes—
keyobjectyes—
key.entity_typestringyes—
key.identity_keystringyes—
statusone of confirmed, contradicted, inconclusiveyes—
reasonstringnoWhat 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.

KeyTypeRequiredDescription
typealways PENDINGyes—
tokenstringyesProvider-opaque handle for the in-flight work (such as a Message Batches id). Never interpreted by the runner.
detailobjectnoFree-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.

KeyTypeRequiredDescription
typealways PREFLIGHTyes—
statusone of ok, blocked, inconclusiveyes—
checksarray of objectno—
checks[].namestringyes—
checks[].okbooleanyes—
checks[].detailstringno—
reasonstringnoWhy blocked or inconclusive. Omitted when empty.
destinationstringnoADR-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.

KeyTypeRequiredDescription
typealways COSTyes—
keyobject or nullno—
key.entity_typestringyes—
key.identity_keystringyes—
providerstringyes—
amount_usdnumberno0 allowed (unknown/free). Absent means 0.
basisone of measured, estimatednoADR-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.
detailobjectnoCredits, 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.

KeyTypeRequiredDescription
typealways STATEyes—
cursorobjectyesOpaque 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.

KeyTypeRequiredDescription
typealways LOGyes—
levelone of info, warn, erroryes—
msgstringyes—

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.

KeyTypeRequiredDescription
typealways ERRORyes—
keyobject or nullno—
key.entity_typestringyes—
key.identity_keystringyes—
verdictone of fail_record, fail_run, skip, retryyes—
reasonstringyesWhat 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