Aktion language.
A compact, line-oriented language designed for LLMs. A program is a
flat list of name = expression statements. The parser
is error-tolerant — invalid lines are dropped and valid lines
render immediately as they stream in.
Mental model
Aktion has a single program shape: a list of named bindings. The
reserved name _app_ is the entry point — every
response MUST begin with _app_ = ... on the first line.
Every other binding is referenced from _app_ directly or
transitively. Re-render happens automatically whenever a reactive
atom changes; there is no manual subscription model.
_app_ = Stack([header, dashboard])
header = SectionHeader("Acme Dashboard")
dashboard = Card([
CardHeader("Active users", subtitle: "Last 7 days"),
Text(@Format($activeUsers))
])
$activeUsers = 12540
Three identifier conventions cooperate:
- Plain bindings —
name = expr. A non-reactive alias; reading it never subscribes. - Reactive atoms —
$name = value. A tracked cell; reading subscribes the surrounding component / effect, writing notifies subscribers. - Reserved built-ins —
_app_(UI root),theme(optional brand override),_route_(router-owned reactive surface),$i18n(i18n bundle).
Statements
| Kind | Syntax | Notes |
|---|---|---|
| Root assignment | _app_ = Expr | Reserved entry point; required, first line. |
| Binding | name = Expr | Plain identifier — non-reactive alias, can reference other bindings (forward refs OK). |
| Reactive atom | $name = Expr | Mutable, triggers re-render on change. |
| Component decl | component Name(args) { … return Expr } | User-declared component with per-instance state. MUST end with explicit return. |
| Action decl | action Name(args) { steps } | Imperative block triggered by events. MAY return. |
| Effect decl | effect [ ...deps ] { steps } | Anonymous declarative side effect. Dependencies ($atom, on:mount, on:unmount, on:every(N), debounce(N), throttle(N)) live in the bracketed list. effect { … } is equivalent to effect [on:mount] { … }. |
| Emit | emit "name" { detail } | Dispatch a CustomEvent on the host (inside action/effect bodies). |
| Theme override | theme = Theme({ tokens }) | Top-level binding named theme — the runtime detects it and applies the tokens as CSS custom properties. |
| i18n config | $i18n = i18n({ … }) | Configures internationalization — messages, locale, fallback. |
| HTTP defaults | $http = Http({ … }) | Optional response-wide defaults (base URL, headers, retry). |
| Comment | # text | Whole-line comment; ignored by the parser. |
Statements are line-oriented — newline terminates a
statement. Never use semicolons or statement-level commas.
Expressions may span multiple lines as long as they sit inside an
unmatched [, (, or {.
Expressions
- Literals — strings (
"hello"or'hello'), numbers (42,3.14,1_000_000), booleans (true/false),null. - Template literals — backticks with
${expr}interpolation:`Hi ${$user.name}, you have ${@Count($todos)} todos`. - Arrays —
[1, 2, 3],[Card1(), Card2()], multi-line OK. - Object literals —
{ name: "Ada", role: "Engineer" }. Commas optional between rows on separate lines. - References —
name,$reactive,_route_. - Member access —
$user.email,$orders.data[0].id. Optional chaining:$user?.profile?.name. - Spread —
[...$a, ...$b],{ ...$cur, status: "done" }. - Function calls — component calls (
Card([…])), built-in helpers (http({…}),_router_({…})), and@-builtins (@Format($n)). - Operators — arithmetic (
+ - * / %), comparison (== != < <= > >=), logical (&& || !), nullish coalescing (??), ternary (cond ? a : b). - Lambdas —
(args) => exprfor one-liners,(args) => { … }for multi-statement bodies.
# Arithmetic + ternary + reactive read
greeting = "Hi, " + ($user.name ?? "stranger") + "."
score = $hits * 10 + $bonus
status = $isActive ? "online" : "offline"
summary = `${@Count($orders)} ${@Plural(@Count($orders), "order", "orders")} this week`
Array shortcuts
$rows.length/"hi".length— element / character count.$rows.first/$rows.last— first or last element (nullif empty).- Array pluck —
$rows.titlereturns[row.title for each row]. Composes with tables (Col("Title", $rows.title)) and charts (PieChart($rows.label, $rows.value)).
Reactive state
Aktion has one reactive atom kind. There is no
$state, $persist, $session, or
$computed keyword. Every reactive cell is declared and
read with the same surface:
$count = 0
$user = { name: "Ada", role: "Engineer" }
$todos = []
$theme = "dark"
action increment() {
$count = $count + 1
}
Sigil contract
count(no sigil) is a plain binding — NOT tracked, NOT reactive.$count(with sigil) is a tracked atom — reading subscribes, writing notifies subscribers.
Assignment rules
- Render position (top-level bindings, component body output, prop values): assignment is forbidden. Use
$name = …declarations to seed. - Inside
action/effect/ lambda bodies:= += -= *= /= ??= ++ --are allowed against any$nameatom. - Nested writes require whole-object replacement. Direct
$user.name = "Alex"is rejected — spread instead:$user = { ...$user, name: "Alex" }. Arrays follow the same rule:$todos = [...$todos, item],$todos = @Filter($todos, "id", "!=", id).
Component-scoped state
A $name = value declared inside a
component body is per-instance. Two
UserCard siblings each have their own
$hover. Top-level $name declarations live
for the lifetime of the response.
Computed values
There is no dedicated $computed keyword. Just compute:
$cart = []
$total = @Sum($cart.price) # re-derives when $cart changes
$open = @Filter($todos, "done", "==", false)
Persistence
Persistence lives on the storage global (see
Built-in globals). Read and write directly:
$theme = storage.get("theme") ?? "light"
action toggleTheme() {
$theme = $theme == "light" ? "dark" : "light"
storage.set("theme", $theme)
}
HTTP — the network primitive
There is exactly one network primitive: http({ url, method, headers, body, query, … }).
Pass any fetch-compatible option plus a convenience
query object that is serialised into the URL. The result
is a reactive resource bag.
$orders = http({
url: "/api/users/" + $userId + "/orders",
method: "GET",
query: { limit: 5, status: "open" },
headers:{ "X-Tenant": $tenant }
})
Reactive resource shape
$orders.data # parsed response body (null until resolved)
$orders.error # null on success
$orders.status # HTTP status code, e.g. 200
$orders.loading # true while in-flight
$orders.headers # response headers as a plain object
$orders.lastUpdated # ms-epoch of last successful response
$orders.refetch() # re-issue the request
$orders.cancel() # abort in-flight request
Async helper
Use the Async helper component to branch on a resource's state:
_app_ = Async($orders,
loading: LoadingState("Loading orders…"),
error: ErrorState("Couldn't fetch orders"),
empty: EmptyState("No orders yet"),
data: Table([Col("Item", $orders.data.title), Col("Total", $orders.data.total, format: "currency")])
)
Mutations
Mutations look identical — fire from inside an action body and observe the resource:
action saveOrder(payload) {
$save = http({ url: "/api/orders", method: "POST", body: payload })
emit "assistant-message" { message: "Saved." }
}
Host-wide defaults
$http = Http({
baseUrl: "https://api.example.com",
headers: { "Accept": "application/json" },
timeout: 10000,
retry: { count: 2, backoff: "exponential" }
})
Components and lambdas
component UserCard(user, tone: "default") {
$hover = false
return Card([
Avatar(user.name, size: "md"),
Text(user.name, variant: "large-heavy"),
Text(user.role, tone: "muted"),
Badge(tone, tone: tone)
])
}
_app_ = Stack([
UserCard($alice), # positional arg
UserCard($bob, tone: "primary"), # named arg
UserCard(user: $carol, tone: "warning") # both named
])
- Components must end with an explicit
return Expr. - Defaults use
= expression(literal or computed in the component's scope). childrenis the implicit named slot — the trailing positional argument is delivered aschildreninside the body.
Local helpers — lambda form
Use a lambda binding for one-off helpers that don't need their own component:
priorityTone = (p) => match p { "high": "danger" "med": "warning" default: "muted" }
rowFor = (item) => Stack([Badge(item.label, tone: priorityTone(item.priority)), Text(item.title)])
list = for item in $items { rowFor(item) }
Actions
Actions are imperative blocks invoked from event-bearing components
(Button(action:), Button(onClick:), form
submits, …). They run in order; state writes batch into a
single re-render.
action addTask(title) {
$tasks = [...$tasks, { id: $tasks.length + 1, title: title, done: false }]
$newTaskTitle = ""
}
action submit() {
$save = http({ url: "/api/tasks", method: "POST", body: { title: $newTaskTitle } })
}
For the full body grammar and patterns, see the Actions guide.
Effects
Effects are declarative, anonymous side effects. Every
dependency lives in a single bracketed list right after the
effect keyword — there is no name and no
on keyword. Use them for polling, analytics,
hydration, and one-shot setup.
# Mount-once: run when the app first renders
effect [on:mount] {
$session = http({ url: "/api/session", method: "GET" })
}
# Reactive: re-run whenever $query changes (debounced 300ms)
effect [$query, debounce(300)] {
$results = http({ url: "/api/search", method: "GET", query: { q: $query } })
}
# Interval: run every 5 seconds
effect [on:every(5000)] {
$now = @Now()
}
# Empty list — equivalent to `effect [on:mount] { … }`
effect {
console.log("mounted")
}
A dependency entry is one of:
$atom— re-run when the named reactive atom changes (mixes multiple:[$a, $b]).on:mount— run once when the surrounding scope mounts.on:unmount— run once when it unmounts.on:every(ms)— re-run everymsmilliseconds.debounce(N)/throttle(N)— wrap the body with a trailing-edge rate limit.
The order inside the brackets does not matter. effect { … } with no brackets and effect [on:mount] { … } are equivalent.
Use cleanup(fn) inside an effect body (typically from js{}) to register teardown for intervals, listeners, or observers.
Control flow
All three control-flow keywords are expressions — they yield a value (or array of nodes) that can be assigned, passed as a prop, or returned from a component / action body.
if / else
banner = if $hasError { Banner("Something went wrong", tone: "danger") } else { null }
active = if $tab == "billing" { billingPanel } else { overviewPanel }
A trailing else is optional — without it an unmatched if evaluates to null (renders nothing).
match
panel = match $stage {
"draft": DraftView()
"review": ReviewView()
"shipped": ShippedView()
default: EmptyState("Pick a stage")
}
- Arms use
: valuelike object properties — not->. default:is the wildcard.- Arms can return arbitrary expressions, not just strings.
- Wrap an arm body in
{ … }to run a statement block (multiple state writes, ending in an optional last-expression result). To return a literal object from an arm, parenthesise it:"a": ({ y: 1 }).
for
rows = for item in $todos { TaskRow(item) }
rowsWithIndex = for (item, idx) in $todos { TaskRow(item, index: idx) }
for produces an array of nodes — assign it and
reference the binding from a container (Stack(rows),
Table([Col("Task", rows)])). The loop variable is
block-scoped, so a stale closure can never see the
wrong row.
Statement form inside action / effect bodies
action submit(payload) {
if !payload.email { return }
for tag in payload.tags { $tags = [...$tags, tag] }
match payload.kind {
"draft": { $drafts = [...$drafts, payload] }
default: { $records = [...$records, payload] }
}
}
Two-way binding
Two-way binding is implicit: pass a
$variable (or a member chain rooted at one) as the
value of an input prop and the runtime wires the change handler
automatically.
$search = ""
bar = SearchBar("q", placeholder: "Search…", value: $search)
list = for row in @Filter($rows, "title", "contains", $search) { ListItem(row.title) }
$tags = []
chips = TagInput("tags", value: $tags)
# Member chains rooted at a $variable bind deeply — the runtime rebuilds
# the root object immutably so subscribers always wake up.
$form = { email: "", remember: false }
emailIn = Input("email", value: $form.email)
remember = Switch("remember", value: $form.remember)
Any form control whose spec declares a primary value prop
participates: Input, TextArea,
Select, Combobox, MultiSelect,
Checkbox, CheckBoxGroup,
Switch, ToggleGroup, Slider,
NumberInput, DatePicker,
DateRangePicker, TimePicker,
DateTimePicker, SearchBar,
PinInput, PasswordInput,
TagInput, MentionInput,
MaskedInput, RichTextEditor,
CodeEditor, ColorPicker,
Rating (when interactive: true), and
Pagination.
Built-in @-functions
Builtins are pure, prefixed with @, and
never have side effects. Use them for data shaping, formatting, and
inline iteration. The runtime ships 50+; the catalogue below covers
what's available.
Data & aggregation
@Count(arr),@Sum(arr),@Avg(arr),@Min(arr),@Max(arr).@First(arr),@Last(arr).@Filter(arr, field, op, value)/@FilterBy(...)—opis one of== != > < >= <=orcontains.@Find(arr, field, op, value),@Sort(arr, field, "asc"|"desc"),@GroupBy(arr, field).@Slice(arr, start?, end?),@Unique(arr, field?),@Reverse(arr),@Range(start, end, step?),@Repeat(value, count).@Pick(obj, ["a","b"])— keep only listed keys.
Formatting
@Format(value, mode?, options?)— locale-aware.mode:"number"(default),"currency","percent","compact".options:{currency?, locale?, decimals?}. Legacy positional shape (@Format(v, "currency", "USD")) still accepted.@FormatDate(value, format)— named modes ("relative","date","time","datetime","iso") or moment-like patterns ("MMM D","YYYY-MM-DD").@Plural(n, "order", "orders")—"1 order"/"2 orders".@Capitalize(s),@Lowercase(s),@Uppercase(s),@Titlecase(s),@Case(s, "camel"|"snake"|"kebab"|"pascal").@Join(arr, sep?),@Split(s, sep?),@Trim(s),@Replace(s, search, replacement?),@Substring(s, start, end?).@StartsWith(s, prefix),@EndsWith(s, suffix),@Contains(s, needle),@Match(s, pattern).
Date / time helpers
@Now()— current moment as epoch ms.@Today()— today's date at midnight, as an ISO string.@AddDays(date, n),@AddHours(date, n),@DiffDays(start, end).@StartOfWeek(date),@EndOfMonth(date).
Math & numeric utility
@Round(n, decimals?),@Floor(n),@Ceil(n),@Abs(n),@Clamp(n, min, max).@Pow(base, exp),@Sqrt(n),@Log(n),@Random().
Iteration & conditional fallbacks
The blessed surface is the expression-form
for / if / match covered
above. The @-form survives for tight inline expressions:
@Each(arr, "row", template)— loop with a named variable.@If(cond, trueBranch, falseBranch?)— lazy branch.@Switch(value, { overview: Node, billing: 'Invoice' }, default?)— key-based selector.
Built-in globals
Two namespace globals are always in scope — no import, no
declaration. Invoke them through the standard
obj.method(args) syntax. Named-arg options collapse into
a single trailing options object.
storage — browser storage
storage.set("name", "John") # alias of storage.local.set
$name = storage.get("name")
storage.remove("name")
storage.clear()
storage.session.set("draft", $draft) # per-tab sessionStorage
$draft = storage.session.get("draft")
storage.cookies.set("user", "John", expires: 7, path: "/", sameSite: "Lax")
$user = storage.cookies.get("user")
storage.cookies.remove("user", path: "/")
- Values that aren't strings round-trip through JSON; missing keys return
null. - Cookie options:
expires(days, Date, or ISO string),maxAge(seconds),path,domain,secure,sameSite.
console — host console forwarder
console.log("Hello", $user)
console.error("Failed", $error)
console.warn("Deprecated path")
console.info("Route changed", _route_.path)
console.debug({ days: $days, count: $count })
Routing — _router_({ … })
The router is a plain function call. It returns the matched arm's evaluated value:
pages = _router_({
"/": Dashboard(),
"/orders": OrdersPage(),
"/orders/:id": OrderDetail(id: params.id),
"/settings/*": SettingsArea(rest: params._),
default: NotFound()
})
_app_ = AppShell(MainSidebar(), pages)
See the Routing guide for path patterns, params, query strings, and NavLink.
Internationalization
$locale = storage.get("locale") ?? "en"
$bundle = http({ url: "/i18n/" + $locale + ".json", method: "GET" })
$i18n = i18n({
locale: $locale,
messages: $bundle.data ?? {},
fallback: "en"
})
Text(t("orders.title")) # "Orders"
Text(t("orders.greeting", { name: $userName })) # "Welcome back, Alex"
t(key, vars?)looks up the translation by dot-pathed key with${name}interpolation.Locale()returns the active locale tag.- Formatting builtins (
@Format,@FormatDate) consultLocale()automatically.
JavaScript escape hatch — js{ … }
js{ /* opaque JS body */ } runs raw JavaScript inside
an effect, action, or lambda body. Use
sparingly — every other surface is preferred — but it is
always available for browser APIs not exposed natively (clipboard,
keyboard listeners, IntersectionObserver, audio, custom DOM work).
See the JavaScript interactions guide for the full ctx surface.
Markup escape hatches — HTMLTag & Styles
When the standard component catalogue cannot express the markup or styling you need, two last-resort components are available. Reach for them only after confirming nothing in the standard library captures the design — the dedicated components produce a more consistent UI for fewer tokens.
-
HTMLTag(tag, attributes?, children?)renders an allow-listed HTML tag with the supplied attribute object and child nodes. Tag names outside the allow-list collapse todiv;on*attributes,javascript:URLs inhref/src, and unsafestylepatterns (expression(),@import, …) are stripped. -
Styles(css)injects a<style>block whose CSS targets your own selectors. Payloads containing</style>,<script>,expression(,javascript:,behavior:, or@importare dropped.
_app_ = Stack([
Styles(`
.hero-callout { background: linear-gradient(135deg, #6366f1, #10b981); color: white; padding: 24px; border-radius: 12px; }
.hero-callout h2 { margin: 0 0 8px; }
`),
HTMLTag("div", attributes: { class: "hero-callout" }, children: [
HTMLTag("h2", children: [Text("Custom block")]),
Text("Use HTMLTag + Styles only when the standard components cannot capture the design.")
])
])
In-script theming
Assign a Theme({ … }) call to a top-level binding
named theme — the runtime detects that exact name
and applies the tokens as CSS custom properties on the host:
theme = Theme({
colors: {
primary: "#635bff",
bg: "#0a0a23",
surface: "#10103a",
text: "#ffffff"
},
radius: { button: "999px" },
font: { family: "'Inter', sans-serif" }
})
_app_ = AppShell(...)
The top-level keys must be one of colors, radius, font, motion, elevation (plus the metadata keys name and direction). See the Themes guide for the full token taxonomy.
Icons (Font Awesome)
Icon-typed props accept a Font Awesome name as a string. The host element auto-loads the Font Awesome stylesheet — no setup needed.
- Format:
"name"(defaults to the solid set), e.g."house","chart-line","star","circle-check". - Variants: prefix with
"regular:name"(outline set) or"brands:name"(brand logos). - Never emit emoji characters in
iconprops. - Use the
Icon(name, variant?, size?)component to render an icon inline.
Comments & whitespace
- Whole-line comments start with
#. - End-of-line comments are not supported — put comments on their own line.
- Indentation is decorative; braces and brackets drive parsing.
- Blank lines are ignored.
Hoisting & streaming
Aktion supports hoisting: a reference can be used before it is defined. The renderer re-parses the program on every streamed chunk and silently treats unresolved references as empty, so a partially-streamed response renders progressively from the top.
Required statement order for streaming-friendly output:
_app_ = ...— emit this FIRST so the UI shell appears immediately.component/action/effectdeclarations — fill in layout & behaviour.- Leaf data values (strings, numbers, arrays, objects) — last.
Error tolerance
- Lines that fail to parse are dropped with a diagnostic.
- References to missing bindings render as empty (or a small placeholder when
showerrorsis enabled). - Runtime errors inside actions and effects are caught; the rest of the program continues.
Next
Actions
The full body grammar: state writes, HTTP, navigation, emit, JS escape hatch.
Read the guide → NavigationRouting
Hash-based multi-page UIs via _router_ and NavLink.
Component reference
Every built-in component, prop, and enum the LLM can reach for.
Browse library →