Agent identity delegation
Scoped, time-bound identity delegates for AI agents: how to create, verify and revoke one, how behavioral trust scoring narrows its scopes, and which auth lane each endpoint answers on.
Overview
An identity delegate is a token an AI agent presents to prove that a human authorized it. A tenant admin creates one, hands the agent the token once, and the agent calls verify before it acts. Each delegate carries a trust score between 0.000 and 1.000 that PasskeyBridge lowers when its recent activity looks anomalous, narrowing the delegate's scopes or deactivating it.
Three edge functions are involved, on three different auth lanes.
| Function | Auth lane | Who calls it |
|---|---|---|
shield-agent-delegate | Dashboard session JWT for create, list and revoke; the agent token for verify | Tenant admins, and the agent itself |
shield-agent-trust | Service-role key only | PasskeyBridge internally |
shield-a2a-handshake | Dashboard session JWT of a tenant member | Your backend, negotiating between two of your delegates |
No agent endpoint accepts an API key. There is no agents scope, and x-pb-api-key is not read by any of the three. The authenticated lane is Authorization: Bearer carrying the Supabase session access token of a signed-in dashboard user. The one exception is verify, which is public and identifies the caller by the agent token in the body.
`shield-agent-trust` is internal. It answers 401 to anything that is not the service-role key, with an error field saying the function requires a service-role key.
You see its results in Dashboard > Agents and in the trust_score that verify returns. There is no way to call it yourself: shield-agent-trust compares the Authorization header against the service-role key before it reads the action, so every request carrying an API key answers 401. The API Playground offered an "Evaluate Agent Trust" entry that did exactly that until 2026-09-17, when it was removed.
No plan gate. Nothing in these functions calls a plan check, and the Agents tab is available on every plan. The only hard ceiling is the batch cap described under Limits and quotas.
Delegate lifecycle
Creating a delegate. Admin role in the tenant is required; a member gets 403 Only admins can create agent delegates.
curl -X POST https://api.passkeybridge.io/v1/shield-agent-delegate \
-H "Authorization: Bearer <dashboard session access token>" \
-H "Content-Type: application/json" \
-d '{
"action": "create",
"agent_label": "Invoice Processor",
"scopes": ["events:read", "verify"],
"expires_in_hours": 72,
"initial_trust": 0.85,
"auto_revoke_below": 0.2
}'| Field | Type | Default | Rules |
|---|---|---|---|
action | string | required | create |
agent_label | string | Unnamed Agent | 200 characters or fewer |
scopes | array of strings | [] | 20 items or fewer, each 50 characters or fewer |
expires_in_hours | number | none, meaning no expiry | Greater than 0 and at most 8760 |
initial_trust | number | 1.0 | Greater than 0 and at most 1 |
auto_revoke_below | number | 0.2 | Between 0 and 1 exclusive, and strictly below initial_trust |
tenant_id | uuid | your oldest membership | Must be a tenant you belong to |
Scopes are free strings that you define. PasskeyBridge matches them only in the verify scope check and in the narrowing ladder, which recognizes ingest:read, events:read, playbook:read, intelligence:read and verify and drops anything else when it narrows. The New delegate wizard offers a different vocabulary (ingest, events:read, playbooks:execute, credentials:present, spatial:attest, shadow:create), so a wizard-created delegate keeps only events:read and verify if it is ever narrowed to read-only.
The response is the stored row plus the one-time token:
{
"delegate": {
"id": "0f2c9c2e-...",
"agent_label": "Invoice Processor",
"scopes": ["events:read", "verify"],
"original_scopes": ["events:read", "verify"],
"trust_score": 0.85,
"auto_revoke_below": 0.2,
"expires_at": "2026-09-19T12:00:00Z",
"is_active": true
},
"agent_token": "pb_agent_1f8c...",
"warning": "Store this token securely. It will not be shown again."
}The token is pb_agent_ followed by two UUID v4 values with the hyphens stripped. Only its SHA-256 digest is stored, in agent_identifier_hash; the plaintext cannot be recovered or reissued.
Verifying. Public, no session needed. The agent presents its token:
curl -X POST https://api.passkeybridge.io/v1/shield-agent-delegate \
-H "Content-Type: application/json" \
-d '{
"action": "verify",
"agent_token": "pb_agent_1f8c...",
"tenant_id": "<tenant uuid>",
"scope": "events:read"
}'{
"authorized": true,
"agent_label": "Invoice Processor",
"scopes": ["events:read", "verify"],
"expires_at": "2026-09-19T12:00:00Z",
"trust_score": 0.85
}tenant_id is required and must be a UUID; scope is optional and at most 100 characters. The server hashes the token, requires an active delegate in that tenant, checks expires_at, and checks the scope against the delegate's current list. A delegate whose scopes array is empty passes any scope value, so an empty array means unrestricted rather than restricted. Each call updates last_used_at and records one activity row for trust scoring, both fire-and-forget.
Listing. { "action": "list" } with a session JWT returns every delegate in the tenant, active and revoked, newest first:
{ "delegates": [{ "id": "0f2c...", "agent_label": "Invoice Processor", "scopes": ["events:read", "verify"], "is_active": true, "expires_at": "2026-09-19T12:00:00Z", "last_used_at": null, "created_at": "2026-09-16T09:00:00Z" }] }The list response does not include trust_score; read the score from verify, or from the Agents tab, which queries the table directly.
Revoking. Admin only. { "action": "revoke", "delegate_id": "..." } sets is_active to false and answers { "revoked": true }. There is no un-revoke: the Restore control in the Agents tab restores narrowed scopes on a delegate that is still active, and a revoked agent needs a new delegate and a new token.
From the dashboard. Dashboard > Agents > New delegate runs the same create call through a five-step wizard (label, scopes, trust thresholds, expiry, create) and shows the token once, on the final step.
Confirm it worked. The new delegate appears as a row in Dashboard > Agents with its label, a trust gauge, its scope count and its auto-revoke floor.
Trust engine
supabase/functions/_shared/agent-trust-engine.ts is a pure function with no I/O. It takes the delegate's current score, up to 100 recent activity rows, the current and original scope lists, an optional AI delta and the delegate's revocation floor, and returns a new score, the signals it found, a narrowing action and the scope list to write. The calling edge function does every read and write.
When it runs. There is no scheduled job and no idle decay. Evaluation happens in exactly three places:
shield-ingestreceives a soft signal for the tenant and firesevaluate_tenant, debounced to one call per tenant and signal type per 30 seconds.shield-intelligence-workerfinishes an analysis whoseagent_trust_deltaexceeds 0.05 in absolute value and firesevaluate_tenantwith that delta.- An operator-triggered single
evaluate, which is service-role only.
A delegate that is never touched by one of these keeps the score it was created with, however long it sits idle.
Signals. Seven types, each weighted by how far past its threshold the behavior is. The watermark is trust_updated_at: every detector except extended_inactivity counts only rows newer than it, so evidence judged by one evaluation is not charged again by the next (since 2026-09-18).
| Signal | Window | Trigger | Maximum weight |
|---|---|---|---|
velocity_spike | 5 minutes | More than 50 activity rows | 0.30 |
error_burst | 2 minutes | More than 10 rows whose result is not success | 0.25 |
scope_violation | 24 hours, since the watermark | Any row whose result is scope_violation | 0.35 |
ip_rotation | 10 minutes | More than 5 distinct ip_hash values | 0.20 |
extended_inactivity | 24 hours | A gap over 24 hours followed by more than 10 actions in 5 minutes | 0.15 (flat) |
ai_behavioral | Per evaluation | A non-zero ai_trust_delta | 0.30 |
normal_usage | Per evaluation | At least one activity row newer than the watermark and no negative signal | +0.05 |
Severity scaling. A negative weight is its maximum multiplied by how far past the threshold the count is, capped at 1. Sixty actions in five minutes is ten over the threshold of 50, so the velocity weight is 0.30 multiplied by 10/50, which is 0.06. A hundred or more actions reaches the full 0.30. scope_violation scales on violations divided by 5.
Score arithmetic. Each negative weight is subtracted from the current score, normal_usage adds 0.05, and the AI contribution is the raw delta multiplied by 0.3 (so it can raise the score as well as lower it). The result is clamped to the range 0.000 to 1.000 and rounded to three decimals.
Hard signals never reach the engine. evaluate_tenant called with a hard signal type answers { "status": "hard_signal_bypass" } and changes nothing, because shield-ingest has already revoked every delegate in the tenant inline. See Cascade classification.
Graduated scope narrowing
The new score maps to one of four actions. The first threshold is per delegate; the rest are fixed in the engine.
| Score | Action | Scopes written |
|---|---|---|
Below auto_revoke_below (default 0.200) | recommend_revocation | Empty, and is_active is set to false |
| At least 0.700 | none | Unchanged, or original_scopes restored if the delegate was narrowed |
| At least 0.400 | narrow_to_read | Only ingest:read, events:read, playbook:read, intelligence:read and verify that the delegate already had, with verify always added |
| Otherwise | narrow_to_verify | Exactly ["verify"] |
auto_revoke_below is set at creation (the wizard allows up to 0.50) and is checked before the ladder, so a delegate with a floor of 0.45 is deactivated at 0.44 rather than narrowed to read-only. A value outside the open interval 0 to 1 falls back to 0.200.
Scope preservation and restoration. The first time a delegate is narrowed (that is, while scope_narrowed_at is still null), its current scopes are copied to original_scopes, an empty list included, because an empty list means unrestricted. When the score climbs back to 0.700 or above and a narrowing is on record, the original list is written back automatically, empty or not, scope_narrowed_at is cleared, and the reason reads Trust score recovered—restoring original scopes. Until 2026-09-17 an unrestricted delegate could not be restored: its original list was empty, the restore rule compared lengths, and a second narrowing overwrote the empty original with the narrowed list.
Manual restore. Dashboard > Agents, expand a narrowed delegate, press Restore. That routes through shield-admin-mutations action restore_agent_scopes, which reads original_scopes from the server rather than trusting the browser, writes them back, sets the trust score to 1.0, clears the narrowing reason and writes a delegate.trust_restored audit row. A delegate with no recorded original scopes answers 400 and nothing changes.
Revocation is not reversible. recommend_revocation sets is_active to false. Restore does not bring a revoked delegate back; issue a new delegate and a new token.
A2A horizontal trust protocol
Two delegates in the same tenant can negotiate a shared trust level and transaction limits through shield-a2a-handshake, implementing the protocol in U.S. patent application 19/561,964.
In order: initiate creates a pending negotiation between two of your delegates, attest runs five blocking checks on the humans behind them (both delegates active, both delegators hold at least one passkey, no hard signal for either in the last five minutes), and negotiate computes a Combined Trust Coefficient from the two trust scores and writes a transaction ceiling and a per-minute rate against the negotiation row. status, lookup and renew read and extend it; invalidate is a service-to-service action.
Every action except invalidate takes the dashboard session JWT of a tenant member, so this is a call your backend makes on behalf of your own agents rather than something an agent makes with its own token.
The full request and response shape for each of the seven actions, the coefficient formula, the limit tiers and the cache rules are in A2A trust negotiation.
What to expect in the dashboard. Dashboard > Agents > A2A trust negotiations lists the most recent 50 negotiations for the tenant, each with its two agent labels, its status, its trust state, its coefficient gauge and its transaction limits.
Security hardening
Replay protection. initiate, attest, negotiate and renew each require a fresh nonce in the body. The nonce is hashed with SHA-256 and inserted into shield_a2a_nonces, so a reused value collides on the unique constraint and answers 409 with code: "REPLAY_DETECTED". A timestamp field is optional; when present it must fall within 300 seconds of server time. Stored nonce hashes carry a five-minute expiry.
mTLS certificate pinning. initiate, attest and negotiate each look up pinned certificates for both delegates in shield_agent_certificates and compare them with the fingerprint in X-Client-Cert-Fingerprint, X-SSL-Client-SHA256 or X-Agent-Cert-Fingerprint. A delegate with no pinned certificate passes, so with an empty table every handshake passes this check. There is no API action or dashboard control that writes a pin today, so in practice mTLS is inert; the pinning and revocation functions exist in _shared/mtls-agent-identity.ts with no caller.
Attestation proofs. A successful attestation signs a payload with HMAC-SHA-256 and ML-DSA-65 and stores the first 44 characters of each result in initiator_proof_hash and responder_proof_hash, with the algorithm, a truncated key fingerprint and a quantum nonce in metadata. These are truncated prefixes kept as evidence that the step ran; they cannot be verified as signatures later. If the post-quantum module is unavailable the handshake falls back to halves of a SHA-256 digest and records proof_scheme: "sha256_fallback".
Tenant isolation. Every delegate and negotiation query is filtered by tenant_id, and verify requires the tenant id alongside the token. A delegate in one tenant is invisible to another.
Admin-only mutations. create and revoke require the admin role in shield_tenant_members. list needs only membership. verify is public by design and rate limited, because an agent has no user session.
Token handling. Only the SHA-256 digest of the agent token is stored. The plaintext is returned once by create and shown once by the wizard.
Audit trail. Single-delegate evaluation writes delegate.trust_degraded, delegate.trust_restored, delegate.scope_narrowed or delegate.trust_revoked when the score moves by more than 0.05 or scopes change. The batch path that production actually uses writes one delegate.batch_trust_evaluated row per run with counts instead of per-delegate rows, so the per-delegate timeline in the Agents tab fills in only from manual restores and single evaluations. A2A actions write a2a.negotiation_initiated, a2a.attestation_passed, a2a.attestation_failed, a2a.negotiation_completed, a2a.trust_cache_renewed and a2a.hard_signal_invalidation.
Limits and quotas
There are no plan tiers for agent delegation. No function in this area calls a plan check, the Agents tab is not gated, and there is no quota on the number of delegates a tenant may hold.
| Limit | Value | Where |
|---|---|---|
| Delegates evaluated in one batch | 200, with _note in the response when the cap is hit | shield-agent-trust evaluate_tenant |
| Activity rows read per delegate | 100 most recent | Trust engine input |
| Concurrent activity queries | 20 | Batch evaluation |
verify requests | 30 per 60 seconds per client IP, in-memory per isolate | shield-agent-delegate |
| Authenticated delegate actions | 60 per 60 seconds per client IP, in-memory per isolate | shield-agent-delegate |
| Scopes per delegate | 20 items, 50 characters each | create |
| Label length | 200 characters | create |
| Delegation lifetime | 8760 hours, or omit expires_in_hours for none | create |
| Negotiation TTL | 10 to 3600 seconds, default 60 | shield-a2a-handshake |
| Request body | Small-JSON cap, 413 above it | All three functions |
The two IP limits are best-effort: they are counted in the memory of whichever isolate serves the request, so a burst spread across isolates can exceed them briefly. See Rate limits and quotas.
Errors
`shield-agent-delegate`
| Status | Body error | Cause |
|---|---|---|
| 400 | Invalid JSON body | Body did not parse |
| 413 | Payload too large | Body above the small-JSON cap |
| 400 | Unknown action | action is not create, list, revoke or verify |
| 401 | Missing authorization header | No Authorization: Bearer on an authenticated action |
| 401 | Invalid auth token | The session token did not resolve to a user |
| 403 | Only admins can create agent delegates | Member role calling create |
| 403 | Only admins can revoke agent delegates | Member role calling revoke |
| 404 | No tenant found | The caller has no membership, or none in the requested tenant |
| 400 | Invalid tenant_id format | tenant_id present but not a UUID |
| 400 | scopes must be an array of strings (max 20 items, 50 chars each) | Scope list rejected |
| 400 | auto_revoke_below must be below initial_trust, otherwise the delegate is revoked on its first assessment | Trust bounds cross |
| 400 | Missing agent_token | verify without a token |
| 403 | Unknown or revoked agent | Token digest matches no active delegate in that tenant |
| 403 | Agent delegation has expired | expires_at is in the past |
| 403 | Agent not authorized for scope: and the scope name | Requested scope is not in the delegate's current list |
| 404 | Delegate not found for this tenant | revoke with an id from another tenant |
| 429 | Too many requests. Please retry after 60 seconds. | Above the per-IP limit |
| 403 | Direct access denied. Use https://api.passkeybridge.io/v1/{function}. | Called at the Supabase functions host instead of the public host |
`shield-agent-trust` answers 401 to every caller that is not holding the service-role key, so from outside the platform that is the only status you can observe.