Tu — Agent Skill
Audience: LLM agents (Claude, GPT, etc.) that need to write or reason about Tu source code. This page is written for ingestion by automated tools — copy-paste it into a system prompt, save it as
.claude/skills/tu/SKILL.md, orfetchit programmatically. A plain-text version is mirrored at/llms.txt.
Identity (the one-liner you need to keep in mind)
Tu is a reactive UI language that compiles to JS/TS. It is JS-superset-with-types-via-TS in spirit — most JS expression-level constructs work, types come from TypeScript (Volar pattern), reactivity comes from TC39 Signals. The grammar converges JS, never collides with active TC39 proposals.
The compiler maps Tu source to TS shadow files; tsserver does the type checking. The universal runtime provides Signals, vnodes, and SSR helpers; browser DOM glue lives in @tu-lang/dom. There is no virtual machine — it’s vanilla JS at runtime, with Signal.State / Signal.Computed cells as the main library-level primitive.
Mental model order when reading Tu:
- Top-level
let— module-private binding. Auto-binds to aSignal.Statecell unless the value is a() => …lambda (then it’s a plain const) orcomputed(...)(thenSignal.Computed). - Lambdas are components — capitalized lambda → component callable as
Foo()orFoo() { children }. Lowercase → HTML tag-call (in markup position) or plain function (in expression position). { … }after a callee = children block — the trailing-closure DSL. Each child is whitespace-separated; no;or,between children.- Markup, props, and style live in one syntax, top-to-bottom.
File anatomy
// 1. Imports. Sources end in .tu (cross-Tu) or are bare (npm packages).
import { Fragment } from "@tu-lang/runtime"
import { type } from "@tu-lang/std"
import { Card } from "./Card.tu"
// 2. Runtime-visible object and error shapes.
interface Point { x: number; y: number }
Exception ValidationError { field: string }
// 3. Module-private cell (top-level `let` → Signal.State<number>).
let count = 0
// 4. Public cell (export → consumers can import the cell).
export let origin: Point = { x: 0, y: 0 }
// 5. Computed cell (re-derives when its read-cells mutate).
export let doubled = computed(count * 2)
// 6. Components and errors compose with normal control flow.
let parsePoint = (raw: unknown): Point ? ValidationError => {
if (type.is(raw, Point)) { raw }
else { throw ValidationError("Invalid point", { field: "point" }) }
}
// 7. Component (capitalized lambda; not wrapped in a Signal cell).
export let App = (children: Child[]) => .panel() {
h1 { "count = " count " (doubled = " doubled ")" }
button(onClick: () => count = count + 1) { "+1" }
children
style {
.panel { font-family: system-ui, sans-serif; padding: 1rem; }
.panel > h1 { color: #312e81; }
}
}
Bindings
let X = value
Module-private.
- Value is a primitive / object literal / array literal /
[…]/ call result →let X = …compiles toconst X = new Signal.State(…). - Value is
(args) => body→ plainconst X = (args) => body. Not wrapped. - Value is
computed(expr)→const X = new Signal.Computed(() => expr).
let count = 0 // Signal.State<number>
let make = (n) => n * 2 // plain function, no cell
let doubled = computed(count * 2) // Signal.Computed<number>
export let X = value
Public — appears in the module’s named exports. Same wrapping rules as bare let.
Annotated bindings
let count: number = 0 // Signal.State<number>
let names: string[] = [] // Signal.State<string[]>
let snap: Point = { x: 0, y: 0 } // Signal.State<Point>
let cell: Signal.State<MyT> = … // user opts out of double-wrap; the
// compiler honors a pre-wrapped Signal.* type
The annotation is a raw source slice (depth-tracked across (), {}, [], <…>). The TS-mode emit threads it through verbatim; JS-mode strips it.
Local let (inside a block)
let App = () => {
let greeting = "Hello, " + name + "!"
p { greeting }
}
A local let is a plain const, not a Signal cell. It exists for closures, derived values, and small locals. Block bodies with one or more lets compile to an IIFE.
Interfaces, aliases, enums, and Exceptions
interface Point { x: number; y: number }
type RGB = readonly [number, number, number]
enum Tone { Neutral, Accent = "accent" }
Exception ValidationError { field: string }
export interface AppProps { children?: Child[] }
Use interface for object shapes, especially component props and exported data. Interfaces emit both a TS type and a runtime descriptor, so type.is(value, Point) performs a structural check and narrows in the LSP/TS shadow. Use type for erased aliases such as tuples or ad hoc unions; the RHS is captured verbatim and emitted into the TS shadow. Use Exception for structured errors: it creates an Error-compatible factory with a default message: string, optional custom fields, stack capture, and a descriptor usable with type.is.
Values
Literals
"a string" // StringLit (escapes: \n \t \r \" \\)
42 // NumberLit (integers only at lexer level; decimals work via JS)
[1, 2, 3] // ArrayLit
[] // empty ArrayLit (Signal.State<any[]> auto-widened)
{ x: 1, y: 2 } // ObjectLit
{ "data-id": 7 } // ObjectLit with string key
{} // EMPTY OBJECT — not an empty block
{ is disambiguated against the block form by lookahead: { }, { Ident :, { String :, { [expr] :, or { ...expr } triggers an ObjectLit. Anything else ({ x }, { let y = 1; y }, { tag(...) }) stays a Block.
Not yet supported (don’t emit these — see the Deferred backlog):
- Object shorthand:
{ x }— write{ x: x }
Supported modern object/array forms:
{ [key]: value }
{ ...base, x: 1 }
[...items, next]
Identifiers + member access
count // bare ident — reads the binding
origin.x // member access (postfix)
make(n).field // member access on a call result
nested.outer.inner // chained
Member access only works on value-yielding expressions — Ident, plain CallExpr (no children block), existing MemberExpr, ObjectLit, ArrayLit. It does not work after a TagCall / IfExpr / Block / lambda body. This rule prevents div { x }\n.body() { y } from re-parsing as (div{x}).body(){y}.
Lambdas
(x) => x + 1
(x: number) => x + 1
(name: string, age: number) => p { name }
() => p { "hi" }
(x: number): string => "ok" // return-type annotation
(): Map<string, { v: number }> => empty // generics + nested types OK
Param types and return types are raw slices — preserved in TS mode, erased in JS mode. The body is any expression (including a Block, IfExpr, ForExpr, TagCall, ObjectLit, …).
Calls
foo(arg, another) // CallExpr — positional args
make({ x: 1 }) // arg can be any expression
Identifiers followed by ( and positional args (no Ident: immediately inside) parse as call expressions. The result is whatever the function returns.
Blocks
{
someStmt
anotherStmt
finalExpr // value of the block
}
Each item is parsed as an expression (or a LocalLet). The last non-LocalLet expression is the block’s value. Multi-statement blocks compile to an IIFE; single-statement blocks compile to (stmt). Note: {} is an empty object literal, not an empty block — write { null } if you want a block that evaluates to an intentional empty value.
Markup (tag-calls)
Trailing-closure DSL. Capitalization is the discriminator (mirrors React/JSX):
- Lowercase identifier →
h("tag", props, children)— an HTML element. - Uppercase identifier →
Callee(props)— a real component function call. Named props and trailing children are merged into one props object.
Bare tag with children
div { "Hello" } → h("div", {}, ["Hello"])
h1 { "title" p { "body" } } → h("h1", {}, ["title", h("p", {}, ["body"])])
Children are whitespace-separated, NOT comma-separated. Newlines / spaces between them are insignificant.
Tag with named props
div(class: "card", id: "main") { … }
button(onClick: () => count = count + 1) { "+1" }
input(type: "text", value: name)
Props are name: value pairs separated by ,. Values can be any expression — strings, idents (cell reads inject .get()), lambdas, ObjectLits, ClassRefs, etc.
Component invocation
Card(title: "title") → Card({ "title": "title" })
Card(title: "title") { p { "body" } }
→ Card({ "title": "title", "children": [h("p", {}, ["body"])] })
Components are real functions. tsserver sees them as such — hover, goto-definition, and rename all work cross-.tu. The trailing children block becomes props.children, conventionally typed as children?: Child[].
Fragment (multi-root return)
import { Fragment } from "@tu-lang/runtime"
let App = () => Fragment {
header { … }
main { … }
footer { … }
}
Fragment is a built-in helper that takes the children array and returns it as-is, letting a component return multiple sibling vnodes without an enclosing wrapper.
Pug-style class shorthand
.card // ClassRef (used as a value, e.g. class: .card)
.card.elevated // multi-class binding
.card() { "x" } → div(class: "card …") { "x" }
.card.elevated() { "x" } → div(class: "card elevated …") { "x" }
.card(tag: "section") { "x" } // override default tag with a string literal
Pug-shorthand desugars to a div (or the tag: override) with the listed classes injected. An explicit class: prop in shorthand-position is a parse error — the shorthand already binds class.
Children types
A child can be: TagCall, CallExpr, BinaryExpr, StringLit, NumberLit, Ident, IfExpr, ForExpr, StyleBlock, ClassRef, ArrayLit, ObjectLit, MemberExpr.
A child cannot be: Lambda, Block, AssignExpr (these throw at parse time).
Control flow
if / else
if (count > 0) { p { "positive: " count } }
else if (count == 0) { p { "zero" } }
else { p { "negative" } }
The condition is parenthesized; both branches are blocks. Else-if chains are supported as nested IfExpr. if is an expression — its value is the chosen branch’s block-value.
for
for item in items {
li { item }
}
Compiles roughly to Array.from(items, (item) => …). The iterable’s tail { … } is the loop body, not a tag-call on the iterable (the parser suppresses brace-block parsing inside the iter expression for exactly this reason).
try / catch / throw
Exception ValidationError { field: string }
let loadUser = (raw: unknown): User ? ValidationError => {
if (type.is(raw, User)) { raw }
else { throw ValidationError("Invalid user", { field: "user" }) }
}
try {
loadUser(input)
} catch if ValidationError as e {
"ValidationError on " + e.field + ": " + e.message
} catch e {
"Error: " + e.message
} finally {
cleanup()
}
Prefer catch if SomeError as e for structured Tu exceptions. The binding is narrowed inside that block. A plain catch e is the fallback branch; e defaults to Error-like fields, including message: string.
Modern JS expression forms
Tu intentionally supports common JS expression syntax when it does not collide with the language grammar:
`Hello ${name}`
user?.profile?.name ?? "Anonymous"
items[index]
{ [field]: value, ...base }
[...items, next]
total += 1
await fetchUser(id)
import("./plugin.tu")
Still banned in Tu source: instanceof (use type.is(value, T)), value-position undefined (use null), ternary ?:, ++ / --, user-defined class, and raw function.
Reactivity
- Top-level
let X = …(non-lambda, non-computed(…)) →Signal.State<T>. Reads ofXinside any expression context emit asX.get(). AssignmentsX = exprdesugar toX.set(expr). let X = computed(expr)→Signal.Computed<T>whose body re-runs whenever any cell read insideexprmutates.- Local
let(inside a block) is a plain const; reads/writes pass through unchanged. - Lambda params are plain idents (no
.get()injection). mount(thunk, container)re-runsthunkwhenever any cell it reads mutates.computed(...)cells lazily re-evaluate on read after invalidation.
The .get() injection rule: a bare ident emits as name.get() if and only if name resolves to a top-level state or computed cell and is not shadowed by a local let, lambda param, or for binder.
Style block
let Card = (title: string) => .card() {
h1(class: .card__title) { title }
p { "body" }
style {
.card { padding: 1rem; border-radius: 8px; }
.card__title { font-size: 1.25rem; }
:global(.legacy) { color: gray; } // unscoped escape hatch
}
}
style { … }is a special form (no parens). The body is raw CSS, preserved verbatim in the StyleBlock AST and emitted as a<style>sibling vnode.- Top-level CSS rules must be class-rooted (M5/D).
body { … }or* { … }at the top level is a compile error. Nested rules (.card > h1 { … }) are fine. - Scoped classes (M5/F dual-class injection): every
ClassRefin the markup gets BOTH the original name AND a per-component hashed name (<div class="card card-tu-XXX">). The CSS rewriter rewrites the selector to the hashed form (.card-tu-XXX { … }). Global selectors / dev-tools targeting.cardstill work, but.card’s rules don’t bleed across components. :global(.foo): escape hatch — selectors inside this wrapper stay unhashed.
ClassRef syntax
.card // bare ClassRef — used as a value
class: .card // assigned to the class prop
class: .card.elevated // multi-class space-joined
.card() { … } // pug-shorthand (see above)
A ClassRef to an undeclared class (one not declared in the enclosing component’s style { … } block) is a compile error.
Imports / exports
Named import
import { Card } from "./Card.tu" // cross-.tu (sibling)
import { Fragment } from "@tu-lang/runtime" // npm package
V1 supports named imports and default imports. Namespace imports remain deferred.
Re-export
export { Card } from "./Card.tu"
Cross-.tu reactivity
When you import a state/computed cell from another .tu, the importer’s codegen knows to inject .get() on reads. The compiler analyzes the imported module’s AST to classify each export’s CellKind.
Common gotchas (study these — they prevent bugs)
{}is an empty OBJECT, not an empty block. Write{ null }for an empty block.- Children are whitespace-separated. Don’t write
,between them:div { x, y }parses asdiv { (x, y) }which is not what you want. - No shorthand object props yet.
{ x }is a Block, not{ x: x }. Write the key explicitly. Computed keys, spread, and indexed access are supported. .foo()after a sibling expression is NOT a method call. It’s pug-shorthand for the next element.tag1 { x }\n.foo() { y }parses as two siblings, not one chained call. (Member accessobj.fooonly applies to value-yielding exprs.)- Capitalized names are components, lowercase are HTML tags.
Card { … }andcard { … }parse to entirely different things. - Style block top-level rules must be class-rooted. No
body,*,:rootat the top level (use:global(...)if you really need them). - An explicit
class:prop inside a pug-shorthand is an error. The shorthand already binds class. - No
match/ pattern matching. Removed in M1.11 due to TC39 Pattern Matching collision. Use chainedif / else if / else. - No
functionkeyword anywhere. All functions are arrow-style(args) => body. - No
classkeyword for OOP. Tu is immutable-by-default; user-defined types are functions, not classes. - No
instanceof. Usetype.is(value, InterfaceOrDescriptor)from@tu-lang/std; the LSP narrows after successful guards. - No member access through component-call children results.
make(n).xworks;Card(title: "hi") { … }.xdoes not (the second is a vnode, not a value).
Compilation model (high-level)
Each .tu file compiles to a single .js (or .ts shadow) module. The compiler:
- Tokenizes the source (lexer in
packages/compiler/src/lexer.ts). - Parses to AST (
parser.ts). All AST nodes carrystart/endbyte offsets for source maps + LSP. - Analyzes scoped components: every
let X = (...) => …whose body usesClassRefs gets a per-component hash (FNV-1a over name + style-body). Declared classes are extracted from the style block’s CSS via a regex scanner. - Generates JS/TS via a streaming buffer that records
TokenMappings as it emits. Top-level lets becomeconst X = new Signal.State(…)/Signal.Computed(…)/ plain const based on classification. Tag-calls becomeh("tag", props, children). Component calls stay as real function calls. Pug-shorthand desugars in the AST. ClassRefs emit hashed class strings. Style blocks emit as<style>vnode children with the CSS rewritten. - Source maps are V3, per-token + per-statement.
The universal runtime is @tu-lang/runtime — h(tag, props, children), renderToString(node), async SSR helpers, Fragment(children), Signal.State, Signal.Computed. Browser entry points live in @tu-lang/dom — mount(thunk, container), hydrate(thunk, container), and defineCustomElement(...). Standard descriptors, type.of, type.is, type.as, type.tryFrom, and time helpers live in @tu-lang/std. Mount drives a keyed diff (LIS-based reorder, focus / scroll / <input> value preserved). Routing lives in @tu-lang/router via createRouter, renderRoute, and renderRouteToStream; the router package source is Tu-native.
The VS Code extension and browser playground use the same @tu-lang/lsp shadow-graph path, so hover and diagnostics should match between local editor and live docs.
Testing pattern
Per-package tests live in packages/<name>/tests/. Compiler tests:
// packages/compiler/tests/parser.test.ts
import { describe, expect, it } from 'vitest'
import { tokenize } from '../src/lexer.js'
import { parse } from '../src/parser.js'
function ast(src: string) { return parse(tokenize(src), src) }
describe('parser', () => {
it('parses an export let with object literal', () => {
const tree = ast('export let p = { x: 1 }')
expect(tree.body[0]).toMatchObject({
kind: 'LetDecl',
exported: true,
name: 'p',
value: { kind: 'ObjectLit', properties: [{ key: 'x' }] },
})
})
})
Codegen tests assert on the emitted JS string. Integration tests (tests/integration.test.ts) compile a Tu source, write the result to a temp .mjs, dynamic-import it, and exercise the exported cells/components against renderToString / mount.
Deferred features (do NOT emit these)
See the full list at DEFERRED.md. Quick exclusions:
match/ pattern matching — removed in M1.11.- Generic syntax on Tu declarations.
- User-defined
class; use functions, interfaces, and enums instead. - Object shorthand
{ x }in Tu-authored object literals. - Per-component fine-grained HMR boundaries.
- Local reactivity (
letinside functions remains plain local state). - Lifecycle hooks and ref sugar.
- Qwik-style SSR resumability.
- HTML-section
awaitsugar andfor await.
When unsure: stick to the constructs documented above. The language deliberately surfaces a small, opinionated set; if a JS-side feature isn’t listed here, it probably isn’t in V1.
Quick reference: emit shapes
| Tu | JS emit |
|---|---|
let count = 0 |
const count = new Signal.State(0) |
let App = () => … |
const App = () => … (plain) |
let d = computed(c * 2) |
const d = new Signal.Computed(() => (c.get() * 2)) |
count = count + 1 |
count.set(count.get() + 1) |
div { x } |
h("div", {}, [x.get()]) |
Card(title: "hi") { p { y } } |
Card({ "title": "hi", "children": [h("p", {}, [y.get()])] }) |
.card() { x } |
h("div", { class: "card card-tu-XXX" }, [x.get()]) |
{ x: 1, y: 2 } |
{ x: 1, y: 2 } |
obj.x (cell) |
obj.get().x |
obj.x (param/local) |
obj.x |
for x in xs { … } |
Array.from(xs.get(), (x) => …) |
type.is(v, User) |
structural runtime check + TS/LSP narrowing |
catch if ValidationError as e { … } |
type.is-guarded catch branch |
catch e { … } |
fallback catch branch with Error-like e |
`hi ${name}` |
JS template literal |
{ ...base, [k]: v } |
JS object spread + computed key |
Related resources
- Language reference — every Tu syntactic form with examples and emit shapes.
- Deferred backlog — every “leave for later” decision, indexed by milestone.
- GitHub repository — source, examples, playground.
- llms.txt — plain-text mirror of this skill for direct fetch.
Tu is pre-alpha (0.1.0-alpha.8 on npm). This skill reflects the language as of 2026-05-08 after Tu-native router, shareable playground routes, shared browser/workspace LSP, filtered catch narrowing, runtime type metadata, and modern JS expression compatibility. When in doubt, read LANGUAGE.md for the canonical reference, and check the git log for the latest changes.