npm package: @footnote/aside
Description: Shared message wire types and Zod schemas for Footnote clients and services
Author: Kirill Dubovitskiy
Latest version: 0.1.0

# @footnote/aside

Canonical wire specification package for Footnote 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.

## Quick Examples (Legacy vs New)

Both legacy and new formats are transported inside encrypted session messages.

Legacy format examples (decrypted payload):

```json
{
  "role": "user",
  "content": {
    "type": "text",
    "text": "fix the failing test"
  },
  "meta": {
    "sentFrom": "mobile"
  }
}
```

```json
{
  "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):

```json
{
  "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):

```json
{
  "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:
- outer `role = "session"` marks modern session-protocol payloads.
- inside `content`, envelope `role` is only `"user"` or `"agent"`.

Session protocol send rollout (`ENABLE_SESSION_PROTOCOL_SEND`):
- sender emits modern session-protocol user payloads (`role = "session"` with `content.role = "user"`).
- default (disabled): app consumes legacy user payloads (`role = "user"`, `content.type = "text"`) and drops modern user payloads.
- enabled: app consumes modern user payloads and drops legacy user payloads.
- truthy values: `1`, `true`, `yes` (case-insensitive).

Wire-level encrypted container (same for legacy and new):

```json
{
  "id": "msg-db-row-id",
  "seq": 101,
  "localId": null,
  "content": {
    "t": "encrypted",
    "c": "BASE64_ENCRYPTED_PAYLOAD"
  },
  "createdAt": 1739347230000,
  "updatedAt": 1739347230000
}
```

## Purpose

`@footnote/aside` centralizes definitions for:
- encrypted message/update payloads
- session protocol envelope and event stream
- helper for creating valid session envelopes

The goal is to keep CLI/app/server/agent on the same wire contract and avoid schema drift.

## Package Identity

- Name: `@footnote/aside`
- Workspace path: `packages/footnote-aside`
- Entry: `src/index.ts`
- Runtime deps: `zod`, `@paralleldrive/cuid2`

## Public Exports

`src/index.ts` exports everything from:
- `src/messages.ts`
- `src/legacyProtocol.ts`
- `src/sessionProtocol.ts`

### `messages.ts` exports

Schemas + inferred types:
- `SessionMessageContentSchema`
- `SessionMessage`
- `SessionMessageSchema`
- `MessageMetaSchema`
- `MessageMeta`
- `SessionProtocolMessageSchema`
- `SessionProtocolMessage`
- `MessageContentSchema`
- `MessageContent`
- `VersionedEncryptedValueSchema`
- `VersionedEncryptedValue`
- `VersionedNullableEncryptedValueSchema`
- `VersionedNullableEncryptedValue`
- `UpdateNewMessageBodySchema`
- `UpdateNewMessageBody`
- `UpdateSessionBodySchema`
- `UpdateSessionBody`
- `VersionedMachineEncryptedValueSchema`
- `VersionedMachineEncryptedValue`
- `UpdateMachineBodySchema`
- `UpdateMachineBody`
- `CoreUpdateBodySchema`
- `CoreUpdateBody`
- `CoreUpdateContainerSchema`
- `CoreUpdateContainer`

Compatibility aliases:
- `ApiMessageSchema` -> `SessionMessageSchema`
- `ApiMessage` -> `SessionMessage`
- `ApiUpdateNewMessageSchema` -> `UpdateNewMessageBodySchema`
- `ApiUpdateNewMessage` -> `UpdateNewMessageBody`
- `ApiUpdateSessionStateSchema` -> `UpdateSessionBodySchema`
- `ApiUpdateSessionState` -> `UpdateSessionBody`
- `ApiUpdateMachineStateSchema` -> `UpdateMachineBodySchema`
- `ApiUpdateMachineState` -> `UpdateMachineBody`
- `UpdateBodySchema` -> `UpdateNewMessageBodySchema`
- `UpdateBody` -> `UpdateNewMessageBody`
- `UpdateSchema` -> `CoreUpdateContainerSchema`
- `Update` -> `CoreUpdateContainer`

### `legacyProtocol.ts` exports

Schemas + inferred types:
- `UserMessageSchema`
- `UserMessage`
- `AgentMessageSchema`
- `AgentMessage`
- `LegacyMessageContentSchema`
- `LegacyMessageContent`

### `sessionProtocol.ts` exports

Schemas + inferred types:
- `sessionRoleSchema`
- `SessionRole`
- `sessionTextEventSchema`
- `sessionServiceMessageEventSchema`
- `sessionToolCallStartEventSchema`
- `sessionToolCallEndEventSchema`
- `sessionFileEventSchema`
- `sessionTurnStartEventSchema`
- `sessionStartEventSchema`
- `sessionTurnEndStatusSchema`
- `SessionTurnEndStatus`
- `sessionTurnEndEventSchema`
- `sessionStopEventSchema`
- `sessionEventSchema`
- `SessionEvent`
- `sessionEnvelopeSchema`
- `SessionEnvelope`
- `CreateEnvelopeOptions`
- `createEnvelope(...)`

## Wire Type Specifications

## Common Primitive Rules

These are schema-level requirements, not just recommendations.

- `id`, `sid`, `machineId`, `call`, `name`, `title`, `description`, `ref`: `string`
- `seq`, `createdAt`, `updatedAt`, `size`, `width`, `height`, `version`, `activeAt`: `number`
- All nullable fields are explicitly marked with `.nullable()`.
- All optional fields are explicitly marked with `.optional()`.
- `.nullish()` means `undefined | null | <type>`.

## Message/Update Specs (`messages.ts`)

### `SessionMessageContentSchema`

```ts
{
  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`

```ts
{
  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`

```ts
{
  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;
}
```

## Legacy Decrypted Payload Specs (`legacyProtocol.ts`)

### `UserMessageSchema` (legacy decrypted payload)

```ts
{
  role: 'user';
  content: {
    type: 'text';
    text: string;
  };
  localKey?: string;
  meta?: MessageMeta;
}
```

### `AgentMessageSchema` (legacy decrypted payload)

```ts
{
  role: 'agent';
  content: {
    type: string;
    [key: string]: unknown;
  };
  meta?: MessageMeta;
}
```

### `LegacyMessageContentSchema`

Discriminated union on `role`:
- `'user'` -> `UserMessageSchema`
- `'agent'` -> `AgentMessageSchema`

## Top-Level Decrypted Payload Specs (`messages.ts`)

### `SessionProtocolMessageSchema` (modern decrypted payload wrapper)

```ts
{
  role: 'session';
  content: SessionEnvelope;
  meta?: MessageMeta;
}
```

### `MessageContentSchema`

Discriminated union on top-level `role`:
- `'user'` -> `UserMessageSchema` (legacy)
- `'agent'` -> `AgentMessageSchema` (legacy)
- `'session'` -> `SessionProtocolMessageSchema` (modern)

## Message/Update Specs (`messages.ts`) Continued

### `VersionedEncryptedValueSchema`

```ts
{
  version: number;
  value: string;
}
```

Used for encrypted, version-tracked blobs that cannot be null when present.

### `VersionedNullableEncryptedValueSchema`

```ts
{
  version: number;
  value: string | null;
}
```

Used where payload presence can be intentionally reset to null while still versioning.

### `VersionedMachineEncryptedValueSchema`

```ts
{
  version: number;
  value: string;
}
```

Machine update variant. Equivalent shape to `VersionedEncryptedValueSchema`.

### `UpdateNewMessageBodySchema`

```ts
{
  t: 'new-message';
  sid: string;
  message: SessionMessage;
}
```

### `UpdateSessionBodySchema`

```ts
{
  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`

```ts
{
  t: 'update-machine';
  machineId: string;
  metadata?: VersionedMachineEncryptedValue | null;
  daemonState?: VersionedMachineEncryptedValue | null;
  active?: boolean;
  activeAt?: number;
}
```

### `CoreUpdateBodySchema`

Discriminated union on `t` with exactly 3 variants:
- `'new-message'`
- `'update-session'`
- `'update-machine'`

### `CoreUpdateContainerSchema`

```ts
{
  id: string;
  seq: number;
  body: CoreUpdateBody;
  createdAt: number;
}
```

## Session Protocol Specs (`sessionProtocol.ts`)

## Role

### `sessionRoleSchema`

```ts
'user' | 'agent'
```

Role meaning:
- `'user'`: user-originated envelope.
- `'agent'`: agent-originated envelope.

## Event Variants

`sessionEventSchema` is a discriminated union on `t` with 9 variants.

### 1) Text event

```ts
{
  t: 'text';
  text: string;
  thinking?: boolean;
}
```

### 2) Service event

```ts
{
  t: 'service';
  text: string;
}
```

### 3) Tool-call-start event

```ts
{
  t: 'tool-call-start';
  call: string;
  name: string;
  title: string;
  description: string;
  args: Record<string, unknown>;
}
```

### 4) Tool-call-end event

```ts
{
  t: 'tool-call-end';
  call: string;
}
```

### 5) File event

```ts
{
  t: 'file';
  ref: string;
  name: string;
  size: number;
  image?: {
    width: number;
    height: number;
    thumbhash: string;
  };
}
```

### 6) Turn-start event

```ts
{
  t: 'turn-start';
}
```

### 7) Start event

```ts
{
  t: 'start';
  title?: string;
}
```

### 8) Turn-end event

```ts
{
  t: 'turn-end';
  status: 'completed' | 'failed' | 'cancelled';
}
```

### 9) Stop event

```ts
{
  t: 'stop';
}
```

## Envelope

### `sessionEnvelopeSchema`

```ts
{
  id: string;
  time: number;
  role: 'user' | 'agent';
  turn?: string;
  subagent?: string; // must pass cuid2 validation when present
  ev: SessionEvent;
}
```

Additional validation (`superRefine`):
- If `ev.t === 'service'`, then `role` MUST be `'agent'`.
- If `ev.t === 'start'` or `ev.t === 'stop'`, then `role` MUST be `'agent'`.
- If `subagent` is present, it MUST satisfy `isCuid(...)`.

## Helper Function Contract

### `createEnvelope(role, ev, opts?)`

Input:
- `role: SessionRole`
- `ev: SessionEvent`
- `opts?: { id?: string; time?: number; turn?: string; subagent?: string }`

Behavior:
- If `opts.id` is absent, generates id using `createId()`.
- If `opts.time` is absent, sets `time` to `Date.now()`.
- Includes `turn` only when provided.
- Includes `subagent` only when provided.

Output:
- Returns a `SessionEnvelope` parsed by `sessionEnvelopeSchema`.
- Throws on invalid combinations (for example `role = 'user'` with `ev.t = 'service'`).

## Normative JSON Examples

## Update container with `new-message`

```json
{
  "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
    }
  }
}
```

### Decrypted `new-message` content example

`message.content.c` (ciphertext) decrypts into the payload below for a session-protocol message:

```json
{
  "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:
- clients emit only the modern payload (`role = "session"` with `content.role = "user"`).
- if `ENABLE_SESSION_PROTOCOL_SEND` is disabled, app keeps consuming legacy payloads and drops modern payloads.
- if `ENABLE_SESSION_PROTOCOL_SEND` is enabled, app consumes modern payloads and drops legacy payloads.

## Update container with `update-session`

```json
{
  "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 container with `update-machine`

```json
{
  "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
  }
}
```

## Session protocol envelope

```json
{
  "id": "x8s1k2...",
  "role": "agent",
  "turn": "turn-42",
  "ev": {
    "t": "turn-start"
  }
}
```

## Parsing/Validation Usage

```ts
import {
  CoreUpdateContainerSchema,
  sessionEnvelopeSchema,
} from '@footnote/aside';

const maybeUpdate = CoreUpdateContainerSchema.safeParse(input);
if (!maybeUpdate.success) {
  // invalid update payload
}

const maybeEnvelope = sessionEnvelopeSchema.safeParse(envelopeInput);
if (!maybeEnvelope.success) {
  // invalid envelope/event payload
}
```

## Build and Distribution Specification

`package.json` contract:
- `main`: `./dist/index.cjs`
- `module`: `./dist/index.mjs`
- `types`: `./dist/index.d.cts`
- `exports["."]` provides both CJS and ESM entrypoints with type paths.

Build script:
- `shx rm -rf dist && npx tsc --noEmit && pkgroll`

Tests:
- `vitest` against `src/*.test.ts`

Publish gate:
- `prepublishOnly` runs build + test

Published files:
- `dist`
- `package.json`
- `README.md`

## Monorepo Build Dependency Behavior

In this repository, consumer workspaces import `@footnote/aside` through package exports that point at `dist/*`.

That means on a clean checkout:
1. Build wire first: `pnpm --filter @footnote/aside build`
2. Then build/typecheck dependents.

After publishing to npm, dependents consume prebuilt artifacts from the published tarball.

## Change Policy

When modifying wire schemas:
- Prefer additive changes to keep older consumers compatible.
- Treat discriminator values (`t`) as protocol-level API and avoid breaking renames.
- Document semantic changes in this README.
- Bump package version before downstream releases that depend on new schema behavior.

## Development Commands

```bash
# from repository root
pnpm --filter @footnote/aside build
pnpm --filter @footnote/aside test
```

## Release Commands (maintainers)

```bash
# interactive release target selection from repo root
pnpm release

# direct release invocation
pnpm --filter @footnote/aside release
```

This prepares release artifacts using the same `release-it` flow as other publishable libraries in the monorepo.