Skip to content

Build for agents

Ignite Element is behavior-first. The same igniteCore(...) registration that creates a custom element also exposes a headless runtime, so agents and tests can inspect the contract, execute commands, observe events, and verify projected view state before any DOM selector exists.

The practical model is:

  1. Define component behavior and boundaries.
  2. Test workflows through the headless runtime.
  3. Add the DOM layer as a projection over already-proven behavior.

Start by describing the state source, projected view, commands, events, and effects. igniteCore(...) infers snapshot, prevSnapshot, emit, select, actor, and host from the source plus your events builder, so standard authoring needs no internal helper-type imports.

import { igniteCore } from 'ignite-element/xstate';
export const counter = igniteCore({
source: counterMachine,
view: ({ snapshot }) => ({
count: snapshot.context.count,
limit: snapshot.context.limit,
isLimited: snapshot.matches('limited'),
}),
commands: ({ actor, command }) => ({
increment: () => actor.send({ type: 'INCREMENT' }),
setLimit: command(
(limit: number) => actor.send({ type: 'SET_LIMIT', limit }),
{
description: 'Set maximum count',
input: command.number({ minimum: 3, maximum: 12 }),
},
),
}),
events: (event) => ({
'limit-reached': event<{ count: number; limit: number }>(),
}),
effects: ({ snapshot, prevSnapshot, emit }) => {
if (!prevSnapshot.matches('limited') && snapshot.matches('limited')) {
emit({
type: 'limit-reached',
count: snapshot.context.count,
limit: snapshot.context.limit,
});
}
},
});

That registration gives agents a contract to inspect and a command surface to drive — all without the DOM:

counter.getSchema(); // commands + metadata + events + state shape
counter.getView(); // current projected view
counter.canExecute('increment'); // current command availability
await counter.execute({ command: 'setLimit', input: 6 });
while (!counter.getView().isLimited) {
await counter.execute({ command: 'increment' });
}
expect(counter.getView()).toMatchObject({ count: 6, limit: 6, isLimited: true });

See Headless runtime for every method (execute, canExecute, getSnapshot, getView, getSchema, on, watchSnapshot, watchView, record) and its return shape.

In practice, the loop is:

  1. Read getSchema() to discover commands, declared events, snapshot, and projected view.
  2. Use command metadata input schemas to shape payloads instead of guessing argument structure.
  3. Check canExecute() when a command is marked as gated.
  4. Run execute() and inspect the returned snapshot plus emitted events.
  5. Keep long-lived observers attached with on(...) or watchView(...) when the workflow spans multiple actions.

Once the behavior is stable, the custom element stays thin — it consumes projected view values and command functions. The default DOM layer uses the public ignite-element/jsx runtime and ordinary local style tags; no project config is required.

const panelStyles = `
section {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
border-radius: 1rem;
}
`;
counter('counter-panel', ({ count, limit, isLimited, increment, setLimit }) => (
<section>
<style>{panelStyles}</style>
<strong>
{count} / {limit}
</strong>
<button type="button" onClick={() => increment()}>
Increment
</button>
<input
type="range"
min={3}
max={12}
value={limit}
onInput={(event) => setLimit(Number(event.currentTarget.value))}
/>
{isLimited && <p>Limit reached</p>}
</section>
));

This keeps DOM tests focused on rendering and accessibility, while behavior tests stay selector-free. To assert the rendered controls by role and accessible name against the same proven runtime, mount a test-only DOM bridge with igniteTest.accessibilityBridge(...) / igniteTest.expectControls(...) — see the Testing DSL.

Command metadata is optional. Plain functions remain valid commands; command(fn, metadata) enriches the runtime schema when an agent or inspector needs more context.

commands: ({ actor, command }) => ({
reset: () => actor.send({ type: 'RESET' }),
renamePreset: command(
(label: string) => actor.send({ type: 'RENAME_PRESET', label }),
{
description: 'Rename the saved preset label.',
input: command.string({ minLength: 1, maxLength: 32 }),
},
),
});

Built-in helpers cover number, string, boolean, enum, object, and array while staying JSON-serializable, and getSchema() returns executable commands plus their metadata in one map. The full helper catalog and the serialized schema shape live in Command metadata.

Availability predicates live on the same command metadata but stay off the JSON schema. They are evaluated against the current snapshot by the headless runtime:

commands: ({ actor, command }) => ({
submit: command(
() => actor.send({ type: 'SUBMIT' }),
{
description: 'Submit the current form.',
canExecute: ({ snapshot }) => snapshot.can({ type: 'SUBMIT' }),
},
),
});
counter.getSchema().commands.submit.gated; // true
counter.canExecute('submit'); // boolean for the current snapshot

That means an agent or provider can keep the contract discovery step pure and JSON-safe, then defer the live authorization question to canExecute() immediately before invocation. The full runtime example is in getSchema().

Stories make behavior workflows first-class: a recorder captures command, event, state, and projected-view changes as one trace, while DOM lifecycle evidence stays on a separate channel of the same story object. Serialize a trace for snapshot tests with igniteTest.serializeTrace(...) and assert ordered checkpoints with igniteTest.expectTrace(...).

const story = counter.record('reaches limit');
await story.execute({ command: 'setLimit', input: 6 });
await story.until((view) => view.isLimited, async () => {
await story.execute({ command: 'increment' });
});
igniteTest.expectTrace(igniteTest.serializeTrace(story.trace()), [
{ kind: 'command', command: 'setLimit', payload: 6 },
{ kind: 'event', event: 'limit-reached', payload: { count: 6, limit: 6 } },
{ kind: 'view', phase: 'after', view: { isLimited: true } },
]);
story.stop();

story.trace() holds only behavior evidence; story.lifecycle() holds DOM evidence (registration, connection, render, disconnect, cleanup) when a custom element or accessibility bridge participates. The exact recorder, trace, and serialization signatures are in the Testing DSL.

The important boundary: logic stays inspectable without the DOM, and the DOM remains a projection over the tested contract.