Core API#
Everything importable from janux.
component(def)#
Defines a bifacial component. Returns a frozen ComponentDef.
| Key | Type | Notes |
|---|---|---|
name |
string (kebab-case, required) |
Resource uri becomes ui://<name> |
description |
string |
Shown to agents in the manifest — write it for them |
state |
schema({...}) |
Serializable by construction; powers snapshots & resume |
derived |
Record<string, (state) => unknown> |
Computed values, cached reactively |
sources |
Record<string, source(...)> |
Declarative async data-in |
effects |
Record<string, effect(...)> |
Named side effects with declared triggers |
intents |
Record<string, intent(...)> |
The tools; also the click handlers |
emits |
Record<string, schema> |
Typed events this component may emit |
on |
Record<string, (bag) => void> |
Event subscriptions (component, store or server events) |
lifecycle |
{ attach?, detach? } |
Mount = publish capabilities; detach awaits cleanup |
use |
Record<string, StoreDef> |
Declared store dependencies |
view |
(bag) => JSX (required) |
The human face — agents never see it |
The bag passed to view, run, ready, lifecycle and on handlers:
{ state, derived, sources, intents, use, emit, ctx, input?, event? }Island props#
An island is not a function of props the way a static component is — its view receives the bag, never a props object. The renderer recognizes a fixed, closed set of attributes at the call site; everything else is ignored:
| Prop | Type | Effect |
|---|---|---|
key |
string |
Identity of this instance — the #<key> suffix in ui://<name>#<key>. As a JSX key it also re-keys the island (<Cart key={locale} /> remounts on change). |
id |
string |
Alternative identity when you're not using JSX key. key wins if both are set. |
initial |
matches state schema |
Seeds this instance's state (validated against state). SSR initialState for the same uri wins over it; when neither is given, buildDefault(state) boots it. |
persist |
boolean | Keep the live instance across SPA navigations (data-jx-persist). |
eager |
boolean | Mount on load/navigation without waiting for interaction (data-jx-eager). |
<Cart key="checkout" initial={{ items: [] }} persist />There is no arbitrary prop drilling into an island: passing data down means seeding initial, or sharing it through a store (use) or cross-island events (emits/on) — the same JSON-serializable channels the agent surface and resume rely on. Static components above the island still drill props normally.
Caveat:
ComponentTagcarries a phantom(props?) => JanuxNodecall signature so TSX accepts<Cart />as an element. It is intentionally loose:<Cart anything={x} />type-checks, but any attribute outside the table above is silently dropped at runtime. If a value must reach the island, it goes throughinitial, a store or an event — not a prop.
store(def)#
Same as component minus view. Projects as store://<name>. scope: 'app' | 'route' (default 'app').
intent(def)#
| Key | Type | Notes |
|---|---|---|
description |
string |
The agent reads this — make it actionable |
input |
schema({...}) |
Validated before run; errors carry paths |
guard |
'auto' | 'confirm' | 'forbidden' or ({ ctx }) => guard |
Default 'auto' |
ready |
(bag) => boolean |
Announced in the manifest; not-ready calls throw not_ready |
glowTarget |
(bag) => string | undefined |
CSS selector for the DOM this intent's effect lands on |
run |
(bag) => unknown (required) |
The only place state may be mutated |
Invocation results for agent-origin calls with guard: 'confirm' are proposals: { status: 'proposal', id, tool, input }.
glowTarget exists for intents that create DOM: a node the view renders a tick later has no delegation marker to highlight, and the element does not exist when the call starts. Declare where the effect lands and the resolved selector rides the janux:tool-call event, so the feedback layer waits for it to mount:
addStep: intent({
input: schema({ label: str() }),
// Resolved after `run`, so post-run state is available.
glowTarget: ({ state }) => `.react-flow__node[data-id="${state.nodes.at(-1).id}"]`,
run: ({ state, input }) => state.nodes.push({ id: `wf-${state.nodes.length}`, label: input.label }),
}),It never affects the call: a resolver that throws reports on janux:error and the mutation stands. Proposals carry no target — nothing ran yet. The start event of such a call carries glowTargetPending: true, so a feedback layer knows not to guess a target from the view and be overridden a moment later.
effect(def)#
{ description?, when?: (state) => slice, debounce?: '300ms', run: (bag) => cleanup | Promise }
Runs on attach and whenever the when slice changes. run may return a cleanup function. Durations: ms, s, m, h.
source(def) / every() / onEvent()#
source({ description, query: ({ ctx }) => data, refresh: every('5m').orOn('inventory.changed') })Readers expose .value, .pending, .error, .refresh(). On SSR sources load before render and their values travel in the snapshot — resumed islands never double-fetch.
Signals (advanced)#
signal(initial), computed(fn), watch(fn) (effect; returns dispose), batch(fn), untrack(fn). You rarely need these directly — state, derived and effects cover the usual cases.
Ownership tree: createRoot(fn) runs fn(dispose) inside a disposal scope — effects and computeds created within register automatically and are cleaned up on dispose() (nested roots cascade with their parent). onCleanup(fn) registers an arbitrary cleanup in the current scope; getOwner() / runWithOwner(owner, fn) move work between scopes (e.g. async continuations). This is the primitive behind island teardown and foreign-framework roots.
createInstance(def, options) (advanced)#
The runtime beneath islands — also your unit-testing entry point. See the Testing recipe.
const cart = createInstance(Cart, { initial, ctx, bus, stores, onAudit, onProposal });
await cart.attach(); // starts sources, effects, lifecycle
await cart.intents.addItem({ productId: 'p1' }); // human origin
await cart.intents.addItem({ productId: 'p1' }, { origin: 'agent' });
await cart.settled();
cart.snapshot(); // plain JSON state
cart.resource(); // the agent projection
await cart.dispose();Low-level exports (advanced)#
These come out of janux too. You rarely import them directly — the CLI, the Vite plugin and @janux/server wire them for you — but they're the seams for embedding Janux, testing, or building your own server.
renderToString(node, options?)#
Renders a page (or any node) to HTML on the server. Returns a RenderResult:
const { html, registry, snapshots, i18nKeys } = await renderToString(<ShopPage />, { ctx });| Field | Meaning |
|---|---|
html |
The rendered markup |
registry |
The mounted islands + stores discovered during render |
snapshots |
Per-island state snapshots ({ uri, state }) — what the client resumes from, no hydration replay |
i18nKeys |
Exactly the i18n keys the page's islands resolved, so the page ships only those messages |
options accepts { ctx, bus, storeDefs, initialState } — the same wiring createJanuxServer supplies per request (initialState reseeds islands by uri when resuming).
renderToStream(node, options?)#
The same render, handed over as it is produced instead of after the last island resolves. This is what createJanuxServer serves pages with — see SSR and resumability.
const { chunks, done } = renderToStream(<ShopPage />, { ctx });
for await (const chunk of chunks) process.stdout.write(chunk);
const { snapshots, registry, i18nKeys } = await done; // only after chunks is drainedSame options as renderToString, and the joined chunks are byte-identical to its html. Islands still load their sources in parallel — a slow one holds back its own children, not the page — and the snapshots only exist once the render finished, which is why they arrive through done rather than up front.
The third field, cancel(), is for a response the client abandoned mid-stream: it stops the renderer from descending into new island work and settles done with what rendered. Call it from your stream's cancel hook — the generator protocol alone can't reach a renderer parked on its own await.
speculationRules(config, options?)#
The JSON behind the <script type="speculationrules"> the shell emits, exported so a custom server can build its own. config is the navigation.speculationRules value (true, false, or { eagerness, exclude }); pass { nativeOnly: true } for the narrowed form the client swaps in once it intercepts navigations. SPECULATION_SCRIPT_ID and CONFIG_SCRIPT_ID are the script ids both sides agree on — the second one is where the shell ships the navigation config for the client to read back. See Navigation.
buildManifest(entries, ctx?)#
Builds the agent-facing manifest for a set of mounted defs — the projection external MCP clients and GET /_janux/manifest consume:
const manifest = buildManifest([{ def: Cart, key: 'default', instance }], ctx);
// { janux: '0.1', resources: [...], tools: [...], events: [...] }resources are the ui:///store:// snapshots (with JSON Schema for typed state), tools are the non-forbidden intents (guard, input schema, ready), events are the union of every emits key. resolveGuard(def, ctx) is the same helper it uses to collapse function guards to a concrete 'auto' | 'confirm' | 'forbidden'.
createBus()#
The typed event bus that carries component, store and server events. One is created per request/client; you only construct your own for tests or a custom runtime.
const bus = createBus();
const off = bus.on('inventory.changed', (payload) => { /* ... */ });
bus.emit('inventory.changed', { sku: 'p1' });
off();JanuxIntentError#
Thrown by intent dispatch when a call is refused. error.code is one of 'forbidden' | 'not_ready' | 'invalid_input' | 'unknown_intent' — the same codes the HTTP layer maps to 4xx envelopes.
AuditEntry / Proposal#
The shapes the onAudit and onProposal hooks receive:
type AuditEntry = { tool: string; origin: 'human' | 'agent'; guard: GuardValue;
input: unknown; ok: boolean; error?: string; at: number; agent?: string };
type Proposal = { id: string; tool: string; input: unknown; execute: () => Promise<unknown> };AuditEntry.agent is the verified Web Bot Auth key id, present only for authenticated external agents. A Proposal is what a confirm guard produces; execute() runs it exactly once (the /_janux/approve endpoint calls it for you).
parseDuration(input)#
Parses the duration strings used by every() and effect({ debounce }) into milliseconds — '300ms', '2s', '5m', '1h'. Throws on anything else. Exposed mostly so custom refresh/debounce logic can share one parser.
JSX runtime#
Fragment, jsx, jsxs and the JanuxNode type are the automatic-runtime targets your .tsx compiles to (jsxImportSource: 'janux'). You import them only if you emit JSX calls by hand; normal components never touch them.