itx (src)
This folder is itx behind /api — os' one API — and everything project-scoped in
OS: streams, repos, agents, secrets, dynamic workers, egress, and the itx
capability surface itself. It began life as apps/minimal-itx-v4 and was
transplanted here whole during the itx-v4 replacement (PR #1585 has the
history; this README describes what is).
The public contract of record is types.ts — handwritten,
import-free, and what every client (browser, CLI, scripts, dynamic workers)
programs against. When this README and types.ts disagree, types.ts wins.
Layout#
| Path | What |
|---|---|
types.ts |
The public itx contract (the design of record) |
rpc-targets.ts |
ALL RpcTarget classes: the session/project/agent surfaces, MCP/OpenAPI clients, capability provision, stream subscriptions, egress |
auth.ts |
The auth adapter: credentials → ItxAuth (see below) |
itx-client.ts |
connectItx() — the Node/CLI client over a Cap'n Web WebSocket |
ingress.ts |
The shared routing decision (which requests belong to itx) |
project-directory.ts |
Slug → project id resolution against the auth worker, cached in the PROJECT_DIRECTORY KV namespace |
env.ts |
The single worker's binding contract (worker topology) |
worker.ts |
The single worker entry |
domains/ |
One folder per domain: streams, projects, repos, agents, secrets, workers (dynamic), capability-host, itx, inbound-mcp-server |
Each domain owns its Durable Object plus a stream-processor contract
(*-processor-contract.ts, pure: event schemas + reducer) and implementation
(*-processor-implementation.ts, side effects). RpcTargets deliberately do NOT
live in domain files.
The four nouns#
- A session is what
authenticate()returns: a catalog that vends itxs (projects, plus admin-only deployment-widestreams/repos). It is not itself an itx. - A project is the tenant / isolation boundary — a
prj_…id, its Durable Objects, its streams. Per-project confinement is the one security invariant itx keeps. - An itx is a capability context scoped into one project at one path.
"itx" is a NAMING CONVENTION, not a class: an itx is normally an instance of
ProjectRpcTargetwhose capability host sits at"/"— and sometimes at"/agents/…", which is what "an agent context" means. Same type either way; a nested scope sees its own mounted capabilities plus everything its journaled fallback host reports (usually the project root, one hop). - A capability host is the durable dynamic-capability table (and script
journal) at one scope path — one
CapabilityHostDurableObjectper{projectId, path}. Host operations areprovideCapability,revokeCapability,runScript, and__describe(). Each itx fronts exactly one host (itx.capabilityHost;itx.provideCapability/revokeCapabilityare shortcuts onto it), anditx.capabilityHosts.get(path)addresses any other scope's host —get("/")mounts on the whole project.
__describe(): discovery everywhere#
Every node in the tree answers __describe() with the same envelope
(Description in domains/itx/describe.ts): instructions (prose for this node), types
(TypeScript source), children (one-line blip per member — the high-level
map), and parent (where the node sits). Nodes add structured extras — a
project adds projectId/name/capabilities, a session adds principal,
an agent adds whoami — so __describe is also the identity query; there is
no separate describe()/whoami().
Deep discovery is a walk: read children, recurse into what you care about
(see the discover-tree example). Mounted capabilities answer __describe()
too — the capability host serves it from the mount's provide-time
instructions/types metadata, never dialing the live target, so discovery
works even when a session-bound provider is offline. __describe is an
invalid MOUNT name (a mount there would be unreachable behind the
interception), but it traverses dynamic paths like any other segment — the
interception is the only mechanism, no proxy special cases.
Today each node hand-writes its description (describeNode in
domains/itx/utils.ts only enforces the shape); the intended evolution is a
transitive mechanism where a parent composes its children's descriptions.
The capability tree#
What a caller can reach, top to bottom. Concrete classes in parentheses (all in
rpc-targets.ts); -> marks methods that vend a new capability. Every node
also has __describe() (not repeated below).
UnauthenticatedOs (UnauthenticatedOsRpcTarget) ws/POST /api
`-- authenticate(credentials) -> Session
Session (SessionRpcTarget) a catalog; NOT an itx
| __describe().principal = who you are
|-- streams / repos (projectId: null collections) admin: deployment-wide
`-- projects (ProjectCollectionRpcTarget)
|-- list()
|-- create({ slug }) -> Itx (project root)
`-- get("prj_...") -> Itx (project root)
Itx (ProjectRpcTarget) -- "itx" is a convention: capabilityHost.path selects
| the scope. "/" = project root; "/agents/..." = an agent context.
| __describe() = identity + children map + full capability inventory.
|-- capabilityHost (CapabilityHostRpcTarget) THIS scope's durable table
| |-- path
| |-- __describe() .capabilities = own + inherited
| | mounts, scope-tagged
| |-- provideCapability(input) -> CapabilityProvision (revoke handle)
| |-- revokeCapability({ path, providedAtOffset? })
| |-- invokeCapability({ path, args }) explicit dynamic dispatch
| |-- runScript(code) async (itx) => {...} in THIS scope
| `-- <anything else> dotted fallback -> invokeCapability
|-- capabilityHosts (CapabilityHostCollectionRpcTarget)
| `-- get(path) -> CapabilityHost of ANY scope;
| get("/") mounts project-wide
|-- provideCapability / revokeCapability shortcuts -> capabilityHost
|-- debug() dashboard/debug info (Slack-friendly)
|-- integrations (ProjectIntegrationsRpcTarget) connections + connection-scoped proxies
| |-- getConnection / startOAuthFlow / disconnect
| |-- gmail (GmailRpcTarget) gmail.request({ path, query })
| `-- slack (SlackRpcTarget) slack.chat.postMessage({ ... })
|-- streams repos repo agents sandboxes secrets project built-ins, resolved in
| workers worker egress ai mcp openapi the isolate (never shadowable)
| examples processor
|-- agent? chat? DERIVED getters: present only when
| capabilityHost.path is /agents/...
`-- <anything else> DYNAMIC: the proxy routes unknown
roots to capabilityHost
.invokeCapability({ path, args }),
which chains child -> parent -> "/"
Agent (AgentRpcTarget) -- via itx.agents.get("/agents/...") or itx.agent
| __describe().whoami = "agent <prj>:<path>"
|-- capabilityHost (CapabilityHostRpcTarget) the AGENT scope's table
|-- provideCapability / revokeCapability shortcuts -> capabilityHost
|-- chat (AgentChatRpcTarget), stream, processor
|-- sendMessage(text), ask({ message })
`-- <anything else> same dynamic fallback, agent scope
CapabilityProvision (CapabilityProvisionRpcTarget) returned by every provide
|-- path, providedAtOffset
|-- revoke()
`-- [Symbol.dispose] `using` revokes on scope exit
The itx and agent surfaces have NO dispatch machinery of their own: the
withInvokeCapabilityFallback proxy routes every unknown dotted root straight
to the injected capability host, and the host itself carries the same fallback
(host.foo.bar(x) is host.invokeCapability({ path: ["foo","bar"], args: [x] })).
The load-bearing asymmetry: reads fall back, writes stay local.
invokeCapability/__describe follow the scope's birth-certificate fallback
expression on a miss — one direct hop, normally to the project root host, with
no path-prefix walking — so a root mount is visible everywhere.
provideCapability always mounts on exactly the host you called it on — to
mount elsewhere, address that scope explicitly via capabilityHosts.get(path).
Slack webhook ingress (/api/integrations/slack/webhook) is deliberately NOT
on this tree: it is an HTTP lane in the worker's api pipeline
(domains/integrations/slack-webhook-api.ts) that routes signed events
directly into the claiming project's stream. The OAuth callback routes stay
app-side (they need the browser session).
Connecting and authenticating#
/api exports one unauthenticated Cap'n Web target with a single method:
using unauthenticated = connectItx({ baseUrl });
using session = unauthenticated.authenticate({ type: "admin-secret", secret });
using itx = session.projects.get("prj_…");authenticate() is the only way in — authority is never forged, only handed
back by a method that checked you. Credential lanes (auth.ts):
from-server-cookie— the same-origin browser lane: a signed-in user's session cookie or a short-lived operator cookie on the WebSocket handshake.bearer— an auth-worker OAuth access token as RPC data.admin-secret— the deployment admin API secret (CLI, tooling, e2e).operator-session— a short-lived deployment- and origin-bound operator grant. A project grant carries one project ID and reconstructs a synthetic operator principal; it never adopts a customer identity. A platform grant is a separate, explicit authority kind.impersonate— admin-gated fake principal, so test suites can exercise per-project confinement without minting real users.
Project access comes from auth-worker session claims, with a directory
fallback: on a claims miss, ensureCanAccessProject consults the auth worker's
project directory (through the KV cache) and widens the live context — this is
how a just-created project is usable before the JWT refreshes. Scoped operator
grants disable this fallback and remain confined to their one signed project
ID, including when the operator knows another valid project slug or ID.
connectItx overloads are client-side convenience only:
using session = connectItx({ auth, baseUrl });
using itx = connectItx({ auth, baseUrl, projectId });
using agent = connectItx({ agentPath: "/agents/demo", auth, baseUrl, projectId });Project creation#
session.projects.get(slug) returns a possibly nonexistent project handle
without creating anything. handle.create({ organizationSlug?, projectId? })
registers that slug with the auth worker (the project directory — OS has no
database of its own), adopts the directory-issued project ID, primes the KV
cache, then appends project/create-requested plus Notification's birth and
the two platform processor subscriptions onto the project's root stream. By
default it waits for terminal project/created; callers rendering live
bootstrap progress pass { waitUntilCreated: false }. The Project processor
creates the root capability host, scheduler, email router, and config repo at
/repos/config (an ordinary repo on its own stream — itx.repo is the
shorthand). The config repo is seeded from the template folder at
configs/default (thin TypeScript worker.ts router, modular
apps under apps/, and package.json — platform types come from its
iterate devDependency's iterate/sdk export — and AGENTS.md;
codegen keeps the seeded file map in
domains/repos/config-repo-template.generated.ts in sync). Once that trusted
seed worker builds and answers a readiness probe, the Project processor
atomically installs the ordinary root worker feed (starting after the creation
request), emits terminal project/created, and appends the first
project/worker-updated with the OS-stamped seed commit. It does not wait for
userspace to consume either event. Because the feed is installed immediately
before those facts in the same append, project/created is the template's
first userspace lifecycle hook. A config-repo or deterministic worker
source-build failure emits terminal project/create-failed; transient
infrastructure availability and in-progress builds stay open for durable
redelivery. Agents are created separately and explicitly; a stream path alone
never creates one. The config repo's stream
carries a project-config-to-root copy subscription from birth, so every config-repo event
(including repos/created and repo/commit-completed) is copied onto the
project stream / with provenance. Streams are the coordination layer for all
of this — bootstrap is events and processors, not a setup RPC.
After terminal creation, the Project processor recognizes each exact
/repos/config repo/commit-completed copy, waits for the authoritative
current default worker to build, load, and answer, then appends root
project/worker-updated. The trusted seed commit is creation input and is not
translated; creation's worker probe publishes its first certificate instead. A
deterministic source-build failure instead appends
project/worker-update-failed; head convergence, in-progress builds, and
transient availability leave the processor cursor behind for redelivery. A
later HEAD may satisfy an earlier commit fact, so this certifies that current
configuration is runnable rather than activating one exact artifact.
The seeded worker's literal switch exposes project/created,
project/worker-updated, project/heartbeat-triggered, and root
stream/woken. Each case is ordinary userspace TypeScript: get itx and make
whatever calls belong to that lifecycle event. There is no reconciliation
framework or shared hook. project/create-requested remains platform-only
because it precedes the userspace feed. The default template uses
project/created to create and start its onboarding agent and to navigate
connected OS browser clients; templates can change or omit that behavior.
The seeded project/worker-updated case calls scheduler.set(...) for one
15-minute heartbeat whose script appends
project/heartbeat-triggered with { scheduleKey }. An unchanged canonical
definition preserves the schedule's clock, run count, and defining event. Copy
the call for multiple schedules, change it to test-speed { every: 1 }, or
remove it for none. Existing schedule changes and cancellation are explicit
itx calls too. Interval schedules coalesce missed occurrences; they do not
backfill one event per missed interval.
Events#
Event types are past-tense facts under events.iterate.com/...; the repo-wide
rules are in docs/events.md. In itx,
contracts declare event schemas and reducers in *-processor-contract.ts, and
implementations put side effects in *-processor-implementation.ts.
Streams keep raw ingress facts where that matters for audit and replay. For
example, Slack webhook delivery appends
events.iterate.com/slack/webhook-received to /integrations/slack, and the
Slack processors route or project that fact into agent-facing behavior without
mutating the original payload.
Capabilities#
Built-ins are explicit members of the Itx interface (streams, repos,
repo, agents, sandboxes, secrets, workers, worker, egress,
mcp, openapi, ai, examples, processor, debug, plus agent/chat
on agent scopes). A call like itx.streams.get("/x") resolves in the isolate
without touching the capability-host Durable Object; the trade-off is that a
mounted capability can never shadow a built-in name.
Everything else is dynamic: unknown dotted paths fall through to the mounted
capability table (longest-prefix resolution in the capability-host processor, backed by
capability-provided events on the scope's stream). capabilityHost.provideCapability
accepts two recipes (ProvideCapabilityInput):
live— any RPC-able value: a bare function, an object of methods, or aninvokeCapability({ path, args })target whenflattenNestedPathsis set. Live capabilities are session-bound: the mount event is durable, but calls travel back over the provider's connection and die with it.itx-expression— a durable expression replayed against the project's own itx surface (domains/capability-host/itx-expression.ts), so a mount survives disconnects without holding a live stub.
Every mount carries optional instructions (prose) and types (a TypeScript
source string exporting type Capability). itx.__describe() returns project
identity plus the full capability inventory — built-ins and mounts, from
declared metadata only, never by probing live targets. Agents are a first-class
audience and describe() is their only sense organ; write instructions for the
stranger who finds the capability there.
project.mcp.connect(...) and project.openapi.connect(...) return ad-hoc
client targets (no mount, no events): connect discovers (lists MCP tools /
fetches the OpenAPI spec through project egress), and the returned target
answers describe() and fallback-dispatches every other property as a tool
name / flat operationId. project.mcp.exa is the same client shape
pre-connected to Exa's public MCP server (https://mcp.exa.ai/mcp), so every
project has web search (web_search_exa) and page reading (web_fetch_exa)
with zero setup.
Secrets and egress#
Secret material is write-only: itx.secrets.get(path).update({ material, egress: { urls } }) stores it encrypted in the Secret Durable Object;
describe() returns audit metadata, never material. Outbound requests
reference secrets as placeholders — getSecret("/secrets/foo") in a
header — and itx.egress.fetch(request) substitutes them only when the
request origin is in the secret's egress allowlist, recording usage audit
events. Dynamic workers' bare fetch() routes through the same egress path.
itx.egress.intercept(handler) installs a live replacement for testing;
the interceptor sees placeholders, never material
(apps/os/docs/adr/0002-project-egress-interception-uses-fetch-capabilities.md).
Dynamic workers#
itx.workers.get(ref) runs caller-supplied code in an isolate via the Worker
Loader. Runners are DynamicWorkerRunner
(domains/workers/worker-runner.ts) — its constructor is the one place a
dynamic isolate gets its scoped itx binding and egress fetcher. A DynamicWorkerRef is
stateless (a WorkerEntrypoint export, with
optional props) or stateful (a DurableObject class export hosted by
StatefulWorkerDurableObject under a durableWorkerKey). Its source is a
direct createWorker or createApp call: each function's files option may
come inline or from a repo snapshot (branch late-bound or commit-pinned,
masked by include/exclude globs), and an isolated workerd sidecar runs
@cloudflare/worker-bundler (worker-bundler.ts). OS resolves that one
repo-aware value and otherwise passes the serializable options and unchanged
paths to the named library function. App layouts and entry points are not
fixed, and worker-bundler may install root package.json dependencies.
Project build commands do not run. One JSON artifact record is
cached in KV under a deterministic key shared by identical inputs. Builds pass
inert source text by value and leave no events in the journal; errors returned
by worker-bundler are cached briefly, while sidecar/KV/repo errors remain
retryable. Inside
loaded code, await env.ITX.get() returns a full itx at the ref's scope path.
itx.worker is the seeded project worker — the same mechanism pointed at the
default repo's worker.ts.
Note: method-returned itx surfaces pipeline on every transport, including
script isolates over Workers RPC — await itx.workers.get(ref).method(...),
await itx.agents.get(path).create(), and (after birth)
await itx.agents.get(path).message(...) work as one expression (the
dynamic-capability fallback lives on the classes' prototype chains, so the
returned instances are genuine RpcTargets; see
installPrototypeInvokeCapabilityFallback). For several calls on one
surface, take the handle WITHOUT awaiting it and fan out — the capnweb
pattern:
using agent = itx.agents.get(path); // no await
await agent.create();
const [sent, description] = await Promise.all([agent.message("hello"), agent.__describe()]);Await a handle itself only when you truly need the settled stub.
Agents#
An agent is a stream (/agents/<name>) plus processors. agent.message()
appends events.iterate.com/agents/context-added: a user-role item for an
external caller, or a developer-role item with an agent actor for agent-to-agent
messages. The single agent processor folds all model-visible context into a
provider-neutral projection with a compaction-immune system lane and a history
lane, applies user/developer request policies, debounces, and appends
events.iterate.com/agent/llm-request-requested — by reference: no prompt
body, and the event offset is the llmRequestId. That same processor rebuilds
the request by reducing committed events through that offset, runs it through
the Cloudflare AI binding (env.AI), and journals the request lifecycle plus
the assistant context item. See Agent context and turns
for projection, key publication, provider-role, and compaction semantics.
The agent contract is to respond with exactly one fenced TypeScript block
containing a single async (itx) => { … }, which the capability-host processor
executes. Replies reach the user via itx.chat.sendMessage(message)
(events.iterate.com/agents/web-message-sent). Scripts behave like tool calls:
a returned value (or thrown error) becomes a developer context item and
triggers another turn, while a script that returns undefined ends the loop —
the completion event then carries no result key. agent.ask({ message }) is
the send-and-wait convenience.
Stream processor hosting#
StreamDurableObject owns the journal (DO SQLite); its storage methods stay
synchronous internally while the public Stream capability is async through
an RpcTarget. A domain Durable Object constructs its processors and one
StreamProcessorRunner per processor. A thin StreamProcessorRegistry routes
wakeStreamProcessor calls and alarms to the right runner.
When a hosted-processor subscription has events to send, the source stream
calls wakeStreamProcessor({ name, stream }) (the name equals the contract
slug). The
runner's openEventBatchCallback() returns its committed checkpoint plus a
live processEventBatch callback. The source retains that callback and sends
ordered batches after the checkpoint. The processor, not the source, commits
the authoritative processing cursor with its reduced state. There is no
callback-registration call in the opposite direction.
Processors receive a full public Stream capability, never raw Durable Object
stubs. The domain's own guide is
domains/streams/README.md; the doctrine is
docs/domain-objects-and-stream-processors.md.
The browser stream database uses the same runner directly: the dashboard
keeps a local event table plus derived tables and runs real StreamProcessor
contracts over SQLite, with announcements preserved
(domains/streams/client-libraries/browser/).
Workers RPC types patch#
itx relies on patches/@cloudflare__workers-types@4.20260621.1.patch:
upstream types collapse to never when an RPC method returns a
non-serializable nested object, but itx passes typed capability objects
over Durable Object RPC (and needs ctx.exports loopback types). The patch
changes the fallback to keep those returns usable. pnpm-workspace.yaml
applies it via patchedDependencies; run pnpm install from the repo root
after touching the patch or the workers-types version.
Testing#
apps/os/e2e/vitest/— itx e2e suites (streams, itx, project ingress, security), run throughpnpm e2eagainst a live deployment.apps/os/e2e/examples/— the example matrix: the REPL example catalogue executed across every runtime (browser REPL, Node,runScript, project worker). Part ofpnpm e2e(thenodeproject runs the matrix headless; thebrowserproject runs it in a real browser).- Known caveat: repo-sourced project-worker scenarios fail against LOCAL vite
dev with a masked
internal error; reference =(capnweb/vite-dev RpcTarget identity class). They pass against deployed previews — verify there before treating one as a regression.