Eight envelopes, one per line
The semantic layer — frames, queries, capabilities — is defined independently of transport. The current binding is NDJSON: every message is a single JSON envelope, internally tagged by type.
Over stdio, exactly one envelope travels per line — an envelope must not contain a literal newline. Over streamable HTTP, one envelope is the request body and one is the response body.
jsonrpc member and no method/params split. The lifecycle is informed by MCP — handshake before payload — but the framing is its own. A JSON-RPC binding may be specified later as an alternate encoding of the same semantic layer.The envelope vocabulary
| type | Direction | Payload |
|---|---|---|
| handshake | host → provider | protocol_version |
| handshake_ack | provider → host | protocol_version, provider, capabilities |
| query | host → provider | query (ContextQuery), optional id |
| frames | provider → host | result (ContextQueryResult), optional id |
| verify | host → provider | request (VerifyRequest) |
| verified | provider → host | response (VerifyResponse) |
| shutdown | host → provider | — |
| error | provider → host | message, optional id, code |
Handshake and lifecycle
The host opens with handshake; the provider replies with handshake_ack carrying its protocol version, identity, and capabilities. No query payload moves before this exchange completes. The acknowledged version must share the host’s major family (H1); provider name and version must be non-empty (H2); a version-family mismatch must surface as a named error, never a hang (H3).
{"type":"handshake","protocol_version":"contextgraph/1.0-draft"}
{"type":"handshake_ack","protocol_version":"contextgraph/1.0-draft","provider":{…},"capabilities":{…}}
{"type":"query","id":"q1","query":{…}}
{"type":"frames","id":"q1","result":{"frames":[…],"truncated":false}}
{"type":"verify","request":{"frames":[{"provider_id":"docs","frame_id":"doc:1","content_digest":"sha256:…"}]}}
{"type":"verified","response":{"verdicts":[{"frame":{…},"status":"valid"}]}}
{"type":"shutdown"}Versioning
version-string = "contextgraph/" major "." minor [ "-draft" ]
major = 1*DIGIT
minor = 1*DIGITThe major family is the substring up to the first dot. Two versions interoperate if and only if they share a major family: contextgraph/1.0-draft and contextgraph/1.0 both belong to contextgraph/1 and interoperate; contextgraph/2.0 does not. This is what lets the spec drop -draft at freeze without a flag day. A new optional field is a minor change; a removed or renamed field requires a new major family.
Correlation
query, frames, and error may carry an opaque id. A provider that declares capabilities.correlation must echo a request’s id verbatim on the corresponding reply (H4) — and a host must not send an id to a provider that never declared the capability. An envelope with no id is a notification: it expects no reply, the shape a future push extension needs.
{
"type": "query",
"id": "q1",
"query": {
"goal": "why does the retry loop give up",
"query_text": "retry loop",
"kinds": ["snippet"],
"anchors": ["file:///repo/src/net.rs"],
"max_frames": 8,
"max_tokens": 2000,
"as_of": "2026-07-01T00:00:00Z"
}
}Errors
Six codes form an open vocabulary: bad_request, unsupported_kind, budget_unsatisfiable, unavailable, shutting_down, internal. A host treats an unknown or absent code as internal. Robustness is part of the contract: a provider must ignore-or-error a malformed line, never crash (R1), and must tear down cleanly on shutdown (R2).