Skip to content

RFC 0001 — Janux: An Agent-Native Fullstack UI Framework #1

Description

@aralroca
Field Value
RFC 0001
Title Janux: An Agent-Native Fullstack UI Framework
Status Draft — Request for Comments
Date 2026-07-21

Abstract

Janux is a fullstack UI framework designed for a web with two first-class audiences: humans and AI agents. Its core primitive is the bifacial component — a single declarative definition that projects three synchronized surfaces:

  1. A view — what the human sees and interacts with.
  2. A resource — the component's typed state, readable and subscribable by agents (MCP resource).
  3. A set of intents — named, schema-typed actions invocable by a human click or an agent tool call through the exact same code path.

The framework is named after Janus, the two-faced Roman god of doorways: one face toward the human, one toward the agent, one threshold.

Janux is compiler-first (Vite plugin), ships 0 KB of JavaScript for static pages, achieves structural resumability (no hydration replay, no closure serialization — improving on Qwik's model), embeds Mastra as its server-side agent runtime and gui-agent as its client-side execution protocol, requires near-zero configuration for model/provider selection, and exposes server functions as typed RPC endpoints that are simultaneously agent tools.


1. Motivation

1.1 The dual-audience problem

Every production web UI today has two consumers, but frameworks acknowledge only one:

  • Humans get the rendered DOM: pixels, affordances, ARIA.
  • Agents get whatever they can scavenge: DOM scraping, brittle CSS selectors, screenshot-based vision models, or a hand-maintained MCP tool layer that lives beside the UI and inevitably drifts from it.

Teams building agentic copilots over existing UIs (browser tools, ui_* client tools, DOM automation) consistently hit the same failure modes:

  1. Drift. The MCP tool says the button exists; the component was refactored last sprint. Two sources of truth, maintained by hand, desynchronize by construction.
  2. Opacity. useEffect closures are invisible. An agent cannot know that a component syncs to the server on every keystroke, or when that sync has finished. The result is sleep(500) polling and flaky automation.
  3. Selector fragility. Agents address the UI by CSS selector or accessibility-tree heuristics — coordinates into an artifact that was never designed to be addressed.
  4. Security as an afterthought. Human-in-the-loop confirmation is bolted on per-tool, per-app, with no framework-level primitive.

1.2 The framework-shaped hole

Existing approaches each solve a fragment:

Approach What it solves What it misses
React/Vue/Svelte Declarative views Agents entirely; effects are opaque closures
Qwik Resumability, lazy JS Agents; serialization complexity; interaction waterfalls
Astro Islands, multi-framework views Agents; islands are opaque; content-site-first
HTMX Server-driven simplicity Typed agent surface; rich client state
WebMCP / MCP-UI Agent protocol Component model; it's a wire protocol, not a framework
CopilotKit / assistant-ui Copilot chrome The UI itself is still opaque to the agent
Mastra Server agent runtime Not a UI framework
gui-agent Client-side agent execution Needs a manifest to execute against

Janux's claim: the component model itself must be the agent protocol. When the mounted component tree is the MCP tree, drift is structurally impossible.

1.3 Non-goals

  • Janux is not a chatbot SDK. The copilot UI is one consumer of the agent surface, not the point.
  • Janux does not target native mobile in v1.
  • Janux does not attempt to make arbitrary existing React apps agent-readable. It is a new component model with an interop layer (§14).

2. Design Principles

  1. One definition, three projections. View, resource, and tools derive from a single source. Nothing agent-facing is written twice.
  2. Declared, not discovered. Agents read a compile-time manifest, never the DOM. Behavior (effects, data sources, events) is declared data, so the runtime can answer "are you settled?" deterministically.
  3. Zero JS until proven interactive. Static components compile to HTML. The compiler — not the developer — decides what ships.
  4. Guards are a language feature. Every action declares auto / confirm / forbidden. Human-in-the-loop is a keyword, not a convention.
  5. Zero config, sharp defaults. A Janux app with no config file has a working agent, a model resolved from the environment, and a full manifest.
  6. The server is part of the framework. Fullstack by default: server APIs, agent runtime, memory, and streaming are built in, not bolted on.

3. Terminology

  • Bifacial component — a component defined with component({...}) exposing view + resource + intents.
  • Static component — a pure function of props compiled to HTML; no state, no runtime, no JS.
  • Intent — a named, schema-typed action on a component. The unit of interactivity and the unit of code-splitting.
  • Source — a declarative async data dependency (replaces fetch-in-effect).
  • Effect — a named side effect with a declared trigger (when) and optional cleanup.
  • Manifest — the compile-time-generated JSON description of every component's resources, tools, events, and guards; filtered at runtime by mount state and auth.
  • Agent surface — the union of the manifest, the ui:// resources, and the server api() tools.
  • Structural resumability — Janux's SSR model: the client resumes from serialized state and compile-time binding maps, never from serialized closures.
  • Adapter — a plugin providing server-render and client-mount entrypoints for a foreign view framework (React, Vue, Svelte, …), Astro-renderer-style (§4.6).
  • Opaque island — a foreign component mounted as-is, with no agent surface; marked opaque: true in the manifest (§4.6 Mode 2).

4. Component Model

Janux has exactly two component kinds. The compiler infers which one you wrote; there is no annotation.

4.1 Level 0 — Static components (pure HTML, zero JS)

A static component is a pure function of props. It has no state, no intents, no effects. The compiler proves this by static analysis and compiles it to an HTML template executed only on the server (or at build time for prerendered routes).

// components/PriceTag.tsx
export function PriceTag({ amount, currency = 'EUR' }: { amount: number; currency?: string }) {
  return <span class="price">{format(amount, currency)}</span>
}

Properties:

  • Ships 0 KB of JavaScript. No hydration marker, no island wrapper, no runtime reference. The output is bytes of HTML.
  • May still be agent-visible. An optional describe export contributes a node to the page's semantic outline — a static section of the manifest generated at build time. The agent learns "there is a price of X here" from the manifest, not from parsing HTML:
export const describe = semantic({
  role: 'price',
  reads: ({ amount, currency }) => ({ amount, currency }),
})
  • Composable freely. Static components can appear inside bifacial views. Bifacial components cannot appear inside static ones (the compiler rejects it — a static parent cannot host a stateful child without becoming an island boundary itself; instead, the route hosts islands).

The design intent: the default component is static. A typical content page in Janux is 100% Level 0 and ships the same JS as a hand-written HTML file: none.

4.2 Level 1 — Bifacial components

The full primitive. All sections are optional except name and view; a bifacial component with only state + view is a plain island; adding intents makes it agent-operable.

// components/Cart.tsx
import { component, schema, intent, source, effect, semantic } from 'janux'
import { str, int, money, list, enums } from 'janux/types'
import { saveCart, catalog, pay } from '~api/shop'   // server APIs, see §7

export const Cart = component({
  name: 'cart',
  description: 'Shopping cart. Holds line items with product, quantity and unit price.',

  // ── STATE: the public, typed contract ─────────────────────────────
  // Humans see it rendered; agents read it as resource `ui://cart`
  // and subscribe to changes. Serializable by construction (schema-typed),
  // which is what makes structural resumability possible (§10).
  state: schema({
    items: list({ productId: str(), qty: int().min(1), unitPrice: money() }),
    coupon: str().nullable(),
  }),

  derived: {
    total: (s) => s.items.reduce((acc, i) => acc + i.qty * i.unitPrice, 0),
  },

  // ── SOURCES: declarative async data-in ────────────────────────────
  // Each source exposes typed .value / .pending / .error to the view
  // AND to the manifest. Refresh policy is data, not code.
  sources: {
    catalog: source({
      description: 'Product catalog with prices and stock',
      query: ({ ctx }) => catalog({ storeId: ctx.storeId }),
      refresh: every('5m').orOn('inventory.changed'),
    }),
  },

  // ── EFFECTS: named side effects with declared triggers ────────────
  // `when` declares the state slice that triggers the effect. Because
  // it is data, the manifest exposes it and the runtime can report
  // in-flight status (the basis of ui.settled(), §5.4).
  effects: {
    persist: effect({
      description: 'Syncs the cart to the server',
      when: (s) => s.items,
      debounce: '300ms',
      run: async ({ state, signal }) => saveCart(state, { signal }),
    }),
    couponExpiry: effect({
      description: 'Coupons expire after 15 minutes',
      when: (s) => s.coupon,
      run: ({ state }) => {
        if (!state.coupon) return
        const t = setTimeout(() => { state.coupon = null }, 15 * 60_000)
        return () => clearTimeout(t)               // cleanup, React-style
      },
    }),
  },

  // ── LIFECYCLE: mounting = publishing capabilities ──────────────────
  lifecycle: {
    attach: ({ emit }) => emit('cart.opened', {}),
    detach: async ({ state }) => saveCart(state),  // guaranteed flush
  },

  // ── EVENTS: typed emissions → DOM events + MCP notifications ──────
  emits: {
    'cart.checkedOut': schema({ orderId: str(), total: money() }),
    'cart.abandoned': schema({ itemCount: int() }),
  },

  // ── ON: typed subscriptions (other components, server, agent) ─────
  on: {
    'auth.loggedOut': ({ intents }) => intents.clear({}),
    'inventory.changed': ({ state, event }) =>
      state.items = state.items.filter(i => event.stillInStock(i.productId)),
  },

  // ── INTENTS: the unit of action AND the unit of code-splitting ────
  intents: {
    addItem: intent({
      description: 'Add a product to the cart',
      input: schema({ productId: str(), qty: int().default(1) }),
      guard: 'auto',
      ready: ({ sources }) => !sources.catalog.pending,
      run: ({ state, sources, input }) =>
        state.items.push({ ...input, unitPrice: sources.catalog.value.price(input.productId) }),
    }),

    clear: intent({
      description: 'Empty the cart',
      guard: 'auto',
      run: ({ state }) => { state.items = [] },
    }),

    checkout: intent({
      description: 'Start payment. Has monetary side effects.',
      guard: 'confirm',                            // HIL as a keyword
      server: true,                                 // runs on the server (§10.4)
      run: async ({ state, emit, ctx }) => {
        const order = await pay(state, ctx.user)
        emit('cart.checkedOut', { orderId: order.id, total: order.total })
      },
    }),
  },

  // ── VIEW: consumes the SAME state and SAME intents as the agent ───
  view: ({ state, derived, sources, intents }) => (
    <section>
      {sources.catalog.pending
        ? <Skeleton />
        : state.items.map(i => <CartLine key={i.productId} item={i} />)}
      <PriceTag amount={derived.total} />
      <button on={intents.checkout}>Pay</button>
    </section>
  ),
})

4.3 Semantics of each section

Section Human projection Agent projection Compiler output
state reactive render inputs MCP resource ui://<name> JSON schema + binding map
derived computed values included in resource snapshot pure fn, memoized
sources .pending/.value/.error in view sync status in manifest; not ready reasons per-source chunk (only if client-refreshed)
effects invisible (side effects) declared in manifest; in-flight counter feeds settled() per-effect chunk
intents event handlers via on={...} MCP tools <name>.<intent> one chunk per intent
emits/on DOM CustomEvents MCP notifications / subscriptions event table (data)
lifecycle mount/unmount capability (de)registration attach/detach chunk
view DOM — (agents never see the view) server template + DOM binding map

4.4 Rules enforced by the compiler

  1. state must be schema-typed. Arbitrary class instances, functions, or DOM nodes in state are compile errors. This is the load-bearing constraint: it makes state serializable, diffable, resumable, and agent-readable by construction.
  2. run bodies may only close over module imports and their declared parameters (state, input, sources, emit, ctx). Closing over lexical component scope is a compile error — this is what eliminates closure serialization (§10.2).
  3. when in effects must be a pure selector over state. The compiler derives the dependency set statically; there is no dependency array to get wrong.
  4. Every intent reachable from the view (on={intents.x}) or the manifest gets its own emitted module with a deterministic URL (§9.2).

4.5 Shared state: stores

Cross-component state uses the same primitive minus the view. A store is a bifacial component with no view and no DOM lifecycle: schema-typed state, derived values, sources, effects, events, and intents — projected to agents as store://<name> with its own tools.

// stores/session.store.ts
import { store, schema, intent } from 'janux'
import { str, obj, enums } from 'janux/types'

export const Session = store({
  name: 'session',
  description: 'Cross-component session state: user, locale.',
  scope: 'app',                     // 'app' | 'route' — lifetime boundary
  persist: 'local',                 // built-in persistence driver, resume-safe

  state: schema({
    user: obj({ id: str(), name: str() }).nullable(),
    locale: enums(['en', 'es']).default('en'),
  }),

  derived: { isLoggedIn: (s) => s.user !== null },

  intents: {
    setLocale: intent({
      input: schema({ locale: enums(['en', 'es']) }),
      guard: 'auto',
      run: ({ state, input }) => { state.locale = input.locale },
    }),
    logout: intent({
      guard: 'confirm',
      run: ({ state, emit }) => { state.user = null; emit('auth.loggedOut', {}) },
    }),
  },

  emits: { 'auth.loggedOut': schema({}) },
})

Components consume stores through a declared dependency, not a runtime hook call:

export const Header = component({
  name: 'header',
  use: { session: Session },        // declared, statically analyzable
  view: ({ use }) => (
    <nav>{use.session.derived.isLoggedIn ? use.session.state.user.name : <LoginLink />}</nav>
  ),
  intents: {
    switchLocale: intent({
      guard: 'auto',
      run: ({ use }) => use.session.intents.setLocale({ locale: 'es' }),
    }),
  },
})

Rules and consequences:

  1. Mutations only through store intents. Consumers cannot write use.session.state.user = …; they call use.session.intents.*. Every mutation of shared state is therefore named, schema-validated, guard-checked, and audit-logged — for humans and agents identically. (This is the deliberate departure from Zustand's set-anywhere model.)
  2. use: is static. Because the dependency is declared, the compiler extends binding maps across store paths (a store write updates exactly the DOM nodes bound to that path, in every consuming island, with no re-render concept and no manual selectors), and the manifest lists each store's readers — an agent can see the blast radius of a store mutation before making it:
{ "resources": [
    { "uri": "store://session",
      "schema": { "user": "…|null", "locale": "en|es" },
      "readers": ["ui://header", "ui://cart"] } ],
  "tools": [
    { "name": "session.setLocale", "guard": "auto" },
    { "name": "session.logout",   "guard": "confirm" } ] }
  1. SSR/resumability unchanged. Store state serializes once per page (<script type="application/janux+state" data-store="session">); scope: 'app' stores are request-scoped instances on the server, eliminating the classic shared-singleton SSR leak by construction. persist: 'local' reconciles after resume, never before (no hydration mismatch class).
  2. Quiescence composes. Store effects/sources feed the same counters: ui.settled() covers stores; ui.settled('store://session') scopes to one.
  3. Zustand parity, agent surplus. No provider wrapping, minimal API, built-in persistence and devtools (Inspector shows store state, mutation log, and reader graph). Beyond parity: typed resource, guarded tools, audit trail, and cross-island fine-grained updates without selector functions.

A future scope: 'shared' (multi-client sync over a CRDT driver) is deliberately deferred — see Open Question 15.1; the schema constraint keeps it tractable.

4.6 Foreign views: framework adapters (Astro-style)

Janux supports React, Preact, Vue, Svelte, Solid, and Angular components in the view layer, through an adapter contract modeled directly on Astro's renderer architecture (addRenderer({ name, clientEntrypoint, serverEntrypoint }), server-side check()/renderToStaticMarkup(), client-side mount/hydrate entrypoint with unmount cleanup). Adapters are registered in the Vite plugin:

// vite.config.ts
import { janux } from 'janux/vite'
import react from '@janux/react'
import vue from '@janux/vue'

export default { plugins: [janux({ adapters: [react(), vue()] })] }

Each adapter provides: a server entrypoint (check(Component) to claim ownership, renderToStaticMarkup(Component, props) for SSR), a client entrypoint (mount(element, Component, props) returning { update(props), unmount() }), and per-environment Vite config (dedupe, optimizeDeps — as Astro's integrations do). When multiple JSX frameworks coexist, include/exclude glob patterns disambiguate ownership, exactly as in Astro.

The crucial departure from Astro is where the boundary sits. Astro's islands are opaque: the foreign component owns its state and behavior, invisible from outside. Janux offers that as a fallback, but its primary mode keeps the bifacial contract intact:

Mode 1 — Foreign view, bifacial preserved (primary). The component definition keeps state, derived, sources, effects, and intents in Janux; only the view is foreign. The foreign component is a presentational function receiving the same object a native view receives — as props:

// components/Cart.tsx — definition unchanged except the view line
import { react } from '@janux/react'
import { CartView } from './CartView'          // a plain React component

export const Cart = component({
  name: 'cart',
  state: schema({ /* … as §4.2 … */ }),
  intents: { /* … as §4.2 … */ },
  view: react(CartView),                        // ← the only change
})
// components/CartView.tsx — idiomatic React, presentational only
export function CartView({ state, derived, sources, intents }) {
  return (
    <section>
      {state.items.map(i => <CartLine key={i.productId} item={i} />)}
      <button onClick={() => intents.checkout({})}>Pay</button>
    </section>
  )
}

The runtime bridges reactively: Janux signal changes call the adapter's update(props) (a root re-render in React's case); intents arrive as plain async callbacks. The agent surface is completely unchangedui://cart, cart.addItem, guards, settled(), proposal mode all work identically, because none of them ever depended on the view. This is what makes ecosystems portable: a shadcn/ui or MUI view drops in while the component stays fully agent-operable.

Rule: foreign views may hold ephemeral presentational internal state (hover, input composition, animation) — but domain state kept in useState instead of Janux state is invisible to agents and lost on resume. The docs frame this as the styled-components rule of Janux: if the agent should know it, it lives in state.

Mode 2 — Opaque island (migration vehicle). Any existing foreign component mounts as-is, Astro-style, with loading directives:

import { island } from 'janux'
view: () => <island.react component={LegacyDashboard} props={{ orgId }} load="visible" />

load: 'resume' | 'visible' | 'interaction' | 'only' mirrors Astro's client:* directives. The manifest lists the node as { "opaque": true, "framework": "react" } — honestly invisible rather than pretending. Opaque islands are the on-ramp for incremental adoption inside existing apps; the migration path is opaque island → Mode 1 foreign view → native view.

Costs (explicit, per §11's no-silent-caps spirit):

Concern Native view Mode 1 foreign view Mode 2 opaque island
Framework runtime on page none once per framework (Preact ~4 KB, Vue ~20 KB, React ~45 KB gz) same
Update granularity path-level DOM writes props-level re-render inside the island framework-internal
Resumability structural (no execution) hydration inside the island only; props come from the Janux snapshot (no double-fetch) hydration
Agent surface full full none (opaque node)
settled() exact adapter reports render-complete mount-complete only

5. The Agent Surface

5.1 The manifest

At build time, the compiler emits manifest.json: every component's resources, tools (with input schemas, guards, descriptions), events, effects, and semantic outline entries. At runtime, the server filters it by (a) which components are mounted on the current route, and (b) the caller's authorization scope. The filtered manifest is:

  • Served at /_janux/manifest (and as an MCP resources/list + tools/list response).
  • Embedded as a <link rel="janux-manifest"> reference in SSR output so agents discover it without probing.
  • Live: mount/unmount and ready transitions push notifications/tools/list_changed.
{
  "resources": [
    { "uri": "ui://cart",
      "schema": { "items": "", "coupon": "string|null", "total": "money" },
      "sync": { "persist": "idle" } }
  ],
  "tools": [
    { "name": "cart.addItem", "guard": "auto", "ready": true,
      "input": { "productId": "string", "qty": "int=1" } },
    { "name": "cart.checkout", "guard": "confirm", "ready": true }
  ],
  "events": ["cart.checkedOut", "cart.abandoned"],
  "outline": [ { "role": "price", "of": "ui://cart", "field": "total" } ]
}

5.2 Addressing: no selectors, ever

Agents address components by name and instance key, not by DOM position: ui://cart, ui://orders-table#row-42. Repeated components get stable instance keys from their key prop. The DOM remains a private implementation detail of the view.

5.3 Guards

Three values, enforced by the runtime on every invocation path (human click, agent call, RPC):

  • auto — agent may invoke unattended.
  • confirm — agent invocation suspends; the component enters proposal mode (§5.5); a human approves or rejects. The approval is recorded in the audit log with the agent's stated rationale.
  • forbidden — never invocable by an agent; invisible in the manifest. Human-only.

Guards may be dynamic: guard: ({ ctx }) => ctx.user.role === 'admin' ? 'auto' : 'confirm'.

5.4 Quiescence: settled()

Because all async is declared (sources with pending, effects with in-flight counters, debounce as data), the runtime maintains a per-component and global quiescence state:

await ui.call('cart.addItem', { productId: 'p_42' })
await ui.settled('cart')        // resolves when no source is loading and
                                // no effect (incl. debounced persist) is in flight

This single primitive eliminates the sleep()/retry idiom in agent automation and E2E tests. settled is also exposed as a manifest field so agents can observe rather than await.

5.5 Proposal mode (HIL rendered in the real UI)

When an agent invokes a confirm intent, Janux does not show a generic modal. The target component renders its own view against the hypothetical post-intent state (a fork of the state tree — cheap, because state is plain data), visually diffed against the current state. The human confirms on the real UI, seeing exactly what would change. Confirmation applies the forked state transactionally; rejection discards it. The UI becomes the shared ground where human and agent negotiate state — not just where results are displayed.


6. Fullstack Architecture

┌────────────────────────── Browser ──────────────────────────┐
│  Views (DOM)      janux-core runtime (~2.5 KB gz)           │
│                   ├─ state store (signals)                  │
│                   ├─ event delegator (1 listener)           │
│                   ├─ intent loader (per-intent chunks)      │
│                   └─ gui-agent bridge  ←──────────┐         │
└───────────────────────────────────────────────────┼─────────┘
              HTML + state snapshot │ RPC │ SSE     │ ui.* calls
┌───────────────────────────────────▼────────────── ▼─────────┐
│                        Janux Server                         │
│  Router/SSR   api() endpoints   Manifest service            │
│  ┌─────────────────── Mastra runtime ────────────────────┐  │
│  │  Agent(s) · Memory · Workflows · Model router         │  │
│  │  tools = all api() defs + mounted component intents   │  │
│  └───────────────────────────────────────────────────────┘  │
│  Audit log · Guard enforcement · Auth context (ctx)         │
└─────────────────────────────────────────────────────────────┘
  • Mastra is the embedded agent runtime. Janux vendors Mastra's agent, memory, and workflow primitives; janux dev boots them in-process — no separate service. Memory defaults to libsql (file) in dev and Postgres in production via a single DATABASE_URL.
  • gui-agent is the client execution protocol. The browser-side bridge exposes ui.read / ui.call / ui.subscribe / ui.settled against the local mounted tree, so the server agent operates the UI through the manifest — the same path a remote MCP client would use.
  • The built-in agent endpoint /_janux/agent streams (SSE) and is what the optional <Copilot /> chrome component talks to. Any external MCP client can connect to /_janux/mcp and see the identical surface.

7. Server APIs as Agent Tools (RPC)

7.1 The api() primitive

A server function defined once becomes three things: a validated HTTP endpoint, a typed client stub, and a registered agent tool.

// server/shop.api.ts
import { api, schema } from 'janux/server'
import { str, enums, list } from 'janux/types'

export const searchOrders = api({
  description: 'Search orders by text query and status',
  input: schema({ query: str(), status: enums(['pending', 'paid']).optional() }),
  output: schema({ orders: list(Order) }),
  guard: 'auto',
  run: async ({ input, ctx }) =>
    ({ orders: await db.orders.search(input, { org: ctx.orgId }) }),
})

export const refundOrder = api({
  description: 'Refund an order. Irreversible monetary action.',
  input: schema({ orderId: str(), reason: str() }),
  guard: 'confirm',
  run: async ({ input, ctx }) => payments.refund(input.orderId, ctx.user),
})

What the compiler does with each api() in server/**.api.ts:

  1. Endpoint. Registers POST /_janux/api/shop.searchOrders, validates input against the schema before run, validates output after (dev mode), serializes errors into a typed envelope.
  2. Client stub. In client bundles, import { searchOrders } from '~api/shop' compiles to a ~100-byte typed fetch stub. Server code never ships to the browser; importing an api() from client code is safe by construction.
  3. Agent tool. Registers api.shop.searchOrders in the Mastra runtime with the same description, schema, and guard. confirm guards route through the same proposal/audit pipeline as component intents (§5.3) — a refund proposed by the agent shows up for human approval identically whether it originated from a UI intent or a raw tool call.
  4. Context injection. ctx carries the authenticated session (user, org, scopes) resolved by the framework's auth hook. Agent invocations run under the end user's ctx, never a service identity — the agent can do at most what its human can, further narrowed by guards.

7.2 Streaming and subscriptions

api.stream() defines an SSE/generator endpoint that projects as an MCP resource subscription; api.event() declares server-emitted events that fan into component on: handlers and MCP notifications through one bus.

7.3 Relationship to component intents

Rule of thumb enforced by convention: intents mutate UI state; APIs mutate world state. An intent with server: true is compiled into a private api() under the hood, sharing the entire pipeline (validation, guards, audit, ctx).


8. Agent Runtime Configuration — Zero Config, Full Harness

The design goal is a two-sided contract:

  • Easy: every capability of the embedded Mastra harness (storage, memory, observability, scorers, processors, workflows) is reachable from defineAgent through flat, declarative shortcuts with environment-sniffed defaults.
  • Plug & play: Janux never re-implements or forks Mastra behavior. Shortcuts compile to Mastra configuration 1:1, unknown options pass through verbatim, and a total escape hatch accepts a raw Mastra instance. Upgrading Mastra is a version bump, not a Janux release.

8.1 Model resolution order

  1. Explicit code: defineAgent({ model: 'anthropic/claude-fable-5' })
  2. Environment: JANUX_MODEL=anthropic/claude-fable-5
  3. Key sniffing: exactly one known provider key present in the environment (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, …) → that provider's current default model, logged at boot: ✔ agent model: anthropic/claude-fable-5 (inferred from ANTHROPIC_API_KEY).
  4. Nothing found → the app still boots; the agent surface responds with a setup card that names the exact env var to set. No stack trace, no dead server.

Model identifiers are single strings provider/model, routed through Mastra's model router — no per-provider SDK to install or import.

8.2 The full defineAgent surface

An app with zero config files gets: default copilot agent, tools = all api() + mounted intents, storage inferred from DATABASE_URL, traces persisted locally, PII redaction on. Everything below is optional refinement:

// app/agent.ts — optional; every key is optional
import { defineAgent, schema } from 'janux/server'

export default defineAgent({
  instructions: 'You are the shop assistant. Prefer proposing over acting.',
  model: 'anthropic/claude-fable-5',

  // ── Memory → compiles to Mastra Memory on the shared storage ──────
  memory: {
    scope: 'user',                      // 'session' | 'user' | 'org' → thread/resource mapping
    lastMessages: 20,
    semanticRecall: { topK: 4 },        // auto-provisions pgvector on the same DATABASE_URL
    workingMemory: { schema: schema({ preferences: '…' }) },
  },

  // ── Storage → PostgresStore / LibSQLStore / MastraCompositeStore ──
  // Usually omitted: DATABASE_URL present → Postgres (threads, workflow
  // snapshots, traces, vectors — one database, one env var);
  // absent → libsql file in .janux/. Granular form routes domains:
  storage: { default: 'postgres', observability: 'duckdb' },   // → MastraCompositeStore

  // ── Observability → Mastra Observability config ───────────────────
  observability: {
    serviceName: 'shop',                // default: package.json name
    exporters: 'auto',                  // sniffed from env, see §8.3
    redactPii: true,                    // SensitiveDataFilter preconfigured (default: true)
    sampling: { rate: 1.0 },
  },

  // ── Quality harness → Mastra scorers & processors ─────────────────
  scorers: { relevance: true, sampling: { rate: 0.1 } },        // eval sampling to storage
  processors: {
    input: [promptInjectionGuard()],    // Mastra input processors (guardrails)
    output: [tokenBudget({ max: 8192 })],
  },

  // ── Workflows → Mastra workflows with suspend/resume ──────────────
  workflows: './workflows',             // snapshots persist in the shared storage;
                                        // suspended runs survive deploys

  tools: { include: ['api.shop.*', 'cart.*'] },  // default: everything mounted

  // ── Verbatim passthrough → merged into `new Mastra({...})` ────────
  mastra: { /* any current or future Mastra option, untranslated */ },
})

Multiple named agents (app/agents/*.ts) each get their own endpoint, tool scope, and memory namespace, sharing one storage and one observability pipeline.

8.3 Observability: zero-config sniffing

exporters: 'auto' (the default) assembles the Mastra Observability config from the environment at boot:

Present in env Exporter wired
always MastraStorageExporter → traces queryable in the Inspector (§9.4)
BRAINTRUST_API_KEY BraintrustExporter
MASTRA_PLATFORM_ACCESS_TOKEN MastraPlatformExporter
OTEL_EXPORTER_OTLP_ENDPOINT OTLP exporter
SENTRY_DSN Sentry exporter

SensitiveDataFilter is installed as a span output processor unless redactPii: false. The boot log prints a capability report so inference is never silent:

✔ model         anthropic/claude-fable-5   (ANTHROPIC_API_KEY)
✔ storage       postgres                   (DATABASE_URL) — threads, snapshots, vectors, traces
✔ observability braintrust + storage       (BRAINTRUST_API_KEY) · PII redaction on
✔ tools         14 api() + 9 intents       guards: 19 auto / 4 confirm

8.4 The plug & play contract

Four rules keep Janux permanently current with Mastra:

  1. Shortcuts compile to config, never reimplement. storage: 'postgres' emits new PostgresStore({ connectionString }); the granular object form emits MastraCompositeStore with domain routing; observability: emits new Observability({ configs }); memory: emits Mastra Memory. Janux owns zero runtime behavior in this layer — it is a config compiler.
  2. Verbatim passthrough. The mastra: key merges untranslated into the new Mastra({...}) call (shortcut-generated keys win on conflict, with a boot warning). A Mastra feature released tomorrow is usable tomorrow, before Janux ships a shortcut for it.
  3. Peer-dependency coupling. @mastra/core is a peer dependency; driver packages (@mastra/pg, @mastra/libsql, @mastra/braintrust, …) are optional peers loaded only when the corresponding config/env appears — missing ones produce an actionable boot error naming the exact package. Types re-export through janux/mastra, so app code never imports a Mastra path that Janux hasn't pinned compatible.
  4. Total escape hatch. If app/mastra.ts exports a hand-built new Mastra(...), Janux skips config compilation entirely and mounts that instance — injecting only its tool registry (api() defs + mounted component intents, still guard-wrapped) and the /_janux/* endpoints. defineAgent returns a real Mastra Agent either way; anything expressible in raw Mastra is expressible in a Janux app.

9. Compilation with Vite

9.1 Plugin architecture

janux() is a Vite plugin suite using the Environments API with three environments:

  • client — the ~2.5 KB core runtime, per-intent chunks, per-source refresh chunks.
  • ssr — route modules, HTML templates for static components, api() endpoints, the Mastra runtime.
  • manifest — a build-only environment that evaluates component metadata (schemas, descriptions, guards, event tables) and emits manifest.json + the semantic outline. Nothing from this environment ships to the client.

9.2 Compiler passes (transform pipeline)

For each module containing component():

  1. Macro extraction. component({...}) must be a statically analyzable object literal (like meta in workflow scripts). The compiler lifts each section into its own virtual module: Cart.state, Cart.view.server, Cart.view.bindings, Cart.intent.addItem, Cart.effect.persist, …
  2. Level inference. No state/intents/effects/sources → Level 0: emit an HTML template into the ssr environment only; the module contributes zero client code.
  3. Closure lint. Verify §4.4 rule 2 (no lexical capture in run bodies). Violations are build errors with a fix-it (hoist to module scope or pass through state).
  4. View compilation. The JSX view compiles twice: (a) a server template (string concatenation, fastest possible SSR), and (b) a binding map — a compact table of [state-path → DOM operation] pairs (text, attribute, list reconcile, conditional). There is no VDOM and no client-side re-execution of the view function; updates are direct DOM writes driven by the signal graph (§9.3), whose edges are exactly the binding map's entries.
  5. Chunking. Each intent/effect becomes an emitted chunk with a deterministic name: cart.addItem-[hash].js. The manifest records the URL, enabling both the resumability protocol (§10) and speculative prefetch.
  6. Manifest emission. Schemas serialize to JSON Schema; descriptions, guards, events, and outline entries join them in manifest.json.

9.3 State compiles to signals

state is not observed at runtime — it is compiled into a signal graph at build time. This is the reactivity substrate beneath the binding maps, and it falls out of constraints the framework already imposes for the agent surface:

  1. The shape is fully known statically. state is a schema literal, so the compiler knows every possible path (items, items[i].qty, coupon) and its type without executing anything. Runtime-proxy reactivity (Vue reactive, Solid stores) exists to discover what gets read; Janux has nothing to discover, so no proxies ship to the client.
  2. Read sites are enumerable. State is observed in exactly four declared places: view bindings (the binding map), derived (→ computed signals), effect when selectors (→ path subscriptions), and store readers via use:. The compiler materializes one signal per observed path — not per leaf. A path nothing reads stays plain data with zero reactive cost, a pruning that dynamic tracking cannot do.
  3. Write sites are enumerable too. Mutations are only legal inside run bodies (intents, effects, on handlers — §4.4 rule 2 forbids everything else), so the compiler rewrites state.x = v assignments into signal writes, Svelte-style. Reactivity is a compile-time rewrite, not a runtime interception.
  4. The binding map is the signal graph, serialized. Each [state-path → DOM operation] entry is an edge; at resume the runtime replays edges as subscriptions. Signals themselves are never serialized — they are a runtime view over the plain-JSON snapshot. Serialization stays JSON.stringify; resume wraps snapshot leaves in signals lazily, on first binding attach or subscription.
  5. Lists. list({...}) declares its key at the schema level, so keyed reconciliation compiles to a structural signal plus per-item signals — the key is known at build time, not guessed from a key prop at runtime.
  6. Dynamic access fallback. A path the compiler cannot resolve statically (state.items[i] with variable i) coarsens to a signal on the nearest statically-known ancestor. Correct, just less fine-grained — and flagged by the compiler in dev.

The pattern repeats: the schema-typed state constraint was introduced for agent legibility and serialization (§4.4, §10.2); it is also precisely what makes fine-grained signals a build-time artifact instead of a runtime machine.

9.4 Dev experience

  • Section-level HMR. Editing an intent hot-swaps only its chunk; state survives because state is data, not closure — HMR in Janux never loses component state.
  • Agent Inspector at /__janux: the live filtered manifest, a tool invoker (call any intent with a generated form), the audit log, the quiescence board (which effects are in flight), and a "what does the agent see" panel showing the resource snapshot for any component. This is the framework's answer to React DevTools, but for the second audience.
  • janux dev = Vite dev server + in-process Mastra + manifest service, one command, one port.

10. SSR and Structural Resumability

10.1 The Qwik baseline and its costs

Qwik's resumability serializes the application — including lexical closures — into HTML, and lazily resolves QRLs on interaction. This achieves near-zero startup JS but pays real costs: serialization complexity and limits (what is serializable is a long doc page), the $ boundary ergonomics that infect every API, waterfalls on first interaction (event → resolve QRL → fetch chunk → execute), and a large mental model surface.

10.2 Janux: nothing to serialize but state

Janux gets resumability structurally, from two constraints already imposed for the agent surface:

  1. State is schema-typed plain data (§4.4 rule 1) → serializing a component is JSON.stringify of its state. Always. No heuristics, no bail-outs, no serialization doc.
  2. Behavior never closes over lexical scope (§4.4 rule 2) → every behavior is a named module export addressable as (component, section) with a deterministic URL recorded in the manifest. There are no closures to serialize because the language of the framework has no anonymous behavior.

SSR output per island is therefore just:

<section data-jx="cart#main">…rendered HTML…</section>
<script type="application/janux+state" data-for="cart#main">
  {"items":[{"productId":"p_1","qty":2,"unitPrice":1999}],"coupon":null}
</script>

plus one <link> to the manifest. Total per-island overhead: the state JSON and a 30-byte marker.

10.3 Resume protocol

On load, the 2.5 KB core runtime:

  1. Installs one delegated event listener on document (like Qwik's global listener).
  2. Indexes data-jx markers and their state scripts. No component code runs. No view re-execution, no reconciliation against server HTML — the binding map (§9.2.4) generated at compile time tells the runtime which DOM node corresponds to which state path, keyed by structural position within the island. Hydration diffing is eliminated by construction rather than optimized.
  3. On first interaction (or first agent call) targeting an intent: fetch the intent chunk by its manifest URL, execute against the already-live state store.

10.4 Beating the interaction waterfall

Where Qwik pays event → resolve → download → run on a cold click, Janux has three mitigations that fall out of the manifest:

  • Informed prefetch. The manifest statically lists every intent, its guard, its ready state, and its chunk URL. A tiny prefetch worker warms intents by declared priority (prefetch: 'eager' | 'visible' | 'idle' on the intent — data, not code) — no speculative bundle graph analysis needed, the graph is the manifest.
  • Server-executable fallback. Any intent, while its chunk is cold, can execute via RPC on the server against the serialized state, returning a state patch (server: true intents only ever run there). First interaction is never blocked on JS download; worst case it costs one round trip.
  • Forms without JS. Intents bind to native <form> elements (<form intent={intents.addItem}>); with JS disabled or unloaded, submission posts to the intent's RPC endpoint and the server re-renders. Progressive enhancement is the floor, not a pattern.

10.5 Comparison

React SSR Qwik Janux
Startup JS execution full hydration replay ~1 KB loader, resume ~2.5 KB runtime, resume
Serialized in HTML props (then replays) state + closures (QRLs) state only
Serialization limits documented, non-trivial none (schema-enforced)
First cold interaction ready (already hydrated, paid upfront) waterfall: resolve+fetch+run prefetched, or 1 RTT server fallback
Works with JS disabled no partially yes (form-bound intents)
Agent surface none none manifest, first-class
API ergonomics tax hooks rules $ boundaries everywhere schema-typed state + no-capture rule

The honest trade: Janux's constraints (typed state, no lexical capture) are real restrictions on authoring freedom. They are the same restrictions the agent surface needs anyway — paid once, cashed twice.


11. Bundle-Size Strategy

Numbers are targets for v1, gzipped:

Payload Size
Level 0 page (static components only) 0 KB
Core runtime (store + delegator + loader + gui-agent bridge) ≤ 2.5 KB
Per intent chunk 0.3–2 KB
Per client-refreshed source ~0.5 KB
Manifest (typical page, filtered) 1–4 KB of cacheable JSON, not JS
Copilot chrome (optional, lazy) ~8 KB, loaded on open

How each is achieved:

  1. Compile-away components. Like Svelte, the component abstraction has no runtime cost; unlike Svelte, views compile to binding maps (data) + a shared micro-interpreter rather than per-component imperative code — output size scales sub-linearly with component count, which matters at app scale.
  2. The intent is the code-splitting unit. Not the route, not the component: the action. A page with 40 visible intents where the user touches 3 downloads 3.
  3. Schemas compile to validators once. JSON-schema validation code is shared; per-schema footprint is a table, not generated functions.
  4. Agent logic weighs nothing on the client. Mastra, model routing, memory — all server-side. The client carries only the gui-agent bridge (part of core).
  5. No VDOM, no reconciler, no scheduler. Signal-driven direct DOM writes.

12. Security, Auditability, Multi-Tenancy

  • Single enforcement point. Guards evaluate in the server runtime for server: true intents and api() calls, and in the core runtime for client intents — but agent-originated calls always transit the server bridge, so an agent can never bypass a guard by construction.
  • Audit log. Every agent invocation records: tool, input, resolved guard, ctx identity, proposal diff (if confirm), human decision, and resulting state patch. Exposed in the Inspector and as an api.stream().
  • Ctx scoping. Manifest filtering by auth scope means an agent acting for user A cannot see — let alone call — tools outside A's scope. Cross-tenant leakage is a filtering bug class, not an app-code bug class.
  • Rate and budget. Per-agent token budgets and per-tool rate limits declared in defineAgent, enforced server-side (Redis-backed in production, in-memory in dev).

13. End-to-End Example (minimal app)

app/
  routes/
    index.tsx          → static landing (Level 0) — ships 0 KB JS
    shop.tsx           → mounts <Cart /> and <OrderList />
  components/
    Cart.tsx           → bifacial (§4.2)
    PriceTag.tsx       → static (§4.1)
  agent.ts             → optional, 5 lines (§8.2)
server/
  shop.api.ts          → searchOrders, refundOrder (§7.1)
.env                    → ANTHROPIC_API_KEY=... DATABASE_URL=...
vite.config.ts          → export default { plugins: [janux()] }

janux dev

  • http://localhost:3000/shop — the human UI.
  • http://localhost:3000/_janux/mcp — the same app as an MCP server: cart.addItem, cart.checkout (confirm), api.shop.searchOrders, resources ui://cart, notifications cart.checkedOut.
  • http://localhost:3000/__janux — the Inspector.

A user asks the built-in copilot: "add two of the blue one and check out." The agent reads ui://cart and the catalog source, calls cart.addItem (auto), calls cart.checkout (confirm) → the Cart renders the proposed post-checkout state diffed in place → the human clicks Approve → payment runs server-side under the user's ctx → cart.checkedOut notifies the agent → the agent confirms completion. Zero selectors, zero sleeps, one definition.


14. Prior Art & Interop

  • Qwik — resumability goal shared; Janux removes closure serialization by construction (§10).
  • Svelte/Solid — compile-time reactivity and signals; Janux adds the agent projection and binding-map output.
  • HTMX — server-first, progressive enhancement; Janux's form-bound intents are HTMX-spiritual with a typed agent surface.
  • WebMCP / MCP-UI / gui-agent — the wire layer Janux emits natively rather than adapts to.
  • Mastra — embedded as the server agent runtime rather than reinvented.
  • Astro — the adapter/renderer architecture (§4.6) is modeled on Astro's integrations (addRenderer + server check/renderToStaticMarkup + client mount entrypoint + client:* directives). The difference: Astro's islands are opaque by design; Janux's primary foreign-view mode keeps state and intents in the framework so the agent surface survives the foreign renderer.
  • Interop, both directions: foreign components render inside Janux via adapters (§4.6 — bifacially in Mode 1, opaquely in Mode 2 as a migration vehicle); and janux/interop-react wraps a bifacial definition as a React component (view renders via a portal; intents/resource still register) for embedding into existing consoles. Opacity remains the disease — Mode 2 exists to make the cure incremental, and the manifest never hides that a node is opaque.

15. Open Questions

  1. Multi-client state. Should state optionally sync across tabs/users (CRDT driver) with the resource reflecting merged state? Strong candidate for v2; the schema constraint makes CRDT mapping tractable.
  2. Manifest versioning. Long-lived agent sessions across deploys need manifest ETags + capability re-negotiation; exact protocol TBD.
  3. Granular scopes. Is guard + ctx filtering enough, or do tools need OAuth-style scope strings for third-party agent clients?
  4. Semantic outline depth. How much of Level 0 content belongs in the manifest before it duplicates the document itself? Current position: only semantic()-annotated nodes.
  5. Naming of bifacial. The term is precise but Latin-heavy; "two-faced component" has unfortunate connotations. RFC feedback welcome.

16. Summary

Janux collapses the two artifacts every agentic product currently maintains — the UI and the tool layer that mirrors it — into one primitive with two faces. The constraints that make components agent-legible (typed state, named behavior, declared effects) are the same constraints that make them resumable without serialization heroics, splittable to the intent level, and testable without a browser. One definition; the human face and the agent face can never disagree, because they are the same head.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions