Service-side agent runtime (server-hosted agent endpoints).
createAgentServicefunction createAgentService(options?: AgentServiceOptions): AgentServiceCreate an `AgentService`.
decideEmissionfunction decideEmission( principal: Principal, query: EmissionQuery, deps?: EmissionDeps, ): EmissionDecisionDecide whether `principal` may receive the emission this query describes.
isScopeValuefunction isScopeValue(value: unknown): value is ExtractScopeValueIs this axis value the `{ scope }` shape?
resolvePrincipalasync function resolvePrincipal( source: PrincipalSource, deps?: PrincipalGateDeps, ): Promise<Principal>Resolve THE principal for one request (spec §4.1 order): 1.
surfaceCallPolicyfunction surfaceCallPolicy(meta: AgentMetadata | undefined): ExtractCallValueRead the `extract.call` policy off a compiled `AgentMetadata` entry (the agent-meta artifact of the Phase 1 fan-out; rides the spec-mandated open index signature).
AgentManifestinterface AgentManifest {
tools: AgentToolEntry[]
}MCP multi-tool manifest aggregating all registered agents.
AgentServiceinterface AgentService {
/** Returns the aggregated manifest for all registered agents. */
getManifest(): AgentManifest
/**
* Routes a tool call to the correct agent binding.
* `toolName` format: `"<tag>/<actionName>"`.
*
* v0.3.0: full live-dispatch per RFC §5. Error ordering invariant:
* 404 → 401 → 403 → 429 (Amendment 4 timing-channel invariant).
*
* The gate is ASYNC end-to-end (#420): scoped/rate-limited tools require a
* signature-verified JWT (via `AuthPlugin.verify`, `crypto.subtle`-based),
* and verification completes before any scope or rate-limit consult.
*
* @param toolName - `"<tag>/<actionName>"` or `"<tag>/<signalName>"`.
* @param params - Action arguments or signal write value.
* @param requestContext - Auth context. `jwt` is the credential and is
* required for scoped/rate-limited components; `userId` is a hint the
* gate never trusts (see {@link RequestContext}).
*/
handleToolCall(
toolName: string,
params: unknown,
requestContext?: RequestContext,
): Promise<unknown>
/**
* Run the security gate WITHOUT dispatching. Returns `{ authorized: true }`
* when the call would be allowed, or the same `{ error, code, jsonrpc }`
* rejection envelope `handleToolCall` would return (404/401/403/429). Shares
* the single gate implementation with `handleToolCall`, so the error-ordering
* invariant is identical. Used by `@aihu/agent-server`'s capability-bridge
* path to gate on the server while delegating execution to the visible
* browser instance.
*/
authorize(toolName: string, params: unknown, requestContext?: RequestContext): Promise<unknown>
/**
* Returns a fetch-compatible middleware function.
* Handles `POST /__aihu/tools/call` with `{ tool, params }` JSON body.
* Returns `null` for non-matching requests (pass-through).
*/
asMiddleware(): (req: Request) => Promise<Response | null>
}The service handle returned by `createAgentService`.
AgentServiceOptionsinterface AgentServiceOptions {
/**
* Explicit list of agent metadata entries.
* When omitted, reads the global registry via `getAgentMetadata`.
*/
manifests?: AgentMetadata[]
/**
* Optional auth plugin. When absent and a component uses `$scope`,
* `handleToolCall` returns 401 AUTH_MISSING (fail-closed, Amendment 2).
*/
authPlugin?: AuthPlugin
/**
* Optional rate-limit plugin. Fail-closed, matching `authPlugin`: when a
* component declares `$rate-limit` and this is ABSENT, the gate returns 429
* `RATE_LIMIT_MISSING` rather than dispatching. A component that declares no
* `$rate-limit` is unaffected and dispatches normally.
*
* "Optional" therefore means "un-rate-limited components need no plugin" — it
* is NOT optional for a component that asks to be rate-limited.
*/
rateLimitPlugin?: RateLimitPlugin
/**
* Getter for the `componentInstanceRegistry` from `@aihu/arbor/mount`.
* Injected at construction time to avoid a circular import.
* When absent, all `handleToolCall` calls return 404 (no live instance).
*
* The registry is consumed READ-ONLY here (only `.get(tag)` is called),
* so the getter is typed as `ReadonlyMap` — a zero-cost type-level lock that
* prevents external mutation of the live registry without snapshotting it.
*/
getRegistry?: () => ReadonlyMap<string, LiveBinding[]>
/**
* Optional per-request auth resolver. When provided, `asMiddleware()` calls
* it to build the `RequestContext` (userId + raw jwt) passed to
* `handleToolCall`, enabling scoped tools over the bundled HTTP path. When
* absent, `asMiddleware()` passes no context (fail-closed: scoped tools 401).
* Injected to keep agent-service auth-library-agnostic and avoid a circular
* `@aihu/auth` dep. The host wires `getAuthState` (`@aihu/auth/server`).
*/
resolveAuth?: (req: Request) => RequestContext | Promise<RequestContext>
/**
* Optional auth-discovery URL (e.g. the deployment's
* `/.well-known/oauth-protected-resource`) included as `authDiscoveryUrl`
* in every 401 envelope the gate returns, so an agent that is refused
* knows WHERE to obtain a credential. Purely informational — never a
* policy input. When unset, 401 envelopes carry no discovery field.
*/
authDiscoveryUrl?: string
/**
* GX Phase 4 (#466, spec §4.6): the live-entitlement registry handle —
* pass the SAME `createGovernedRegistry()` instance (`@aihu/server`) the
* server router was constructed with, so one scope has one live meaning on
* both axes. When present, `runGate` gains one stage between the scope meet
* and the rate limit: every scope in the met set is consulted through the
* shared `check` (deny → 403 `ENTITLEMENT_DENIED`; resolver failure → 503
* `ENTITLEMENT_UNAVAILABLE` + Retry-After — the §4.3 ladder in the tool
* envelope). ABSENT ⇒ byte-identical behavior to today (the same injected
* posture as `resolveAuth`: no ambient state, trivially testable).
*/
entitlements?: EntitlementsHandle
}Options for `createAgentService`.
AgentToolEntryinterface AgentToolEntry {
/** The custom-element tag name (tool identifier prefix). */
name: string
/** Custom-element tag, identical to `name`. */
tag: string
/** Input fields declared on the component. */
inputs: Record<string, import('@aihu/agent').InputSchema>
/** Callable actions declared on the component. */
actions: Record<string, import('@aihu/agent').ActionSchema>
}Per-tool entry in an `AgentManifest`.
AnonymousPrincipalinterface AnonymousPrincipal {
readonly class: 'anonymous'
/** Declared-crawler tier from the shared bot registry, or null. */
readonly uaTier: AnonymousUaTier | null
/** Why no verified principal could be produced (the AUTH_* ladder input). */
readonly credentialFailure: CredentialFailure
}An unauthenticated requester — possibly a declared crawler.
AuthPlugininterface AuthPlugin {
/**
* Verify that the JWT carries the required scope claim.
* Returns true if the claim is present, false otherwise.
*
* SECURITY (#420): the gate calls this only AFTER `verify` has confirmed
* the token's signature, so implementations may decode without their own
* signature check — the claims they read are already authenticated. This
* method alone is NOT sufficient for a scoped tool: a plugin without
* `verify` is treated as unable to verify and the gate fails closed (401).
*/
checkScope(jwt: string, scope: string): boolean
/**
* Signature-verify `jwt` and return its claims, or `null` when the token
* is malformed, forged, or otherwise unverifiable. Never throws.
*
* This is THE single verified-claims source for the gate (#420): the
* rate-limit key derives from the returned `sub`, and the scope check runs
* only against a token this method has authenticated. Async because
* verification uses `crypto.subtle` (e.g. `@aihu/auth/server`'s HMAC path).
*
* OPTIONAL so existing third-party `AuthPlugin` implementations still
* typecheck — but a plugin without it cannot serve scoped or rate-limited
* tools: the gate FAILS CLOSED (401 `AUTH_UNVERIFIABLE`) rather than fall
* back to caller-supplied identity.
*/
verify?(jwt: string): Promise<VerifiedClaims | null>
}Optional auth plugin injected into `createAgentService`.
EmissionDepsinterface EmissionDeps {
/**
* Scope-membership predicate. The tool-call path injects
* `(s) => authPlugin.checkScope(jwt, s) === true` so the refactor is
* byte-for-byte the check the gate always ran (third-party `checkScope`
* semantics preserved). Default: membership in `principal.scopes` — the
* claims-derived set the emission taps use, where no raw JWT exists.
*/
readonly hasScope?: ((scope: string) => boolean) | undefined
}EntitlementMemointerface EntitlementMemo {
/** @internal scope → in-flight/settled verdict for this request. */
readonly verdicts: Map<string, Promise<EntitlementVerdict>>
}The per-request memo (spec §4.4 layer 1, always on): a scope is resolved at most once per (request, scope) no matter how many surfaces/members consult it.
EntitlementsHandleinterface EntitlementsHandle {
/** One fresh per-request memo (spec §4.4 layer 1). */
createMemo(): EntitlementMemo
/**
* THE single live check, both axes (spec §4.6). Memoized through `memo`
* when supplied; TTL-cached per (scope, sub) when the registration
* configured `cache:` (positive verdicts only — negatives are NEVER cached,
* ratified Q2). A scope with no live resolver (unregistered or
* `'token-only'`) returns `'granted'` — the step is a no-op and the static
* meet's verdict stands.
*
* `url` is the request URL when the transport has one (threaded into
* `EntitlementContext.request`); the call axis may omit it.
*/
check(
scope: string,
principal: EntitledPrincipal,
memo?: EntitlementMemo,
url?: URL,
): Promise<EntitlementVerdict>
}The structural handle `runGate` consults (spec §4.6).
ExtractScopeValueinterface ExtractScopeValue {
readonly scope: string
}The `{ scope: 'x' }` value shape shared by both axes.
HumanSessionPrincipalinterface HumanSessionPrincipal {
readonly class: 'human-session'
readonly sub: string
readonly scopes: readonly string[]
}LiveBindinginterface LiveBinding {
/** Stable root ID from the mount scope (spec §2.7 path prefix). */
readonly rootId: number
/** Custom-element tag name identifying the component type. */
readonly tag: string
/** Read the current value of a named signal (prop or computed). */
getSignal(name: string): unknown
/** Write a value to a named writable signal (prop only). */
setSignal(name: string, value: unknown): void
/** Invoke a named action with arguments, returning the result. */
callAction(name: string, args: unknown[]): Promise<unknown>
/** The `$scope` claim string, or null when absent. */
scope(): string | null
/** The `$rate-limit` spec string (e.g. `'100/min'`), or null when absent. */
rateLimit(): string | null
/** Disposal function: removes this binding from the registry. Returns true on success. */
dispose$: () => boolean
}A live binding for one mounted component instance.
PrincipalGateDepsinterface PrincipalGateDeps {
/** The verifying auth plugin (same instance the tool gate uses). */
readonly authPlugin?: AuthPlugin | undefined
/**
* Anonymous-UA classifier — wire `classifyBotUserAgent` from
* `@aihu/plugin-agent-readiness` (the single shared bot registry). Absent →
* every anonymous principal is unclassified (`uaTier: null`), which is the
* fail-open-for-HUMANS / decided-later posture: `read` compliance values
* treat unclassified as an anonymous human, and nothing downstream acts on
* `read` decisions in Phase 2 anyway.
*/
readonly classifyUserAgent?: ((userAgent: string) => AnonymousUaTier | null) | undefined
}PrincipalSourceinterface PrincipalSource {
/** Raw JWT (with or without `Bearer ` prefix — the plugin's concern). */
readonly jwt?: string | null | undefined
/** `User-Agent` header, for anonymous crawler classification. */
readonly userAgent?: string | null | undefined
/**
* A session the HOST already verified (e.g. `getAuthState` over the cookie
* — which signature-verifies via the same `verifyJwt` primitive). `null`/
* absent when there is no session. Never pass unverified cookie contents.
*/
readonly session?: { readonly sub: string; readonly scopes: readonly string[] } | null | undefined
}The credential material for one request, normalized.
RateLimitPlugininterface RateLimitPlugin {
/**
* Check and consume one unit of quota for `key`.
* `rateSpec` is a string like `'100/min'`.
* Returns false when quota is exhausted (caller should return 429).
*/
checkRateLimit(rateSpec: string, key: string): boolean
}Rate-limit store injected into `createAgentService`.
RequestContextinterface RequestContext {
/**
* Caller-supplied identity hint. Retained for interface compatibility and
* telemetry only — the gate ignores it for every policy decision. The
* authoritative identity is the `sub` claim of the signature-verified `jwt`.
*/
readonly userId: string | null | undefined
/**
* The raw JWT string. This is the sole credential: scoped and rate-limited
* tools require it, and all claims (identity + scopes) are read from it
* only after `AuthPlugin.verify` has checked its signature.
*/
readonly jwt?: string | null
/**
* GX Phase 4 (#466, spec §4.4/§4.6): an OPTIONAL shared per-request
* entitlement memo. A host serving one page request that both renders a
* governed route and executes governed tool calls passes the same memo to
* both transports so a scope is live-resolved at most once per request.
* When absent, `runGate` creates a fresh memo per call. Never a policy
* input — only a deduplication scope.
*/
readonly entitlementMemo?: EntitlementMemo
}Request context passed to `handleToolCall` for authorization and rate-limit checks.
ScopedAgentPrincipalinterface ScopedAgentPrincipal {
readonly class: 'scoped-agent'
readonly sub: string
readonly scopes: readonly string[]
readonly claims: VerifiedClaims
}A signature-verified agent principal whose claims carry ≥ 1 scope.
VerifiedAgentPrincipalinterface VerifiedAgentPrincipal {
readonly class: 'verified-agent'
readonly sub: string
readonly scopes: readonly string[]
readonly claims: VerifiedClaims
}A signature-verified agent principal (Bearer credential) with no scopes.
VerifiedClaimsinterface VerifiedClaims {
/** Subject — the verified principal identity. */
readonly sub?: string
/** Space-separated scope string (RFC 6749 §3.3). */
readonly scope?: string
/** Array-form scopes (Auth0 convention). */
readonly scp?: readonly string[]
/** Array-form scopes (Okta convention). */
readonly scopes?: readonly string[]
readonly [key: string]: unknown
}Claims decoded from a JWT whose signature HAS been verified.
ActionSchematype ActionSchemaAnonymousUaTiertype AnonymousUaTier = 'searcher' | 'user-fetcher' | 'training-crawler'CredentialFailuretype CredentialFailure =
/** No auth plugin is registered (→ AUTH_MISSING when a principal is required). */
| 'no-auth-plugin'
/** The registered plugin cannot signature-verify (no `verify`; → AUTH_UNVERIFIABLE). */
| 'unverifiable-plugin'
/** No credential was presented (→ AUTH_REQUIRED when a principal is required). */
| 'no-credential'
/** A credential was presented and `verify` rejected it (→ AUTH_INVALID). */
| 'invalid-credential'
/** The credential verified but carries no usable `sub` claim (→ AUTH_INVALID). */
| 'no-subject'Why this request resolved to anonymous rather than a verified principal.
EmissionDecisiontype EmissionDecision =
| { readonly allow: true; readonly axis: 'call' | 'read'; readonly tier: EnforcementTier }
| {
readonly allow: false
readonly axis: 'call' | 'read'
readonly tier: EnforcementTier
/**
* 503 is produced ONLY by the GX Phase 4 live-entitlement stage
* (`ENTITLEMENT_UNAVAILABLE`); `decideEmission` itself emits 401/403/404.
*/
readonly code: 401 | 403 | 404 | 503
readonly reason: EmissionDenyReason
/** Human-readable message — for 401/403 this IS the envelope message. */
readonly message: string
}The decision for one principal × one surface policy.
EmissionDenyReasontype EmissionDenyReason =
/** `call: 'none'` — the agent surface does not exist for anyone (404-shaped). */
| 'SURFACE_UNAVAILABLE'
/** The AUTH_* ladder (401): no plugin / unverifiable plugin / no / bad credential. */
| 'AUTH_MISSING'
| 'AUTH_UNVERIFIABLE'
| 'AUTH_REQUIRED'
| 'AUTH_INVALID'
/** A required scope (surface or member) is not carried (403). */
| 'SCOPE_DENIED'
/** `read: 'human'` and the verified principal is an agent (403). */
| 'HUMAN_ONLY'
/** Compliance-tier: the declared crawler tier is refused by the `read` value (403). */
| 'UA_REFUSED'
/**
* GX Phase 4 (#466, 70-governed-data-access §4.3/§5): the LIVE entitlement
* stage. These two reasons are produced by the governed-boundary stage that
* runs AFTER `decideEmission` — `decideEmission` itself NEVER returns them
* (its contract stays sync/pure/static). Additive widening only.
*
* `ENTITLEMENT_DENIED` (403): the registered resolver answered `false` —
* the principal's token carries the scope but the authority is not current.
*/
| 'ENTITLEMENT_DENIED'
/**
* `ENTITLEMENT_UNAVAILABLE` (503 + Retry-After): the resolver threw or
* exceeded its deadline. An outage is NEVER presented as a verdict — this is
* fail-closed withholding, honestly labeled (spec §4.3).
*/
| 'ENTITLEMENT_UNAVAILABLE'Machine-readable refusal reasons.
EmissionQuerytype EmissionQuery =
| {
readonly axis: 'call'
/** The surface's `extract.call` value. */
readonly value: ExtractCallValue
/**
* The member's own `$scope`, met with any surface `{ scope }` — BOTH
* must pass (spec §3 R1). The surface value is a CEILING, never a
* grant: it can only add requirements on top of the member's.
*/
readonly memberScope?: string | null | undefined
/** The member declares `$rate-limit` (forces a verified principal, as today). */
readonly memberRateLimited?: boolean | undefined
}
| {
readonly axis: 'read'
/** The surface's `extract.read` value. */
readonly value: ExtractReadValue
}One emission question: a surface's policy on ONE axis, plus (call axis) the member-level gates that MEET with it.
EnforcementTiertype EnforcementTier = 'compliance' | 'hard'Which enforcement tier a decision belongs to (spec §2.1 / §1).
EntitledPrincipaltype EntitledPrincipal = Exclude<Principal, AnonymousPrincipal>A principal that passed the static meet — the live resolver's contract assumes a verified principal and must never run for an anonymous request (spec §4.2: ordering static-first is both cheap and correct).
EntitlementVerdicttype EntitlementVerdict = 'granted' | 'denied' | 'unavailable'The three-way outcome of one live entitlement consult (spec §4.3): - `'granted'` — the resolver answered `true` (or the scope is registered `'token-only'` / carries no live resolver: step 3 is a no-op and the static meet's verdict stands).
ExtractCallValuetype ExtractCallValue = 'none' | 'anonymous' | 'verified' | ExtractScopeValue`call` (agent-callability) axis values — spec §2.1.
ExtractReadValuetype ExtractReadValue =
| 'all'
| 'agents'
| 'search'
| 'none'
| 'verified'
| 'human'
| ExtractScopeValue`read` (crawl-visibility) axis values — spec §2.1.
InputSchematype InputSchemaPrincipaltype Principal =
| AnonymousPrincipal
| VerifiedAgentPrincipal
| ScopedAgentPrincipal
| HumanSessionPrincipalThe one principal per request (spec §4.1).
PrincipalClasstype PrincipalClass = Principal['class']The four principal classes.