CLI#
Commands#
janux dev [--port 3000] # Vite dev server: SSR, HMR, api stubs, agent endpoint
janux build # client bundle + styles + public/ → dist/client (+ prerendered HTML with output: "static")
janux start [--port 3000] # production server on Bun (no Vite at runtime)
janux test [files...] # the app's suite via bun test (flags pass through)
janux run [tool] [--arg] # invoke an intent or an api() from the terminal (no tool: list them)
janux verify # agent-surface contract checks (CI-friendly)
janux eval [files...] # scripted agent-task scenarios against a live app
janux info # versions, resolved config and routes — paste into an issue
janux upgrade # codemods for the breaking changes between two Janux versions
janux codemod [id] # one codemod by id, including the Next and Astro migrationsAll of these run the janux bin that @janux/cli installs into your app's node_modules — there is no global install. Apps scaffolded by create-janux wrap the common ones as package scripts (bun run dev); for the rest, prefix with bunx: bunx janux info.
PORT env is honored when --port is absent.
janux start serves dist/client before falling back to the app: compressed with brotli (or gzip, whichever the request accepts), each file compressed once and kept in memory, and cached immutable for a year when its name carries a content hash. Behind a CDN that already does this, it costs nothing; on a box without one, it is the difference between shipping a bundle and shipping four of them.
janux dev#
The error overlay#
Every framework can show you the stack of a throw. Janux can show you why the
invocation was there at all — because every call, from a click or from an
agent, goes through one pipeline that knows who asked and what the guard
decided. When something throws inside an intent(), effect() or source(),
janux dev puts that whole sentence on screen:
| row | what it answers |
|---|---|
route |
the URL, the pattern it matched, and the route module that answered |
layouts |
the _layout chain that wrapped it, outermost first |
island |
the island instance it landed in (ui://cart#default) |
intent / effect / source |
the declared behavior that ran, as the agent surface names it |
guard |
what the guard resolved to for this caller — auto, confirm, forbidden |
origin |
human for a DOM interaction, agent for a call through the agent surface |
input |
the validated input the invocation carried |
Under it, the full JS stack. Sourcemaps are on in dev (the framework's own frames included, so the trace does not stop at your app's edge), so DevTools resolves every frame back to your source.
The overlay never swallows anything: the original error object is logged to the
console as well, exactly as thrown. Press Esc to dismiss it.
An error that did not come through the pipeline — a plain uncaught TypeError —
still gets a panel with its route and stack, and says so instead of inventing a
chain it does not have.
Dev only, and measurably so. The overlay is behind
import.meta.env.DEV, whichjanux buildeliminates: not one byte of it reaches a production bundle, andpackages/janux-cli/src/bundle-size.test.tsproves it by building the same app twice — once normally, once with the dev guards forced on.
Production builds emit hidden sourcemaps: .map files are written next to the
bundle for an error tracker to consume, with no sourceMappingURL appended, so
the browser never downloads them.
janux info#
Everything a bug report needs, as markdown you can paste into an issue unedited — versions, the resolved config, which adapters and zero-config integrations are actually installed, and every route the file-system router found:
bunx janux info### janux info
| | |
|---|---|
| janux | 0.5.0 |
| @janux/cli | 0.5.0 |
| bun | 1.3.14 |
| os | darwin 25.5.0 (arm64) |
| app | janux-example-shop 0.2.1 |
**Resolved config** (paths relative to the app root)
…
**Integrations**
| | |
|---|---|
| @janux/tailwind | not installed |
**Routes** (3)
| route | module | layouts |
|---|---|---|
| `/shop` | src/routes/shop.tsx | — |Installed-but-invisible is the reason it exists: zero-config integrations are configured by being installed, so nothing in your own source says whether Tailwind is on. Paths are reported relative to the app root and the root itself is never printed — there is nothing to redact before posting.
janux upgrade#
Runs the codemods for the breaking changes between two Janux versions. --from
defaults to the janux the app actually resolves, --to to the version of the
CLI being run, so after bumping the dependency the bare command is usually
right.
janux upgrade --dry-run # the diff it would write, written nowhere
janux upgrade
janux upgrade --from 0.4.0 --to 0.6.0The range is half-open — a codemod runs when its release is after --from and
at or before --to — and every codemod is idempotent, so a second run reports
nothing to do rather than doing it twice.
janux codemod#
One codemod by id, including the framework migrations, which janux upgrade
never selects on its own.
janux codemod --list
janux codemod next/routes --dry-runBoth commands are documented in full, with the catalog, on
Codemods & janux upgrade.
janux test#
bun test, run from the app root. Everything after the command name goes through verbatim — file filters, --watch, --coverage, -t — and the exit code is the suite's. Janux does not ship a test runner: bun:test covers components and routes (see @janux/testing), Playwright covers the browser. The command exists so every project drives its suite the same way, whatever the package manager scripts look like.
janux run#
The terminal projection of the agent surface. agent-native promises one
definition projected to UI, agent, HTTP, MCP, A2A and CLI; this is the last
of those faces, and it is derived rather than designed — the tools, their
arguments and their guards are the ones already declared.
janux run # every tool this app projects
janux run cart.addItem --help # usage, generated from the input schema
janux run cart.addItem --productId p1 # invoke it
janux run api.shop.catalog | jq '.products[0]'Nothing is declared for the CLI. If an app needs a single line of code to be runnable from a terminal, the projection is wrong.
What it is for#
Scripting and CI against your own app without writing a client for it. A
release job that seeds a catalog, a smoke test that calls the same api() the
copilot calls, a cron that reconciles orders — all of it with the app's guards,
validation and audit trail in force, and none of it duplicating a schema in a
curl invocation that drifts the day an argument is renamed.
# in CI
bunx janux run api.orders.reconcile --since 2026-01-01 > report.jsonThe tool list#
janux run with no tool prints the manifest, one line per tool:
Tools:
api.shop.catalog [auto] List every product in the store…
api.shop.pay [confirm] Charge the cart. Irreversible monetary action.
cart.addItem [auto] Add a product to the cart by idA forbidden tool is never listed and never callable, exactly as it is never
advertised to an agent — the list is the manifest, not a second inventory.
Arguments come from the schema#
Each declared property of a tool's input becomes one --flag, typed by that
property: integer/number parse numerically, boolean is bare (--verbose)
or explicit (--verbose false), list()/obj() take JSON, and an enum()
shows its members in --help. A flag the schema does not declare is refused
rather than dropped — a typo that silently changes the input of a scripted call
is the one failure a script cannot see.
What is missing is not checked here: the invocation pipeline validates every call anyway, and its error already names the path and the reason.
Guards, and why a terminal is an agent#
Calls go out as origin: 'agent'. A terminal is not a session: there is no
signed-in human behind a CI job, so the CLI knocks on the door an agent knocks
on. forbidden denies, and confirm parks the call instead of running it.
| guard | interactive terminal | non-interactive (CI, pipes) |
|---|---|---|
auto |
runs | runs |
confirm |
prompts approve <tool> {…}? [y/N], runs only on y |
fails, exit 1 — nothing ran |
forbidden |
not listed, not callable | not listed, not callable |
The non-interactive refusal is the whole point: a script that auto-approved its
own irreversible call would turn the guard into a comment. There is no
--yes flag, because a flag that answers a human's question is the same
mistake with a longer name.
Nothing runs before the approval, including the proposal's shadow diff: a
terminal shows no before/after, so the CLI asks the pipeline not to compute one
(proposalDiff: false) rather than speculatively execute the body of a call
that may never be approved.
How each half is invoked#
An api() is an HTTP endpoint, so it travels its own HTTP boundary
in-process — no port, no socket — and meets the same guards, CSRF policy, audit
entries and proposal vault a browser meets. An intent() belongs to a mounted
component, so it is invoked on the instance a fresh render of the page that
mounts it produces, exactly as the client bridge does it.
That fresh render is worth stating plainly: a terminal has no session. An
intent whose ready depends on accumulated state (a cart with items in it)
answers "not ready", the same answer an agent gets on a page where it is not
ready. Tools that are meaningful without a session — the api() half, above
all — are what a script reaches for.
The ctx is the anonymous one an unauthenticated request gets: the app's own
ctxFor runs, with no cookies and no session, so a guard that depends on a
signed-in user denies rather than inherits one.
Exit codes#
| code | when |
|---|---|
| 0 | the tool ran (its result is on stdout, as JSON) |
| 1 | unknown tool, bad argument, refused or unapproved confirm, or the tool threw |
Results go to stdout and everything else to stderr, so janux run … | jq works
without filtering out prose. Like janux build and janux verify, the command
does not mount the app's schedules: one invocation must not claim a background
job's occurrence.
janux verify#
Renders every route's manifest and fails (exit 1) when an agent-reachable tool
— an intent() or api() whose guard is not forbidden — has no
description. Descriptions are the contract agents plan against; an
undescribed tool degrades every conversation silently, so it fails the build
instead. Routes that throw during render are reported as warnings (their
surface cannot be verified).
janux eval#
"Can an agent actually complete this task through my tools?" as a repeatable
CI check. Runs evals/**/*.eval.json (or explicit files) against a live app
and exits 1 when the regression gate fails — with one trial (the default),
that is any failed expectation.
janux eval --start "janux start" # boots the app, runs, stops it
janux eval --url http://localhost:3000 # against a server you manage
janux eval evals/checkout.eval.json --json
janux eval --trials 2 # gate only on reproducible failures
janux eval --baseline evals/baseline.json # compare against a committed run{
"name": "shop agent checkout",
"steps": [
{ "tool": "api.shop.catalog", "expect": { "result": { "products": [{ "id": "p1" }] } } },
{ "tool": "api.shop.pay", "input": { "total": 5999 },
"expect": { "result": { "status": "proposal" } } },
{ "approve": "$steps[1].result.id", "expect": { "result": { "charged": 5999 } } }
]
}Steps run in order with x-janux-origin: agent. $steps[i].<path> references
resolve against earlier outcomes ({ status, ok, result, error }) anywhere in
input, approve or reject. An approve step exercises the real
human-in-the-loop flow (POST /_janux/approve) — the same pipeline your UI
uses — and a reject step its mirror (POST /_janux/reject), answering
{ "ok": true } when the proposal existed and { "ok": false } once settled.
expect checks any of ok (default true when omitted), status, error
(substring) and result (deep subset match).
Inside result, four matchers extend the positional subset match:
{ "$some": {…} } passes when any item of an array matches, { "$not": {…} }
inverts a match, { "$contains": "…" } matches a substring of a string (how a
tool result, which travels as JSON text, is asserted), and the value
"$absent" requires the field to be missing. All three wrappers are
single-key — never mixed with literal keys. A
throw inside a tool's run() surfaces as { "ok": false, "status": 500 }
with error starting "Error: …", assertable like any other outcome.
A step can also be a whole agent turn: { "turn": "Add two units of p1", "path": "/shop" } POSTs the message to /_janux/agent and the reply envelope
is the outcome (result matches type, calls, messages…), with the
turn's token usage (and costUsd when the app declared its model's cost)
accounted per scenario and per run. A turn is ok only when the agent
answered — text or ui_calls, and not the stopReason: "max_turns" give-up.
An unconfigured model (setup), a refusal, a provider failure and an exhausted
loop are not ok, so a run without a key fails loudly instead of passing
green; assert those on purpose with
{ "ok": false, "result": { "type": "refusal" } }.
Scenario files run sorted by filename, and a scenario with "reset": true
reboots the --start app first, so it starts from seed state (without
--start, reset is ignored). With --json the booted app's stdout is
silenced — the report is the only thing on stdout, safe to pipe.
--trials N replays the whole set N times and the gate fails only on
scenarios that fail every trial — a real regression reproduces, a wobble
does not block. The verdict (which scenario, how many trials, which step)
goes to stderr and, structured, to eval-gate.json. Above one trial each
stdout report also carries its trial index, so the extra dimension is
legible rather than a silently duplicated name; at the default single trial
the JSON is unchanged.
Every run is appended to .janux/evals/history.jsonl with its metadata
(commit, the model that actually answered, date, usage), and the end of the
run compares against the previous one — or against --baseline <file> —
saying what improved, what regressed and what it cost. Costs are compared
per trial, so a nightly on --trials 3 is not a permanent 3× regression
against a single-trial baseline. A --baseline that cannot be read as a run
record is an error, never silence: a renamed baseline must not quietly stop
comparing. The agent evals in CI recipe
wires all of it into a workflow.
create-janux#
bun create janux my-app # the starter app
bun create janux my-shop --example shop # start from any examples/ app--example <name> scaffolds a copy of one of the example apps (shop, i18n, interop-react, nested-islands, data-cache) instead of the starter template; omit the name to list them. bunx create-janux is the same command.
The starter template scaffolds the conventional layout with a resumable counter island, an agent panel and an example unit test.
Project conventions#
Everything is convention over configuration — each of these is optional:
| Path | Purpose |
|---|---|
src/routes/** |
File-system routing (index.tsx → /, [id].tsx → :id) |
src/server/*.api.ts |
api() modules → endpoints + client stubs + agent tools |
src/stores.ts |
Store defs available during SSR |
src/agent.ts |
export default defineAgent({...}) |
src/i18n.ts (or src/i18n/index.ts) |
export default an I18nConfig — activates internationalization |
src/ctx.ts |
export default a (req) => ctx — per-request context and auth |
src/middleware.ts |
export default a (req) => Response | undefined — runs before routing |
src/matchers.ts |
Named exports = custom [param=matcher] matchers |
src/feed.ts |
export default a FeedConfig — serves GET /rss.xml (see RSS) |
src/client.ts |
boot({ defs }) — omit for fully static apps (0 KB JS) |
src/styles.css |
App stylesheet, linked automatically |
public/ |
Static assets served at / (favicon.svg auto-linked) |
janux.config.ts |
Optional app config (title, llmsTxt, output, …) — same shape as the Vite plugin options, which win over it |
// janux.config.ts
import { defineConfig } from 'janux';
export default defineConfig({
llmsTxt: { title: 'My App', description: 'What agents should know.' },
output: 'static',
});A
"janux"field inpackage.jsonstill works as a deprecated fallback;janux.config.tswins over it.
All config fields#
Everything is optional — the defaults are the conventional layout. Override a field only to move things off-convention. The Vite plugin accepts the same shape and wins over janux.config.ts.
| Field | Default | Purpose |
|---|---|---|
title |
— | Default document title / shell title |
lang |
'en' |
<html lang> for the whole app. An i18n app ignores it: each page declares its own locale and direction |
siteUrl |
— | Public origin (https://janux.dev). Resolves a route's relative image/canonical into the absolute URLs Open Graph needs (see PageMeta), and opts into /sitemap.xml + /robots.txt |
llmsTxt |
off | { title?, description? } — opt into serving GET /llms.txt |
feed |
off | { title?, description?, items } — opt into serving GET /rss.xml. items() returns { url, title, description?, date?, author? }[], typically a content collection mapped, newest first. Needs siteUrl; every page advertises the feed with a rel="alternate" link |
inlineStyles |
false |
Inline the built stylesheet into every page instead of linking it: one less render-blocking round trip before the first paint, at the cost of a cacheable request. Production only — dev keeps the link so CSS hot-reload works |
csp |
off | true for a strict Content Security Policy: a fresh nonce per request on every inline script and style the framework emits, plus the header. { nonce?, header? } to bring your own. Ignored by output: 'static', which has no per-request anything |
output |
'bun' |
'bun' or 'static' — see output |
routesDir |
src/routes |
File-system routing root |
serverDir |
src/server |
Where *.api.ts modules are discovered |
clientEntry |
src/client.ts |
Client boot() entry; absent → fully static app, 0 KB JS |
agentModule |
src/agent.ts |
defineAgent() default export; absent → the built-in default agent |
storesModule |
src/stores.ts |
Store defs available during SSR |
websocket |
src/ws.ts |
Module whose default export is the first-class WebSocket endpoint ({ path, ...handlers }) — janux dev and janux start upgrade it themselves (custom server) |
mcpAuth |
off | { tokenEnv?, token?, resourceMetadataUrl? } — bearer-protect POST /_janux/mcp and POST /_janux/a2a; tokenEnv names the env var read at boot and wins over the literal token. The GET landing stays public and prints $TOKEN-placeholder connect commands |
agents |
off | { webBotAuth: { keys }, policy? } — Web Bot Auth agent verification (see Server API) |
compiler |
{ bindingMaps: true, splitIntents: false } |
Compiler switches. bindingMaps rewrites provable static state reads in views into binding thunks, so a write becomes one DOM write instead of an island re-render — false is the escape hatch for a rewrite the analysis got wrong. splitIntents (opt-in) moves provably self-contained intent run() bodies into chunks fetched on first invocation (Build internals) |
fonts |
off | { family, weights?, styles?, subsets?, display?, preload?, fallback?, variable? }[] — fonts to self-host. Declaring one is the whole API: the build downloads it once, ships only the declared subsets, preloads the critical file and generates a metrics-adjusted fallback face (Fonts) |
redirects |
off | { from, to, status? }[] — legacy URLs answered with a 3xx (default 308) before the route resolves. from uses the router's own segment grammar ([param], [...rest]), captures are spendable in to by name, and rules match in declaration order |
rewrites |
off | { from, to }[] — URLs served by another route of this app, with the address bar keeping what the visitor asked for. Same grammar and ordering as redirects; to must be a route of this app — /_janux/* and other origins are refused |
navigation |
on | { spa?, prefetch?, speculationRules?, viewTransitions? } — SPA navigation, hover prefetch and speculation rules, all on by default; viewTransitions is opt-in because it waits for the full page before the swap (Navigation) |
cache |
on | { tagHeader?, shared?, maxEntries?, maxBytes? } — the header the CDN reads cache tags from (default Cache-Tag) and the server's own shared copy of scope: 'public' responses, inert until a route declares a public policy (HTTP cache) |
serviceWorker |
{ register: true } |
What to do about registering the worker built from src/sw.ts — the file itself is the opt-in. register: false still builds and serves /sw.js but leaves registration to the app (Service workers) |
output#
| Value | Meaning |
|---|---|
"bun" (default) |
janux start serves the app on a Bun server |
"static" |
janux build also prerenders every page into dist/client (/docs/x → docs/x/index.html + docs/x.md, plus llms.txt and, from _404.tsx, 404.html) — deploy to any static host, no server. Dynamic routes need staticParams (Route modules); those without it are skipped with a warning |
More output targets will come later. Full walkthrough: Deploying → Static export.
Programmatic use#
@janux/cli is also a module: runCli(argv) is what bin.ts calls, parseArgs(argv, cwd) parses a command line into { command, root, port, … }, and HELP_TEXT is the usage string.
import { createJanuxServer } from '@janux/server';
import { prodServerOptions } from '@janux/cli';
const server = createJanuxServer(await prodServerOptions(process.cwd()));prodServerOptions(root) resolves an app's conventions into the ServerOptions that janux start uses — routes, *.api.ts modules, stores, agent, i18n, per-request ctx, middleware, matchers, src/api/** handlers, the built client.js and stylesheet. Spread it to override individual fields. It expects janux build to have run (that's where dist/client/client.js comes from) and it does not serve static files: see custom server.
Environment#
| Variable | Purpose |
|---|---|
JANUX_MODEL |
provider/model for the copilot |
ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / OPENROUTER_API_KEY |
Provider auth (also drives model sniffing) |
PORT |
Server port |