packages/happy-wire/README.md
Canonical wire specification package for Happy clients and services.
This package defines shared wire contracts as TypeScript types + Zod schemas. It is intentionally small and focused on protocol-level data only.
Both legacy and new formats are transported inside encrypted session messages.
Legacy format examples (decrypted payload):
{
"role": "user",
"content": {
"type": "text",
"text": "fix the failing test"
},
"meta": {
"sentFrom": "mobile"
}
}
{
"role": "agent",
"content": {
"type": "output",
"data": {
"type": "message",
"message": "I found the issue in api/session.ts"
}
},
"meta": {
"sentFrom": "cli"
}
}
New session protocol format example (decrypted payload):
{
"role": "session",
"content": {
"id": "msg_01",
"time": 1739347230000,
"role": "agent",
"turn": "turn_01",
"ev": {
"t": "text",
"text": "I found the issue in api/session.ts"
}
},
"meta": {
"sentFrom": "cli"
}
}
Modern session protocol user envelope (decrypted payload):
{
"role": "session",
"content": {
"id": "msg_legacy_user_01",
"time": 1739347231000,
"role": "user",
"ev": {
"t": "text",
"text": "fix the failing test"
}
},
"meta": {
"sentFrom": "cli"
}
}
Protocol invariant:
role = "session" marks modern session-protocol payloads.content, envelope role is only "user" or "agent".Session protocol send rollout (ENABLE_SESSION_PROTOCOL_SEND):
role = "session" with content.role = "user").role = "user", content.type = "text") and drops modern user payloads.1, true, yes (case-insensitive).Wire-level encrypted container (same for legacy and new):
{
"id": "msg-db-row-id",
"seq": 101,
"localId": null,
"content": {
"t": "encrypted",
"c": "BASE64_ENCRYPTED_PAYLOAD"
},
"createdAt": 1739347230000,
"updatedAt": 1739347230000
}
@slopus/happy-wire centralizes definitions for:
The goal is to keep CLI/app/server/agent on the same wire contract and avoid schema drift.
@slopus/happy-wirepackages/happy-wiresrc/index.tszod, @paralleldrive/cuid2src/index.ts exports everything from:
src/messages.tssrc/legacyProtocol.tssrc/sessionProtocol.tsmessages.ts exportsSchemas + inferred types:
SessionMessageContentSchemaSessionMessageSessionMessageSchemaMessageMetaSchemaMessageMetaSessionProtocolMessageSchemaSessionProtocolMessageMessageContentSchemaMessageContentVersionedEncryptedValueSchemaVersionedEncryptedValueVersionedNullableEncryptedValueSchemaVersionedNullableEncryptedValueUpdateNewMessageBodySchemaUpdateNewMessageBodyUpdateSessionBodySchemaUpdateSessionBodyVersionedMachineEncryptedValueSchemaVersionedMachineEncryptedValueUpdateMachineBodySchemaUpdateMachineBodyCoreUpdateBodySchemaCoreUpdateBodyCoreUpdateContainerSchemaCoreUpdateContainerCompatibility aliases:
ApiMessageSchema -> SessionMessageSchemaApiMessage -> SessionMessageApiUpdateNewMessageSchema -> UpdateNewMessageBodySchemaApiUpdateNewMessage -> UpdateNewMessageBodyApiUpdateSessionStateSchema -> UpdateSessionBodySchemaApiUpdateSessionState -> UpdateSessionBodyApiUpdateMachineStateSchema -> UpdateMachineBodySchemaApiUpdateMachineState -> UpdateMachineBodyUpdateBodySchema -> UpdateNewMessageBodySchemaUpdateBody -> UpdateNewMessageBodyUpdateSchema -> CoreUpdateContainerSchemaUpdate -> CoreUpdateContainerlegacyProtocol.ts exportsSchemas + inferred types:
UserMessageSchemaUserMessageAgentMessageSchemaAgentMessageLegacyMessageContentSchemaLegacyMessageContentsessionProtocol.ts exportsSchemas + inferred types:
sessionRoleSchemaSessionRolesessionTextEventSchemasessionServiceMessageEventSchemasessionToolCallStartEventSchemasessionToolCallEndEventSchemasessionFileEventSchemasessionTurnStartEventSchemasessionStartEventSchemasessionTurnEndStatusSchemaSessionTurnEndStatussessionTurnEndEventSchemasessionStopEventSchemasessionEventSchemaSessionEventsessionEnvelopeSchemaSessionEnvelopeCreateEnvelopeOptionscreateEnvelope(...)These are schema-level requirements, not just recommendations.
id, sid, machineId, call, name, title, description, ref: stringseq, createdAt, updatedAt, size, width, height, version, activeAt: number.nullable()..optional()..nullish() means undefined | null | <type>.messages.ts)SessionMessageContentSchema{
t: 'encrypted';
c: string;
}
Meaning:
t is a strict discriminator with value 'encrypted'.c is encrypted payload bytes encoded as a string (typically base64 in current usage).SessionMessageSchema{
id: string;
seq: number;
localId?: string | null;
content: SessionMessageContent;
createdAt: number;
updatedAt: number;
}
Notes:
localId is .nullish() for compatibility with different producers.createdAt and updatedAt are required in this shared schema.MessageMetaSchema{
sentFrom?: string;
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo';
model?: string | null;
fallbackModel?: string | null;
customSystemPrompt?: string | null;
appendSystemPrompt?: string | null;
allowedTools?: string[] | null;
disallowedTools?: string[] | null;
displayText?: string;
}
legacyProtocol.ts)UserMessageSchema (legacy decrypted payload){
role: 'user';
content: {
type: 'text';
text: string;
};
localKey?: string;
meta?: MessageMeta;
}
AgentMessageSchema (legacy decrypted payload){
role: 'agent';
content: {
type: string;
[key: string]: unknown;
};
meta?: MessageMeta;
}
LegacyMessageContentSchemaDiscriminated union on role:
'user' -> UserMessageSchema'agent' -> AgentMessageSchemamessages.ts)SessionProtocolMessageSchema (modern decrypted payload wrapper){
role: 'session';
content: SessionEnvelope;
meta?: MessageMeta;
}
MessageContentSchemaDiscriminated union on top-level role:
'user' -> UserMessageSchema (legacy)'agent' -> AgentMessageSchema (legacy)'session' -> SessionProtocolMessageSchema (modern)messages.ts) ContinuedVersionedEncryptedValueSchema{
version: number;
value: string;
}
Used for encrypted, version-tracked blobs that cannot be null when present.
VersionedNullableEncryptedValueSchema{
version: number;
value: string | null;
}
Used where payload presence can be intentionally reset to null while still versioning.
VersionedMachineEncryptedValueSchema{
version: number;
value: string;
}
Machine update variant. Equivalent shape to VersionedEncryptedValueSchema.
UpdateNewMessageBodySchema{
t: 'new-message';
sid: string;
message: SessionMessage;
}
UpdateSessionBodySchema{
t: 'update-session';
id: string;
metadata?: VersionedEncryptedValue | null;
agentState?: VersionedNullableEncryptedValue | null;
}
Important distinction:
metadata.value is string when metadata block exists.agentState.value may be string or null when block exists.UpdateMachineBodySchema{
t: 'update-machine';
machineId: string;
metadata?: VersionedMachineEncryptedValue | null;
daemonState?: VersionedMachineEncryptedValue | null;
active?: boolean;
activeAt?: number;
}
CoreUpdateBodySchemaDiscriminated union on t with exactly 3 variants:
'new-message''update-session''update-machine'CoreUpdateContainerSchema{
id: string;
seq: number;
body: CoreUpdateBody;
createdAt: number;
}
sessionProtocol.ts)sessionRoleSchema'user' | 'agent'
Role meaning:
'user': user-originated envelope.'agent': agent-originated envelope.sessionEventSchema is a discriminated union on t with 9 variants.
{
t: 'text';
text: string;
thinking?: boolean;
}
{
t: 'service';
text: string;
}
{
t: 'tool-call-start';
call: string;
name: string;
title: string;
description: string;
args: Record<string, unknown>;
}
{
t: 'tool-call-end';
call: string;
}
{
t: 'file';
ref: string;
name: string;
size: number;
image?: {
width: number;
height: number;
thumbhash: string;
};
}
{
t: 'turn-start';
}
{
t: 'start';
title?: string;
}
{
t: 'turn-end';
status: 'completed' | 'failed' | 'cancelled';
}
{
t: 'stop';
}
sessionEnvelopeSchema{
id: string;
time: number;
role: 'user' | 'agent';
turn?: string;
subagent?: string; // must pass cuid2 validation when present
ev: SessionEvent;
}
Additional validation (superRefine):
ev.t === 'service', then role MUST be 'agent'.ev.t === 'start' or ev.t === 'stop', then role MUST be 'agent'.subagent is present, it MUST satisfy isCuid(...).createEnvelope(role, ev, opts?)Input:
role: SessionRoleev: SessionEventopts?: { id?: string; time?: number; turn?: string; subagent?: string }Behavior:
opts.id is absent, generates id using createId().opts.time is absent, sets time to Date.now().turn only when provided.subagent only when provided.Output:
SessionEnvelope parsed by sessionEnvelopeSchema.role = 'user' with ev.t = 'service').new-message{
"id": "upd-1",
"seq": 100,
"createdAt": 1739347200000,
"body": {
"t": "new-message",
"sid": "session-1",
"message": {
"id": "msg-1",
"seq": 55,
"localId": null,
"content": {
"t": "encrypted",
"c": "Zm9v"
},
"createdAt": 1739347199000,
"updatedAt": 1739347199000
}
}
}
new-message content examplemessage.content.c (ciphertext) decrypts into the payload below for a session-protocol message:
{
"role": "session",
"content": {
"id": "env_01",
"time": 1739347232000,
"role": "agent",
"turn": "turn_01",
"ev": {
"t": "text",
"text": "I found 3 TODOs."
}
},
"meta": {
"sentFrom": "cli"
}
}
For user text migration behavior:
role = "session" with content.role = "user").ENABLE_SESSION_PROTOCOL_SEND is disabled, app keeps consuming legacy payloads and drops modern payloads.ENABLE_SESSION_PROTOCOL_SEND is enabled, app consumes modern payloads and drops legacy payloads.update-session{
"id": "upd-2",
"seq": 101,
"createdAt": 1739347210000,
"body": {
"t": "update-session",
"id": "session-1",
"metadata": {
"version": 8,
"value": "BASE64..."
},
"agentState": {
"version": 13,
"value": null
}
}
}
update-machine{
"id": "upd-3",
"seq": 102,
"createdAt": 1739347220000,
"body": {
"t": "update-machine",
"machineId": "machine-1",
"metadata": {
"version": 2,
"value": "BASE64..."
},
"daemonState": {
"version": 3,
"value": "BASE64..."
},
"active": true,
"activeAt": 1739347220000
}
}
{
"id": "x8s1k2...",
"role": "agent",
"turn": "turn-42",
"ev": {
"t": "turn-start"
}
}
import {
CoreUpdateContainerSchema,
sessionEnvelopeSchema,
} from '@slopus/happy-wire';
const maybeUpdate = CoreUpdateContainerSchema.safeParse(input);
if (!maybeUpdate.success) {
// invalid update payload
}
const maybeEnvelope = sessionEnvelopeSchema.safeParse(envelopeInput);
if (!maybeEnvelope.success) {
// invalid envelope/event payload
}
package.json contract:
main: ./dist/index.cjsmodule: ./dist/index.mjstypes: ./dist/index.d.ctsexports["."] provides both CJS and ESM entrypoints with type paths.Build script:
shx rm -rf dist && npx tsc --noEmit && pkgrollTests:
vitest against src/*.test.tsPublish gate:
prepublishOnly runs build + testPublished files:
distpackage.jsonREADME.mdIn this repository, consumer workspaces import @slopus/happy-wire through package exports that point at dist/*.
That means on a clean checkout:
yarn workspace @slopus/happy-wire buildAfter publishing to npm, dependents consume prebuilt artifacts from the published tarball.
When modifying wire schemas:
t) as protocol-level API and avoid breaking renames.# from repository root
yarn workspace @slopus/happy-wire build
yarn workspace @slopus/happy-wire test
# interactive release target selection from repo root
yarn release
# direct release invocation
yarn workspace @slopus/happy-wire release
This prepares release artifacts using the same release-it flow as other publishable libraries in the monorepo.