aihu — The reactive Web Components framework

aihu aihu

Durable Web Components your AI agent can drive — not disposable UI it generates.

A complete Web Components framework — routing, SSR, auth, data, and deploy — where every component is also an MCP tool.

npx create-aihu my-app --template agent

Authoring Components

A .aihu Single File Component (SFC) is composed of named blocks. Each block uses the @blockname { ... } syntax.

@state block

The @state block declares the reactive contract of the component. All macros use the v2 collection-form: each macro keyword appears at most once per @state block and takes a single object whose keys are entry names.

Macros

`$prop` — a public, reactive property settable from HTML attributes. Always wrapped (bare form forbidden).

$prop: {
  name: {
    default: value,
    type?: "TypeAnnotation",   // required when default can't carry the TS type
    describe?: "human-readable description",
    expose?: { read?: true, write?: true }
  }
}

Use expose: { read: true } to expose the prop value to agents. Add write: true to also allow agents to set it.


`$computed` — a derived, read-only signal. Re-evaluates when dependencies change. Supports bare (no metadata) or wrapped (with metadata) form.

$computed: {
  // bare — value-thunk, no metadata
  name: () => expr,
  // wrapped — add describe/expose
  namedWithMeta: { value: () => expr, describe?: "...", expose?: { read?: true } }
}

The type: key is not valid for $computed. Annotate the thunk's return type inline when needed: value: (): T[] => [].


`$action` — a named method on the component. Bare (no metadata) or wrapped.

$action: {
  // bare handler
  doSomething: (args) => { /* body */ },
  // wrapped — add describe/expose to surface to agents
  doSomethingExposed: {
    handler: (args) => { /* body */ },
    describe?: "human-readable description",
    expose?: { read?: true, write?: true }
  }
}

`$effect` — runs a side effect when tracked signals change. Two valid forms per @state block:

// anonymous — macro keyword takes a single function directly
$effect: () => { /* side effect body */ }

// named collection — auto-tracks deps (bare) or explicit deps (wrapped)
$effect: {
  logData: () => { console.log(data()) },                        // bare — auto-tracks
  updateList: { on: [data], value: () => { updateList(data()) } } // wrapped — explicit deps
}

Both forms may coexist in the same @state block. Two anonymous $effect: lines in one block is a parse error.


`$resource` — binds an async fetcher to a reactive signal. Returns a 3-state loader: { pending, value, error }. Bare or wrapped.

$resource: {
  // bare fetcher-thunk
  data: () => fetchUsers(),
  // wrapped — add describe/expose
  user: { value: () => fetchUser(userId), describe?: "...", expose?: { read?: true } }
}

`$lifecycle` — lifecycle hooks. Always bare functions; wrapped form is forbidden.

$lifecycle: {
  mount:   () => { /* runs after first DOM mount */ },
  dispose: () => { /* runs on unmount */ }
}

Only mount and dispose are valid keys. describe, expose, value, and handler are forbidden on lifecycle entries.

Complete example

@state {
  $prop: {
    count: { default: 0, describe: 'Current counter value', expose: { read: true } }
  }

  $computed: {
    doubled: () => count * 2,
    isHigh: { value: () => count > 100, describe: 'True when count exceeds 100', expose: { read: true } }
  }

  $action: {
    increment: {
      describe: 'Add 1 to the counter',
      expose: { read: true, write: true },
      handler: () => { count++ }
    },
    reset: () => { count = 0 }
  }

  $lifecycle: {
    mount:   () => console.log('mounted'),
    dispose: () => console.log('unmounted')
  }

  $effect: () => { document.title = `Count: ${count}` }
}

`describe:` and `expose:` — agent visibility

describe: and expose: are per-name keys on $prop, $computed, $action, and $resource entries. They replace the old @agent-level $expose and $describe macros (which are C440 errors in v2).

@template block

The @template block defines the DOM output using aihu's template DSL.

Tag naming. Components compile to native custom elements, so every component tag must normalize to a hyphenated name: multi-word PascalCase kebab-cases automatically (`<UserCard>` → `user-card`), hyphenated tags pass through lowercased, and referencing a single-word component tag is a hard compile error (C450) (`<Comment>`) — use a hyphenated tag (e.g. `<x-comment>`) or an explicit hyphenated `@meta name`. Full rules and examples: Composition guide, "Tag naming".

Grammar v2 — the prefix-less template. One rule: naked keywords + naked HTML attributes + naked framework vocabulary. `{expr}` braces mean expression; quoted strings mean static; `$` retreats to `@state` macros only. Reactive attribute bindings are plain braces (`class={…}`, `href={…}`, `disabled={loading}`); events and two-way binds are colon directives (`on:click={…}`, `bind:value={…}`); control flow attaches to the element it governs (`if={…}`, `each={item of items}`). The entire v1 `$`-attribute layer, the Svelte-shaped block DSL and the `@html` block, `{{…}}` double braces, and the `<$…>` macro elements are hard compile errors (C601–C611), each carrying a precise fix hint. Run `npx aihu migrate <file>` to mechanically rewrite older sources.

Text interpolation

Event handlers

A colon separates the directive from the event name: on:<event>. Dotted modifiers compose behavior: on:click.prevent, on:submit.once (supported: .prevent, .stop, .self, .once).

Two-way binding

Conditional rendering

List rendering

<li each={todo of todos} key={todo.id}>{todo.text}</li>

HTML output

Memoization and DOM stability

Class bindings

Special elements

`<slot>` — inserts slotted children provided by the parent:

<slot name="header" />

Use expose to pass context to slot consumers:

<!-- In UserList.aihu -->
<slot name="row" expose="user, index">
  <!-- default content -->
</slot>

`<suspense>` — wraps an async resource with a loading fallback:

<!-- Simple: fallback attribute (component name) -->
<suspense fallback="Skeleton">
  <UserProfile />
</suspense>

<!-- Context-aware: slot form -->
<suspense>
  <UserProfile />
  <slot name="fallback">
    {loadAttempts > 3 ? <SlowConnection /> : <Spinner />}
  </slot>
</suspense>

The fallback attribute takes a component name (quoted string); fallbackProps may be added for static props. fallback attribute and <slot name="fallback"> are mutually exclusive.

`<shield>` — isolates a subtree behind an error boundary:

<shield>
  <UserProfile />
  <slot name="fallback">
    <ErrorPage error="shield.error" retry="shield.retry" />
  </slot>
</shield>

Exposes shield.error (Error) and shield.retry (function) to the fallback slot.

`<guard>` — conditionally renders based on an auth scope:

<guard scope="admin" fallback="UnauthorizedPage">
  <AdminPanel />
</guard>

Attributes: scope (scope-name), permissions, rateLimit, fallback (component-ref), redirect (path), onDeny (function-ref). Exposes guard.user, guard.reason, guard.path to the fallback slot.

`<warp>` — renders children into a portal target:

<warp to="#modal-root">
  <div>Portal content</div>
</warp>

Attribute value forms

Every attribute value must be in one of two forms — bare unquoted values are forbidden:

✗ <button on:click=save>            ← parse error (bare value)
✓ <button on:click={save}>          ← handler reference
✓ <button on:click={() => save()}>  ← inline handler expression

Some attributes are boolean-only (present-or-absent): once, raw, else, empty, disabled, required, etc.

@style block

The @style block defines component-scoped styles with reactive capabilities.

`$reactive(signal)`

Binds a CSS custom property value to a signal. Updates reactively without JavaScript in the template:

@style {
  $global {
    :root {
      --color-primary:    $reactive(primary);
      --color-on-primary: $reactive(onPrimary);
      --color-surface:    $reactive(surface);
    }
  }
  .host { color: $reactive(textColor); }
}

$global { ... } hoists styles out of the shadow root to the document root.

`$media(query)`

A responsive breakpoint block. Compiles to a standard @media rule but participates in the reactive style system:

@style {
  $media(max-width: 480px) {
    label { grid-template-columns: 1fr; }
  }
}

Standard CSS

All standard CSS is valid inside @style. Styles are scoped to the component shadow root by default (unless $global is used).

@agent block

The @agent block is a vestigial cross-cutting block. Per-name agent metadata (describe:, expose:) lives on @state collection entries, not here.

@agent now holds only two cross-cutting declarations:

@agent {
  $scope "user:read"     // agent permission scope
  $rate-limit 100        // requests per minute
}

Both are optional. The entire block may be omitted. For full agent authoring details — tool exposure, MCP compliance, and the agent capability contract — see Authoring Agents.

Common diagnostics

The compiler enforces the v1 grammar with named diagnostics. The hard errors C304 (Vue-shape :attr=), C305 (colon-form event/bind alias), C306 (plain-curly attribute binding), and C107 (HTML-tag SFC framing) are covered by the Amendment 04 callout under @template. Three more are easy to hit and worth calling out:

Reading a `$prop` in a bare `@state` const

Reading a prop inside a plain @state const/let compiles — the prop getter (const name = ctx.props.name) is hoisted above the @state body, so there is no temporal-dead-zone hazard. (An earlier compiler release rejected this with a C205 error on the premise that the binding was emitted after the body; that ordering was fixed and the diagnostic retired.)

The remaining reason to prefer $computed is reactivity, not compilation: a bare const captures the prop's value once at setup and never updates, whereas $computed re-reads the prop inside a thunk and stays reactive:

// compiles, but greeting is captured once and will not track prop changes
@state {
  $prop: { name: { default: 'world', type: 'string' } }
  const greeting = `Hello, ${name()}!`
}

// reactive — re-reads the prop whenever it changes
@state {
  $prop: { name: { default: 'world', type: 'string' } }
  $computed: {
    greeting: () => `Hello, ${name()}!`
  }
}

C204 — unknown `@block`

The only recognized top-level blocks are @state, @template, @style, @agent, @route (plus the deprecated @layout shorthand). Any other @<name> header is an unknown block (C204). The most common offender is a v0 @props block — the hint steers you to declare props via $prop: inside @state:

// ✗ C204 — there is no @props block
@props { name: { default: 'world' } }

// ✓ declare props via $prop: inside @state
@state {
  $prop: { name: { default: 'world', type: 'string' } }
}

C450 — single-word component tag (custom elements need a hyphen)

A component tag (or a component's resolved name) that normalizes to a single hyphen-less word can never be a valid custom-element name, so the compiler rejects it with C450. <UserCard> kebab-cases fine (user-card), but <Comment> resolves to comment — no hyphen, hard error:

// ✗ C450 — 'Comment' resolves to 'comment', which has no hyphen
<Comment item={c} />

// ✓ use a hyphenated tag (and file stem), or set a hyphenated @meta name
<x-comment item={c} />

See Composition guide, "Tag naming" for the full normalization table.

W210 — `on:<non-event>` (use `html` for innerHTML)

on:<name> referencing anything that is not a real DOM event compiles to a dead on<name> handler that never fires; the compiler warns with W210. To set raw HTML reactively, use the html attribute, not an on: binding:

// ✗ W210 — on:innerHTML is not a DOM event → dead handler
<div on:innerHTML={markup}></div>

// ✓ use the html attribute
<div html={markup}></div>

For the full v0 → v1 mapping of every diagnostic, see the Migration guide.