Frontend development (apps/os)
How we write the apps/os dashboard: one programming model, a handful of thin
hooks over a capnweb capability tree reached through one WebSocket, with live
state pushed from Durable Objects — Elixir-LiveView/Phoenix in a React
TanStack Start app.
If you only read one thing: you talk to the backend through itx. itx is a
capnweb RpcStub — a capability handle you call like a local object; the calls
travel over the tab's single /api WebSocket and the server answers or pushes.
The client lives in the published iterate package and is layered so every
runtime shares one implementation:
| Entry | What it is |
|---|---|
iterate/sdk/itx/react |
The thin ITX hooks below. Renderer-agnostic — the same module runs under react-dom (this dashboard), @opentui/react (the chat TUI), and React Native. |
iterate/client |
The framework-free ITX layer under them: the one-socket session keeper and transport-error classification. No React anywhere. |
iterate/sdk/capnweb |
Project-independent snapshot+patch protocol, client store, and server engine over Cap'n Web. Its README documents the standalone surface. |
iterate/sdk/capnweb/react |
Generic CapnWebProvider, useCapnWebRoot, and useLiveState; ITX's ProjectScope and live-state hooks are deliberately tiny adapters over this entry. |
iterate/node |
The node one-shot dial (ws, custom headers, frame observer) for scripts and e2e — using-scoped, no keeper. |
In a browser the keeper needs zero configuration (it dials the page's /api
with cookie auth); non-browser consumers point it at a deployment with
configureIterateSession({ baseUrl, credentials }). Credential providers are
resolved for every dial, and native clients can replace deployments or
disconnect at sign-out. That is the entire runtime-specific binding around the
shared keeper.
The stack#
| Concern | What we use |
|---|---|
| App framework | TanStack Start (SPA mode), TanStack Router (file-based routes) |
| Server cache / async | TanStack Query (reads ride useSuspenseQuery/useQuery) |
| RPC to the backend | capnweb — promise-pipelined capability RPC over one WebSocket to /api |
| Forms | TanStack Form + shadcn field components from @iterate-com/ui |
| UI | shadcn/ui via @iterate-com/ui/components/* |
| Validation | Zod |
| Auth (identity) | @iterate-com/auth client, seeded SSR-side |
The backend surface (itx) is the project's one API — see
apps/os/src/README.md (the "four nouns") and the
generated contract apps/os/src/itx-api.generated.ts.
The four nouns#
- A Session is what
authenticate()returns — a catalog that vends project itxs (projects.list/create/get), plus admin-only deployment-widestreams. It is not itself an itx. - A project is the tenant boundary (
prj_…), its Durable Objects, its streams. - An itx is a capability handle scoped into one project —
RpcStub<Project>.itx.streams,itx.repos,itx.agents,itx.secrets,itx.integrations,itx.chat,itx.liveState, … Unknown dotted names fall through to the project's dynamic capability table. - A capability is anything callable on that tree — built-in or provided.
One socket, invisible reconnect#
The whole tab shares one WebSocket — at most one active socket at a time,
authenticate()d with the signed-in session cookie, and everything — the sidebar
catalog, every project page, the stream mirror — rides it. The connection layer
(packages/iterate/src/itx/itx-session.ts,
exported as iterate/client) keeps it in
module state (outside React) so it survives client-side navigation, and makes
reconnect invisible: the last session is kept across a transport gap, so a
dropped socket re-dials in the background without re-suspending the UI or showing
a spinner. Precisely: committed UI keeps rendering, resolved reads keep their
cached data (TanStack doesn't drop caches on a socket death), live subscriptions
silently re-establish, and only an in-flight read retries (on a finite,
transport-error-only policy — reconnect does not itself refetch resolved
queries). The one accepted edge: an imperative call fired during the sub-second
gap rides the dead stub and rejects. The full model (generations, the half-open
verifier, the semantic-vs-transport reset) is documented in that file's header
and in apps/os/src/itx/DECISIONS.md D24.
Rules of thumb (the LiveView analogy — the server owns durable reduced views, React owns local interaction):
- Immutable / historical / versioned / paged data → a finite read.
- Mutable current server state → a live projection pushed from the DO.
- Mutations → just call the capability; the resulting state comes back through the live projection (no manual cache invalidation where a projection exists).
- Ephemeral interaction state (draft forms, open panels, URL state, optimistic pending) → stays in React.
The hooks#
Everything a component needs comes from one import,
import { … } from "iterate/sdk/itx/react"
(packages/iterate/src/sdk/itx/react.ts).
Get a handle#
const session = useIterateSession(); // the Session catalog (suspends once, on first connect)
const itx = useItx(); // the project itx for the ambient <ProjectScope>
const itx = useItx("other-slug"); // …or a specific project by slug/idImperative siblings for event handlers / closures (can't call a hook there):
const session = await connectIterateSession();
const itx = await connectItx(slug);Read (finite, cached)#
useItxQuery reads through a project itx and suspends until it resolves.
Just put it in a route component — TanStack Router wraps every route match in
<Suspense> (the router's defaultPendingComponent), so navigation shows a
spinner in the page area, never a blank. The key is the TanStack cache key
(prefixed with "itx"); include the project id so two projects can't collide.
const files = useItxQuery({
key: ["repo-files", projectId, repoPath],
query: (itx) => itx.repos.get(repoPath).listFiles(),
});useIterateSessionQuery is the session sibling — non-suspending (it serves the
always-mounted shell: sidebar, ⌘K, admin), same transport-only retry:
const { data } = useIterateSessionQuery({
key: ["projects"],
query: (session) => session.projects.list(),
});Both resolve the connection per fetch (never a render-captured stub — that would pin a dead socket after a reconnect). A resolved read shows its cached data straight through a reconnect; only an in-flight read retries.
Live state (server pushes)#
useLiveState subscribes to a .liveState node; the server pushes a snapshot
then minimal diffs, and your stable-slice selector picks what you render. It
never suspends — value is undefined until the first snapshot, then the last
value stays visible through a reconnect while the subscription silently
re-establishes.
const streams = useLiveState(
(itx) => itx.liveState,
(s) => s.streamsIndex,
);
// re-renders only when streamsIndex changes.useIterateSessionLiveState is the same primitive rooted at the Session catalog,
for deployment-wide nodes such as admin streams:
const runtime = useIterateSessionLiveState(
(session) => session.streams.get(path).liveState,
(state) => state,
[path],
);The selector must be a pure function of the state — return a stable slice
((s) => s.rows), never a fresh object, and never close over props/state
((s) => s.rows[props.id] goes stale invisibly: selection is cached by state
identity). Select the broader slice and index in render, or route the changing
input through deps.
Act (mutations)#
No extra primitive — just call the capability on the handle, usually inside a
TanStack Query useMutation:
const itx = useItx();
const send = useMutation({ mutationFn: (text: string) => itx.chat.sendMessage(text) });Mount#
One component. <ProjectScope slug> carries the ambient project slug (so
useItx() resolves without an argument), pre-warms the one socket, and installs
the generic reconnectable Cap'n Web provider consumed by useLiveState. Mount
it under an ssr: false route — itx never SSRs (it dials a WebSocket and throws
on the server). No additional provider is needed above it. Session-scoped hooks
in the sidebar / ⌘K / admin use the module-global keeper directly.
<ProjectScope slug={project.slug}>
<Outlet />
</ProjectScope>useItxSubscription is the low-level escape hatch under useLiveState for
genuinely event-oriented streams (the activity tail); most UI wants useLiveState.
Hooks & components — the whole surface#
The entire browser API, from one file. Itx = RpcStub<Project>, a project
capability handle; a "read" is a finite cached fetch, "live" is server-pushed
state. A slug argument also accepts a prj_… id.
| Symbol | Kind | What it gives you |
|---|---|---|
useIterateSession() |
hook | The Iterate session — the catalog authenticate() returned (projects.list/create/get, admin streams). Suspends once, on first connect. (Named to not collide with the auth session from useAuthClient().) |
useItx(slug?) |
hook | A project itx (session.projects.get(slug)). Defaults to the ambient <ProjectScope>. The everyday handle: itx.streams, itx.repos, itx.chat, … |
connectIterateSession() |
fn | Imperative useIterateSession — a Promise, for event handlers / mutationFns / closures. |
connectItx(slug) |
fn | Imperative useItx — a Promise. |
useItxQuery({ key, query }) |
hook | Read once through a project itx; suspends. For content-addressed / historical / one-shot reads. |
useIterateSessionQuery({ key, query }) |
hook | Read once through the session; non-suspending (serves the always-mounted shell — sidebar, ⌘K, admin). |
useLiveState(node, selector) |
hook | Subscribe to a .liveState node; server pushes a snapshot then diffs, the stable-slice selector picks what you render. Never suspends. The LiveView primitive. |
useIterateSessionLiveState(node, selector) |
hook | Session-root sibling of useLiveState, for deployment-wide live nodes such as admin streams. |
useItxSubscription(subscribe, deps) |
hook | Low-level escape hatch for raw ordered event streams (the activity tail). Most UI wants useLiveState. |
<ProjectScope slug> |
component | Sets the ambient project, pre-warms the socket, and provides its reconnectable project root to generic LiveState. The one mount. |
reconnectIterateSession() |
fn | The deliberate semantic reset — drop + re-dial the socket to pick up new claims (after creating a project / unlocking admin). Distinct from the automatic, invisible transport reconnect. Pair with invalidateQueries({ queryKey: ["itx"] }) when cached reads must refresh under the new claims. |
disconnectIterateSession() |
fn | Release the current socket and authority without reconnecting, for native sign-out/process lifecycle boundaries. The selected deployment remains configured for a later connection. |
isItxTransportError(e) |
fn | Is an error a transport-close (retryable), vs auth/validation/app (not)? |
reportTransportSuspicion() |
fn | Escape hatch for the non-React transport consumer (the browser stream mirror): "this socket looks half-open." Routes to the socket-owned verifier — which two-strike-checks and may re-dial — but never closes the socket itself. |
configureIterateSession(config) |
fn | Point the keeper at a deployment explicitly ({ baseUrl, credentials }) — the non-browser entry into the one-socket model (mobile, chat TUI, keeper-based scripts). A credential provider is resolved per dial; the same target updates it in place, while a different target replaces the socket. |
Itx (type) |
type | RpcStub<Project> — a project handle, for typing helpers that take one. |
ItxLiveSubscriptionHandle (type) |
type | What any subscribe() returns — ping() + unsubscribe() (+ optional [Symbol.dispose]; the hook both unsubscribes AND disposes on teardown). The shape useItxSubscription drives. |
ItxSubscriptionStatus (type) |
type | "connecting" | "live" | "error" — the lifecycle a subscription reports. |
Mutations have no hook — you call the capability on the handle
(itx.chat.sendMessage(text)), usually inside a useMutation.
That's the whole surface — one entry (iterate/sdk/itx/react), no others. The everyday
four are useIterateSession / useItx / useItxQuery / useLiveState; the
rest are imperative siblings, the mount, the one escape hatch, and types. The
chat TUI consumes the SAME entry under OpenTUI — its data layer is
configureIterateSession + useItxSubscription and nothing else.
The one exception (for now): the stream feed#
The main stream feed view does not yet speak this model. It runs the
browser stream mirror: useStreamMirror() downloads a stream's whole event
log once per (project, path) and runs two processors client-side into an OPFS
SQLite database (with cross-tab leader election and durable checkpoints), and
components query it with useStreamQuery(db, sql) — ~4,850 LOC under
apps/os/src/domains/streams/client-libraries/browser/.
It rides the same one socket (it reports transport suspicion, never closes
anything), but it is a second consumption model with its own hooks. The ⌘K
switcher and stream tree already use plain useLiveState — the feed is the
holdout, because its projection (the rendered feed + live agent activity) is
folded in the browser today. The plan to collapse it — server-owned feed live
view + cursor-paged history, then delete the mirror — is designed in
apps/os/docs/replace-browser-stream-database.md.
Don't build new UI on the mirror; use useLiveState/useItxQuery.
Where the boundary is#
Reads/mutations of product state travel the itx tree. The few
createServerFns are request-native SSR orchestration only — auth snapshot,
cookies, root redirects, redacted bootstrap config — because they run in
beforeLoad before the client socket exists. Don't add a second data path; if a
component needs project data, call useItxQuery/useLiveState at the leaf.
Where this is going#
The model is ~95% in place. The non-React transport core now exists
(iterate/client — the TUI and React Native app run on it). The remaining
steps — capability-aligned live projections so mutable lists
(integrations, scheduler) stop hand-invalidating, and retiring the browser
stream mirror for server-owned live views — are laid out in
apps/os/docs/itx-frontend-model-roadmap.md.
(We deliberately did not collapse the two read hooks into a useRead({ from }):
TanStack splits suspense/non-suspense reads, so a merged hook would need an option
that flips the return type — more confusing, not less.)