Build for agents
A source-backed core works with or without a rendered view.
Agents and tests can read derived states, execute commands, and observe events through its headless runtime.
The same core can register a custom element.
The practical model is:
- Define component behavior and boundaries.
- Test workflows through the headless runtime.
- Add the DOM layer as a projection over already-proven behavior.
Behavior-first components
Section titled “Behavior-first components”Start by describing the state source, derived states, commands, events, and effects.
igniteCore(...) infers the effect context and command source from the configured source and event builder.
Environmental and host capabilities remain application-owned.
Copy apiShowcaseMachine.ts beside this module. It defines the count, limit, ADD and SET_LIMIT behavior used below.
import { apiShowcaseMachine as counterMachine } from './apiShowcaseMachine';import { igniteCore } from 'ignite-element/xstate';
export const counter = igniteCore({ source: counterMachine,
states: (snapshot) => ({ count: snapshot.context.count, limit: snapshot.context.limit, isLimited: snapshot.matches('limited'), }),
commands: ({ source: actor }) => ({ increment: () => actor.send({ type: 'ADD', amount: 1 }),
setLimit: (limit: number) => actor.send({ type: 'SET_LIMIT', limit }), }),
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:
This Vitest fragment assumes counter is imported from ./agent-counter and expect from vitest. Use a fresh module instance per test and dispose its test-owned core in finally.
counter.get('schema'); // pure catalogue; configured commands unknown until boundcounter.get('states'); // current derived statesconst canIncrement = !counter.get('states').isLimited; // projected source hint
await counter.execute({ command: 'setLimit', input: 6 });
for (let step = 0; step < 20 && !counter.get('states').isLimited; step += 1) { await counter.execute({ command: 'increment' });}
expect(counter.get('states')).toMatchObject({ count: 6, limit: 6, isLimited: true });See Headless runtime for get, watch, on, execute, and owning dispose.
In practice, the loop is:
- Read
get('schema')for pure minimal discovery; useget('states')for live projections and real binding. - Use explicit application tool schemas to validate payloads; null discovery schemas are unknown.
- Read projected availability through a separately supplied tool predicate; the source remains authoritative.
- Run
execute()and inspect the returned snapshot, derived states, and emitted events. - Keep long-lived observers attached with
on(...)orwatch(...)when the workflow spans multiple actions.
Add the DOM layer
Section titled “Add the DOM layer”Once the behavior is stable, the custom element stays thin — its renderer consumes derived states and command functions to produce the view.
The default DOM layer uses the public ignite-element/jsx runtime and ordinary local style tags.
Machine-backed headless access and each registered element have independent state. To let an agent drive the same state as a mounted view, pass an existing actor to the core; see Shared sources.
/** @jsxImportSource ignite-element/jsx */import { counter } from './agent-counter';
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', (ctx) => ( <section> <style>{panelStyles}</style> <strong> {ctx.count} / {ctx.limit} </strong>
<button type="button" onClick={() => ctx.increment()}> Increment </button>
<input type="range" aria-label="Count limit" min={3} max={12} value={ctx.limit} onInput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) { ctx.setLimit(Number(event.currentTarget.value)); } }} />
{ctx.isLimited && <p>Limit reached</p>} </section>));This keeps DOM tests focused on rendering and accessibility, while behavior tests stay selector-free.
To assert rendered controls by role and accessible name, mount the registered component and use DOM Testing Library on its actual rendered elements — see the Testing guide.
Agent-readable contracts
Section titled “Agent-readable contracts”Ordinary commands retain TypeScript inference and source-directed intent.
Descriptions and input schemas belong at the application/tool boundary:
import { buildManifest, resolveCall } from 'ignite-element/tools';
const counterToolSchema = { commands: { setLimit: { description: 'Set maximum count', input: { type: 'number', minimum: 3, maximum: 12 }, }, },};const manifest = buildManifest(counterToolSchema);const validLimit = resolveCall(manifest, 'setLimit', 6);const invalidLimit = resolveCall(manifest, 'setLimit', 99);Only offer definitions for commands the application’s core actually exposes.
Use igniteTools(counter, dialect, { schema: counterToolSchema, canExecute }) for provider translation and validated invocation.
The optional tool predicate reads projected availability; source guards still enforce behavior.
A schema can explicitly mark a tool gated: true; this is application metadata, not automatic core discovery.
Scalar inputs are wrapped/unwrapped only at the provider boundary.
A command such as submit: () => actor.send({ type: 'SUBMIT' }) needs no helper.
Project snapshot.can({ type: 'SUBMIT' }) into a canSubmit state if useful.
Do not infer schemas from that function, expose unavailable actions as universally available, or confuse availability with authorization.
Read the command catalogue
Section titled “Read the command catalogue”The core catalogue contains bound own names with input: null, declared event names with payload: null, and states: { schema: null }.
Before command callback binding it is commands: null; without a callback it is commands: {}.
Pure discovery never creates a source or manufactures an actor.
See Commands and tool schemas.
Ordinary workflow tests
Section titled “Ordinary workflow tests”Assert the direct runtime results shown above, preserving source-owned asynchronous outcomes and real DOM coverage.
Replace unbounded loops with an application-specific step limit; after a command starts asynchronous source work, wait on its correlated source state/event rather than assuming execute() completes it.
Release observers and let the source owner stop native resources.
Logic remains inspectable without the DOM, and the DOM remains a projection over that contract.