Skip to content

The Protocol

@maelstrom-co/protocol is the lingua franca between the engine and everything that talks to it. Both sides import from it; neither has its own dialect. It’s transport-agnostic — WebSocket, HTTP, in-process, or your own carrier — every payload is the same shape on both sides.

The protocol is the API surface that survives implementation changes. The current Engine is one implementation; future engines speak the same envelopes.

A branded string is a normal string with a compile-time label attached. The label disappears at runtime, but TypeScript can still prevent you from passing a VesselId where a SessionId belongs.

Every identifier is a branded string — structurally a string, nominally distinct.

import { SessionId, VesselId } from '@maelstrom-co/protocol';
const session = SessionId('user-42'); // SessionId
const vessel = VesselId('calendar'); // VesselId
session === vessel; // type error — different brands

Brands prevent the confusion bugs that pure-string IDs invite. They cost nothing at runtime — they erase to plain strings on the wire.

The catalogue: SessionId, MessageId, InstanceId, VesselId, VariantName, ActionName. MessageId() auto-generates a UUID when you don’t supply one; SessionId() does the same with a session- prefix for log grepping. The rest are always developer-provided.

Every message into the engine has a uniform shape:

type EngineMessage =
| { type: 'chart_request'; sessionId; id; timestamp; request }
| { type: 'assistant_message'; sessionId; id; timestamp; content };

ChartRequestMessage asks the engine for a layout. AssistantInjectionMessage persists developer-supplied content into history without invoking the LLM. Outputs mirror the same envelope shape, so callers can correlate request and response without external bookkeeping.

Smart constructors fill in defaults:

import { ChartRequestMessage, SessionId } from '@maelstrom-co/protocol';
const message = ChartRequestMessage({
sessionId: SessionId('user-42'),
request: buildChartRequest(),
// id and timestamp auto-fill unless passed.
});

A Vessel describes one component the engine can reason about: variants (states), actions (transitions), and the metadata the LLM uses to choose between them.

import { defineVessel, validateVessel } from '@maelstrom-co/protocol';
const calendar = defineVessel({
id: 'calendar',
name: 'Calendar',
defaultVariant: 'month',
variants: { month: { /* ... */ }, week: { /* ... */ } },
});
const result = validateVessel(calendar);
if (result.valid) {
// result.vessel is fully branded
}

Validation enforces that defaultVariant exists, action names are unique within a variant, and transitionsTo targets resolve. Hand-written authoring stays readable; brands flow in after validation.

Two layout modes are first-class:

  • GridLayout — explicit (row, column, width, height) placement on a discrete grid.
  • TilingLayout — a tree of horizontal / vertical splits with instance leaves. A split is even and two-way by default, or carries integer weights that set both its child count and their proportions.

The engine returns one or the other inside MaelstromResponse; the client renders it.

A small discriminated union for fallible operations. The engine returns Result<EngineOutput, EngineError> from handle() and never throws across the boundary.

type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };

Use isOk / isErr to narrow, or unwrap when you’ve already checked.

Internal compile-time checks keep the hand-written TypeScript interfaces and Zod schemas synchronised. A wire-format change that drifts from the type contract fails to build — the package can’t ship a silent divergence.