Skip to content

Adapters and tool projection ​

Adapters translate registry snapshots and invocations into a provider or browser protocol. They never become an execution authority.

Registry-backed adapters inherit the compiler authority already verified at registration and execute only through registry.invoke. Standalone provider tools must be compiled contracts admitted by createAgentExposureGateway(authority).

Adapter contract ​

ts
interface AdapterHost {
  registry: AgentSurfaceRegistry;
  consumer: AgentConsumer;
  snapshotContext?: Omit<SnapshotContext, "consumer">;
}

interface AgentSurfaceAdapter {
  readonly name: string;
  start(host: AdapterHost): void | Promise<void>;
  stop(): void | Promise<void>;
}

An adapter must:

  1. attach a stable, unique consumer identity to snapshots and invocations;
  2. build exposure from registry.snapshot, never private registry state;
  3. subscribe to surface-changed and refresh between agent turns;
  4. pass registrationId and relevant surfaceVersion tokens back on invocation;
  5. keep one invocationId for transport retries and never reuse it for a different request;
  6. preserve error code, retry, and details in tool content;
  7. keep an authoritative wire-name-to-capability-id map;
  8. declare topology: "embedded" | "remote" or an explicit confirmation mode;
  9. release subscriptions and pending waits on shutdown.

Consumer identity scopes policy and idempotency. Two adapter instances addressing the same registry need distinct, stable consumer.id values.

Wire names ​

Provider tool names use this reversible encoding for ordinary ids:

text
view:devices.table.selectRows  → view_devices__table__selectRows
domain:devices.disable        → domain_devices__disable
  • : becomes _ and . becomes __.
  • repeated instances append _at_<encoded-instanceId>.
  • encoded names are at most 64 characters.
  • longer names use a deterministic hash suffix.
  • collisions are detected across the emitted catalog and re-encoded with a longer hash.
  • decodeWireName returns undefined for shortened, instance-qualified, or non-canonical names.

Always reverse provider names through the map produced for that catalog:

ts
const capabilityId = toolset.wireNameMap().get(toolCall.name);

Do not fall back to string surgery: the canonical capability id is the audit and invocation identity.

Embedded toolset ​

createAgentToolset is the provider-neutral adapter built into core:

ts
const toolset = createAgentToolset(registry, {
  consumer: { id: "copilot-panel", kind: "embedded" },
  topology: "embedded",
});

const providerTools = toolset.tools().map((tool) => ({
  name: tool.name,
  description: tool.description,
  input_schema: tool.inputSchema,
}));
ts
interface AgentToolsetOptions {
  consumer: AgentConsumer;
  mode?: "direct" | "meta";
  topology?: "embedded" | "remote";
  confirmations?: "wait" | "two-phase";
  scope?: string[];
  budget?: { maxComponents?: number; maxBytes?: number };
}

mode defaults to direct, which produces one provider tool per capability. budget is valid only in meta mode; a direct catalog uses scope because silent truncation would hide tools without a model-visible marker.

Rendering capability state ​

Each AgentTool separates stable definition text from live state:

ts
interface AgentTool {
  name: string;
  description: string;
  inputSchema: JsonSchema;
  state: {
    available: boolean;
    unavailableReason?: string;
    note?: string;
  };
  execute(
    input: JsonValue,
    call: { toolCallId?: string },
  ): Promise<AgentInvocationResult>;
}

Place description in the provider tool block. It contains stable plane, effect, confirmation, and authored text. Render state in a trailing context block that can change between turns without invalidating the stable prompt prefix:

ts
function capabilityState(tools: AgentTool[]): string {
  const lines = tools.flatMap((tool) => {
    const parts = [
      tool.state.available
        ? undefined
        : `unavailable: ${tool.state.unavailableReason ?? "not right now"}`,
      tool.state.note,
    ].filter(Boolean);

    return parts.length ? [`- ${tool.name}: ${parts.join(" · ")}`] : [];
  });

  return lines.length ? `Current capability state:\n${lines.join("\n")}` : "";
}

Hidden capabilities have no tool. Visible but unavailable capabilities remain discoverable with their reason. state.note contains live oRPC binding context from describe().

Confirmation topology ​

createAgentToolset requires a topology or explicit confirmation mode.

TopologyDefaultBehavior
embeddedwaitWait for host confirmation and retry internally; the model sees one final result
remotetwo-phaseReturn CONFIRMATION_REQUIRED; retry in a later turn with confirmationId

A remote host may opt into wait, but it must keep the confirmation wait below its transport timeout and handle reconnects. toolset.dispose() aborts active waits. registry.dispose() expires pending confirmation records.

Choosing a mode ​

Surface shapeRecommended mode
Small or moderate catalogdirect
Moderate catalog with clear feature boundarydirect plus scope
Catalog too large for a provider tool blockmeta (Experimental)

Measure the target model and provider. The library does not automatically switch modes.

Meta-tools mode ​

Meta mode is Experimental. It exposes three fixed tools and discovers capabilities lazily:

text
surface_discover({ scope? })
surface_read({ capabilityId, instanceId? })
surface_act({ capabilityId, instanceId?, input,
              invocationId?, confirmationId?, surfaceVersion? })

It changes projection, not execution. All reads and actions use the same registry resolution, validation, policy, confirmation, concurrency, and staleness pipeline as direct tools.

Required behavior:

  • ambiguous targets are left for the registry to classify; the adapter never invents a placeholder registrationId;
  • surface_act accepts retry identity, confirmation evidence, and the discovery surfaceVersion;
  • destructive and externally visible calls should echo surfaceVersion so movement under the plan fails closed;
  • the three tool schemas are constant across surface mutations;
  • verb envelopes reject unknown or missing properties as INVALID_INPUT before registry invocation;
  • surface_act.input is an object; a JSON-encoded object string is repaired only when the resolved capability expects an object, with a development warning;
  • a discovery budget reports truncated: { droppedComponents } in the model-visible payload;
  • a refused scope reports scopeRejected: { prefixes };
  • agents observe change by calling surface_discover again and comparing surfaceVersion.

Scope is discovery-only ​

Configured scope is a floor. A model-supplied meta scope may narrow it but cannot widen it. An empty array means no additional narrowing. A request entirely outside the floor returns an empty surface and identifies the refused prefixes.

Scope limits discovery, not authority: registry.invoke does not accept a scope token. Use policies and server enforcement for authorization. For a less-trusted peer, direct mode plus scope provides the stronger exposure boundary because hidden capabilities have no provider tool.

WebMCP adapter ​

@agent-surface/webmcp is Experimental and maps the registry to navigator.modelContext:

ts
const adapter = createWebMcpAdapter({
  snapshotContext: { scope: ["devices"] },
  exposeCapability: (descriptor) =>
    shouldExpose(descriptor) ? undefined : null,
});

await adapter.start({
  registry,
  consumer: { id: "browser-agent", kind: "webmcp" },
});

The curation hook may skip a capability or replace its description. It cannot replace execution, and it cannot change annotations.

Current mapping:

  • one wire-named tool per available capability;
  • reconciled on surface-changed: with registerTool/unregisterTool present, only added, removed, or changed tools are touched; otherwise the full set is re-sent through provideContext;
  • a tool is re-registered when its description, input schema, registrationId, or read-only hint changes; a tool that survives a change unmodified keeps its registration and echoes the latest projected surfaceVersion;
  • execution through registry.invoke;
  • annotations: { readOnlyHint: true } for observations and read/server-query effects only, derived from the declared kind and effect, never from curation;
  • confirmation within one call: waiting for the host's confirmation UI by default, or an opt-in requestUserInteraction hook (below);
  • capability errors encoded in tool content;
  • no-op start when navigator.modelContext is unavailable;
  • stop() withdraws every tool the adapter exposed (unregisterTool for its own names, else clearContext(), else an empty provideContext), releases the subscription, and is safe to call repeatedly.

Unavailable capabilities are omitted because the current transport has no disabled-tool state. Use snapshotContext.scope to minimize exposure to the browser peer.

Confirmation ​

Plain two-phase confirmation cannot work over WebMCP. A tool's input schema is the capability's own, so there is no slot for a confirmationId: a retry would never carry evidence, and each one would open a new confirmation. The adapter therefore completes confirmation inside the tool call.

By default, when an invocation returns CONFIRMATION_REQUIRED, execute stays pending while the host renders its usual confirmation UI (from registry.confirmations), then retries once with the same invocationId and confirmationId:

OutcomeTool result
approvedthe capability's output
deniedCONFIRMATION_INVALID { reason: "denied" }, never retried
no decision within the TTLCONFIRMATION_INVALID { reason: "expired" }
adapter.stop() while waitingthe original CONFIRMATION_REQUIRED; nothing executes

The wait is bounded by limits.confirmationTtlMs (default 120 s). If the user agent times out tool calls sooner, lower the TTL to fit.

Through requestUserInteraction ​

WebMCP passes a client to execute(input, client) whose requestUserInteraction(callback) lets a tool run in-page UI during the call. The confirm option routes a required confirmation through it:

ts
const adapter = createWebMcpAdapter({
  confirm: (request) => showConfirmDialog(request.summary, request.input),
});

When an invocation returns CONFIRMATION_REQUIRED and the client supports requestUserInteraction, the adapter runs confirm(pendingConfirmation, client) inside it, resolves the registry's pending record with the answer, and retries once with the same invocationId and confirmationId. The browser agent sees one final result. The registry stays the confirmation authority: it checks the evidence, consumes the record, and returns CONFIRMATION_INVALID on denial.

The hook is opt-in because it needs host UI. Without confirm, without client support, or if the UI throws, the call falls back to waiting for the host's confirmation UI. If the adapter stops during the prompt, the call returns CONFIRMATION_REQUIRED and nothing executes.

What WebMCP does not restrict ​

WebMCP adds a structured channel next to whatever DOM access the user agent grants its agent; it does not remove that access. Registry policies and confirmations govern only calls routed through the registry. A browser agent that operates the DOM can click the app's own controls, which run the app's handlers directly, and it can click a confirmation dialog rendered in the same page. Treat in-page confirmation as protection against a misled model using the tools, not against an agent that drives the page. Domain authority stays on the server.

Imperative API only ​

The adapter uses only WebMCP's imperative API. The declarative API, where form attributes turn rendered DOM forms into tools, is a non-goal: capabilities must be compiler-authorized, not derived from the rendered DOM.

Targeted WebMCP revision ​

The adapter targets the WebMCP draft from the W3C Web Machine Learning Community Group (webmachinelearning/webmcp) as exposed by the Chrome early preview (Chrome 146+, chrome://flags/#enable-webmcp-testing). It assumes exactly this surface; anything else is drift the adapter must absorb:

MemberUse
navigator.modelContext.provideContext({ tools })required; fallback full-set replacement
navigator.modelContext.clearContext()optional; stop() on the fallback path
navigator.modelContext.registerTool(tool) / unregisterTool(name)optional, feature-detected together; incremental path
tool { name, description, inputSchema, annotations?: { readOnlyHint? }, execute(input, client) }tool shape
client.requestUserInteraction(callback)optional; in-page confirmation

The same table lives in the package README. Update both when moving the target.

Testing adapter ​

@agent-surface/testing consumes the same public registry seams with consumer kind test. See Testing.

Custom adapter checklist ​

  • [ ] Stable consumer id, unique per adapter instance.
  • [ ] Snapshot scope appropriate for peer trust.
  • [ ] Refresh on surface-changed.
  • [ ] Resolution tokens preserved on invocation.
  • [ ] Stable invocationId per attempt; conflicting reuse treated as a caller bug.
  • [ ] Error fields preserved in tool content.
  • [ ] Confirmation topology declared and host UI connected.
  • [ ] Wire names reversed through the catalog map.
  • [ ] Live capability state rendered outside stable descriptions.
  • [ ] stop() fully unsubscribes and can run repeatedly.
  • [ ] Behavior covered with createTestSurface.

Compiler-authorized frontend capabilities for agents. Source on GitHub.