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. It is ordinary TypeScript: each entry — a prop, a derived value, an action, an effect, a resource, or a lifecycle hook — is its own let/const declaration that calls the matching wrapper intrinsic (state, prop, derived, action, resource, effect, onMount/onDispose). One signature applies everywhere: the optional config object comes first, the running code (value thunk or handler) comes last — wrapper(config?, valueOrFn).
State wrapper intrinsics
prop() — a public, reactive property settable from HTML attributes. Always takes a config object (there is no bare prop(default) shorthand).
let name = prop<TypeAnnotation>({
default: value,
describe: 'human-readable description', // optional
expose: 'read', // optional: 'read' | 'write' | 'read write'
attribute: true, // optional
reflect: false, // optional
required: false, // optional
})Use expose: 'read' to expose the prop value to agents. Add 'write' (expose: 'read write') to also allow agents to set it. The generic <TypeAnnotation> carries the type — there is no separate type: config key.
derived() — a derived, read-only signal. Re-evaluates when dependencies change. Supports bare (no metadata) or wrapped (config object first, thunk second) form.
// bare — value-thunk, no metadata
const name = derived(() => expr)
// wrapped — add describe/expose, config comes first
const namedWithMeta = derived({ describe: '...', expose: 'read' }, () => expr)There is no type: config key for derived. Annotate the thunk's own return type inline when needed: derived((): T[] => []).
action() — a named method on the component. Bare (no metadata) or wrapped, config first.
// bare handler
const doSomething = action((args) => { /* body */ })
// wrapped — add describe/expose to surface to agents
const doSomethingExposed = action(
{ describe: 'human-readable description', expose: 'read write' },
(args) => { /* body */ },
)Writes to props or state from inside an action are plain assignment (count++, count = 0) — there is no setter-function call to make.
effect() — runs a side effect when tracked signals change. Two valid forms:
// auto-tracked — dependencies inferred from what the function reads
effect(() => { /* side effect body */ })
// explicit deps — config object's "on:" lists what to watch
effect({ on: [data] }, () => { /* side effect body */ })Call effect() as many times as needed in one @state block — each call stands on its own, so there is no name to collide on (unlike the retired named-collection form).
resource() — binds an async fetcher to a reactive signal. Returns a 3-state loader: { pending, value, error }. Bare or wrapped.
// bare fetcher-thunk
const data = resource(() => fetchUsers())
// wrapped — add describe/expose
const user = resource({ describe: '...', expose: 'read' }, () => fetchUser(userId))onMount() / onDispose() — lifecycle hooks. Plain statement-position calls; there is no collection object to assemble.
onMount(() => { /* runs after first DOM mount */ })
onDispose(() => { /* runs on unmount */ })There are also onAdopt and onAttributeChange lifecycle hooks, not shown above.
Complete example
@state {
let count = prop({ default: 0, describe: 'Current counter value', expose: 'read' })
const doubled = derived(() => count * 2)
const isHigh = derived(
{ describe: 'True when count exceeds 100', expose: 'read' },
() => count > 100,
)
const increment = action(
{ describe: 'Add 1 to the counter', expose: 'read write' },
() => { count++ },
)
const reset = action(() => { count = 0 })
onMount(() => console.log('mounted'))
onDispose(() => console.log('unmounted'))
effect(() => { document.title = 'Count: ' + count })
}describe and expose — agent visibility
describe and expose are config keys inside the optional config object passed as the first argument to prop(), derived(), action(), and resource(). They replace the old @agent-level $expose and $describe macros.
describe: 'text'— makes the entry visible in the agent's capability description.expose: 'read'— the agent can read this value.expose: 'read write'— the agent can read and write this value (props, actions).
@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: see the Composition guide's "Tag naming" section.
The prefix-less template. One rule: naked keywords + naked HTML attributes + naked framework vocabulary.
{expr}braces mean expression; quoted strings mean static;$is retired from both@templateand@state— the only surviving$-prefixed forms anywhere in the SFC are the two cross-cutting@agentdeclarations,$scopeand$rate-limit. 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}). Older$-prefixed attribute forms and<$…>macro elements from pre-redesign sources are compile errors under this grammar. Runnpx aihu migrate <file>to mechanically rewrite older sources.
Text interpolation
{expr}— reactive text node. Writes throughnodeValue, which updates the text node in place instead of reparsing the element's children.
Event handlers
on:click={handlerName}— attach an event listener (quoted identifier reference).on:click={() => expr}— attach an inline handler (curly expression).
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
bind:value={signalName}— two-way bind a writable signal to a form element. The name after the colon is the bound property:bind:value,bind:checked, etc.
Conditional rendering
if={cond}— remove/insert the element based on a boolean signal or expression.elseif={cond}/else— chain onto the immediately precedingif/elseifelement sibling (only whitespace/comments may sit between).show={cond}— toggle visibility without removing from DOM.- Wrap multi-element branches in
<group>— the invisible fragment carrier.
List rendering
each={item, i of items}— render a list, item-first (<binder> [, <index>] of <list-expr>). Destructuring binders work:each={[k, v] of entries}. Pair withkey={…}for stable reconciliation, and putemptyon the immediately following sibling for the empty state:
<li each={todo of todos} key={todo.id}>{todo.text}</li>HTML output
html={expr}— render raw HTML from an expression (trusted content only). Underoutput: 'static'this is prerendered too: the value is interpolated unescaped into the HTML you serve, not just injected into the DOM after load. Treat it asinnerHTMLat build time — never point it at untrusted or remote content.
Memoization and DOM stability
key={expr}— key for list reconciliation (pairs witheach).memo={expr}— memoize a subtree; only re-renders when expr changes.once— boolean attribute; renders once and never re-renders.raw— boolean attribute; verbatim element, no macro processing.
Class bindings
class={cond ? 'active' : ''}— reactive whole-string class expression.class:active={cond}— per-class toggle; composes with a staticclass="…"base list (the toggle is authoritative when both address the same name).
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 expressionSome 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 wrapper calls, 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 the Authoring Agents guide.
Common diagnostics
The compiler enforces the 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 Grammar v2 callout under @template. Three more are easy to hit and worth calling out:
C205 — reading a prop in a bare @state const
A plain @state const/let can be emitted before the prop binding, so reading a prop there risks a temporal-dead-zone (TDZ) throw at runtime; the compiler steers you to derived(), where the read happens lazily inside a thunk instead of eagerly at setup:
// ✗ risks C205 — prop read in a plain const/let, no thunk to defer it
@state {
let name = prop<string>({ default: 'world' })
const greeting = 'Hello, ' + name + '!'
}
// ✓ read the prop inside derived()
@state {
let name = prop<string>({ default: 'world' })
const greeting = derived(() => 'Hello, ' + name + '!')
}Beyond sidestepping the hazard, derived() is also what keeps greeting reactive — a bare const would only capture the prop's value once at setup, where derived() re-reads it whenever name changes.
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 {
let name = prop<string>({ default: 'world' })
}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 the Composition guide's "Tag naming" section 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 diagnostic mapping, see the Migration guide.