iii worker add consoleThis fetches the prebuilt binary, writes a console: block into ~/.iii/config.yaml, and the engine launches the worker the next time it boots.
curl -fsSL https://install.iii.dev/iii/main/install.sh | sh
iii project init iii-app && cd iii-app
iii # engine on ws://127.0.0.1:49134# New terminal, same folder
iii worker add console # UI + /ws proxy on :3113
open http://127.0.0.1:3113The browser hits / for the SPA shell and upgrades /ws to the engine WebSocket — one origin, no CORS, no API base URL to configure.
Chat runs on the harness durable turn loop. Its manifest declares the whole stack as dependencies — session-manager (the conversation store the sidebar, transcripts, and live token rendering are backed by), llm-router (generation + the model catalog), context-manager (the /compact summariser), approval-gate (human-in-the-loop approvals), and the provider workers — so a single command resolves and installs all of it:
iii worker add harnessThe provider workers install with the harness, but they need credentials before any model appears — until then the model picker reads no models and chat won't generate. Add a key either way:
- In the UI (recommended) — open the model picker and use configure anthropic / configure openai to paste a key. It's written to that provider's slice of the
llm-routerconfiguration entry, and the catalog populates within seconds. - From the environment —
llm-routerfalls back to a provider's credential env var (e.g.ANTHROPIC_API_KEY), read in the router's own process. Seellm-routerfor the credential model.
Pick a model in the composer and send — the turn streams back through the harness loop.
Programmatic check from the SDK
use iii_sdk::{register_worker, InitOptions, TriggerRequest};
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let iii = register_worker("ws://localhost:49134", InitOptions::default());
let result = iii.trigger(TriggerRequest {
function_id: "console::status".into(),
payload: json!({}),
action: None,
timeout_ms: Some(5_000),
}).await?;
println!("{result:#?}");
Ok(())
}Returns { http_port, engine_url, version } — useful for liveness and readiness probes.
- One port, one binary. The React UI is baked into the executable with
rust-embed, and the engine WebSocket is reverse-proxied at/wson the same origin. No CORS preflight, no side-car static server, nodist/directory to deploy. Seesrc/assets.rsandsrc/proxy.rs. - Live engine, live UI. Functions, models, traces, and chat sessions all stream over a single WebSocket via the iii browser SDK. Mention any registered function with
@, switch models on the fly, and watch the trace appear in the panel next to you. Seeweb/src/lib/iii-client.ts. - Built for production. SIGINT and SIGTERM graceful shutdown (so
docker stopandkubectl deleteactually drain), URL credentials redacted from logs, immutable cache headers on content-hashed assets, and a fully self-contained binary with no runtime filesystem deps. Seesrc/main.rs.
A purpose-built agentic chat UI on top of Lexical. Lives in web/src/components/chat/.
- Two modes —
askandagenttoggle right in the composer - Live model picker — provider-grouped from
router::models::list; static fallback (OpenAI, Anthropic, Google) when the catalog is unreachable @-mentions — fuzzy-search every function registered against the engine/compactslash command — summarises conversation history via thecontext-managerworker'scontext::compact, then persists acompactioncustom session entry; the durable transcript is untouched — the marker renders from that entry and the summary anchors future turns- Attachments — multi-file picker with text/image previews
- Function calls — running / pending / error cards, consecutive calls grouped, with approve/deny gating for pending approvals (
approval::resolve) - Streaming — abortable mid-flight; thinking shimmer; collapsible thought messages
- Markdown — GFM, code blocks with prism-react-renderer, syntax-highlighted JSON inputs and outputs
- Conversation sidebar — create, inline rename, delete, auto-title from the first message
- Context-usage meter — token estimate with warn / danger thresholds and a
/compactnudge - Session ID — copyable, deep-links every conversation into the trace explorer via
iii.session.id - Persistence — conversations, active id, last model, sidebar state — all in
localStorage
Full-fledged OpenTelemetry explorer over engine::traces::* and engine::logs::list. Lives in web/src/pages/TracesV2/.
- Live timeline strip masthead — every span streamed in real time, with a shared funnel as the volume control
- Two detail visualizations — lane timeline (same visual grammar as the strip) and a waterfall tree virtualized for huge traces
- Rich filtering — status, time presets, min/max duration, arbitrary attribute key/value pairs, debounced free-text search, saved views
- Group by — server-side aggregation with lazy per-group member expansion
- Span detail tabs — info, attributes, events, errors, OTel logs, context (baggage), links
- Live streaming — spans append over iii streams (
iii:devtools:*) instead of polling; one seed read, then append
The human window into the worktree worker: parallel agent checkouts, ownership, and land outcomes. Lives in web/src/pages/Worktrees/ and web/src/components/chat/. The whole surface is presence-gated: it appears only while the worktree worker is connected to the engine (the nav entry and picker tab hide; a direct #/worktrees hit lands on an install notice).
- Graph page (
#/worktrees) — repo, worktree, and owning-session nodes fromworktree::list { include_status: true }, refreshed live off all sixworktree::*lifecycle trigger types (poll fallback while bindings are unavailable), with a per-worktree detail panel: branch, base, advisory dev port, clean / ahead / behind, diffstat, and the integrated marker for squash- or rebase-landed branches - Picker tab — the chat working-directory picker grows a worktrees tab next to directory browsing: picking a managed worktree validates the path and claims it for the conversation's session; the console-made claim auto-releases when the conversation points elsewhere (best-effort; the worker's prune sweep is the durable backstop). Worktrees with a land in progress are listed but not retargetable
- Working-dir badge — a conversation rooted in a managed worktree shows branch, short id, dirty
*/ ahead+nindicators, and a lifecycle dot instead of the plain path chip; the raw path stays reachable as the tooltip - Live land notices —
worktree::landed/worktree::land-blockedevents surface in the chat as notices (target branch and merged sha, or the block reason and conflicted files) and refresh the badge
The human window into the memory worker: named banks of always-injected markdown rules and auto-extracted memories. Lives in web/src/pages/Memory/. Presence-gated like Worktrees: the page appears only while the memory worker is connected (a direct #/memory hit lands on an install notice).
- Bank rail — every bank with live memory / pinned / rule counts and inline create
- Rules tab (first) — the bank's markdown rules as in-place editors (save appears only on edit; delete asks to confirm); the agent appends learned standing instructions to the auto-managed
learnedrule as you correct it in chat - Memories tab — server-paged newest-first list with pin / edit-in-place / tombstone delete, a show-history toggle, and search that runs
memory::recall(the ranked scorer, not a client filter) - Graph tab — entity hubs with memories as spokes: level-of-detail for large banks, draggable nodes, wheel zoom/pan, click a node for an inspect card
- Preview tab — the whole turn before it happens:
memory::previewcomposes the exact system-prompt rules section and the appended memories (ambient floor + budgets applied) for a hypothetical question, with clickable example questions from the bank's own content - Live — the page re-reads off
memory::item-changed/memory::bank-changed(poll fallback), so memories appear the moment they're learned - In chat — a bank picker in the composer (session metadata
memory_bank) and a memory chip on each assistant reply naming the bank, how many rules and memories fed the turn, and whether recall ran semantic; click it to expand the exact records
The composer's @-mentions and the model picker pull from the engine in real time.
engine::functions::list— TTL-cached function list (VITE_FUNCTIONS_LIST_CACHE_MS, default 10s) →web/src/lib/functions-catalog.tsrouter::models::list— provider-grouped model catalog, refreshed live off therouter::models::changedtrigger type →web/src/lib/models-catalog.ts
Light and dark themes via data-theme + CSS custom properties. Persisted to localStorage with an inline init script in index.html to prevent flash-of-wrong-theme on first paint.
console registers a single function against the engine for health probes and iii worker info smoke tests:
| Function | Input | Output |
|---|---|---|
console::status |
{} |
{ http_port, engine_url, version } |
Defined in src/functions/status.rs.
flowchart LR
Browser["Browser SPA<br/>(iii-browser-sdk)"] -->|"HTTP GET /"| Console
Browser -->|"WS /ws"| Console
Console["console binary<br/>(axum + rust-embed)"] -->|"WebSocket"| Engine["iii engine<br/>:49134"]
Console -. "registers console::status" .-> Engine
console is a thin HTTP server with exactly two jobs: serve the embedded SPA bundle (with appropriate cache headers) and transparently proxy /ws to the iii engine. The browser only ever talks to one origin.
http_port: 3113 # port the UI + /ws are served on (default: 3113)
injectable_ui: true # kill switch for runtime-injected worker UI (default: true)| Key | Default | Description |
|---|---|---|
http_port |
3113 |
TCP port the worker binds for /, /assets/*, and /ws |
injectable_ui |
true |
When false, skips the console:script / console:style / console:assets trigger types, the /ui + /vendor routes, and the SPA loader (console::ui-manifest answers disabled: true) |
| Flag | Default | Description |
|---|---|---|
--config <path> |
./config.yaml |
Path to the YAML config |
--url <ws://…> |
ws://127.0.0.1:49134 |
iii engine WebSocket URL (DEFAULT_ENGINE_URL in src/config.rs) |
--http-port <port> |
from config | Overrides http_port from the config file |
--manifest |
— | Print the publish manifest as JSON and exit (used by the registry pipeline) |
| Path | Behavior |
|---|---|
GET / |
Embedded index.html (SPA shell, hash-routed client-side). Cache-Control: no-cache, must-revalidate |
GET /assets/* |
Embedded JS / CSS, content-hashed filenames. Cache-Control: public, max-age=31536000, immutable |
GET /ui |
Injected-asset manifest JSON (same shape as console::ui-manifest). no-cache |
GET /ui/* |
Current bytes for a registered injected UI asset. no-cache + ETag: "<hash>" (304 on If-None-Match) |
GET /vendor/* |
Shared-dep ESM shims for injected scripts (react, @iii-dev/console-ui), generated at web build time. no-cache |
GET /ws (Upgrade) |
WebSocket upgrade; transparent proxy to engine_url (drops browser-originated registertriggertype frames; stamps metadata.internal on registerfunction) |
| anything else | 404 Not Found |
The SPA bundle is embedded into the binary at compile time via rust-embed — the released console has no separate dist/ directory, no side-car asset server, and no runtime filesystem dependency for the UI.
Workers extend the console at runtime — whole pages and function-trigger
renderers as plain React components sharing the console's React instance
(spec: iii/tech-specs/2026-07-17-injectable-ui). The console owns three
trigger types:
| Type id | Registered by | Carries |
|---|---|---|
console:script |
workers | an ESM script asset; config.path (e.g. state/page.js) is its identity — re-registering a path overrides it (hot reload) |
console:style |
workers | a CSS asset, applied as a <link> swap |
console:assets |
console tabs | the live-update subscription the console pushes sync/set/delete events to |
The trigger's function_id is the worker's content function
({path} → {content, content_type?}); the console fetches over the bus,
hashes, serves from /ui/*, and pushes invalidations so every open tab
disposes the old module and re-imports the new one. Injected scripts default-
export setup(host) and register through host.pages (whole pages at
#/ext/<id>), host.functionTriggers (function-trigger message renderers —
injected renderers dispatch before the built-in families, so matching a
built-in id overrides it), and host.configForms (replace the workers-tab
form region for one configuration id; dirty/save/reset stay host-owned).
Renders are fenced by an ErrorBoundary and scoped under
data-iii-ui="<worker>". console::ui-manifest (internal) lists the
loadable assets. The state worker's ui/ directory is the working
reference implementation of all three slot kinds.
| Layer | Choice |
|---|---|
| Web server | axum 0.7, tokio, tokio-tungstenite |
| Asset embedding | rust-embed 8, mime_guess |
| Worker SDK | iii-sdk 0.19.4 |
| UI framework | React 19, Vite 8, TypeScript 6 |
| Styling | Tailwind CSS v4, Radix UI, class-variance-authority, lucide-react |
| Editor | Lexical 0.44 |
| Data fetching | TanStack Query 5 |
| Trace graphs | @xyflow/react 12 + dagre, TanStack Virtual |
| Markdown | react-markdown + remark-gfm, prism-react-renderer |
| Browser SDK | iii-browser-sdk 0.21.6 |
Build from source (contributors only)
cargo build runs pnpm install --frozen-lockfile && pnpm build inside web/ automatically when the web/dist/ bundle is missing or stale (Node + pnpm must be on PATH). To pre-build the bundle once and skip the embedded-asset rebuild loop:
cd web && pnpm install && pnpm build && cd ..
cargo build --releaseEscape hatches (see build.rs):
SKIP_WEB_BUILD=1— skip the JS build step entirely; the existingweb/dist/(if any) is embedded as-is. Useful in CI when the bundle was built in a previous stage.PNPM=/path/to/pnpm— override pnpm discovery.
Run the test suite:
cargo test # unit + manifest + e2e (e2e self-skips if `iii` isn't on PATH)
cd web && pnpm test # vitest
cd web && pnpm typecheck && pnpm lintApache 2.0 — see LICENSE.