# UJSX v2: TypeBox-Schema-Driven Universal JSX IR
## What Exists: The Current POC
The `/workspace/aui` codebase has two packages:
- **`@ade/ujsx`** — Core universal JSX IR: `h()` factory, `UniversalElement`/`RootElement` types, `HostConfig` reconciler, `TransformRegistry`, `StreamingTransformer`
- **`@ade/aui`** — Markdown consumer: `jsxToMdast` transform rules, `renderMarkdown()` via mdast-util-to-markdown + GFM
The architecture is already right at a high level:
```
JSX (h calls) → UniversalNode tree → Host/Transform → Output
```
## What's Wrong
1. **No actual JSX syntax** — Uses `h()` calls instead of `` syntax. Needs a JSX transform config (like Hono's `tsconfig.json` jsxFactory) so you can write real JSX.
2. **HTML baggage** — `UniversalProps` has `onClick`, `onSubmit`, `onInput`, `onChange`, `className`, `aria-*`, `data-*` — all HTML-specific. For a universal IR targeting markdown (and eventually other formats), these are noise.
3. **No schema-driven types** — Types are hand-written interfaces. The whole point of using TypeBox is that the IR *is* a schema — nodes can be validated at runtime, composed generically, and converted to JSON Schema for tool definitions.
4. **Non-deterministic IDs** — `genId()` uses `Math.random()`. Needs to be injectable/deterministic.
5. **Separate props + children** — `UniversalElement` has both `props` and `children`. This is the React/HTML model where `children` is a special prop. For a universal IR, children is just a prop like any other. Simplify.
6. **No Context system** — React's Context/Provider pattern is essential for passing density/target/host config down the tree without prop drilling.
7. **Fragment not integrated** — Exists but reconciler and transforms don't handle it specially.
8. **Missing markdown nodes** — No blockquote, link, image, hr, table support.
## The Rewrite: TypeBox-Schema-Driven UJSX
### Core Principle
**The UniversalElement IS a TypeBox schema at runtime.** Every node in the tree is both a valid TypeScript value AND a valid JSON Schema value. This means:
- Runtime validation: `Value.Check(UElementSchema, node)`
- Tool schema generation: Convert any component's props schema to JSON Schema for agent tool definitions
- Generic composition: `Type.Intersect()`, `Type.Partial()`, `Type.Omit()` work on node schemas
- Bi-directional transforms: Rules match on schema shape, not string tags
### TypeBox AST Schema
```typescript
import { Type, Static, TSchema } from "@alkdev/typebox"
// The recursive node schema — the heart of the system
const UNodeSchema = Type.Recursive((This) =>
Type.Union([
Type.Null(),
Type.String(),
Type.Number(),
Type.Boolean(),
Type.Object({
type: Type.Union([Type.String(), Type.Function([Type.Object({}), This], This)]),
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(This),
}),
]),
{ $id: "UNode" }
)
type UNode = Static
// Refined: element is the object branch
const UElementSchema = Type.Object({
type: Type.Union([Type.String(), Type.Function([Type.Object({}), UNodeSchema], UNodeSchema)]),
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(UNodeSchema),
})
type UElement = Static
```
Wait — `Type.Function` in TypeBox creates a JavaScript extended schema type, not a JSON Schema one. For the IR's purposes, we want function components to be part of the *runtime* TypeScript type but NOT part of the serializable schema. The schema (what gets validated and what tools see) should only have string types.
Better approach: separate the runtime type from the serializable schema:
```typescript
// Serializable schema — what validation and tool definitions see
const USerializableElementSchema = Type.Recursive((This) =>
Type.Object({
type: Type.String(), // Always a string after component resolution
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(Type.Union([
Type.String(),
Type.Number(),
Type.Null(),
This,
])),
}),
{ $id: "UElement" }
)
// Runtime TypeScript type — includes function components
type UType = string | ComponentFn
type UNode = UElement | string | number | null
type UElement = {
type: UType
props: Record
children: UNode[]
}
```
Actually, let me think about this differently. The key insight from the user's message is **bi-directional transforms**. The current `TransformRegistry` goes one direction (UJSX → mdast). The universal IR concept means rules should go both ways:
- UJSX → mdast (for markdown rendering)
- mdast → UJSX (for parsing markdown back into the IR)
- UJSX → hast (for HTML rendering)
- hast → UJSX (for parsing HTML)
- Eventually: UJSX → Graphology, UJSX → terminal ANSI, etc.
This is where TypeBox schemas really shine: **each node type's schema IS the match criterion**. Instead of string-based matching (`n.type === 'h1'`), rules match on schema shape:
```typescript
// Current: string-based matching
jsxToMdast.register({
name: 'heading',
match: (n) => isUniversalElement(n) && typeof n.type === 'string' && /^h[1-6]$/.test(n.type),
transform: ...
})
// TypeBox-driven: schema-based matching
const HeadingSchema = Type.Object({
type: Type.String({ pattern: '^h[1-6]$' }),
props: Type.Object({ level: Type.Number().optional() }),
children: Type.Array(UNodeSchema),
})
rule({
name: 'heading',
schema: HeadingSchema,
direction: 'ujsx->mdast',
transform: (node, ctx, next) => ({
type: 'heading',
depth: Number(node.type.slice(1)),
children: transformChildren(node, ctx, next),
}),
})
```
This is more verbose but gives us:
1. `Value.Check(HeadingSchema, node)` — validate before transforming
2. The schema can be registered as a tool parameter schema
3. Bi-directional: a `mdast->ujsx` rule for heading uses the same schema
4. `Type.Intersect()` to compose schemas for compound elements
### Simplified Element Model
Remove HTML-specific stuff. The `props` becomes a plain `Record` with no HTML-centric defaults. Event handlers (`onClick`, etc.) and HTML attributes (`className`, `aria-*`) are gone from the core — they belong in an HTML host extension, not the universal IR.
```typescript
// core/schema.ts
import { Type, Static } from "@alkdev/typebox"
// Primitives
const UPrimitive = Type.Union([Type.String(), Type.Number(), Type.Null()])
// Element — the universal IR node
const UElement = Type.Recursive((This) =>
Type.Object({
type: Type.String(),
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(Type.Union([UPrimitive, This])),
}, { $id: "UElement" }),
)
// Root — session/frame container
const URoot = Type.Object({
type: Type.Literal("root"),
props: Type.Object({
id: Type.Optional(Type.String()),
target: Type.Optional(Type.String()),
}),
children: Type.Array(Type.Union([UPrimitive, UElement])),
}, { $id: "URoot" })
// Node = everything a position in the tree can hold
const UNode = Type.Union([UPrimitive, UElement, URoot], { $id: "UNode" })
type UElement = Static
type URoot = Static
type UNode = Static
```
### The `h()` Factory (Cleaned Up)
```typescript
// core/h.ts
import type { UNode, UElement, URoot } from './schema.ts'
let _idCounter = 0
export function h(
type: string,
props?: Record | null,
...children: UNode[]
): UElement {
return {
type,
props: props ?? {},
children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[],
}
}
export function createRoot(id?: string, ...children: UNode[]): URoot {
return {
type: 'root',
props: { id },
children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[],
}
}
// Alias for JSX transform compatibility
export const jsx = h
export const jsxs = h
export const jsxDEV = h
// Fragment: children pass-through
export function Fragment(props: { children?: UNode[] }): UNode[] {
return (props.children ?? []).flat(Infinity).filter(c => c != null && c !== false) as UNode[]
}
```
Key changes from current:
- **No metadata injection** — `timestamp` and `id` on every element was overhead without clear use. If needed, it belongs in the host, not the IR.
- **No `className`/event handlers** — Universal props, not HTML props.
- **Deterministic by default** — No `Math.random()`.
### JSX Transform Config
To support actual JSX syntax (``), you need a `tsconfig.json` or `deno.json` with:
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@ade/ujsx"
}
}
```
And an `jsx-runtime` module that exports `jsx`, `jsxs`, `jsxDEV`, `Fragment`. This is what Hono does (`hono/jsx/jsx-runtime`).
### Host System (Preserved, Simplified)
The `HostConfig` interface is good. Keep it but remove the HTML-specific assumptions:
```typescript
// host/config.ts
export interface HostConfig {
name: string
createRootContext(container: unknown, options?: Record): RootCtx
finalizeRoot?(ctx: RootCtx): void
createInstance(tag: TTag, props: Record, ctx: RootCtx, parent?: Instance): Instance
createTextInstance(text: string, ctx: RootCtx, parent?: Instance): Instance
appendChild(parent: Instance, child: Instance, ctx: RootCtx): void
insertBefore?(parent: Instance, child: Instance, before: Instance, ctx: RootCtx): void
removeChild?(parent: Instance, child: Instance, ctx: RootCtx): void
prepareUpdate?(instance: Instance, tag: TTag, prevProps: Record, nextProps: Record, ctx: RootCtx): unknown | null
commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record, nextProps: Record, ctx: RootCtx): void
}
```
No changes needed. The host is host-agnostic already. The graphology host stays. A markdown host can be added:
```typescript
// host/markdown.ts
export function createMarkdownHost(): HostConfig {
// Maps universal tags to mdast node creation
// Uses the same rules as the TransformRegistry but via HostConfig
}
```
### TypeBox-Driven Transform Rules
The key evolution of the `TransformRegistry`. Rules are now schema-aware and bi-directional:
```typescript
// transform/rule.ts
import { Type, TSchema, Value } from "@alkdev/typebox"
export type Direction = 'ujsx->mdast' | 'mdast->ujsx' | 'ujsx->hast' | 'hast->ujsx'
export interface TransformRule {
name: string
direction: Direction
schema: TSchema // The TypeBox schema this rule matches against
match: (node: TInput) => boolean // Runtime match (can use schema or custom logic)
transform: (node: TInput, ctx: TransformContext, next: TransformFn) => TOutput
priority?: number
}
export class TransformRegistry {
private rules: TransformRule[] = []
register(rule: TransformRule) {
this.rules.push(rule)
this.rules.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
}
transform(node: TInput, ctx: TransformContext, direction?: Direction): TOutput {
const rule = direction
? this.rules.find(r => r.direction === direction && r.match(node))
: this.rules.find(r => r.match(node))
if (!rule) throw new Error(`No rule for node`)
return rule.transform(node, ctx, (n, c) => this.transform(n, c, direction))
}
}
```
With schema-based matching, we can also do:
```typescript
// Validate a node against a rule's schema before transforming
function matchesSchema(schema: TSchema, node: unknown): boolean {
return Value.Check(schema, node)
}
```
### Bi-Directional Markdown Rules
```typescript
// markdown/rules.ts
import { Type } from "@alkdev/typebox"
const HeadingUSchema = Type.Object({
type: Type.String({ pattern: '^h[1-6]$' }),
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(Type.Any()),
})
const HeadingMdastSchema = Type.Object({
type: Type.Literal('heading'),
depth: Type.Number(),
children: Type.Array(Type.Any()),
})
// UJSX → mdast
registry.register({
name: 'heading-ujsx-to-mdast',
direction: 'ujsx->mdast',
schema: HeadingUSchema,
match: (n) => isUElement(n) && /^h[1-6]$/.test(String(n.type)),
transform: (n, ctx, next) => ({
type: 'heading',
depth: Number(String(n.type).slice(1)),
children: transformChildren(n, ctx, next),
}),
})
// mdast → UJSX
registry.register({
name: 'heading-mdast-to-ujsx',
direction: 'mdast->ujsx',
schema: HeadingMdastSchema,
match: (n) => n.type === 'heading',
transform: (n, ctx, next) => ({
type: `h${n.depth}`,
props: {},
children: (n.children || []).map((c, i) => next(c, childCtx(n, ctx, i))),
}),
})
```
This is the bi-directional concept. Same registry, same `transform()` call, different `direction` parameter. The schemas provide the contract for each direction.
### Component Schema = Tool Schema
The real payoff of TypeBox-driven schemas: any component's props schema is automatically a tool parameter schema.
```typescript
// A HUD component defined with TypeBox
const TaskSectionProps = Type.Object({
task: Type.Object({
description: Type.String(),
updatedAt: Type.Number(),
}),
density: Type.Union([Type.Literal('full'), Type.Literal('compact'), Type.Literal('minimal')]),
})
// The component
function TaskSection(props: Static): UElement {
if (props.density === 'minimal') {
return h('p', null, props.task.description)
}
return h('div', null,
h('h2', null, 'Task'),
h('p', null, props.task.description),
)
}
// TaskSectionProps IS the schema for an agent tool that sets the task:
// hud({ tool: "task.set", args: { description: "...", updatedAt: Date.now() } })
// The tool's inputSchema = TaskSectionProps
```
No duplication. The component's type IS the tool's schema. `Value.Check(TaskSectionProps, input)` validates both.
### Context System
Needed for passing density/target config down the tree without prop drilling:
```typescript
// core/context.ts
export interface ContextValue {
density: 'full' | 'compact' | 'minimal'
target: string // 'markdown' | 'graph' | 'html' | etc.
metadata: Record
}
// Simple context implementation (no React dependency)
type ContextListener = (value: ContextValue) => void
export class Context {
private value: ContextValue
private listeners: Set = new Set()
constructor(initial: ContextValue) {
this.value = initial
}
get(): ContextValue { return this.value }
set(partial: Partial) {
this.value = { ...this.value, ...partial }
for (const fn of this.listeners) fn(this.value)
}
subscribe(fn: ContextListener): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
}
```
This is lightweight and doesn't need React. Components receive context via the `host` or `transform` path.
### File Structure (Proposed Rewrite)
```
ujsx/
mod.ts # Re-exports
deno.json # JSX transform config
core/
schema.ts # TypeBox schemas for UNode, UElement, URoot
h.ts # h() factory, createRoot(), Fragment
context.ts # Context system for density/target
jsx-runtime.ts # JSX transform runtime exports
host/
config.ts # HostConfig interface + createRoot reconciler (preserved)
graphology.ts # Graphology host (preserved, fix commented-out append)
markdown.ts # NEW: Markdown host using HostConfig pattern
transform/
registry.ts # Enhanced TransformRegistry with direction + schema
rule.ts # TransformRule type with TSchema
streaming/
transformer.ts # Chunk-based async transformer (preserved)
aui/
mod.ts
deno.json
markdown/
render.ts # renderMarkdown() (preserved, uses new registry)
rules.ts # Bi-directional rules with TypeBox schemas
types.ts # mdast type re-exports
components/ # NEW: Pre-built markdown-targeted components
container.ts #
context-bar.ts # with density awareness
heading.ts #
list.ts # , ,
code.ts # ,
blockquote.ts # NEW:
link.ts # NEW:
table.ts # NEW:
hr.ts # NEW:
hud/ # NEW: HUD-specific components
HUD.tsx # Root HUD component
TaskSection.tsx # Task display
DecisionsList.tsx # Key decisions
NotesSection.tsx # Scratchpad
NextSteps.tsx # Plan/next steps
ActiveFiles.tsx # Currently active files
WarningBanner.tsx # Context pressure warning
```
### tsconfig for JSX Syntax
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@ade/ujsx"
}
}
```
With this, you write:
```tsx
import { h } from "@ade/ujsx"
function TaskSection({ task, density }: TaskSectionProps) {
if (density === 'minimal') {
return
{task.description}
}
return (
Task
{task.description}
)
}
```
Instead of:
```typescript
function TaskSection({ task, density }: TaskSectionProps) {
if (density === 'minimal') {
return h('p', null, task.description)
}
return h('div', null,
h('h2', null, 'Task'),
h('p', null, task.description),
)
}
```
### Migration Steps
1. **Replace types.ts with schema.ts** — TypeBox schemas for UNode/UElement/URoot. Export both schemas and `Static<>` types.
2. **Clean up h.ts** — Remove metadata injection, HTML-specific props, `Math.random()`. Add `jsx-runtime.ts` export.
3. **Add deno.json jsxImportSource** — Enable JSX syntax.
4. **Strip UniversalProps** — Replace with plain `Record`. No `onClick`, `className`, etc.
5. **Upgrade TransformRegistry** — Add `direction` and `schema` fields to rules.
6. **Add missing markdown rules** — blockquote, link, image, hr, table.
7. **Add Context** — Simple context system for density/target.
8. **Fix graphology host** — Uncomment append logic, replace `JSON.stringify` diffing.
9. **Add Markdown host** — `createMarkdownHost()` using HostConfig.
10. **Add bi-directional rules** — mdast → ujsx direction for each element.
11. **Build HUD components** — The `aui/hud/` directory with density-aware components.
### What This Enables for the Agent HUD
With the cleaned-up UJSX:
- Components ARE schemas — any HUD component's props schema is automatically a tool parameter schema
- Bi-directional transforms mean we can parse markdown INTO the IR (parse existing agent notes, markdown files, AGENTS.md)
- The host system means the same HUD component tree could render to markdown for LLM consumption OR to a graph for visualization OR to HTML for a web dashboard
- TypeBox validation means we can validate HUD events against component schemas before accepting them
- The Context system carries density/target through the component tree — clean and no prop drilling