Network protocol
packages/protocol/src/index.ts is the single source of truth for every request,
response, and event shape exchanged between a daemon and the coordination service.
All schemas are Zod (.strict() where applicable); the CLI, MCP server, and
service all validate against the same definitions.
Event envelope
Every network event a daemon uploads is wrapped in eventEnvelopeSchema:
{
id: string;
schemaVersion: 1;
workspaceId: string;
replicaId: string;
actorId: string;
sessionId?: string;
agent?: { provider: "cursor" | "codex" | "claude-code" | "opencode" | "devin-like" | "unknown"; adapterId?: string; sessionReference?: string };
type: string;
clientSequence: number; // non-negative int, per-replica ordering
serverSequence?: number; // assigned by the service on ingest, positive int
createdAt: string; // ISO datetime
payload: unknown;
signature?: string;
}
Concrete event types extend this envelope with type: z.literal("...") and a typed
payload, and most add a superRefine check that the envelope id matches
payload.id (assertPayloadIdMatches).
schemaVersion is currently fixed at z.literal(1) — there is only one version.
Because it's a literal rather than a range, any envelope with a different value
fails schema validation outright. The intended long-term rule (see
BUILD_INSTRUCTIONS.md) is that a future major bump follows the same pattern:
unknown/newer major versions are rejected rather than partially parsed.
Event types actually defined
type |
Event schema | Payload |
|---|---|---|
transaction.created |
transactionCreatedEventSchema |
ChangeTransaction |
task.created |
taskCreatedEventSchema |
Task |
task.updated |
taskUpdatedEventSchema |
Task |
claim.created |
claimCreatedEventSchema |
Claim |
claim.released |
claimReleasedEventSchema |
Claim |
handoff.requested |
handoffRequestedEventSchema |
Handoff |
handoff.responded |
handoffRespondedEventSchema |
Handoff |
intent.published |
intentPublishedEventSchema |
Intent |
validation.completed |
validationCompletedEventSchema |
Validation |
Each of these has a corresponding *IngestRequest schema the service accepts on
its HTTP ingest endpoints, and a *IngestReceipt schema returned back.
WebSocket fan-out
wsFanOutMessageSchema is a discriminated union on type, currently:
operation— wraps aRemoteOperationpresence— wraps aPresenceUpdate(online/idle/offline)task— wraps aRemoteTaskclaim— wraps aRemoteClaimhandoff— wraps aRemoteHandoffintent— wraps aRemoteIntentvalidation— wraps aRemoteValidation
A replica subscribes with wsSubscribeRequestSchema (workspaceId, replicaId,
accessToken) and gets back a wsSubscribeAckSchema with a resume cursor, or a
wsErrorMessageSchema on failure. Each remote* payload additionally carries
eventId, workspaceId, senderReplicaId, and an updatedAt/createdAt
timestamp (validations are immutable, so RemoteValidation uses createdAt
instead of updatedAt) so a receiving replica can dedupe and order it against its
own cursor. POST /v1/validations / GET /v1/validations follow the same
request/receipt/cursor/fan-out pattern as task/claim/handoff/intent above,
letting replicas see each other's local validation results.
Reading operations, and history retention
GET /v1/operations?afterSequence=<n> is how a replica resumes: it asks for everything
after its last-known server_sequence and gets a cursorResponseSchema page back.
That works only while the history is complete. Per-plan retention
(PLAN_LIMITS[plan].historyRetentionDays) deletes operations once they age out, and a
short page and "you are caught up" are the same message on this endpoint — so a replica
whose cursor points into the deleted range would silently lose those proposals. The
service therefore records, per workspace, the highest server_sequence retention has
deleted, and refuses any cursor below it rather than answering with what survives:
{ status: "cursor-too-old"; protocolVersion: 2; resyncFrom: number; retentionDays: number }
resyncFrom is the oldest cursor that can still be served in full. A replica adopts it
and continues from there. What it loses is proposals it never downloaded; Git remains the
source of truth, so no committed or working-tree work is at stake.
protocolVersion (a query parameter on the request, echoed in this response) versions this
endpoint's answers, separately from the envelope's schemaVersion. A client that does not
send protocolVersion=2 predates the status above, so an unservable cursor is answered
with 410 Gone instead — a hard failure it already surfaces, rather than a body it might
misread as success. operationsResponseSchema is the union a version-2 client parses.
Relationship to the daemon's local event log
The schemas above govern only what crosses the wire between a daemon and the
coordination service. Each daemon also keeps a separate, local-only SQLite event
log (<git-dir>/crosscode/state.sqlite) recording every local action — captures,
checkpoints, validations, and outbound/inbound cursors — for crash recovery and
projections. That local log is not part of this network protocol and is not sent
to the service as-is; a parallel workstream is making it schema-validated, but its
internal shape is out of scope here.
View raw markdown · generated from
docs/protocol.md at build time, do not hand-edit this page.