DocsReferenceThe wire protocol

The wire protocol

A dedicated control stream carries a small, fully-typed set of JSON messages between the agent and the edge. Every other stream — one per proxied request — carries a single JSON preamble followed by raw bytes.

Framing & encoding#

Every control message and every stream preamble uses the same trivial framing: a big-endian length prefix followed by a JSON payload.

uint32 big-endian payload length | JSON payload (max frame: 64 KiB)

JSON was chosen over protobuf or gob — it's greppable in debug logs, control-plane volume is a few messages a minute so performance is irrelevant, and it avoids codegen for a handful of message types owned by one repo. Data streams carry raw HTTP/1.1 bytes after the preamble — JSON never touches the hot path.

The message envelope#

Every control-stream message is wrapped in the same envelope:

internal/proto/proto.gogo
type Msg struct {
Type string `json:"type"` // discriminator
ID uint64 `json:"id,omitempty"` // correlates request→reply;
// per-direction ID spaces
Data json.RawMessage `json:"data,omitempty"` // type-specific payload
}

ID spaces are per-direction — the agent mints odd IDs, the edge mints even ones — so both peers can initiate a message (both send Ping) without colliding. Replies don't get a fresh ID: a reply carries the same Msg.ID as the request it answers AuthOK echoes Auth's ID, BindOK echoes Bind's, Pong echoes Ping's.

Handshake#

The first exchange on every control stream. Anything else as the first message closes the connection.

type Auth struct {
Token string `json:"token"`
InstanceID string `json:"instance_id"` // stable per agent-process start
ClientVersion string `json:"client_version"` // e.g. "v0.1.0 (commit 3f1a9c2, built 2026-06-01T12:00:00Z)"
ProtoVersions []int `json:"proto_versions"` // [1] — supported protocol majors
}
type AuthOK struct {
ProtoVersion int `json:"proto_version"` // server picks highest mutual
SessionID string `json:"session_id"` // minted per session
ServerVersion string `json:"server_version"`
}
type AuthErr struct {
Code string `json:"code"` // bad_token, no_common_version
Message string `json:"message"`
}

The first message must be Auth within 5 seconds of TLS establishment, or the edge closes the connection. The token check is a constant-time comparison against the SHA-256 of the configured token.

Tunnel lifecycle#

A bound session can bind, unbind, and be evicted by a takeover — six message types cover the whole lifecycle.

Bind / BindOK / BindErr

kindstring
"http" in v1. "tcp" is reserved for later.
subdomainstring
Empty string requests a random name, e.g. amber-fox-42.
takeoverbool
Reclaim a binding — authorized by identity, not liveness.
hostnamestring
Returned in BindOK: the full bound hostname, e.g. api.ngstone.site.
urlstring
Returned in BindOK: the full public URL.
codestring
Returned in BindErr: subdomain_taken, invalid_subdomain, or kind_unsupported.

Unbind / UnbindOK / Evicted

hostnamestring
The agent drops one tunnel; the session itself stays up.
reasonstring
Sent only on Evicted: always "takeover" in v1 — this binding was reclaimed, stop advertising the URL.

Liveness & shutdown#

Both peers send Ping on the control stream every 15 seconds; Pongechoes the ping's timestamp so the sender can log RTT. Three consecutive misses (≈45 seconds) declares the peer dead.

type Ping struct { TS int64 `json:"ts_unix_ms"` }
type Pong struct { TS int64 `json:"ts_unix_ms"` } // echoes Ping.TS

Graceful shutdown is fire-and-forget by design — there is no GoodbyeOK. The sender starts its own drain timer the moment it sends and behaves the same whether or not the peer honored the message.

type Goodbye struct {
Reason string `json:"reason"` // "shutdown", "reload", "idle"
DrainMS int `json:"drain_ms"` // sender stops opening new streams
}

Stream preamble#

Every data stream opens with one JSON frame — the only JSON on that stream — before raw bytes take over.

type StreamOpen struct {
Kind string `json:"kind"` // "http" v1; "tcp" later
Host string `json:"host"` // which binding this request targets
ReqID string `json:"req_id"` // edge-minted ULID; ties edge logs ↔
// inspector entries
ClientAddr string `json:"client_addr"` // public client ip:port
}

ClientAddr is carried here rather than derived from the stream, because a future Kind: "tcp" stream has no X-Forwarded-For header to fall back on.

Versioning#

The protocol uses a single integer major version. The client offers the versions it supports in Auth.ProtoVersions; the server picks the highest mutual version and returns it in AuthOK.ProtoVersion. v1 ships as 1.

Additive JSON fields never bump the version — unknown fields are simply ignored. Semantic changes do. Within a negotiated version, an unknown Msg.Type or a malformed frame gets a generic Error reply and then the session closes — never a silent ignore.

Error is also a catch-all reply

Error{Code, Message}is sent as the reply to any request the receiver can't satisfy, and also right before a connection is closed for a protocol violation. Codes include unknown_type, malformed, not_bound, and internal.