Appearance
02 — Architecture
NOTE
Status: Draft (normative where marked MUST/SHOULD). Concepts in 01-concepts.md; APIs in 03–05.
Read this page to learn three things: where code lives (packages and their dependency rules), how a call flows (registration → discovery → the ten-phase invocation pipeline), and what the runtime guarantees (ordering, concurrency, memory bounds). If you're deciding whether the library fits your app, the responsibilities table at the bottom is the fastest answer to "what would be mine to build".
Package layout
text
@agent-surface/core framework-agnostic: types, ids, schema layer, registry,
policy pipeline, confirmation, audit, toolset, errors
@agent-surface/react React bindings: provider + hooks (lifecycle-correct)
@agent-surface/orpc domain procedure references + oRPC executor bridge
@agent-surface/testing test harness, matchers, semantic snapshots (no LLM)
@agent-surface/webmcp WebMCP transport adapter [Experimental]Dependency rules (normative):
coreMUST have zero runtime dependencies and MUST NOT import React, oRPC, WebMCP types, DOM APIs (beyond standard JS), or any AI-provider SDK.reactdepends only oncore+react(peer,>=18.2, React 19 supported).orpcdepends oncore+ oRPC client packages (peer). Its React entry point is the subpath@agent-surface/orpc/react.testingdepends oncore; its React entry@agent-surface/testing/reactpeers on@testing-library/react.- Schema libraries (Zod, Valibot, ArkType) are integrated through the
AgentSchemainterface and Standard Schema; none is a dependency of core (D20). - All packages are ESM-only, tree-shakeable, side-effect-free at import time (
"sideEffects": false).
The planes at the architecture level
Key boundary statements:
- The registry is the single in-page source of truth for the surface. Adapters and hooks are thin layers around it.
view:invocations terminate inside the page (handlers registered by components).domain:invocations are forwarded, not executed: the registry applies frontend policy (availability, bindings, confirmation UX), then hands the call to the host-provided oRPC executor, which uses the user's normal authenticated transport. The server re-validates everything. The frontend is never a security boundary.- The agent loop / model is always outside the library. agent-surface produces tool catalogs and executes tool calls; it never talks to an AI provider itself.
Core internal modules
Implementers SHOULD structure @agent-surface/core as:
text
src/
ids.ts grammar, parse/format/validate, wire-name codec
schema.ts AgentSchema, fromStandardSchema, fromJsonSchema,
JSON Schema subset validator (D19)
definition.ts definition types + defineAgentComponent/action/observation
helpers + definition validation (caps, plane rules)
registry.ts registrations, availability, versioning, events
snapshot.ts descriptor projection, filtering, budgets
invoke.ts invocation pipeline: dedupe/conflict → resolve → authorize →
effective input → invoke policies → precondition →
concurrency → execute → settle; tombstones
policy.ts policy types, composition, built-in policies
confirmation.ts pending-confirmation store, evidence lifecycle
audit.ts AuditSink, memory + console sinks
toolset.ts provider-neutral tool projection (embedded adapter)
errors.ts AgentSurfaceError, codes, payload serialization
events.ts event types + ordered dispatcherRuntime data flow
Registration → discovery → invocation
Invocation pipeline (normative order)
For every invoke, the registry MUST execute exactly these phases in order; the first failing phase produces the typed error shown. The order is a protocol guarantee, corrected by 18-spec-corrections-rfc.md (D21): the validated effective input exists before any input-aware policy or confirmation decision runs — a confirmation can only bind to an input that has been fully constructed and validated.
text
1. dedupe + conflict key (consumerKey, invocationId): join in-flight / return
cached terminal; same key with a different request
fingerprint → INVOCATION_CONFLICT (fail closed, D22)
2. resolve capabilityId (+instanceId) → live registration; staleness tokens
└─ STALE_CAPABILITY | COMPONENT_UNMOUNTED | CAPABILITY_NOT_FOUND | AMBIGUOUS_INSTANCE
3. availability enabled + when() re-evaluated → CAPABILITY_NOT_AVAILABLE
4. authorize pre-input authority policies: onAuthorize onion + onDiscovery
re-check (registry → component → capability); NO agent input
is available to this phase, by type construction
└─ NOT_AUTHENTICATED | NOT_AUTHORIZED | RATE_LIMITED | CAPABILITY_NOT_FOUND (hide) | ...
5. effective input reject supplied locked fields → parse agent-facing input →
evaluate live bind() → merge per overridable-field rules →
validate against the FULL original schema
└─ INVALID_INPUT | PRECONDITION_FAILED(binding-failed)
6. invoke policies post-input policies: onInvoke onion over the validated
effective input; input-aware rate decisions; the
confirmation decision + evidence validation (digest over
effective input); audit enrichment
└─ CONFIRMATION_REQUIRED | CONFIRMATION_INVALID | RATE_LIMITED | ...
7. precondition capability precondition(effectiveInput, live state) → PRECONDITION_FAILED
8. concurrency action queue slot / observation admission → RATE_LIMITED(queue-full) or wait
9. execute handler / procedure executor, with AbortSignal + timeout
└─ TIMEOUT | CANCELLED | COMPONENT_UNMOUNTED (mid-flight) | EXECUTION_FAILED
10. settle validate/serialize output, classify, update dedupe record,
audit, ordered eventsAvailability and policies are evaluated at invocation time, never trusted from discovery time (a capability may have been discovered when valid and invoked after the state changed). Phases 5–6 are the reason PendingConfirmation.input always shows the bound values the operation will actually run with — never a raw agent guess (AS-INVOKE-001…005).
Concurrency and ordering guarantees
Normative, implementable guarantees (details and tunables in 03-core-api.md):
- Single-threaded determinism. The registry assumes a JS event loop; all state transitions are atomic within a task.
- Total order of surface mutations. Every register/unregister/availability-change is assigned the next
surfaceVersion. Observers can reconstruct the exact sequence from events. - Event delivery. Events are dispatched in mutation order, after the mutation completes. Listener exceptions are caught and reported; they never corrupt registry state or skip other listeners. Registry methods called from listeners are queued and run after the current dispatch completes (no re-entrant dispatch).
surface-changedMAY coalesce multiple mutations within one microtask; the event always carries the latest version. - Action serialization; bounded observation concurrency. Actions targeting the same component instance run serially (FIFO), with a bounded wait queue. Observations run concurrently up to per-consumer and global limits with a bounded per-consumer FIFO queue; overflow is
RATE_LIMITED, and observations never consume the action queue. (D13, amended by D24) - At-most-once execution per invocation key. The dedupe key is
(consumerKey, invocationId)scoped to the registry (surfaceId). Terminal results are cached; a retry with the same key and the same request fingerprint returns the cached result or joins the in-flight execution; the same key with a different fingerprint fails closed withINVOCATION_CONFLICT. The window is bounded (LRU + TTL). (D14, corrected by D22) - Bounded memory. Dedupe cache, tombstones (recently unmounted registrations, used to distinguish
COMPONENT_UNMOUNTEDfromCAPABILITY_NOT_FOUND), pending confirmations (maxPendingConfirmations), and observation queues are bounded LRU/TTL/cap structures with documented defaults. No runtime collection is unbounded.
SSR, hydration, and environments
- The registry is an in-memory, per-JS-realm object. On the server (SSR/RSC) the React hooks are inert: registration happens in effects, effects don't run during server rendering, so the server-side surface is empty and no cleanup is needed. Snapshot on a server-created registry simply returns an empty surface.
- Hydration performs registrations in mount effects after hydration completes. There is no hydration mismatch risk because registration never renders anything.
- Multiple browser tabs/windows each have their own registry; cross-window surface aggregation is Future (12-roadmap.md).
- The registry accepts an
environment("development" | "production" | "test"): development enables strict validation errors, serializability probes, and loud collision diagnostics; production degrades the same conditions to safe rejections plus audit events.
Where responsibilities live
| Concern | Library | Host application | Server |
|---|---|---|---|
| Declaring components/capabilities | primitives | authors them | — |
| Surface catalog, versioning, staleness | ✅ | — | — |
| view-action execution | dispatch + guarantees | handler logic | — |
| domain execution | forwarding + binding + UX policy | oRPC client/executor wiring | ✅ authoritative |
| AuthN/AuthZ | policy interfaces + context plumbing | provides auth context | ✅ authoritative |
| Confirmation | protocol, evidence, expiry, audit | renders the dialog | optional second approval |
| Rate limiting | advisory client-side policy | config | ✅ authoritative |
| Audit | events + sink interface | persistent sink | ✅ authoritative for domain |
| Agent loop / model calls | ❌ never | ✅ (or external agent) | ✅ (orpc-agent) |
| Router, data fetching, design system | ❌ never | ✅ | — |
Bundle and performance budgets (first measured baselines)
Bundle sizes are enforced by size-limit in CI: core 16.45 kB min+brotli (budget 18 kB, zero runtime dependencies), react 2.08 kB (budget 4 kB). The original ~10 kB core aspiration predates the invocation pipeline, confirmation store, and toolset — the budget is the enforced number, revised consciously, never silently.
Runtime baselines from pnpm bench (packages/core/bench/core.bench.ts, Node 22, Apple-silicon dev machine, 2026-07-30 — machine-local reference points, not CI thresholds yet; directive §7.2 sets thresholds after stable CI baselines):
| Operation | mean |
|---|---|
createAgentSurfaceRegistry() | ~1.4 µs |
| register + unregister, 10 / 100 / 1000 components | ~0.16 ms / ~1.5 ms / ~17 ms |
snapshot() at 100 components (warm descriptor cache) | ~0.16 ms |
| action invoke end-to-end, no-op handler | ~0.05 ms |
| action invoke + 2-policy authorize chain | ~0.13 ms |
| observation invoke end-to-end | ~0.03 ms |
| canonical digest (fingerprint) at ~32 kB input | ~2.1 ms |
Reading the numbers: registration stays allocation-light through a route transition (100 registrations ≈ 1.5 ms, spread across commits); pipeline overhead without handler work is tens of microseconds; the request fingerprint is O(input size) — negligible for typical tool inputs, ~2 ms at the 32 kB ceiling (it runs once per invoke, phase 1).
snapshot()is O(registrations) with cheap descriptor projection; descriptors are cached per registration and invalidated on version bump.- No polling anywhere: everything is event-driven.