Skip to content

Creating Vessels

A Vessel is Maelstrom’s representation of a UI component — a definition that wraps your React component and gives the Engine a typed manifest of its Variants, sizes, and Actions. This guide shows you how to author one, from definition to render.

By the end of this guide you will:

  1. Define a greetingVessel with a single variant and typed props.
  2. Build a React component that renders those props.
  3. Register the entry with createMaelstrom() and mount the Chart.

Descriptions are part of the runtime contract, not decoration. The Engine reads the Vessel description, each Variant description, and each Action description to decide whether the Vessel is relevant to a user’s intent. Write them as product behavior, not implementation notes.

Use defineVessel from @maelstrom-co/protocol to describe the Vessel. It’s an identity function at runtime: it returns the input as is and exists purely for type safety. Its <const V> signature captures the literal types of your Variant names, Action names, and prop shapes. Those types flow downstream so your component and Action runners are fully typed. It also constrains the object to VesselDefinition, so mistakes are caught at the definition site rather than at runtime.

src/vessels/greeting/greeting.vessel.ts
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: [],
},
},
});

Key fields:

  • id: unique, short, and stable string the Engine uses to address this Vessel.
  • name / description: human-readable labels the LLM reads to decide when to show the Vessel.
  • defaultVariant: the Variant that renders before any Action transitions the Vessel. Must be a key in variants.
  • variants: a map of named Variants. Each Variant carries props (the shape your component receives), minSize (in grid cells; see Grid vs Tiling for how cells map to pixels), and actions (covered in Vessel Variants & Actions).

The props values in the definition are used only to infer the TypeScript shape. The LLM fills the actual values at runtime.

Pass the definition and the component to vessel() from @maelstrom-co/react:

src/vessels/greeting/greeting.component.tsx
import type { TypedVesselProps } from '@maelstrom-co/react';
import { greetingVessel } from './greeting.vessel';
export default function GreetingComponent({
variantProps,
}: TypedVesselProps<typeof greetingVessel>) {
// variantProps is a discriminated union narrowed per variant.
// With one variant this is straightforward; narrow when you add more.
if (variantProps.variant === 'card') {
return (
<div style={{ padding: '1rem' }}>
<h2>Hello, {variantProps.name}!</h2>
</div>
);
}
return null;
}

TypedVesselProps<V> delivers three props:

Prop Type Description
instanceId InstanceId Unique ID for this placed instance of the vessel.
variantProps discriminated union { variant: K } & props for the active Variant K. Narrow on .variant to access that Variant’s typed props.
executeAction (name, params?) => void Fire an action; covered in Vessel Variants & Actions.

Maelstrom generates and injects instanceId automatically. You’ll need it if you want to call executeAction on a specific instance from outside the component — for example, from a toolbar or a global keyboard shortcut.

variantProps bundles the active variant name and its props into a single object. With one variant it’s always { variant: 'card', name: string }. When you add more variants it becomes a discriminated union — narrow on .variant to get the right prop types.

Combine the definition and component into an entry with vessel(), then pass it to createMaelstrom():

src/maelstrom.ts
import { createMaelstrom, vessel } from '@maelstrom-co/react';
import GreetingComponent from './vessels/greeting/greeting.component';
import { greetingVessel } from './vessels/greeting/greeting.vessel';
export const greetingEntry = vessel(greetingVessel, GreetingComponent);
export const m = createMaelstrom({
vessels: [greetingEntry],
});

Mount the Provider and Chart in your app shell:

src/app.tsx
import { m } from './maelstrom';
import { myConnector } from './connector';
export default function App() {
return (
<m.Provider connector={myConnector}>
<m.Chart />
</m.Provider>
);
}

<m.Chart /> renders vessels as the engine places them. The greeting vessel appears when the engine decides to show it. You don’t position it manually.