The Vessel State Machine
A Vessel is not a React component. It’s a description of a component — a state machine the engine knows how to drive. The actual render happens later, in whichever framework binding you use. This separation is the whole point: the LLM reasons about a small, validated machine; the framework renders whatever pixels follow.
The machine
Section titled “The machine”Every Vessel declares three things:
variants— the discrete states the component can be in.defaultVariant— which state a new instance starts in.- Per-variant
actions— the transitions available from that state.
That shape is enforced by VesselSchema (packages/protocol/src/lib/vessel.ts). A Vessel with no variants, a defaultVariant that doesn’t exist, an action that transitions to an unknown variant, or duplicate action names within a variant will fail validation before it reaches the engine.
import { defineVessel } from '@maelstrom-co/protocol';
const calendar = defineVessel({ id: 'calendar', name: 'Calendar', description: 'Shows the user\'s schedule.', defaultVariant: 'month', variants: { month: { props: { view: 'month' }, minSize: { width: 6, height: 4 }, actions: [ { name: 'showWeek', description: 'Switch to a one-week view.', transitionsTo: 'week', }, { name: 'collapse', description: 'Shrink to a small summary.', transitionsTo: 'collapsed', }, ], }, week: { props: { view: 'week' }, minSize: { width: 4, height: 4 }, actions: [ { name: 'showMonth', description: 'Return to month view.', transitionsTo: 'month', }, ], }, collapsed: { props: { view: 'collapsed' }, minSize: { width: 2, height: 1 }, actions: [ { name: 'expand', description: 'Restore to month view.', transitionsTo: 'month', }, ], }, },});That’s a three-state machine. The Calendar starts in month. From month it can become week or collapsed. From week it can become month. From collapsed it can become month. There is no edge from week directly to collapsed — and the engine knows that, because the action isn’t declared on week.
Variants are states
Section titled “Variants are states”Each VesselVariant describes what the component is in that state:
props— the data the component renders with. Whatever your framework binding consumes.minSize— the smallest chart-cell footprint this state is usable at. Enforced by the engine in grid mode; ignored in tiling (see Grid vs Tiling).actions— the buttons the LLM can press while you’re here.
A variant is not a route or a tab. It’s the whole shape of the Vessel at one moment: which data it shows, how much room it needs, what it can do next. Two states with the same props but different minSize are still two states — the size difference is the meaningful one.
Actions are transitions
Section titled “Actions are transitions”A VesselAction is an edge in the graph:
name— the identifier the LLM emits in its execution plan.description— what the LLM reads to decide whether to invoke it.parameters— optional structured arguments (ActionParameter[]).transitionsTo— the target variant after the action runs.
transitionsTo is what makes the machine a machine. When it’s set, the action moves the instance to a new state — and a new minSize, and a new action set. When it’s omitted, the action is a self-action: it can still mutate props or trigger side effects via the framework binding, but the Vessel’s variant doesn’t change.
The protocol validates that every transitionsTo target exists. Typos can’t ship.
Benefits of a state machine over a flat config
Section titled “Benefits of a state machine over a flat config”The engine isn’t choosing what to render. It’s reasoning about what can happen next. A state machine gives it three things a flat config cannot:
- A closed set of legal moves per state. For a
collapsedCalendar instance, the only action offered as available isexpand. The model can still see from the Vessel catalogue thatshowWeekexists on themonthvariant, but it can’t invoke it fromcollapsed— and if it tries anyway, the engine’s coherence pass rejects it (action_not_valid) rather than letting it silently fail. - A checkable transition graph. Every action names the variant it
transitionsTo, so the whole state graph is known before anything runs. The protocol rejects a transition to a variant that doesn’t exist, and the engine knows exactly which variant an action lands in without executing your component. - Enforceable size per state. Each variant carries its own
minSize, and in grid mode the engine enforces it: the LLM plans in whole cells, and any placement that comes back smaller than the target variant’sminSizeis dropped before it renders. Each state gets to declare — and the engine to guarantee — its own footprint.
A flat config — one component, one render function, one set of props — gives you none of that. Without explicit states and transitions, the engine has no closed set of legal actions to check a proposed action against — it can’t tell a valid move from a hallucinated one.
What the engine sees
Section titled “What the engine sees”Every ChartRequest carries the full set of registered vessels plus a snapshot of every instance’s current variant (VesselInstanceSnapshot, packages/protocol/src/lib/request.ts) — visible and hidden alike. The engine builds a prompt that tells the LLM:
- Here is every Vessel and every variant it has.
- Here is each instance and what variant it is in right now.
- Here are the actions available from each instance’s current variant — and what variant each one transitions to.
Then it asks for an execution plan. The structured-output schema (ActionExecutionSchema in packages/protocol/src/lib/schemas.ts) requires instanceId and action, with any arguments carried as a paramsJson string; the engine’s coherence pass then rejects actions that aren’t valid for the instance’s current state.
The variant the LLM ends up choosing is rarely the only legal one — it picks the transition and the layout that best fit the room it also wants to give Weather and Tasks, and the engine enforces each variant’s minSize on the result. That trade-off is the orchestration loop — see How the LLM Orchestrates UI for the rest of the picture.
Self-actions and parameters
Section titled “Self-actions and parameters”Not every action has to transition. A Search Vessel might expose a query action with a text parameter and no transitionsTo — it filters results in place, no state change. The protocol allows it: transitionsTo is optional on VesselActionSchema.
parameters is where structured arguments live. Each parameter declares a type ('string' | 'number' | 'boolean' | 'object'), a description the LLM reads, optional required, fallback metadata in defaultValue, and an enum of allowed values. The Engine never inserts defaultValue into a planned Action: omission is legal only when required: false, and the app’s Action runner must substitute any fallback. The model returns its arguments as a JSON object encoded in a single paramsJson string.
Before returning an execution plan, the engine rejects unknown keys, missing required parameters, values of the wrong type, and values outside a non-empty set of JSON-normalized enum options compatible with the declared type. Parameters are required unless you set required: false. An empty enum, or one with no declared-type-compatible options after JSON normalization, leaves the parameter unconstrained beyond its declared type. Actions without declared parameters accept an omitted argument object or {}, but reject non-empty arguments. These checks cover Engine planning, not local Action invocation; the app remains responsible for validating locally supplied values and applying Action-runner fallbacks.
Authoring tips
Section titled “Authoring tips”- Make variants meaningfully different. Two states that differ only by a boolean prop don’t earn their own machine entry — collapse them into one variant whose props the action mutates.
- Use
descriptionliberally. The LLM never reads your TypeScript. It only sees the descriptions on the Vessel, on each action, and on each parameter. Treat those strings as the contract. - Be honest about
minSize. In grid mode it’s the one hard size guarantee: the engine drops any placement smaller than a variant’s declaredminSizerather than render it cramped. Set it too low and cramped layouts slip through; too high and otherwise-fine placements get dropped. (In tiling modeminSizeis ignored entirely.) - Keep default variants small. New instances start in
defaultVariant. In grid mode, if that variant has a largeminSize, the turn that creates the instance has to allocate enough room or the engine drops the placement — either crowding out the rest of the layout or dropping the new instance itself.
Next steps
Section titled “Next steps”- Creating Vessels — wire a definition like the one above into
@maelstrom-co/react. - Vessel Variants & Actions — the practical how-to for designing the machine.
- Grid vs Tiling Layout — where
minSizeactually lands. - How the LLM Orchestrates UI — how the engine drives the machine.
- The Protocol — the full
Vessel,VesselVariant, andVesselActiontypes.