rpc-protocol
Tier 0 · primitives. No titan dependencies and no runtime dependencies at all.
npm install @titan-design/rpc-protocolThe problem it solves
A daemon built on daemon and registry speaks a fixed wire: POST /rpc/<command> returns a JSON envelope, /events streams SSE frames, /health and /version report status. That contract lived implicitly inside the two server packages, so every front end re-typed it by hand, and a browser bundle could not import JsonEnvelope from registry without also naming a package that peers on zod.
The primitive is the contract as data and types, with nothing else: envelope, exit codes, route paths, the /rpc failure statuses, the SSE event names and heartbeat, and the CommandMap shape a typed client is generic over.
When to reach for it
Browser or Node code that talks to a running daemon, and server code that must answer in the same shape. Reach for registry to define and invoke commands, and for daemon to host them; both import their wire vocabulary from here.
Example
Verified against 0.1.0.
import {
EVENTS_PATH,
CLIENT_HEADER,
RPC_PREFIX,
SSE_EVENTS,
type JsonEnvelope,
} from "@titan-design/rpc-protocol";
const res = await fetch(`${origin}${RPC_PREFIX}task.list`, {
method: "POST",
headers: { "content-type": "application/json", [CLIENT_HEADER]: "my-cli" },
body: JSON.stringify({ status: "open" }),
});
const envelope = (await res.json()) as JsonEnvelope<Task[]>;
if (!envelope.ok) throw new Error(`${envelope.error} (${envelope.code})`);
const events = new EventSource(`${origin}${EVENTS_PATH}`);
events.addEventListener("change", refetch);
events.addEventListener(SSE_EVENTS.READY, () => setLive(true));What it deliberately does not do
It makes no requests. The typed client with live and static data sources is rpc-client. It carries no runtime validation, so there is no zod; commands validate their own args in registry. It does not describe the /health payload, the request guards' 403 and 415 refusals, or /mcp; those stay with daemon. The static snapshot format, titan-snapshot@1, is defined in rpc-client, next to its only reader and writer.
Gotchas
rpcFailureStatusmaps onlyEXIT.DATAERRto 400. A command that throws withEXIT.USAGEanswers 500; the only 400 withUSAGEis an unparseable JSON body.- A command that refuses its caller throws with
EXIT.NOPERM(77), which answers 403. - A command that refuses a caller over its limit throws with
EXIT.TEMPFAIL(75), which answers 429. successEnvelopeomitswarningswhen the list is empty, so a client must treat a missing key and an empty list the same.pingframes carry the server'sDate.now()as their data, not a sequence number, and there is noLast-Event-IDresume.
Where it came from
Extracted in September 2026 (TP-138) from registry/src/envelope.ts and the daemon's http.ts and events.ts, unchanged on the wire. A real-socket test in daemon pins the exact bytes of a success envelope, the error envelopes, and SSE frames.