Vessel Variants & Actions
A vessel becomes interactive when it has more than one variant and a set of actions the engine can invoke to move between them. This guide adds both to the greetingVessel from the previous guide.
What you’ll wire up
Section titled “What you’ll wire up”By the end of this guide your greetingVessel will:
- carry a second variant alongside the original
cardvariant - define actions, some with typed parameters and some that transition to another variant
- fire an action from the component with
executeAction - handle that action in your app with
useActions
Mental model
Section titled “Mental model”A variant is a legal UI state. An action is a legal transition or operation from the currently active state. The engine can only invoke actions declared on the active variant, so the state machine is both a UI model and a safety boundary. When external data changes these definitions or a live Instance, use the state synchronization prompt to inspect the supported update path and conflicts before editing.
Synchronize Vessel state and definitions
Diagnose and, if authorized, implement the correct Maelstrom Vessel definition or
runtime-state update path for this case.
Vessel / Instance: [ID OR PATH]
Registration: [FACTORY-DECLARED | RUNTIME-REGISTERED | UNKNOWN]
External state source: [STORE / API / PROPS / OTHER]
Observed conflict or desired change: [DETAILS]
Implementation authorized: [YES / NO]
## Inspect ownership and state
Read the application owner of each relevant value, registration site, installed
SDK version and the matching update API/types/source/tests. Determine whether the
request changes an Instance's Variant/props, republishes a definition, changes
layout, or combines these. Record whether removed Variants or Actions are used by
live Instances, and whether requests/updates may be in flight. Keep unknown
ownership or policy as explicit questions.
## Select the supported path
- The client owns layout state; do not treat the Engine as the source of
application data or overwrite its decisions as a reconciliation loop.
- Use the inspected factory-aware path for factory-declared Vessels and the
inspected registration handle/update path for runtime-registered Vessels.
Confirm method names and return/conflict behavior in the installed version.
- Handle live Variant/Action and in-flight update conflicts explicitly. Do not
drop or force an update unless the application has an authorized policy.
- Preserve UpdateCoordinator's supported batching; do not reproduce its internals
unless the app intentionally uses the lower-level client API.
- When processing an Engine response, preserve atomic command order: apply
`set_layout` before `execute_actions`, so actions may target newly placed
Instances. Do not add inline size/overflow reconciliation; a changed Chart
dimension is handled by the existing resize request path.
If implementation is authorized, make the smallest change within the supplied
scope; otherwise make no edits. Add deterministic tests for the selected update
path and conflict behavior. Run affected checks and report exact commands and
outcomes; do not claim live Engine verification from a test double.
Return an ownership map, observed registration/update mechanism with source
anchors, conflict/race handling, changed files or plan, test results, and
unresolved policy questions.
Define variants
Section titled “Define variants”The variants map holds the discrete states a vessel can be in. Each variant carries its own props, minSize, and actions. Add a second expanded variant next to card:
import { defineVessel } from '@maelstrom-co/protocol';
export const greetingVessel = defineVessel({ id: 'greeting', name: 'Greeting', description: 'Displays a personalised greeting message.', defaultVariant: 'card', variants: { card: { props: { name: '' as string, }, minSize: { width: 3, height: 2 }, actions: [], }, expanded: { props: { name: '' as string, message: '' as string, }, minSize: { width: 4, height: 3 }, actions: [], }, },});Each variant defines an independent prop shape, so expanded can require a message field that card never had. TypedVesselProps turns this map into a discriminated union: variantProps is { variant: 'card', name } | { variant: 'expanded', name, message }. Narrow on variantProps.variant before reading variant-specific props:
import type { TypedVesselProps } from '@maelstrom-co/react';import { greetingVessel } from './greeting.vessel';
export default function GreetingComponent({ variantProps,}: TypedVesselProps<typeof greetingVessel>) { if (variantProps.variant === 'card') { return ( <div style={{ padding: '1rem' }}> <h2>Hello, {variantProps.name}!</h2> </div> ); }
// variantProps narrowed to the 'expanded' variant; `message` is in scope. return ( <div style={{ padding: '1rem' }}> <h2>Hello, {variantProps.name}!</h2> <p>{variantProps.message}</p> </div> );}Define actions
Section titled “Define actions”Actions live on each variant’s actions array. A VesselActionDefinition has five fields:
name: the identifier the LLM emits and your runner keys off. Must be unique within a variant.description: what the action does, in plain language. The LLM reads this to decide when to invoke it.parameters(optional): an array ofActionParameterdescribing typed inputs the LLM fills.transitionsTo(optional): the name of the variant this action moves the vessel into. Omit it for actions that mutate data without changing variant.idempotent(optional, defaults tofalse): whether running the action twice leaves the vessel where running it once would. The Engine may re-emit an action on a turn triggered by nothing but a chart resize, and it cannot see vessel contents to tell a first run from a repeat, so such a turn admits only actions markedtruethat the LLM invoked with no parameters. A parameter value there could only have come from earlier in the conversation, sosetLocation("Tokyo")is refused on a resize even when marked. Mark pure navigation (expand,showWeek); leave anything that appends or accumulates unmarked.
Give card an action that takes a parameter and an action that switches to expanded:
card: { props: { name: '' as string, }, minSize: { width: 3, height: 2 }, actions: [ { name: 'setName', description: 'Update who the greeting is addressed to.', parameters: [ { name: 'name', type: 'string', description: 'The name to greet.', required: true, }, ], }, { name: 'expand', description: 'Show the greeting with a longer message.', transitionsTo: 'expanded', }, ], },ActionParameter reference
Section titled “ActionParameter reference”Each entry in parameters is an ActionParameter:
| Field | Type | Description |
|---|---|---|
name |
string |
Parameter key. Becomes a property on the typed params object. |
type |
'string' | 'number' | 'boolean' | 'object' |
The value type. Drives the TypeScript type your handler receives. |
description |
string |
What the parameter means; the LLM uses this to fill the value. |
required |
boolean (optional) |
When false, the param is optional on the typed params object. Defaults to required. |
defaultValue |
unknown (optional) |
Fallback metadata for your Action runner. The Engine never applies it; to allow omission, also set required: false and have the runner substitute the fallback. |
enum |
unknown[] (optional) |
Constrains Engine-planned Actions only when it contains at least one JSON-normalized value compatible with the declared type; otherwise only type applies. |
The type union maps to TypeScript as you’d expect: 'string' → string, 'number' → number, 'boolean' → boolean, 'object' → Record<string, unknown>. A variant whose actions array is empty produces no callable actions for that state.
These Engine checks do not validate Actions invoked locally through executeAction; validating those values and applying any defaultValue remain the app’s responsibility in its Action runner. See Three rules for the full enum behavior.
transitionsTo wiring
Section titled “transitionsTo wiring”When an action defines transitionsTo, invoking it does two things: it runs the action’s handler, and it moves the instance into the named target variant. defineVessel only preserves literal types for downstream inference — it performs no validation itself. Rejection happens at registration: registerVessel() runs VesselSchema (via validateVessel) and throws if a transitionsTo names a variant that doesn’t exist, so a typo fails fast rather than at runtime.
After a transition, your component re-renders with variantProps narrowed to the target variant. That is why expand above moves card → expanded: the next render hits the expanded branch and gains access to message. The target variant’s minSize becomes the floor for later grid layout validation; it has no effect in tiling mode.
An action without transitionsTo (like setName) keeps the vessel in its current variant. Use the transition only when the action should change the displayed state.
Invoke vs handle (the two halves)
Section titled “Invoke vs handle (the two halves)”An action has two ends. The component fires it; the app handles it. Wiring both completes the round-trip.
The component fires actions
Section titled “The component fires actions”TypedVesselProps gives your component an executeAction function. Its signature is parameter-aware: actions with parameters require a typed params object, parameterless actions take just the name.
export default function GreetingComponent({ variantProps, executeAction,}: TypedVesselProps<typeof greetingVessel>) { if (variantProps.variant === 'card') { return ( <div style={{ padding: '1rem' }}> <h2>Hello, {variantProps.name}!</h2> <button onClick={() => executeAction('setName', { name: 'Ada' })}> Greet Ada </button> <button onClick={() => executeAction('expand')}>Show more</button> </div> ); }
return ( <div style={{ padding: '1rem' }}> <h2>Hello, {variantProps.name}!</h2> <p>{variantProps.message}</p> </div> );}executeAction('setName', { name: 'Ada' }) is type-checked against the parameter list: passing setName without a name string is a compile error, and executeAction('expand') rejects extra arguments because expand has no parameters. executeAction returns void; the result of running the action is observed through the handler, not the return value.
The app handles actions
Section titled “The app handles actions”m.useActions(vesselId, instanceId, runner) registers the handlers. The runner is a partial map from action name to an async handler: you only implement the actions you care about, and the rest are no-ops. Each handler returns an ActionResult, signalled with ok(...) or err(...) from @maelstrom-co/protocol:
import { ok, err } from '@maelstrom-co/protocol';import { m } from '../../maelstrom';
export function GreetingActions({ instanceId }: { instanceId: string }) { m.useActions('greeting', instanceId, { setName: async ({ name }) => { if (!name) return err(new Error('name is required')); await saveGreetingTarget(name); return ok(undefined); }, expand: () => ok(undefined), });
return null;}The handler params are typed from the vessel definition: setName receives { name: string }, and expand (no parameters) takes none. Handlers run for both engine-initiated actions and the executeAction calls fired from your component, so the same setName logic serves the LLM and the button.
Missing handlers and invalid parameters
Section titled “Missing handlers and invalid parameters”If the engine or a component invokes an action without a registered handler, Maelstrom treats that action as a no-op rather than crashing the Chart. Use that default for actions that only transition variants. When an action needs data or side effects, return err(...) from the handler for invalid parameters so the failure reaches the error surface instead of silently mutating state.
setName: async ({ name }) => { if (!name.trim()) return err(new Error('name must not be empty')); await saveGreetingTarget(name); return ok(undefined);}With both halves wired, the loop is complete: the component (or the LLM) fires expand, the runner’s handler runs, the action transitions the vessel to expanded, and the component re-renders into the expanded state.
Options that change at runtime
Section titled “Options that change at runtime”setName’s name parameter is typed 'string', which tells the engine nothing about which names are actually valid — it has to guess, or invent one. Declaring enum on a parameter puts the real list of values directly in the engine’s prompt, instead of a bare type. If you’re using voice, the same enum values are exposed to the realtime model through get_capabilities, so both paths read from the one declaration.
Static lists
Section titled “Static lists”When the valid values are fixed — a module-level constant, not something that changes while the app runs — declare enum inline on the parameter:
const GREETABLE_NAMES = ['Ada', 'Grace', 'Alan'];
// ...{ name: 'setName', description: 'Update who the greeting is addressed to.', parameters: [ { name: 'name', type: 'string', description: 'The name to greet.', required: true, enum: GREETABLE_NAMES, }, ],},The engine now sees setName(name: Ada|Grace|Alan) instead of setName(name: string) — a much narrower target to aim at.
Live lists
Section titled “Live lists”Most interesting option lists aren’t static — they’re derived from data that changes while the app is running (the set of open documents, the names of events on today’s calendar, whatever a store currently holds). For those, build a copy of the vessel definition with fresh enum values and republish it with m.updateVessel(vessel) whenever the underlying data changes:
import { useEffect } from 'react';import { m } from '../../maelstrom';import { greetingVessel } from './greeting.vessel';import { useRosterStore } from '../../roster.store';
function withNameOptions(names: readonly string[]) { return { ...greetingVessel, variants: { ...greetingVessel.variants, card: { ...greetingVessel.variants.card, actions: greetingVessel.variants.card.actions.map((action) => action.name === 'setName' ? { ...action, // An empty list leaves the parameter untouched rather than // publishing `enum: []` — see the "Three rules" section // below for why that distinction matters. parameters: action.parameters?.map((parameter) => parameter.name === 'name' && names.length > 0 ? { ...parameter, enum: [...names] } : parameter, ), } : action, ), }, }, };}
/** Renders nothing — keeps setName's option list in step with the roster store. */export function GreetingOptionSync() { const { names } = useRosterStore(); // Keyed on the serialized list, not the array reference: the store hands // back a new array identity on every render even when the contents are // unchanged, and re-publishing the vessel on every render is wasted work. const namesKey = JSON.stringify(names);
useEffect(() => { m.updateVessel(withNameOptions(names)); }, [namesKey]);
return null;}Mount <GreetingOptionSync /> once, inside <m.Provider>, alongside the rest of your app.
Why not registerVessel
Section titled “Why not registerVessel”The two functions take different inputs, and that difference is the whole reason they behave differently. registerVessel(entry) takes a full VesselEntry — the { vessel, component } pair vessel() produces — and writes entry.component into the component registry <m.Chart /> reads from. updateVessel(vessel) takes a bare VesselDefinition and never touches that registry at all; there’s no component in its argument to write.
That difference in surface area is also why registerVessel can remount a vessel and updateVessel never does. For a lazy entry, registerVessel wraps entry.component in a fresh React.lazy(...) on every call, so the component identity <m.Chart /> renders changes and React remounts it. For a non-lazy entry it’s less clear-cut — passing back the exact same component function reference wouldn’t change identity and wouldn’t force a remount — but the common case is a freshly-declared or re-imported reference, so treat registerVessel as “may remount” and reach for it only when you actually mean to touch the component. updateVessel republishes the description, variants, and action parameters the engine and voice model see, structurally incapable of writing to the component registry, so it never remounts anything. For a vessel like greetingVessel that hardly matters, but it matters a lot for a vessel that owns state React doesn’t manage — a canvas element holding a live WebGL context, a focused input, an open WebSocket. Use updateVessel whenever you’re only changing what the vessel says it can do, not what it is.
Three rules
Section titled “Three rules”- An empty list behaves the same as omitting
enumentirely. Both the engine’s prompt serializer and the voice capability catalog treat a present-but-emptyenum: []as “no options declared”. The prompt renders the parameter asname: string— the declaredtypestanding in for the option list — and the voice catalog, which carriestypeon every parameter regardless, simply leavesoptionsoff.m.updateVesselcallers don’t need to special-case an empty derived list before republishing — passingenum: []and leavingenumunset produce the same result. - The engine enforces prompt-visible
enumvalues. Before returning an execution plan, the engine rejects a model-generated value outside the options it showed in the prompt. It compares JSON-normalized values, so an object option with an omittedundefinedproperty or atoJSONmethod matches the same JSON object the model sees. Values whose normalized JSON type conflicts with the declared parameter type are not shown. Empty lists and lists with no type-compatible JSON-renderable values remain unconstrained. Action runners should still validate application-specific rules and locally supplied values, which do not cross this engine boundary. enumvalues are transmitted verbatim, not just referenced. Every chart request carries the actualenumvalues, and so does the voice model’sget_capabilitiescatalog if you’re using voice — the engine sees the real strings, not a count or a pointer into your store. Don’t put anything sensitive in a value the model shouldn’t be told (a task titled with an access token, an event with a private attendee list), and keep lists bounded — the two paths bound them very differently. The prompt serializer joins values with|and never truncates, so a vessel that reuses the same option list across several actions — like the demo’stasksvessel, which repeats every task title acrossremoveTask,checkTask,uncheckTask, andaddSubtask— pays that full list’s size once per action, per request. The voice catalog does cap it: pastMAX_CAPABILITY_OPTION_COUNT(100) values, orMAX_CAPABILITY_OPTIONS_TOTAL_CHARS(2000) characters across them, it drops the list wholesale rather than truncating it and flags the parameteroptionsOmitted, so the model learns values exist but not which ones. On both paths an individual value with no faithful text form — a cyclic object, a symbol — is dropped rather than shown as a placeholder the model could pick but your vessel couldn’t act on.
Next steps
Section titled “Next steps”- Layout Modes — how variant
minSizeinteracts with grid and tiling layouts - Setting a Default Screen — seed a known first screen with
initialLayoutinstead of waiting on the engine’s first response - Error Handling — surface
err(...)results from action handlers in the UI - Vessel State Machine — the conceptual model of how variants and actions compose