agent proxy
August 13, 2026 · View on GitHub
Status: v1 invoke spine ✅ proven live (2026-05-25); discovery layer redesigned 2026-06-09; AAuth -11 / R3 -02 landed 2026-08-11 (v0.6.0). The agent proxy drives the authorize-first R3 flow end-to-end against a real Person Server + AAuth resource, signing with the bootstrapped @aauth/local-keys identity. -11 adds person-token acquisition and caching in front of the authorize-first path, the auth_token_endpoint / person_token_endpoint split in PS metadata, and three-way access_mode planning; R3 -02 adds operation access annotations and the per-call proposal flow, and removes the openapi-gateway vocabulary. Discovery generalizes to multi-resource: signed-call registry client, three-layer state (added / discoverable / per-resource ops), vocabulary-adapter abstraction (OpenAPI today, AsyncAPI partial, MCP-tools/GraphQL later). v.next (sub-agents, WASM runtime) is still ahead.
Reference implementation of an AAuth agent for MCP-aware agent hosts. The agent proxy represents the user as an AAuth agent, exposes that agent's capabilities to an LLM via MCP, and relays AAuth interactions to the user's Person Server. Published as @aauth/proxy from aauth-dev/praca.
Objectives
- Demonstrate AAuth's value end-to-end: cross-domain agent access with genuine user oversight.
- Give MCP hosts (Claude Code, Claude Desktop, Cursor, CLI tools) access to AAuth resources via a single integration.
- Reusable across agent platforms. Nothing in the agent proxy is host-specific.
- No raw credentials in the agent proxy. The agent proxy's compromise surface is one AAuth agent identity, not a vault of third-party credentials.
- Cryptographic identity per user. Parent agent keypair (software) + AP signing key (enclave-backed via
@aauth/local-keys). - Real oversight via AAuth missions / resource tokens / interactions.
- Trust integrity for PS↔user interactions — never relayed through agents, hosts, or chat platforms.
Architecture context
┌──────────────────────────────────────────────────┐
│ Agent host (Claude Code / Claude Desktop / CLI) │
│ │
│ LLM ──MCP tool calls─────────┐ │
└────────────────────────────────┼─────────────────┘
▼
┌─────────────────────────────────┐
│ agent proxy (stdio MCP server) │ ← this doc
│ - AAuth agent for the user │
│ - software parent keypair │
│ - AP signing key in enclave │
│ - service catalog │
│ - interaction relay to PS │
│ - state in ~/.aauth/proxy/ │
└────────────────┬────────────────┘
│ AAuth-signed HTTPS
┌────────────────▼────────────────┐
│ AAuth resource │
│ - mints resource_tokens │
│ - issues interactions / │
│ escalation tokens │
└─────────────────────────────────┘
▲ governs grants, mints
│ auth_tokens, hosts user
│ approval flows
┌────────────┴────────────────────────────────┐
│ Person Server (e.g. person.hello.coop) │
└─────────────────────────────────────────────┘
The agent proxy sits between the MCP host and AAuth resources. It is the user's signing principal; resources verify its AAuth signature, and PS holds the grants that the agent proxy's identity is authorized against. AAuth resources may be native AAuth services or AAuth-fronted wrappers over OAuth APIs — the agent proxy doesn't care which.
Tool surface
A small fixed set of meta-tools — Mechanism B in the dynamic-discovery taxonomy (Stainless, Speakeasy, GitHub MCP) — keeps init-time tool-definition tokens flat regardless of how many resources the user has added or how many ops each resource exposes. Token-bloat research (docs/research-notes.md if we keep one) puts naive one-tool-per-endpoint at ~1.17M tokens for the Cloudflare API and ~405K for a 400-tool static MCP server; the meta-tool pattern keeps the agent proxy at ~1K of tool descriptions regardless of L1/L2 size.
Each tool's description embeds a short literal snapshot of L1 ("currently added resources: a, b, c") so the common path needs no extra round-trip; list_resources gives the canonical fresh view.
v1 surface (eight tools)
Resource lifecycle (L1 / L2):
find_resources(query)— search the registry (L2). Returns{ resource, name, description, access_mode, added: bool }[].add_resource(host_or_url)— fetch{host}/.well-known/aauth-resource.json, validate, pick the resource's vocabularies, write to L1. Accepts bare host,https://host, or full URL; canonicalizes to bare lowercased host.list_resources()— return L1 with{ resource, name, description, access_mode, ops_count, last_used }.remove_resource(resource)— unregister from L1. Agent-proxy-local only; does not revoke PS-side grants.connect(resource)— pre-authorize a resource (typically a PS-side consent step). No-op foraccess_mode: agent-tokenresources.
Operations within a resource (L3):
list_operations(resource, query?)— return ops across all vocabularies the resource advertises, as{ opId, kind, summary, method?, path?, channel?, tags }[].queryis either free-text (matched againstsummary/tags/opId) or a path/channel prefix (/crm/v3/objects/contacts/*). Bounded result size with explicit "N more — refine query" marker.get_operations(resource, op_ids[])— batch fetch full schemas for one or more operations. Schemas dominate token cost, so this is intentionally separate fromlist_operations(per Speakeasy / OpenMCP).invoke(resource, op_id, args)— execute. Routes internally on the op'skind:sync.request→ R3 HTTP call;async.send→ publish via the resource's send channel;async.receive→ returnsasync_subscribe_requires_subagent(v.next). On first call to a session-token or auth-token resource that hasn't been authorized, returns the interaction URL — the LLM hands it to the user, then retries. An operation whose access mode this agent cannot complete is refused without a request being made (see "Access modes").
kind values: sync.request | async.send | async.receive. The LLM never sees vocab; that's an agent-proxy-internal routing detail (see "Vocabularies"). OpIds are the natural value from the vocab doc; the agent proxy deterministically prefixes (openapi:/asyncapi:) only when two vocabularies at the same resource happen to expose colliding ids.
resource is always the canonical bare lowercased host (e.g., api-hubapi-com.proxy.aauth.dev). Input accepts host / scheme+host / full URL; the agent proxy normalizes.
v.next surface (saved-function runtime)
Each saved function is a real MCP tool surfaced in tools/list via notifications/tools/list_changed (Option A). The LLM calls it like any other tool; the agent proxy runs the saved code in a QuickJS-WASM isolate as an AAuth sub-agent (per issue #23). The runtime is also driven by a small lifecycle meta-surface:
run_code(code, scope?)— ephemeral one-shot in a disposable isolate.register_function(name, description, code, scope)— promote a snippet to a saved tool. Adds totools/list.list_functions()— return saved tools with descriptions and scopes (the canonical snapshot;tools/listis the LLM's primary view).remove_function(name)— delete; removes fromtools/list.- (v.next.next)
register_handler(name, event_pattern, code, scope)— webhook/event handler; only fires when matching events arrive over the DO event tier.
scope is the AAuth scope (resources + operations the function may use). At register time, the agent proxy seeds the isolate's import closure with bindings only for those operations; identity is captured in the closure so sub-agent code can't impersonate or escalate. See "v.next: programmable runtime + sub-agents".
Transport
stdio MCP server in v1. The same code can be exposed over HTTP for container-resident hosts (reaching the agent proxy at host.docker.internal); host-launched stdio ships first.
State
Three layers, all file-backed, all per-machine.
Agent identity lives outside the agent proxy, in ~/.aauth/ via @aauth/local-keys (bootstrapped once per machine via @aauth/bootstrap). The agent proxy reads from it at startup; it owns no key material itself. This is what lets the same identity back multiple agent surfaces (the agent proxy, future CLI tools) without each carrying its own bootstrap.
The agent proxy's own state at ~/.aauth/proxy/:
| Path | What | Refresh |
|---|---|---|
resources.json | L1 — added resources: { resource, name, description, access_mode, picked_vocabs[], last_used }[] | written on add_resource / remove_resource / first successful auth |
catalog/registry.json | L2 — cached GET registry.aauth.dev/resources result | refreshed on startup + 24h background; ETag-conditional |
catalog/{host}/{vocab}.json | L3 — cached vocabulary docs (OpenAPI / AsyncAPI / …) per resource | fetched on first list_operations/get_operations; cached with TTL |
connections/{host}.json | per-resource session state — stored auth-tokens, refresh state, last interaction | written by R3 flow |
person-tokens.json | PS-issued person tokens, keyed (resource, mission_s256), plus the thumbprint of the agent key they all bind | written on person-token acquisition; flushed whole on key rotation |
pending-interactions.json | open interactions awaiting user resolution | written/cleared by interaction relay |
JSON files for v1; promote to SQLite if concurrent writes get painful. File-lock for concurrent writes (multiple host clients OK).
v.next adds:
functions/{name}.json— saved-function source + scope + sub-agent identityfunctions/{name}.wasm(optional) — pre-compiled QuickJS bytecode if we end up caching that- daemon mode (one persistent agent proxy per user, hosts connect via Unix socket — the SSH_AUTH_SOCK pattern)
Bootstrap
Once per machine:
- the agent proxy generates the parent keypair (software)
- the agent-proxy-as-AP signs the parent's agent token (one enclave signature)
- User completes parent grant at PS (mobile app, web)
- Subsequent host launches reuse all of this
Catalog & discovery
The discovery layer is built around the three-layer state model (L1 added / L2 discoverable / L3 per-resource ops) and a small set of meta-tools that walk it lazily.
L2 — discoverable resources (the registry)
The registry at registry.aauth.dev is an agent-token-gated Worker. The agent proxy calls GET /resources over a signed request using the same agent-token + HTTP-signature path it already uses for any AAuth resource. The response is { resources: RegistryEntry[], updated }; each entry carries { issuer, name, description, access_mode, logo_uri?, added, submitted_by } — and only that. The agent proxy caches at ~/.aauth/proxy/catalog/registry.json, refreshes on startup + 24h background, ETag-conditional.
PROXY_REGISTRY_URL overrides the default for tests / self-hosted registries. Operators may also publish their own resource directory: for example, Hello's resource proxy operator advertises its hosted proxies at proxy.aauth.dev and the agent proxy treats it as a registry source under the same signed-call contract.
L1 — added resources
The agent calls add_resource(host_or_url). The agent proxy:
- Normalizes the input to a bare lowercased host.
- Fetches
https://{host}/.well-known/aauth-resource.json(manual redirect, timeout, size cap — same SSRF guards the registry applies). - Validates:
issuer === https://{host}, present description, validaccess_mode, at least one supported entry inr3_vocabularies(or — explicit choice — accepts a vocab-less resource and exposes onlyinvokeas a generic call). - Picks the vocabulary adapters it can use (see "Vocabularies").
- Writes the entry to
~/.aauth/proxy/resources.json.
After add_resource:
access_mode: agent-tokenresources are immediately invokable.access_mode: person-token/auth-tokenresources need a person token from the PS first; the agent proxy obtains one lazily on the firstinvoke(see "Person tokens").access_mode: session-tokenresources are added butinvokewill return an interaction URL on first call so the user can complete the resource's own consent flow;connect(resource)is the explicit pre-auth path.- A resource declaring a mode this agent cannot complete is listed with a
skip_reasonand never called.
add_resource is the canonical entry point for both registry-found and direct-URL resources. No registry inclusion is required — direct URL is first-class. The agent proxy never gatekeeps on registry membership.
L3 — operations within a resource
Per-resource ops are fetched on first list_operations/get_operations call against that resource, cached at ~/.aauth/proxy/catalog/{host}/{vocab}.json. The agent proxy reads the resource's r3_vocabularies and loads each one through the matching adapter; vocab docs are cached with a TTL and refreshed lazily.
list_operations returns a bounded summary list (no schemas); get_operations is the explicit "give me the full schemas for these op_ids" call. This separation matters because schemas dominate token cost — Speakeasy's published numbers show schema-bearing tool listings 5-10× larger than summary-only listings. (See "Tool surface".)
Person tokens
AAuth -11 makes the person token load-bearing: a resource MUST have verified one before it issues a resource token, and the agent MUST present one via Signature-Key on every authorization endpoint request. The agent proxy therefore obtains a person token before the authorize-first path, not only for access_mode: person-token resources.
Acquisition is a signed POST to the PS's person_token_endpoint (published in /.well-known/aauth-person.json alongside auth_token_endpoint, renamed from token_endpoint in -11), presenting the agent token via Signature-Key, with { resource, mission_s256? } as the body. Requests carrying a body to a PS or AS additionally cover content-digest and content-type in the signature. 200 returns { person_token, expires_in }; 202 with requirement=interaction is the deferred path — the PS wants the user to approve this agent acting at this resource, and the agent proxy surfaces it like any other interaction rather than blocking.
Caching. A person token is scoped to one resource and, when it carries mission_s256, to one mission, so the cache key is the pair. Every person token binds the same key through cnf, so a signing-key rotation invalidates the whole set at once — the store records the RFC 7638 thumbprint it was populated under and flushes everything the moment a different one is presented. There is no partial invalidation and no migration.
Missions. mission_s256 is forwarded to the person token endpoint, stamped into the person token, copied by the resource into the resource token, and copied by the PS into the auth token. It appears in no auth-token request body — the claim travels inside the tokens. No PS implements mission_endpoint yet; the claim path is built regardless.
Access modes
access_mode is an IANA registry, not a closed list, and the declaration is advisory: a resource MAY return any AAuth-Requirement at runtime whatever it published. The agent proxy plans three ways and only three:
| Plan | When | What the agent proxy does |
|---|---|---|
| undeclared | absent, or a value this build does not recognize | call the resource and read the AAuth-Requirement. Never an error. |
| satisfiable | recognized, and this agent's setup can complete it | plan against it and skip the speculative call |
| unsatisfiable | recognized, and this agent cannot complete it | skip the resource / operation, with the reason stated |
The third case is the one that pays. An agent whose agent token carries no ps claim has no person server, so it cannot obtain a person token and cannot complete person-token, auth-token or per-call — and it should learn that while planning, not at a 401. find_resources and list_resources carry a skip_reason on such resources; invoke refuses them without sending a request.
Whatever the plan, the runtime loop is the same: make the request, read any AAuth-Requirement, satisfy it, retry. The plan only chooses the opening credential.
Operation access annotations
An agent cannot read R3 documents, so R3 alone tells it nothing about what any one operation needs. The vocabulary is what it can read — it has to parse that to make the call at all — so R3 -02 puts the annotations there:
| Vocabulary | Location | Access mode | Budget |
|---|---|---|---|
| OpenAPI / AsyncAPI | Operation Object | x-aauth-access-mode | x-aauth-budget |
| MCP | Tool _meta | aauth.dev/access-mode | aauth.dev/budget |
The agent proxy reads them off the vocab doc it already fetches for L3 and flattens them onto every list_operations / get_operations result as access_mode (always present — the mode that actually applies to that operation) and budget: true (only when set). Three rules:
- Sparse. An unannotated operation takes the resource-wide
access_mode. - Replacing, not intersecting. A
person-tokenannotation on anauth-tokenresource lowers the requirement for that operation — which is what lets a metered resource serve balance and history calls without an authorization round trip. - Advisory. Never enforced, in either direction. The runtime requirement is authoritative.
session-token MUST NOT appear in an annotation; a value seen anyway is dropped. budget: true implies at least auth-token, since a budget rides in the auth token's budget claim.
The LLM sees this before it plans: which operations need only the agent token, which cost an authorization round trip, and which are per-call and will block on a person every time.
Per-call
A per-call operation is authorized in principle but not for any specific call. The resource challenges the invocation, builds a proposal document carrying that call's concrete parameters, persists it under its content hash, and returns a resource token whose r3_uri/r3_s256 reference it — the token never carries the parameters. The agent proxy exchanges that resource token at the PS for a per-call auth token (the grant lands in r3_per_call, renamed from r3_conditional in R3 -02) and retries the identical call: the resource recovers the proposal by hash and rejects any parameter that differs. The request init is fixed for the whole invoke flow so the retry is byte-identical by construction.
Operator selection
Deferred (2026-06-09). Operator-selection policy needs
kind/wraps/operatorsignals per registry entry to operate on. The liveregistry.aauth.devdoes not carry those fields today — entries are{ issuer, name, description, access_mode, logo_uri?, added, submitted_by }. Until the agent proxy can either (a) get those fields surfaced in the registry or (b) derive them from each resource's well-known (kindis implicit from the resource being a proxy at all;wrapsis not currently advertised;operatorwould need a new well-known field), there's nothing to choose between, so the design below is held against future need rather than implemented. The deferred path is most likely (a) — extend the registry schema once we have a second operator fronting the same upstream.
When the catalog holds more than one entry sharing a wraps value — several operators fronting the same upstream (e.g., Hello and Acme both proxy api.hubapi.com) — the agent proxy picks one by default, without prompting. The registry carries no ranking signals (it stays pure discovery); the selection policy lives here, in the agent.
Resolution order. For a given upstream, the agent proxy resolves to a single operator using, in order:
- User-pinned operator — if the user has pinned an operator for this upstream, use it.
- Trust set — the user's (or org's) configured set of trusted operators; the highest-ranked trusted candidate wins.
- Fallback ordering — a built-in default preference: the agent vendor's first-party operator first, then a curated default list.
Escalation. The agent proxy surfaces the choice to the user (and the LLM) only when the policy can't decide cleanly:
- no candidate operator is in the trust set,
- two or more candidates rank equally, or
- the user's policy says "always ask" for this upstream or operator.
Policy storage. The policy lives in the agent proxy's state dir as ~/.aauth/proxy/operator-policy.json:
{
"pins": { "api.hubapi.com": "hello" },
"trust_set": ["hello", "acme"],
"fallback": ["hello"],
"always_ask": []
}
Defaults on a fresh install: all four lists empty. With no trust set and no fallback, the agent proxy prompts the user (and the LLM) the first time an upstream resolves to more than one operator. Distributions / agent vendors may ship a populated fallback and trust_set to silently prefer their first-party operator; that's a packaging concern, not an agent-proxy-core default. The agent proxy needs nothing more from the registry than the kind, operator, and wraps fields it already exposes to run this policy.
Vocabularies
A resource self-describes by advertising one or more vocabularies in r3_vocabularies — each a { urn → vocab_doc_url } pair. The vocab doc tells the agent proxy what operations exist, how to format requests, and what kinds of responses to expect. Multiple vocabularies per resource are first-class: a HubSpot proxy can carry both OpenAPI (CRM operations) and AsyncAPI (webhook events); a Slack proxy can carry OpenAPI (Web API) and AsyncAPI (RTM events).
The vocabulary is internal to the agent proxy. The LLM never sees the URN, the adapter, or the spec doc; it sees only the operations themselves with a kind tag (sync.request / async.send / async.receive) that captures the semantically-load-bearing distinction.
URN registry (v1)
| URN | Status | Notes |
|---|---|---|
urn:aauth:vocabulary:openapi | v1 adapter, full | OpenAPI 3.x. All ops have kind: sync.request. |
urn:aauth:vocabulary:asyncapi | v1 adapter, partial | AsyncAPI 3.x. send operations → kind: async.send (invokable). receive operations → kind: async.receive (listed; invoke returns async_subscribe_requires_subagent). |
urn:aauth:vocabulary:mcp | future | MCP tool-list as a vocab — useful for resources that ARE MCP servers fronted by AAuth. |
urn:aauth:vocabulary:graphql | future | GraphQL schema as a vocab. |
The URN registry now lives in the R3 spec (urn:aauth:vocabulary:), which defines seven standard vocabularies.
urn:aauth:vocabulary:openapi-gateway was removed in R3 -02 (AAuth issue #72), and its adapter with it. Operation identifiers are scoped to the one discovery endpoint a resource advertises per vocabulary, so there is no composite service:operationId identity and no {service, operationId} entry shape in r3_operations / r3_granted / r3_per_call. A resource fronting several backend services either presents them as one valid definition at its discovery endpoint (renaming collisions) or exposes them under separate resource identifiers, where aud distinguishes them.
Adapter interface
interface VocabAdapter {
vocabUri: string // e.g. urn:aauth:vocabulary:openapi
load(url: string): Promise<VocabDoc> // fetch + parse + cache
listOperations(doc: VocabDoc, query?: string): OpSummary[]
getOperations(doc: VocabDoc, opIds: string[]): OpDetail[]
buildInvocation(doc: VocabDoc, opId: string, args: unknown): InvocationPlan
annotationsFor(doc: VocabDoc, opId: string): OperationAnnotations // access mode + budget
}
type InvocationPlan =
| { kind: 'sync.request'; method: string; path: string; query?: string; headers?: Record<string,string>; body?: unknown }
| { kind: 'async.send'; channel: string; message: unknown }
| { kind: 'async.receive'; channel: string; filter?: unknown } // v.next-only via the runtime
The agent proxy's adapter table is keyed by URN. At add_resource time, the agent proxy walks r3_vocabularies, picks every URN it has an adapter for, and stores the picked list on the L1 entry. list_operations runs all picked adapters and flattens results; get_operations and invoke look up the owning adapter by (resource, opId).
OpId namespacing
OpIds come from the vocab doc as-is (OpenAPI operationId, AsyncAPI operation key, etc.). When two vocabularies at the same resource expose the same opId — rare in practice, since the conventions differ — the agent proxy's resource loader detects the collision deterministically and prefixes both (openapi:contact.created, asyncapi:contact.created) before the ops ever reach the LLM. The prefix scheme is stable, so saved-function code referencing an opId never silently breaks.
Sync vs async lifecycle
sync.request(OpenAPI):invokeruns the R3 path. Today's flow.async.send(AsyncAPI publish):invokebuilds the publish request, signs, calls the resource. Fire-and-forget; response is a delivery ack. Works in v1.async.receive(AsyncAPI subscribe): listed bylist_operationsso the LLM knows it exists;invokereturnsasync_subscribe_requires_subagent. Subscribe semantics live in v.next, behindregister_handler— the only way a subscription is useful is paired with code that decides per event, which is exactly what the sub-agent runtime is for.
Vocab-less resources
A resource MAY advertise no r3_vocabularies. The agent proxy still adds it; list_operations returns empty and the LLM falls back to calling invoke(resource, '/some/path', args) with op_id interpreted as the raw path. This is the escape hatch for resources that haven't (yet) published a spec — the LLM has to know what it's doing, but the door isn't closed.
Trust & key model
| Key | Location | Used for | Cost per use |
|---|---|---|---|
| AP signing key | enclave (SE/PIV via @aauth/local-keys) | Signing agent tokens (parent's, and v.next sub-agents') | SE ~30–50ms, PIV/YubiKey ~150–300ms |
| Parent agent keypair | software (in agent proxy memory) | Parent's request signatures, HTTP signatures to PS | software Ed25519, ~50µs |
The agent proxy is the AP in v1. The MCP server holds both the AP key (enclave-backed) and the parent's software key. The "spawn protocol" question from AAuth issue #23 collapses to an internal function call in this deployment. For multi-tenant agent-proxy deployments, AP and parent agent identity would be separated; for personal-install, the collapse is appropriate.
Enclave signature accounting:
- One sig at first run (mint parent's agent token; reused until expiry)
- Zero sigs per API call
- v.next: one sig per sub-agent spawn
Compromise model: process compromise leaks AP key (forge any future agent for this user/machine), parent's software key (impersonate parent until agent token expires), in-memory sub-agent keys. Does not leak the user's PS grant — that's at PS, keyed on parent identity. Revoke at PS → all forged agents become useless within auth_token TTL.
Interaction relay
When an AAuth resource issues an interaction (escalation needed, fingerprint check, step-up auth), the agent proxy is the conduit between resource and PS:
- Resource responds to a signed request with an interaction payload + resource_token
- the agent proxy forwards the resource_token to PS along with parent's agent token
- PS drives the user-approval flow (mobile app preferred, web fallback)
- PS returns resolution (auth_token, denied, deferred)
- the agent proxy returns auth_token to resource (and surfaces deny/defer to the LLM)
Sensitive operations follow the AAuth escalation path: resource mints a resource_token packed with operation context (sender, recipient, body excerpt), the agent proxy carries it to PS, PS drives mobile-app approval, only on approval mints auth_token bound to the agent proxy's parent key. The agent proxy never sees or constructs the consent URL the user sees.
Notification fallback
When PS can't reach the user via mobile push (offline, no app installed), PS may fall back to host-provided notification surfaces. The agent proxy does not participate in that fallback — it's a PS↔host arrangement, not an agent-proxy concern.
v1 scope (locked)
What ships:
- the agent proxy as
@aauth/proxy, stdio MCP server, eight-tool v1 surface (find_resources,add_resource,list_resources,remove_resource,connect,list_operations,get_operations,invoke) - Three-layer state at
~/.aauth/proxy/(resources / registry cache / vocab cache + connections) - Signed
GET registry.aauth.dev/resourcesfor L2 discovery; direct URL add viaadd_resourceworks without registry - Vocabulary adapter abstraction with one full OpenAPI adapter and one partial AsyncAPI adapter (publish only)
- Interaction relay to PS
- MCP-as-AP key model
- Integration tested against at least one real AAuth resource
What v1 doesn't ship:
- Sub-agents and the WASM programmable runtime
- Dynamic code execution
- Saved-function tools surfaced in
tools/list - AsyncAPI subscribe (
async.receive) invocation - MCP-tools / GraphQL vocabulary adapters
- Operator-selection policy (registry doesn't carry
kind/wraps/operatorsignals yet) - Per-host AAuth identities (AAuth issue #22's
classclaim is deferred) - Daemon mode / Unix socket bridge
v.next: programmable runtime + sub-agents
A coherent additive bundle. Doesn't require rewriting v1 components.
Motivation
In v1, the LLM emits one MCP tool_use per upstream call. Multi-step workflows ("summarize unread Gmail from family@, post to Slack") are token-expensive. v.next extends Anthropic's "MCP code execution" pattern (late 2025): the model writes a script that imports tools as functions and runs in a sandbox. The agent proxy makes each script a verifiable AAuth sub-agent with its own identity and audit trail.
Sub-agents via AAuth issue #23
Each registered function, webhook handler, or one-shot is an AAuth sub-agent of the parent:
agent-proxy parent (top-level agent)
├── register_function("send_digest") → sub-agent parent+send_digest
├── register_handler("slack.message") → sub-agent parent+on_slack_message
└── run_code(code, scope) → sub-agent parent+oneshot-{uuid}
LLM surfacing — Option A
Each saved function appears as a real MCP tool in tools/list (added via notifications/tools/list_changed after register_function). The LLM calls mySalesDigest(args) like any other tool; the agent proxy routes the call to the saved code in a fresh QuickJS-WASM isolate. The lifecycle meta-tools (register_function / list_functions / remove_function / run_code) sit alongside the saved tools; list_functions is the canonical fresh snapshot for clients that don't honor list_changed. (See "v.next surface" under "Tool surface".)
Sub-agent has its own software keypair and an agent token signed by AP (one enclave sig per spawn) with act.agent = parent. When sub-agent code calls upstream, AAuth challenges produce a resource_token bound to sub-agent's key; the agent proxy (parent) takes it to PS as resource_token: sub.rt, actor_token: parent.at; PS returns auth_token bound to sub-agent. PS audit reads "parent, acting via sub-agent X, did Y." Revoking parent's grant kills all sub-agents at next auth.
Runtime
QuickJS-compiled-to-WASM via quickjs-emscripten. Each invocation runs in its own QuickJSContext with isolated heap, memory limit, execution timeout.
Why WASM:
- Cap-based isolation — a WASM module has zero capabilities except what the host injects as imports
- The cap model maps 1:1 to AAuth scopes — sub-agent's WASM imports = its declared scope
- Per-call cold start ~1ms; per-instance memory ~1MB
- Cross-language path open (Rust, Go sub-agents later)
Why quickjs-emscripten:
- Mature, production-used in AI tooling (langchain, Effect, etc.)
- JS-native API for binding host functions
- We don't need WASI HTTP/filesystem — the import surface IS the API
Import surface bound at instantiation
Identity is captured in the import closure, never passed by the WASM module — sub-agent has no API surface to identify itself or impersonate another sub-agent.
Per-spawn binding generation walks declared scope against catalog, materializes named bindings backed by one host dispatcher:
for (const service of subAgent.scope.services) {
const serviceObj = vm.newObject()
for (const method of service.methods) {
const h = vm.newFunction(method.name, (...argHandles) => {
const args = argHandles.map(a => JSON.parse(vm.getString(a)))
const result = hostCall(subAgent, service.name, method.name, args)
return vm.newString(JSON.stringify(result))
})
vm.setProp(serviceObj, method.name, h); h.dispose()
}
vm.setProp(vm.global, service.name, serviceObj); serviceObj.dispose()
}
// plus a generic escape hatch:
vm.setProp(aauthObj, 'call', vm.newFunction('call', (s, m, a) =>
hostCall(subAgent, vm.getString(s), vm.getString(m), JSON.parse(vm.getString(a)))))
Agent code reads naturally:
const msgs = gmail.list({ query: "from:dad is:unread" });
for (const m of msgs) slack.post({ channel: "#fam", text: `Dad: ${m.subject}` });
// or dynamically:
const result = aauth.call(svc, method, args);
hostCall — the single chokepoint
hostCall(callerIdentity, service, method, args)
Owns AAuth signing, scope validation, resource_token bubbling, interaction relay, audit attribution. v1's MCP invoke tool handler calls into the same dispatcher with callerIdentity = parent. v.next bindings call it with callerIdentity = sub_agent. One code path; v.next is purely additive.
Interactions block import calls
Sub-agent calls gmail.send(...) requiring user approval → import handler stays inside hostCall waiting on resolution → WASM module suspended (linear memory held) → PS drives approval → host retries, returns into WASM. Sub-agent sees gmail.send "just took longer."
If memory-while-blocked becomes a concern: quickjs-emscripten supports snapshot/resume of linear memory.
Three design invariants make v.next additive
Ship these in v1 even though v.next won't use them yet:
- Central host-side dispatcher
hostCall(callerIdentity, service, method, args)owns all upstream calls - Catalog is data, not code — services as JSON entries, single source of truth for both MCP tool descriptions and WASM bindings
- Caller identity as first-class parameter to
hostCall— in v1 always parent; in v.next per-sub-agent
Get these right in v1 and v.next is a runtime bolt-on.
Rebuild cadence (v.next)
| Change | Restart the agent proxy? | Rebuild WASM? |
|---|---|---|
| New service in catalog | no | no |
| New method on existing service | no | no |
| Update to agent proxy host code | yes (normal release) | no |
Add host primitive (e.g. register_handler) | yes | yes (rare) |
| QuickJS-WASM version bump | yes | yes (rare) |
Rejected
| Rejected | Why |
|---|---|
| Native AAuth client embedded in host (instead of agent-proxy-as-MCP) | Per-host implementation effort; agent-proxy-as-MCP works for any MCP-aware host |
| Many MCP servers (one per upstream) | Catalog churn; agent-proxy-as-discovery scales better |
| Sibling HTTPS signing proxy | Loses MCP's structured tool descriptions |
Embedding @aauth/local-keys directly in host process | Reduces cross-host reusability |
| Routing PS↔user clarifications through chat | Breaks trust model — too many boundaries |
| Letting the model see or craft consent URLs | Prompt injection can substitute URLs and phish |
| Per-host AAuth identities in v1 | Issue #22's class claim is the right mechanism, deferred |
| Per-service typed WIT bindings for v.next WASM runtime | Per-service rebuild loop; catalog-driven named bindings give same ergonomics with zero rebuilds |
V8 isolates (isolated-vm) for v.next runtime | WASM cap-model gives stronger isolation — absence of an import IS the deny |
Wasmtime-hosted QuickJS over quickjs-emscripten | We don't need WASI HTTP/FS; import surface IS the API |
| Registry entries carrying full method/scope metadata | That belongs at the resource via .well-known/aauth-resource.json; registry stays a thin directory |
| Vocab string exposed to the LLM in OpSummary/invoke | Vocab is agent-proxy-internal routing; kind carries the LLM-relevant distinction (sync vs async, publish vs subscribe). Smaller surface, no learned vocabulary in the prompt. |
| Always-prefixing opIds with vocab | Collisions are rare; prefix-on-collision keeps the common case clean while staying collision-safe |
Code-mode (single execute tool over typed TS API, Cloudflare style) in v1 | Mechanism B (meta-tools) is portable across MCP clients today; Mechanism A (code-mode) is exactly what v.next's QuickJS-WASM sub-agent runtime delivers, locally |
| Embedding-ranked tool retrieval (Portkey-style) in v1 | At ~10²–10³ ops per resource, structural path-prefix search is sufficient; revisit when L3 exceeds comfortable scan budget |
Resource path-prefixed AAuth resources (https://api.foo.com/aauth/v2) | AAuth is origin-rooted today; bare-host canonical form covers everything until/unless that changes |
remove_resource cascading into PS-side revocation | Tight coupling to a PS revoke API that may not exist; user might still want the grant alive for other agents. Agent-proxy-local only; PS revoke is a separate user-driven concern. |
Deferred
- Sub-agents and the WASM programmable runtime (v.next, full design above)
- Saved-function tools surfaced in
tools/list(v.next, Option A — Anthropic clients supportlist_changed; coverage for non-Claude clients is uneven, so an Option-B fallback may land alongside) - AsyncAPI subscribe invocation — only useful behind a sub-agent, so deferred to v.next
- MCP-tools and GraphQL vocabulary adapters
- Operator-selection policy (waiting on registry
kind/wraps/operatorsignals) - Multiple accounts per service — hold more than one connected upstream account at the same resource (e.g. two Gmail accounts behind the same proxy host), with the user/LLM choosing which account an
invokeruns against. Today the agent proxy keeps a singleconnections/{host}.jsonper resource; this needs per-account connection state (connections/{host}/{account}.json), anaccountselector onconnect/invoke(default when one is connected, disambiguate when several), andlist_resources/connectsurfacing the connected-account set. Distinct from operator-selection (which operator fronts an upstream) — this is which end-user account at the chosen operator. Falls out of user-held identity: each account is just another grant under the same AAuth identity, not a new operator-scoped token slot. Contrast: Arcade Omni caps at one account per provider per Arcade user —switch_accountreplaces rather than adds (seeponte/omni-compete.md§2). - AAuth issue #22's
classclaim for distinguishing hosts on shared per-machine agent proxy - AAuth-spec home for the vocabulary URN registry — lift from this doc into the AAuth spec once we have a second adapter to validate against
- Daemon mode with Unix-socket bridge
- AP separation from the agent proxy for multi-tenant deployments
- Snapshot/resume of WASM linear memory during long blocking interactions
- Cross-language sub-agents (Rust, Go, Python via Pyodide)
- SEP-1821 alignment: when MCP
tools/list?query=…lands with real client support, foldfind_resources+list_operationsinto thin wrappers over the spec primitive
Phased plan
- ✅ Phase 0 — Skeleton. the agent proxy stdio MCP server with single-resource
discover/invoke/connect. CentralhostCalldispatcher, catalog-driven, caller identity as parameter. Validated against Claude Code. - ✅ Phase 1 — First real resource. Connected to a real AAuth-fronted upstream and ran the full AAuth dance end-to-end: resource_tokens, escalation, interactions, interaction relay.
- ✅ Phase 2 — Discovery layer (multi-resource). Refactored
catalog.tsinto aVocabAdapterinterface (one OpenAPI adapter); added registry client (signedGET /resources+ ETag); added L1 store at~/.aauth/proxy/resources.json; rewiredserver.tsto the eight-tool surface; addedPROXY_REGISTRY_URL. - Phase 3 — AsyncAPI partial. AsyncAPI adapter listing
send+receiveops;invokerunsasync.send;async.receivereturnsasync_subscribe_requires_subagent. Drives the second-vocab validation of the adapter interface. - Phase 4 — Container host bridge. the agent proxy exposes HTTP listener for container-resident hosts. Register via host's MCP config pointing at
host.docker.internal:<port>. Validate signing + invoke from inside container. - Phase 5 — Approval UX. Wire up notification fallback path (PS → host notification surface) for cases where mobile push is unavailable.
- Phase 6 — v.next bundle. Sub-agents per issue #23, MCP-as-AP minting sub-agent tokens, QuickJS-emscripten runtime, catalog-driven named bindings + generic escape hatch, central
hostCallextended with sub-agent identities. Addsrun_code/register_function/list_functions/remove_functionMCP tools; saved functions surfaced viatools/list_changed(Option A). AsyncAPI subscribe becomes invokable viaregister_handler. - Phase 7+. Issue #22
classclaim, daemon mode, multi-tenant AP separation, multi-language sub-agents, AAuth-spec home for the vocabulary URN registry.
Single-sentence summary
the agent proxy is the user's AAuth agent in MCP form — a stdio-launched Node process that holds the user's parent identity, exposes a fixed eight-tool meta-surface (find_resources / add_resource / list_resources / remove_resource / connect / list_operations / get_operations / invoke) over three layers of state (added resources / cached registry / per-resource ops), dispatches via vocabulary adapters (OpenAPI today; AsyncAPI partial; MCP-tools and GraphQL later) through a single hostCall(caller, resource, opId, args) chokepoint, and relays AAuth interactions between resources and PS — so that v1 ships as a clean, token-flat MCP↔AAuth bridge and v.next bolts on a QuickJS-WASM sub-agent runtime whose saved functions appear as first-class MCP tools without rewriting a line of v1.