connector-protocol
v0.4.0
Published
WebSocket protocol schemas + shared DTOs for the Synx connector daemon (synx-connector). Zod schemas and inferred types for the daemon↔API control channel — zero private workspace dependencies.
Maintainers
Readme
connector-protocol
WebSocket protocol schemas and shared DTOs for the Synx connector daemon
(synx-connector) — the local agent that bridges a developer's machine to the
Synx API so Claude-agent sessions can run against local folders.
This package is the single source of truth for the daemon ↔ API control-channel
message shapes. It is published so the public synx-connector CLI can depend on
it; it has zero dependencies on private @synx/* workspace packages and its
only runtime dependency is zod.
Where the private Synx API / web apps need these DTOs,
@synx/typesre-exports from this package. The dependency direction is always public ← private — never the reverse.
Install
npm install connector-protocol zodzod (^4) is a peer dependency, not a bundled one — install it alongside.
This package constructs and returns z.ZodError values across its API boundary
(parseConnectorMessage returns { success: false, error: ZodError }), so it
must share the same zod instance as its consumer; a peer dependency
guarantees a single deduplicated copy and keeps instanceof ZodError checks
working. (synx-connector already depends on zod directly, satisfying the peer.)
Usage
Parse an inbound WS frame
parseConnectorMessage accepts a raw JSON string or an already-decoded value
and returns a discriminated result — it never throws for control flow.
import { parseConnectorMessage, serializeConnectorMessage } from "connector-protocol";
const result = parseConnectorMessage(rawFrame); // rawFrame: string | unknown
if (!result.success) {
// result.error is a ZodError
return;
}
switch (result.message.type) {
case "session.start":
// result.message is fully typed as SessionStartMessage here
break;
}
// A bare heartbeat — minimum a daemon must send on each cadence tick.
const frame = serializeConnectorMessage({ type: "heartbeat", ts: Date.now() });
// A heartbeat carrying the optional liveness signal: the ids of sessions with
// an SDK turn currently in flight. The server bumps each listed session's
// `updated_at` so the UI can tell "agent busy, just quiet" from "daemon gone".
// `busySessionIds` is OPTIONAL and additive — omit it and you get the same
// behavior as before it existed.
const liveness = serializeConnectorMessage({
type: "heartbeat",
ts: Date.now(),
busySessionIds: ["1f0c…", "2a7b…"],
});Additive-field convention (for external daemon authors). New OPTIONAL fields like
busySessionIdsare added without bumping the messagetype. This is safe in both directions: an older daemon that never sends the field behaves exactly as before, and an older server strips unknown additive fields during parsing (the message schemas arez.object, which drops unrecognized keys by default) — so a newer daemon still talks to an older server. Send new optional fields freely; never rely on the server echoing or persisting a field your server version predates.
Heartbeat cadence (single source of truth)
The cadence and its derived timeout / liveness windows live in one place so the daemon's send interval, the server's reap timeout, and the web liveness UI can never drift apart:
import {
CONNECTOR_HEARTBEAT, // { INTERVAL_MS, TIMEOUT_MULTIPLIER }
HEARTBEAT_INTERVAL_MS, // send a heartbeat this often (ms)
HEARTBEAT_TIMEOUT_MS, // no heartbeat for this long ⇒ peer presumed gone
} from "connector-protocol";HEARTBEAT_TIMEOUT_MS is derived as INTERVAL_MS × TIMEOUT_MULTIPLIER (3× the
cadence), tolerating one missed beat plus clock skew before a peer is judged
gone. A custom daemon should send a heartbeat every HEARTBEAT_INTERVAL_MS.
Session events (forward/backward compatible)
The session.event envelope keeps its eventType as an open string and its
payload as unknown, so a daemon built against an older version of this
package never hard-fails on an event type a newer server introduces. Decode the
payload with parseSessionEvent, which returns a known / unknown result:
import { parseSessionEvent } from "connector-protocol";
const parsed = parseSessionEvent(msg.eventType, msg.payload);
if (parsed.kind === "known") {
// parsed.type narrows parsed.payload to the matching typed shape
} else {
// unknown (or malformed-known) event — store/forward parsed.payload verbatim
}This is the design choice for compatibility: the envelope is permissive, the
typed validation is a second, opt-in step. The SDK is known to emit
undocumented message types (e.g. rate_limit_event); they flow through as
unknown events rather than breaking the channel.
Tool-result truncation (byte-accurate)
import { truncateToolResult, MAX_TOOL_RESULT_BYTES } from "connector-protocol";
const { content, truncated, originalBytes } = truncateToolResult(bigOutput);Truncation is measured in UTF-8 bytes, not characters, and never splits a multi-byte sequence.
Close codes
import { CONNECTOR_CLOSE_CODES } from "connector-protocol";
// 4401 AUTH · 4403 REVOKED · 4408 PROTOCOL_VIOLATIONStability
Pre-1.0 (0.x): the protocol may change between minor versions until the
connector v1 surface settles.
License
MIT
