Skip to content

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.

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.

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.

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 collapsed Calendar instance, the only action offered as available is expand. The model can still see from the Vessel catalogue that showWeek exists on the month variant, but it can’t invoke it from collapsed — 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’s minSize is 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.

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:

  1. Here is every Vessel and every variant it has.
  2. Here is each instance and what variant it is in right now.
  3. 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.

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.

  • 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 description liberally. 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 declared minSize rather than render it cramped. Set it too low and cramped layouts slip through; too high and otherwise-fine placements get dropped. (In tiling mode minSize is ignored entirely.)
  • Keep default variants small. New instances start in defaultVariant. In grid mode, if that variant has a large minSize, 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.