TypeBox is a TypeScript-first schema library. Apus uses it to define the Gateway WebSocket protocol (handshake, request/response, server events). Those schemas drive runtime validation (TypeBox Compile), JSON Schema export, and Swift codegen for the macOS app. One source of truth; everything else is generated.

For the higher-level protocol context, start with Gateway architecture.

Mental model (30 seconds)

Every Gateway WS message is one of three frames:

The first frame must be a connect request. After that, clients call methods (e.g. health, send, chat.send) and subscribe to events (e.g. presence, tick, agent).

Connection flow (minimal):

Client                    Gateway
  |---- req:connect -------->|
  |<---- res:hello-ok --------|
  |<---- event:tick ----------|
  |---- req:health ---------->|
  |<---- res:health ----------|

Common methods and events:

Category Examples Notes
Core connect, health, status connect must be first
Messaging send, agent, agent.wait, system-event, logs.tail side-effecting methods need idempotencyKey
Chat chat.history, chat.send, chat.abort WebChat uses these
Sessions sessions.list, sessions.patch, sessions.delete session admin
Automation wake, cron.list, cron.run, cron.runs wake and cron control
Nodes node.list, node.invoke, node.pair.* Gateway WS plus node actions
Events tick, presence, agent, chat, health, shutdown server push

The authoritative advertised discovery inventory lives in src/server/gateway/server-methods-list.ts (listGatewayMethods, GATEWAY_EVENTS).

Where the schemas live

The derived selection uses lexical order of the full source export names before removing the Schema suffix. The migration and session-placement maps follow it in their own insertion order. This replaces the former manually ordered registration fragments; JSON definition order and Swift declaration placement can change without changing schema data or native declaration bodies.

Membership is now opt-out for eligible source exports: a new *Schema export in the canonical barrel enters the generator registry unless explicitly excluded. Review each new export for intended generated-protocol membership, and add helper-only exports to EXCLUDED_SCHEMA_EXPORTS. The registry guard checks selection consistency, canonical objects, exclusions, and composition; it cannot independently determine whether a newly exported schema belongs in the public generated protocol.

Current pipeline

Commit schema and generator changes together; native outputs are never committed. scripts/native-protocol-inputs.json owns the source inputs shared by repository tooling and both native build systems. Generation checks its import graph against that manifest, so an undeclared dependency fails instead of leaving stale models. Stable decoding behavior belongs in the focused GatewayModelsCompatibilityTests.swift regressions rather than in handwritten model copies.

How the schemas are used at runtime

Example frames

Connect (first message):

{
  "type": "req",
  "id": "c1",
  "method": "connect",
  "params": {
    "minProtocol": 3,
    "maxProtocol": 4,
    "client": {
      "id": "apus-macos",
      "displayName": "macos",
      "version": "1.0.0",
      "platform": "macos 15.1",
      "mode": "ui",
      "instanceId": "A1B2"
    }
  }
}

Hello-ok response:

{
  "type": "res",
  "id": "c1",
  "ok": true,
  "payload": {
    "type": "hello-ok",
    "protocol": 4,
    "server": { "version": "dev", "connId": "ws-1" },
    "features": { "methods": ["health"], "events": ["tick"] },
    "snapshot": {
      "presence": [],
      "health": {},
      "stateVersion": { "presence": 0, "health": 0 },
      "uptimeMs": 0
    },
    "auth": { "role": "operator", "scopes": ["operator.read"] },
    "policy": { "maxPayload": 1048576, "maxBufferedBytes": 1048576, "tickIntervalMs": 30000 }
  }
}

Request and response:

{ "type": "req", "id": "r1", "method": "health" }
{ "type": "res", "id": "r1", "ok": true, "payload": { "ok": true } }

Event:

{ "type": "event", "event": "tick", "payload": { "ts": 1730000000 }, "seq": 12 }

Minimal client (Node.js)

Smallest useful flow: connect + health.

import { WebSocket } from "ws";

const ws = new WebSocket("ws://127.0.0.1:18789");

ws.on("open", () => {
  ws.send(
    JSON.stringify({
      type: "req",
      id: "c1",
      method: "connect",
      params: {
        minProtocol: 4,
        maxProtocol: 4,
        client: {
          id: "cli",
          displayName: "example",
          version: "dev",
          platform: "node",
          mode: "cli",
        },
      },
    }),
  );
});

ws.on("message", (data) => {
  const msg = JSON.parse(String(data));
  if (msg.type === "res" && msg.id === "c1" && msg.ok) {
    ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
  }
  if (msg.type === "res" && msg.id === "h1") {
    console.log("health:", msg.payload);
    ws.close();
  }
});

Worked example: add a method end-to-end

Example: add a new system.echo request that returns { ok: true, text }.

  1. Schema (source of truth)

Add to packages/gateway-protocol/src/schema/system-info.ts (or the closest matching feature module):

export const SystemEchoParamsSchema = Type.Object(
  { text: NonEmptyString },
  { additionalProperties: false },
);

export const SystemEchoResultSchema = Type.Object(
  { ok: Type.Boolean(), text: NonEmptyString },
  { additionalProperties: false },
);

system-info.ts is already exported by packages/gateway-protocol/src/schema-modules.ts, so both schemas enter ProtocolSchemas automatically as SystemEchoParams and SystemEchoResult. For a new owner module, add its export to that canonical barrel. Review any other *Schema exports it introduces and exclude helper-only schemas in protocol-schema-selection.ts. Keep schemas owned by the migration or session-placement maps in those maps and excluded from the derived selection.

Export the corresponding static types from the owner module:

export type SystemEchoParams = Static<typeof SystemEchoParamsSchema>;
export type SystemEchoResult = Static<typeof SystemEchoResultSchema>;
  1. Validation

In packages/gateway-protocol/src/validator-registry.ts, export a validator using its existing lazy compiler:

export const validateSystemEchoParams = compile(S.SystemEchoParamsSchema);
  1. Server behavior

Add a handler in src/server/gateway/server-methods/system.ts:

export const systemHandlers: GatewayRequestHandlers = {
  "system.echo": ({ params, respond }) => {
    const text = String(params.text ?? "");
    respond(true, { ok: true, text });
  },
};

Register it in src/server/gateway/server-methods.ts (already merges systemHandlers), then add "system.echo" to the listGatewayMethods input in src/server/gateway/server-methods-list.ts.

If the method is callable by operator or node clients, also classify it in src/server/gateway/method-scopes.ts so scope enforcement and hello-ok feature advertising stay aligned.

  1. Regenerate
pnpm protocol:check
  1. Tests and docs

Add a server test in src/server/gateway/server.*.test.ts and note the method in docs.

Swift codegen behavior

The Swift generator emits:

Unknown frame types are preserved as raw payloads for forward compatibility.

Some registry names alias the same canonical schema object. The explicit canonical alias preferences in scripts/protocol-gen-swift.ts preserve the existing public Swift type names when registry enumeration changes. Review generated declaration bodies, including field types, initializers, and encoding/decoding behavior; moving declarations must not silently select different nominal types.

The published JSON Schema's oneOf frame order is a separate contract: req, res, then event, with the matching type discriminator mapping. scripts/lib/protocol-schema-document.mts owns and checks that order independently of registry definition order.

Versioning and compatibility

Schema patterns and conventions

Live schema JSON

Generated JSON Schema is a build artifact, not committed to the repo. During the package rollout, the current beta schema is available at:

When you change schemas

  1. Update the TypeBox schemas in the owning packages/gateway-protocol/src/schema/*.ts module. Ensure a new module is exported by schema-modules.ts, review its automatic *Schema membership, and update helper exclusions or existing supplemental owner maps as needed.
  2. Register the method/event in src/server/gateway/server-methods-list.ts.
  3. Update src/server/gateway/method-scopes.ts when the new RPC needs operator or node scope classification.
  4. Run pnpm protocol:check.
  5. Commit the regenerated Swift models.

Related