Core API (@agent-surface/core)
Contract, binding, and authority
defineAgentComponentContract, observationContract, actionContract, and defineAgentProcedureContract declare static identity/governance. .bind() supplies runtime handlers/state. The compiler virtual module returns a CapabilityAuthority; createAgentSurfaceRegistry({ authority }) refuses construction without it and rejects raw, unknown, stale, incomplete, hash-mismatched or semantically changed bindings. defineExternalAgentToolContract plus createAgentExposureGateway(authority) applies the same ceiling at provider/MCP assembly.
Raw definition helpers remain inert construction utilities. No published registry can execute them. Repository tests enable a source-only Vitest seam that is neither exported nor shipped.
NOTE
Every type and signature on this page is public API unless explicitly marked internal or Experimental.
TIP
Reference material, not a tutorial — and the longest page in the spec. First time through, read Definitions (what authors write) and Invocation (what happens when an agent calls); come back for the rest. Application authors rarely touch this API directly, since the React hooks wrap it — start at Getting Started instead.
Contents: schemas · definitions · authority · registry · registration lifecycle · availability · versioning · snapshot · invocation · concurrency · events · confirmation surface · toolset · limits
Schemas
Core is schema-library-agnostic. Everything that crosses the agent boundary is described by an AgentSchema<T>: a pair of (JSON Schema for the agent, validator for the runtime).
/** JSON value constraint: every agent-crossing payload MUST be a JsonValue. */
export type JsonValue =
| string | number | boolean | null
| JsonValue[] | { [key: string]: JsonValue };
/** A JSON Schema document restricted to the supported subset (see below). */
export type JsonSchema = Record<string, unknown>;
export interface AgentSchema<T> {
/** Agent-visible JSON Schema (draft 2020-12, restricted subset). */
readonly jsonSchema: JsonSchema;
/**
* Validates and returns a typed value. MUST throw `AgentSchemaError`
* (with a safe, structured message) on invalid input. MUST NOT coerce
* in ways the jsonSchema does not describe.
*/
parse(value: unknown): T;
}Constructors
/**
* Wraps any Standard Schema (https://standardschema.dev) — Zod ≥3.24,
* Valibot, ArkType — as an AgentSchema. Validation uses `~standard.validate`.
* The JSON Schema MUST be supplied explicitly (core will not depend on a
* converter): pass the library's native conversion.
*/
export function fromStandardSchema<T>(
schema: StandardSchemaV1<unknown, T>,
options: { jsonSchema: JsonSchema },
): AgentSchema<T>;
/**
* Builds an AgentSchema from a raw JSON Schema. Core ships a minimal
* structural validator covering exactly the supported subset. `T` is
* caller-asserted; prefer fromStandardSchema when a schema library is in use.
*/
export function fromJsonSchema<T = JsonValue>(schema: JsonSchema): AgentSchema<T>;
/** Convenience for actions with no input / observations of constant shape. */
export const emptyObjectSchema: AgentSchema<Record<string, never>>;Zod 4 example (the pattern the docs use throughout):
import { z } from "zod";
const SelectRows = z.object({
ids: z.array(z.string()).min(1).describe("Device ids to select"),
mode: z.enum(["replace", "add", "remove"]).default("replace"),
});
const SelectRowsSchema = fromStandardSchema(SelectRows, {
jsonSchema: z.toJSONSchema(SelectRows),
});Supported JSON Schema subset
Accepted keywords — anything else MUST be rejected at registration with INVALID_DEFINITION:
type(object,array,string,number,integer,boolean,null, or an array of these for nullability),enum,const- objects:
properties,required,additionalProperties(boolean only) - arrays:
items(single schema),minItems,maxItems,uniqueItems - strings:
minLength,maxLength,pattern,format∈ {date-time,date,uuid,email,uri} - numbers:
minimum,maximum,exclusiveMinimum,exclusiveMaximum,multipleOf - unions:
anyOf, including discriminated unions byconsttag;oneOf,allOf,not,if/then/else,patternProperties,dependent*, andunevaluated*are unsupported - annotations anywhere:
description,default,examples,title,deprecated $defs+ internal$ref(#/$defs/...) only; remote refs rejected- maximum nesting depth: 8; maximum serialized schema size: 16 kB
Rationale: this is the subset current LLM tool-calling implementations handle reliably, and it keeps the core validator small. The subset is validated at registration time, so authors discover violations in development, not when an agent calls.
Serialization rules
- All inputs and outputs MUST be
JsonValue. Dates travel as ISO-8601 strings; binary values are unsupported. - Type-level note: the
extends JsonValueconstraints are satisfied by schema-inferred types and type aliases; TypeScriptinterfaces lack implicit index signatures and won't satisfy them — use type aliases (schema inference produces them anyway). undefinedproperties are stripped (JSON semantics). Functions, symbols, bigints, and cyclic structures are defects: in development the registry probes outputs (JSON round-trip) and throws; in production the invocation settles asEXECUTION_FAILEDwith a safe message, and the defect is logged.- Observation/action outputs exceeding
limits.maxOutputBytes(default 32 kB) settle asEXECUTION_FAILED(details.reason: "output-too-large"); truncation is never silent.
Definitions
Compiled component contracts
Application authors declare static semantics with the compiler macros:
export interface AgentComponentContractDefinition<
TObservations extends Record<string, AgentObservationContract<any>>,
TActions extends Record<string, AgentActionContract<any, any>>,
> {
type: string;
description: string;
meta?: Record<string, JsonValue>;
origin?: string;
priority?: number;
policies?: CapabilityPolicyAttachment[];
tags?: string[];
observations?: TObservations;
actions?: TActions;
}
export function observationContract<TOut extends JsonValue>(
contract: AgentObservationContract<TOut>,
): AgentObservationContract<TOut>;
export function actionContract<
TIn extends JsonValue,
TOut extends JsonValue | void = void,
>(
contract: AgentActionContract<TIn, TOut>,
): AgentActionContract<TIn, TOut>;
export function defineAgentComponentContract<
TObservations extends Record<string, AgentObservationContract<any>>,
TActions extends Record<string, AgentActionContract<any, any>>,
>(
definition: AgentComponentContractDefinition<TObservations, TActions>,
): AgentComponentContract<TObservations, TActions>;defineAgentComponentContract is a compiler macro. Author code supplies only the definition; the compiler injects private provenance. Calling the function outside the compiled production graph returns an inert contract whose binding fails authority validation.
The contract binds live behavior with a type-safe one-to-one map:
const bound = counterContract.bind({
instanceId: "primary",
observations: {
value: { read: () => currentValue },
},
actions: {
increment: { execute: () => increment() },
},
});
registry.register(bound);Every declared observation needs a read binding and every declared action needs an execute binding. Bindings may add runtime policies and availability, but cannot change contract identity or governance. React applications normally use useAgentComponent, which performs this binding and manages registration lifecycle.
Runtime definition shape
AgentComponentDefinition is the bound shape consumed by the registry and used by low-level integrations:
export interface AgentComponentDefinition {
/** Component type, e.g. "devices.table". MUST match the id grammar. */
type: string;
/**
* Distinguishes simultaneous mounts of the same type. Defaults to "default".
* MUST be derived from data (entity id, semantic slot), never render order.
*/
instanceId?: string;
/** Agent-visible description, ≤ 500 chars. Required, non-empty. */
description: string;
/** Optional containment link for hierarchy-aware consumers. */
parent?: { type: string; instanceId?: string };
/** Agent-visible metadata. JsonValue, ≤ 2 kB serialized. */
meta?: Record<string, JsonValue>;
/**
* Internal metadata for policies/audit sinks. NEVER serialized into
* snapshots or agent-facing payloads (normative; tested).
*/
internal?: Record<string, unknown>;
/** Policies applied to every capability of this component. */
policies?: AgentPolicy[];
/** Registrant trust label; default "first-party". See 06 §trust. */
origin?: string;
/** Snapshot ordering/budget priority; higher survives budgets longer. Default 0. */
priority?: number;
/**
* Master switch. false ⇒ all capabilities are visible-disabled
* (reason "component-disabled") and invocations fail CAPABILITY_NOT_AVAILABLE.
* Registration identity is preserved (no version churn beyond the toggle).
*/
enabled?: boolean;
observations?: Record<string, AgentObservationDefinition<any>>;
actions?: Record<string, AgentActionDefinition<any, any>>;
/** Domain references; normally added via @agent-surface/orpc. */
procedures?: AgentProcedureBinding<any, any>[];
}Runtime capability shapes
These interfaces describe the runtime handlers produced by a compiled contract binding. The raw defineAgentComponent, observation, and action helpers create the same shapes for tooling and isolated construction, but carry no compiler proof. A public registry rejects them.
export interface AgentObservationDefinition<TOut extends JsonValue> {
/** Agent-visible description, ≤ 300 chars. */
description: string;
output: AgentSchema<TOut>;
/**
* Reads current semantic state. MUST be side-effect free. SHOULD be
* synchronous; MAY return a promise (subject to observation timeout).
*/
read(ctx: AgentReadContext): TOut | Promise<TOut>;
/** Availability predicate, re-evaluated at snapshot and at invocation. */
when?: () => boolean;
unavailableReason?: string | (() => string);
policies?: AgentPolicy[];
meta?: Record<string, JsonValue>;
timeoutMs?: number; // default limits.observationTimeoutMs (5000)
}
export interface AgentActionDefinition<TIn extends JsonValue, TOut extends JsonValue | void = void> {
description: string;
input: AgentSchema<TIn>;
output?: AgentSchema<Exclude<TOut, void>>;
/** View actions MUST be "local-state" | "navigation" (plane rule, 01). */
effect: "local-state" | "navigation";
idempotent?: boolean; // default false
reversible?: boolean; // default true (see defaults table in 01)
confirmation?: "never" | "optional" | "required"; // default "never"
audit?: "none" | "metadata" | "full"; // default "metadata"
when?: () => boolean;
unavailableReason?: string | (() => string);
/**
* Input-aware validation beyond the schema (e.g. "ids must exist in the
* current dataset"). Return void to pass; return/throw PreconditionFailure
* to fail with PRECONDITION_FAILED.
*/
precondition?(input: TIn, ctx: AgentReadContext): void | PreconditionFailure;
/**
* TOut is inferred from `output` only (NoInfer, TS ≥5.4): the schema is the
* source of truth and the handler's return is checked against it, never the
* other way around (verified in prototypes/api-typecheck.ts).
*/
execute(input: TIn, ctx: AgentActionContext): NoInfer<TOut> | Promise<NoInfer<TOut>>;
policies?: AgentPolicy[];
meta?: Record<string, JsonValue>;
timeoutMs?: number; // default limits.actionTimeoutMs (10000)
}
export interface PreconditionFailure {
message: string; // agent-safe
details?: Record<string, JsonValue>; // agent-safe
}
/** Identity helpers that fix generics for record-literal authoring. */
export function observation<TOut extends JsonValue>(
def: AgentObservationDefinition<TOut>,
): AgentObservationDefinition<TOut>;
export function action<TIn extends JsonValue, TOut extends JsonValue | void = void>(
def: AgentActionDefinition<TIn, TOut>,
): AgentActionDefinition<TIn, TOut>;
export function defineAgentComponent(def: AgentComponentDefinition): AgentComponentDefinition;Handler contexts
export interface AgentReadContext {
capabilityId: string;
registrationId: string;
consumer: AgentConsumer;
/** Host context (user, tenant, env…) from RegistryOptions.context(). */
host: Readonly<Record<string, unknown>>;
}
export interface AgentActionContext extends AgentReadContext {
invocationId: string;
/** Aborted on timeout, external cancellation, or unmount. Cooperative. */
signal: AbortSignal;
/** Present iff this invocation carries approved confirmation evidence. */
confirmation?: { id: string; approvedAt: string };
}Authority and external exposure
CapabilityAuthority is the runtime source of truth. Its TypeScript brand is not enough: the implementation recognizes only objects minted by createCapabilityAuthority and recorded in a private WeakMap.
export interface CapabilityAuthority {
readonly manifest: CapabilityContractManifest;
// private nominal brand
}
export function createCapabilityAuthority(
manifest: CapabilityContractManifest,
): CapabilityAuthority;
export function assertCapabilityAuthority(
authority: CapabilityAuthority,
): void;Creation verifies format version, completeness.status, each contract hash, declaration uniqueness, and the complete manifest hash. It then clones and deep-freezes the manifest. The Vite virtual module calls this function with compiler output. A direct caller that supplies a verified manifest explicitly chooses that manifest as the runtime source of truth.
Standalone provider tools use the same authority model:
const contract = defineExternalAgentToolContract({
id: "external:reports.export",
description: "Export the report",
input: exportInputSchema,
output: exportOutputSchema,
effect: "external-side-effect",
confirmation: "required",
});
const tool = contract.bind({ execute: exportReport });
const gateway = createAgentExposureGateway(authority);
const exposed = gateway.expose([tool]);The gateway accepts only tools carrying private compiler proof for its manifest and verifies identity, kind, description, and input schema. Raw tool objects and tools compiled against another manifest fail closed.
Registry
export interface RegistryOptions {
/** "development" | "production" | "test". Default: "production". */
environment?: AgentEnvironment;
/**
* Host context provider, called lazily per policy evaluation / handler
* context. MUST be synchronous and cheap (read from stores, don't fetch).
*/
context?: () => Record<string, unknown>;
/** Global policies, outermost layer of every chain. */
policies?: AgentPolicy[];
/** Audit sink; default: bounded in-memory sink (+ console in development). */
audit?: AuditSink;
/** Guard invoked before accepting a registration (trust filtering, 06). */
onRegister?: (candidate: RegistrationCandidate) => "accept" | "reject";
/** Collision handling for duplicate (type, instanceId). Default "reject". */
onDuplicateInstance?: "reject" | "replace";
/** Suffix-collision diagnostics vs known domain ids. Default "warn". */
duplicateSuffixPolicy?: "off" | "warn" | "error";
/** Route descriptor for snapshots (host wires its router here). */
route?: () => AgentRouteInfo | undefined;
limits?: Partial<AgentSurfaceLimits>;
/** Injectable clock for deterministic policy, timeout, and TTL tests. */
now?: () => number;
/** Compiler-generated source of truth. Runtime-mandatory. */
authority?: CapabilityAuthority;
}
export interface AgentRouteInfo { path: string; params?: Record<string, string>; }
export function createAgentSurfaceRegistry(options?: RegistryOptions): AgentSurfaceRegistry;
export interface AgentSurfaceRegistry {
readonly surfaceId: string; // "srf_" + random, per instance
register(definition: AgentComponentDefinition): AgentRegistrationHandle;
snapshot(context?: SnapshotContext): AgentSurfaceSnapshot; // synchronous
invoke(request: AgentInvocation, options?: InvokeOptions): Promise<AgentInvocationResult>;
subscribe(listener: (event: AgentSurfaceEvent) => void): Unsubscribe;
/** Confirmation lifecycle — see 06 and #confirmation-api below. */
confirmations: ConfirmationController;
/** Register a domain-procedure executor (installed by @agent-surface/orpc). */
setProcedureExecutor(executor: AgentProcedureExecutor | undefined): void;
getVersion(): string;
/** Tears down: aborts in-flight invocations (CANCELLED), clears listeners. */
dispose(): void;
}
export type Unsubscribe = () => void;authority remains optional in the TypeScript shape only for the repository's non-exported test seam. Published runtime use without a genuine compiler authority throws during construction.
register never throws in production for runtime conditions such as duplicate instances or guard rejection. It returns a dead handle (handle.status === "rejected"), emits component-rejected, and logs. It DOES throw AgentSurfaceDefinitionError in every environment for structural defects the author must fix: invalid id grammar, unsupported schema keywords, plane violations, or oversize descriptions.
Registration lifecycle
export interface AgentRegistrationHandle {
readonly registrationId: string; // "reg_" + monotonic + random
readonly status: "active" | "rejected" | "unregistered";
/**
* Push dynamic updates. ONLY the listed fields are updatable; anything
* structural (ids, names, schemas, descriptions, effects, policies)
* requires unregister + register, producing a new registrationId.
*/
update(patch: {
enabled?: boolean;
/** Per-capability availability overrides pushed eagerly (event-driven adapters). */
availability?: Record<string, { available: boolean; reason?: string }>;
}): void;
/** Bumps the surface version without changing anything (rarely needed). */
invalidate(): void;
unregister(): void;
}Lifecycle rules (normative):
- Mount → register. A registration is live immediately and appears in the next snapshot;
surface-changedfires with the new version. - Structural immutability per registration. The descriptor—types, names, schemas, descriptions, effects, and policy shape—is frozen at
register(). This makesregistrationIda meaningful staleness token. Handlers are not part of the frozen descriptor. - Handlers use live references.
execute,read,when, andbindare read at invocation time from current runtime state. Swapping handler closures neither bumps the version nor changes identity. See React API. - Unmount unregisters. Capabilities disappear from subsequent snapshots. Active non-navigation invocations abort and settle
COMPONENT_UNMOUNTEDunless the handler settled first. Navigation invocations settle on handler completion, timeout, or cancellation. The tombstone retains identifiers and timestamps, never executable references. - Tombstones. Unregistered registrationIds are remembered in a bounded structure (default 100 entries / 5 min) so late invocations get the precise
COMPONENT_UNMOUNTEDinstead of the genericCAPABILITY_NOT_FOUND. - Post-unregister calls on the handle are no-ops that warn in development.
dispose()unregisters everything, settles in-flight invocations asCANCELLED, resolves pending confirmations as expired, and drops listeners.
Collisions
- Component key =
(plane, type, instanceId). A second live registration with the same key is handled peronDuplicateInstance:"reject"(default): the newcomer gets a dead handle +component-rejectedevent + dev console.error. First-wins keeps behavior deterministic during route-transition overlaps."replace": the previous registration is unregistered (its invocations abort asCOMPONENT_UNMOUNTED), then the newcomer registers. For apps with exit animations that keep the old page mounted.
- Capability key = component key + capability name; duplicates inside one definition are structural (
AgentSurfaceDefinitionError, always thrown). - Cross-plane suffix collision: registering
view:X.Ywhendomain:X.Yis a known procedure id (executor manifest loaded) triggersduplicateSuffixPolicy(default"warn": console +collision-suspectedevent). This is a heuristic lint, not a security control.
Availability
A capability's effective availability for a consumer is computed as:
available :=
registration.status === "active"
AND component.enabled !== false
AND pushed availability override (if any) is not {available:false}
AND when() !== false // evaluated lazily, may throw ⇒ unavailable("when-error")
AND every policy discovery decision === "expose"when()is evaluated at snapshot time and re-evaluated at invocation time (phase 3). It MUST be synchronous, cheap, and exception-safe; a throwingwhencounts asfalse(dev warning).- Lazily evaluated
whenchanges do not bumpsurfaceVersionbecause no mutation was published.update({availability})andupdate({enabled})do bump it. React re-evaluateswhenafter each render and pushes changes. Pure-core users either push changes or accept snapshot-time freshness. - Policy
hidedecisions remove the capability from the snapshot entirely (see Policies & Security); availability as defined above only producesavailable/unavailable.
Versioning
surfaceId:"srf_" + 22 random base62 chars, minted per registry. Distinguishes page loads / realms.surfaceVersion: unsigned integer starting at"0"(empty registry), incremented by 1 for every surface-affecting mutation: register, unregister, replace,update({enabled|availability}),invalidate(), procedure-binding structural change. Serialized as a decimal string; consumers MUST treat it as opaque and only compare equality (ordering is an implementation detail they MAY exploit for logging).- Handler swaps,
when()drift, and bound-value changes do NOT bump the version (they alter neither the catalog nor identities). (surfaceId, surfaceVersion)globally identifies a surface state. A consumer presenting asurfaceVersionunder a differentsurfaceIdis stale by definition (page reloaded): resolution phase treats it exactly like a registration mismatch →STALE_CAPABILITY.
Staleness enforcement:
- If
invocation.registrationIdis set and differs from the live registration for the target →STALE_CAPABILITY(details:{ liveRegistrationId }so a consumer that knows the capability is equivalent can re-discover cheaply). - If it matches a tombstone →
COMPONENT_UNMOUNTED. - If
invocation.surfaceVersionis set and differs from current, the invocation still proceeds (global version changes constantly for unrelated reasons), except when the target capability's effect isdestructiveorexternal-side-effect, where the registry MUST reject withSTALE_CAPABILITY(details.reason: "surface-version-mismatch"). Adapters SHOULD always sendregistrationId(precise) and MAY sendsurfaceVersion(belt-and-braces for dangerous calls). - Every result carries the current
surfaceVersion; adapters SHOULD re-snapshot when it moved.
Snapshot
export interface SnapshotContext {
consumer?: AgentConsumer; // default: {"id":"anonymous","kind":"embedded"}
/** Component-type prefixes to include, e.g. ["devices"]. Default: all. */
scope?: string[];
/** Include visible-disabled capabilities. Default true. */
includeUnavailable?: boolean;
/** [Experimental] Truncation budget; see below. */
budget?: { maxComponents?: number; maxBytes?: number };
}
export interface AgentConsumer {
id: string;
kind: "embedded" | "webmcp" | "mcp-bridge" | "test" | "other";
/** Free-form grant strings interpreted by host policies. */
grants?: string[];
}
export interface AgentSurfaceSnapshot {
surfaceId: string;
surfaceVersion: string;
capturedAt: string; // ISO-8601
route?: AgentRouteInfo;
components: AgentComponentDescriptor[];
/** Domain references, top-level (planes are not nested into each other). */
procedures: AgentProcedureDescriptor[];
/** [Experimental] Present iff a budget truncated the snapshot. */
truncated?: { droppedComponents: number };
/** [Experimental] Present when a scope floor refused requested prefixes. */
scopeRejected?: { prefixes: string[] };
}
export interface AgentComponentDescriptor {
type: string;
instanceId: string;
registrationId: string;
description: string;
parent?: { type: string; instanceId: string };
meta?: Record<string, JsonValue>;
observations: AgentObservationDescriptor[];
actions: AgentActionDescriptor[];
}
export interface AgentObservationDescriptor {
capabilityId: string; // "view:devices.table.readState"
name: string; // "readState"
description: string;
outputSchema: JsonSchema;
available: boolean;
unavailableReason?: string;
meta?: Record<string, JsonValue>;
}
export interface AgentActionDescriptor {
capabilityId: string;
name: string;
description: string;
inputSchema: JsonSchema;
outputSchema?: JsonSchema;
effect: "local-state" | "navigation";
idempotent: boolean;
reversible: boolean;
confirmation: "never" | "optional" | "required";
available: boolean;
unavailableReason?: string;
meta?: Record<string, JsonValue>;
}(AgentProcedureDescriptor is described in oRPC integration. It lives at the snapshot top level with a context link to the component that registered it, keeping domain and presentation capabilities structurally distinct.)
Snapshot semantics (normative):
snapshot()is synchronous and side-effect free: it MUST NOT runread()handlers, MUST NOT await, and discovery-time policy evaluation MUST be synchronous. Async authority checks belong to invocation. The result is a catalog, not a state dump.- Descriptors are deep-frozen plain JSON;
internalmetadata MUST NOT appear anywhere in a snapshot (tested). - Ordering: components sorted by (
prioritydesc,type,instanceId) — deterministic, never DOM- or mount-order-dependent. - Stable and volatile text are separate. A procedure reference's contextual
describe()output iscontextualNote;descriptionis immutable manifest text. Consumers never need to parse live state out of a stable description. - Shape: flat with
parentlinks. Flat output is straightforward to serialize, diff, and budget. Hierarchy-aware consumers rebuild the tree fromparent. - Budgets (Experimental): when set, components are dropped lowest-priority-first after the cap; the snapshot says so via
truncated. No silent truncation, ever. scopeRejected(Experimental) is set by the adapter, never bysnapshot(), because the registry does not know the adapter's configured scope floor. See Adapters §meta-tools-mode.
explainSurface() — developer projection
NOTE
Separate entry point: @agent-surface/core/explain. It is deliberately not exported from the package root. Read Policies and security §explain is never agent-facing before exposing it.
import { explainSurface } from "@agent-surface/core/explain";
explainSurface(registry: AgentSurfaceRegistry, ctx?: SnapshotContext): SurfaceExplanationThe snapshot answers what may this agent call. It bakes policy outcomes, so a hide deletes the capability and the reason together. The explanation answers why, over the same registry and the same context:
interface CapabilityExplanation {
capabilityId: string;
kind: "observation" | "action" | "procedure";
plane: "view" | "domain";
description: string; // hidden capabilities have no snapshot entry to read it from
registrationId: string;
component: { type: string; instanceId: string };
outcome: "expose" | "disable" | "hide"; // "hide" ⇒ absent from snapshot()
reason?: string;
policies: Array<{
name: string; // AgentPolicy.name
scope: "registry" | "component" | "capability";
phases: Array<"discovery" | "authorize" | "invoke">;
discovery?: DiscoveryDecision; // this policy's own vote
threw?: boolean; // onDiscovery threw; evaluateDiscovery failed closed
confirmationEscalation?: boolean;
}>;
availability: { available: boolean; reason?: string }; // `when()`, kept apart from policy
}Semantics (normative):
- It reports every capability the registry holds, hidden ones included.
includeUnavailableandbudgetare ignored: withholding is the one thing an explanation must not do.scopeandconsumerare honoured, so it lines up with the snapshot being debugged. - Its composed outcome MUST equal what
snapshot()did for the same context (AS-EXPLAIN-003).evaluateDiscoveryshort-circuits on the firsthide, so explain cannot reuse it — it re-runs eachonDiscoveryindividually and composes by the same rule. Re-running is safe by contract: discovery policies MUST be synchronous, cheap, and side-effect free (Policies & Security). - Policy attribution keeps
availabilityseparate from policy votes because authority hides, state discloses: the two failures must not look alike. - It throws on a registry it did not create, or a disposed one — rather than reporting an empty surface, which is what a missing internals seam would otherwise look like.
Invocation
export interface AgentInvocation {
/** Idempotency key. Adapters SHOULD pass their tool-call id. Generated if absent. */
invocationId?: string;
capabilityId: string; // "view:..." or "domain:..."
/** Required when >1 live instance of the target component exists. */
instanceId?: string;
/** Staleness token from discovery. Adapters SHOULD always send it. */
registrationId?: string;
/** Version hint; enforced only for destructive/external effects (see Versioning). */
surfaceVersion?: string;
input?: JsonValue;
/** Evidence from a resolved confirmation (see 06). */
confirmationId?: string;
}
export interface InvokeOptions {
consumer?: AgentConsumer;
signal?: AbortSignal; // external cancellation → CANCELLED
timeoutMs?: number; // overrides capability/limits default
}
export type AgentInvocationResult =
| {
status: "ok";
invocationId: string;
capabilityId: string;
output?: JsonValue; // validated against outputSchema if declared
surfaceVersion: string; // current version at settle time
/** Set when the surface changed during execution (agent should re-discover). */
surfaceChanged?: boolean;
}
| {
status: "error";
invocationId: string;
capabilityId: string;
error: AgentCapabilityErrorPayload; // see 07-errors.md
surfaceVersion: string;
surfaceChanged?: boolean;
};Semantics:
- The result is a discriminated union, not an exception:
invokeonly rejects on programmer misuse (e.g. called afterdispose). Everything agent-facing — includingCONFIRMATION_REQUIRED— is a serializablestatus: "error"payload with structureddetailsand retry semantics (Errors).CONFIRMATION_REQUIREDis a protocol step, not a failure; it is encoded as an error so the wire model stays binary. - Instance resolution: if
instanceIdis omitted and exactly one live instance matches, it is used; zero →CAPABILITY_NOT_FOUND(or tombstone/stale variants); more than one →AMBIGUOUS_INSTANCEwithdetails.instances: string[]. - The effective input is constructed and fully validated at phase 5, after pre-input authorization and before input-aware policy or confirmation. Handlers receive the parsed, possibly defaulted effective value. Input-aware policies receive it as
ctx.effectiveInputand never see raw agent input. See Policies and security §policy pipeline. - Observations use the same invocation API with
inputomitted. They skip confirmation and the action queue but pass through bounded observation admission. - Pipeline order is fixed; see Architecture.
Invocation identity, idempotency, and conflict safety
Provider tool-call ids are not globally unique. Invocation identity is therefore consumer-scoped and request-bound:
// per registry (surfaceId scopes page lifetimes):
dedupeKey = consumerKey + " " + invocationId // consumerKey = kind + ":" + id
fingerprint = fnv1a64(canonicalJson({ capabilityId, registrationId, instanceId,
surfaceVersion, input, confirmationId })) // as issued- Results of terminal outcomes are cached per key (default 200 entries / 10 min). Terminal =
okand every error exceptCONFIRMATION_REQUIREDandRATE_LIMITED(expected-retry outcomes; caching them would break the retry).INVOCATION_CONFLICTresults are never cached either — they describe the collision, not the request. - Re-invoke with a known in-flight key and matching fingerprint returns the same pending promise (join, not re-execute); with a cached terminal key + matching fingerprint, the cached result verbatim. This is what makes transport retries safe.
- Re-invoke with a known key and a different fingerprint fails closed:
INVOCATION_CONFLICT(retry: "with-changes"— i.e. use a freshinvocationIdif the new request is intentional). The stored record is untouched. Reusing an id for a different request can never silently succeed. - Different consumers may reuse the same provider tool-call id safely (distinct keys); different page loads cannot collide (distinct registries). Anonymous invocations normalize to
embedded:anonymous— adapters MUST pass a stable per-instance consumer id when more than one consumer addresses the registry (Adapters §adapter contract). - The dedupe window is bounded (
dedupeCacheSize,dedupeCacheTtlMs): an idempotency window, not a forever guarantee. An expired key is a new attempt. - Confirmation retry: reuses the same
invocationId+confirmationId; sinceCONFIRMATION_REQUIREDwas not cached as terminal, the retry executes (once).
Concurrency, timeouts, cancellation
Actions: serialized per component instance (FIFO). One in-flight + a wait queue of
limits.actionQueueDepth(default 2). Overflow →RATE_LIMITEDwithdetails.reason: "queue-full",retry: "after-delay".Observations: admission gates
maxConcurrentObservationsPerConsumer(8) andmaxConcurrentObservationsTotal(32) are independent. A saturated consumer queues FIFO up tomaxQueuedObservationsPerConsumer(8). Overflow returnsRATE_LIMITED {reason: "queue-full", retryAfterMs}. Observations never consume the action queue. Cancellation, timeout, and settlement release slots; disposal drains queues asCANCELLED.Procedures: forwarded, and admitted through one group per procedure identity per referencing registration — repeat calls of the same domain operation serialize client-side, while a view action on the same component is never blocked by an in-flight domain call. The server still governs real concurrency; this is queueing hygiene, not authority.
Concurrency contract:
tsexport type AgentConcurrency = | { mode: "instance"; queueDepth?: number } // default: one queue per registration | { mode: "capability"; queueDepth?: number } // one queue per capability | { mode: "key"; key: string; queueDepth?: number } | { mode: "parallel"; max: number; queueDepth?: number };Declared per action (
action({concurrency})) or per procedure reference (binding config). The default remains{mode:"instance"}— the safe one: two actions on the same component never interleave.parallelrequires an integermax ≥ 1; unbounded parallelism is not offered, and an invalid group throwsAgentSurfaceDefinitionErrorat registration.queueDepthoverrideslimits.actionQueueDepthfor that group only; overflow isRATE_LIMITED {reason:"queue-full"}as everywhere else. Groups are created on demand and dropped when idle, so the runtime holds one entry per currently contended group, not per capability ever invoked. Not model-visible: concurrency is runtime behavior, not planning information.Timeouts:
timeoutMsper capability, else defaults (observation 5 s, action 10 s, procedure 30 s). On timeout the registry abortsctx.signal, settlesTIMEOUT, and ignores (but logs) any late handler settlement. JS cannot force-kill the handler; cooperation viasignalis the contract.External cancellation:
InvokeOptions.signalaborted → settleCANCELLED(same late-settlement rule).Unmount mid-flight: unregistration aborts active non-navigation signals. The invocation settles
COMPONENT_UNMOUNTEDunless the handler settled first; the later settlement is logged aslate-settlement.Navigation settlement: unregistration does not settle an active
navigationaction. It settles on handler completion, timeout, or external cancellation. Unmount before dispatch still returnsCOMPONENT_UNMOUNTED. Resolve when the host router accepts or commits the transition; reject when it refuses.
Events
export type AgentSurfaceEvent =
| { type: "surface-changed"; surfaceVersion: string } // coalesced per microtask
| { type: "component-registered"; registrationId: string; componentType: string; instanceId: string }
| { type: "component-unregistered"; registrationId: string; componentType: string; instanceId: string }
| { type: "component-rejected"; componentType: string; instanceId: string; reason: "duplicate" | "guard" }
| { type: "availability-changed"; registrationId: string; capabilityId: string; available: boolean }
| { type: "collision-suspected"; viewCapabilityId: string; domainProcedureId: string }
| { type: "invocation-started"; invocationId: string; capabilityId: string; consumerId: string }
| { type: "invocation-settled"; invocationId: string; capabilityId: string;
status: "ok" | "error"; code?: AgentCapabilityErrorCode; durationMs: number }
| { type: "confirmation-requested"; confirmationId: string; capabilityId: string; expiresAt: string }
| { type: "confirmation-resolved"; confirmationId: string; outcome: "approved" | "denied" | "expired" };Ordering guarantees are specified in Architecture: total mutation order, post-mutation dispatch, listener-exception isolation, queued re-entrancy, per-invocation started strictly before settled, and confirmation-requested strictly before resolution.
Events are the integration point for audit sinks, adapters (re-snapshot on surface-changed), and confirmation UIs.
Confirmation API
The full protocol (evidence binding, expiry, replay rules, server interplay) is in Policies & Security. Core exposes:
export interface ConfirmationController {
/** Pending requests, for host UI rendering. */
pending(): PendingConfirmation[];
resolve(confirmationId: string, resolution: { approved: boolean; reason?: string }): void;
/** Resolves when the given confirmation settles (approved/denied/expired). */
waitFor(confirmationId: string, opts?: { signal?: AbortSignal }): Promise<"approved" | "denied" | "expired">;
subscribe(listener: (pending: PendingConfirmation[]) => void): Unsubscribe;
}
export interface PendingConfirmation {
confirmationId: string; // "cnf_" + random
capabilityId: string;
registrationId: string;
/** Normalized consumer identity: `kind + ":" + id`. */
consumerKey: string;
/** Effect of the operation being approved (host dialogs render it). */
effect: AgentEffect;
/** Human-readable summary composed from description + effective input. */
summary: string;
/** The exact effective input (bound + agent-supplied) being approved. */
input: JsonValue;
requestedAt: string;
expiresAt: string; // default TTL 120 s
}Toolset
The provider-neutral projection used by the embedded adapter:
export interface AgentToolsetOptions {
consumer: AgentConsumer;
/**
* "direct": one tool per capability — provider-native input typing, catalog
* size linear in the surface. "meta": three fixed tools with lazy discovery —
* constant tool-block size, one extra round trip before the first act.
* [Experimental] "meta" only: its verb envelope may change in any release
* Default "direct"; selection guide in Adapters §choosing-a-mode.
*/
mode?: "direct" | "meta";
/**
* Loop topology. Determines the confirmation-mode default:
* "embedded" → "wait", "remote" → "two-phase". One of `topology` or
* `confirmations` MUST be provided; omitting both throws (programmer
* misuse, every environment) — there is no ambiguous global default.
*/
topology?: "embedded" | "remote";
/**
* "wait": on CONFIRMATION_REQUIRED, await user resolution (up to TTL) and
* auto-retry, so the model sees one tool call → one final result.
* "two-phase": surface CONFIRMATION_REQUIRED to the model, which retries.
* Explicit value overrides the topology default; a remote loop opting into
* "wait" owns its transport-timeout story (docs/09 §confirmation-topology).
*/
confirmations?: "wait" | "two-phase";
/**
* Component-type prefixes this consumer may discover. This is a **floor** —
* in "meta" mode a model-supplied scope narrows it, never widens it.
* Not an authority boundary (docs/09 §scope-is-discovery-only).
*/
scope?: string[];
/**
* [Experimental] Snapshot truncation budget for `surface_discover`.
* "meta" mode only; throws in "direct" mode, where truncation would drop
* tools with no `truncated` marker for anyone to see.
*/
budget?: { maxComponents?: number; maxBytes?: number };
}
export interface AgentTool {
/** Wire-safe name (see 09 §wire-names), ≤ 64 chars, unique in this catalog. */
name: string;
/**
* Plane + effect + confirmation prefix, then the authored description.
* Contains NO live state — safe in a provider tool block with prefix
* caching across steps.
*/
description: string;
inputSchema: JsonSchema;
/**
* Volatile: re-derived on every snapshot. Hosts render this OUTSIDE the tool
* block (e.g. a trailing system message) so availability stays honest without
* invalidating the cached prefix.
*/
state: {
available: boolean;
unavailableReason?: string;
/** Live text contributed by a contextual binding's describe(). */
note?: string;
};
execute(input: JsonValue, call: { toolCallId?: string }): Promise<AgentInvocationResult>;
}
export interface AgentToolset {
tools(): AgentTool[]; // recomputed per surface version
/**
* wireName → canonical capability id, for the catalog tools() last built.
* Authoritative: shortened names are not decodable by string surgery, so a
* host MUST consult this rather than reversing names itself. Empty in
* "meta" mode, whose three tool names are not capability ids.
*/
wireNameMap(): ReadonlyMap<string, string>;
/**
* Fires when tools() would return a different catalog — including a change
* confined to `state`, so a host re-rendering its state block still hears
* about an availability flip that leaves the definitions byte-identical.
* Never fires in "meta" mode: the 3-tool catalog is constant, and agents
* re-discover by comparing `surfaceVersion` (docs/09 §meta-tools-mode).
*/
subscribe(listener: (tools: AgentTool[]) => void): Unsubscribe;
dispose(): void;
}
export function createAgentToolset(
registry: AgentSurfaceRegistry,
options: AgentToolsetOptions,
): AgentToolset;execute fills invocationId from toolCallId (idempotent transport retries), attaches registrationId + surfaceVersion from the catalog it was built from, and never throws — it returns the result envelope for the host to format for its provider.
Limits and defaults
export interface AgentSurfaceLimits {
maxComponentDescription: number; // 500 chars
maxCapabilityDescription: number; // 300 chars
maxMetaBytes: number; // 2048
maxOutputBytes: number; // 32_768
maxSchemaBytes: number; // 16_384
maxSchemaDepth: number; // 8
observationTimeoutMs: number; // 5_000
actionTimeoutMs: number; // 10_000
procedureTimeoutMs: number; // 30_000
actionQueueDepth: number; // 2
maxConcurrentObservationsPerConsumer: number; // 8
maxConcurrentObservationsTotal: number; // 32
maxQueuedObservationsPerConsumer: number; // 8
dedupeCacheSize: number; // 200 entries
dedupeCacheTtlMs: number; // 600_000
tombstoneSize: number; // 100 entries
tombstoneTtlMs: number; // 300_000
confirmationTtlMs: number; // 120_000
maxPendingConfirmations: number; // 32; overflow fails RATE_LIMITED, no record created
}All defaults are overridable via RegistryOptions.limits. Implementations MUST enforce every limit and MUST make violations diagnosable (typed error or dev throw), never silent.