Search by

agentadmit / agentadmit-sdk

agentadmit

AgentAdmit SDK for PHP - User-mediated AI agent authorization for Laravel

v1.13.0 2026-09-30 03:38 UTC

README

User-mediated AI agent authorization. Plug-and-play for any Laravel app.

Get started: Sign up at agentadmit.com → Get your test keys → Install the SDK → Build. Test keys are available immediately after signup. Live keys become available when you subscribe an app.

Where the consent step runs (live keys). The agent grant is approved on the AgentAdmit hosted consent page, opened on your app's behalf: your backend creates a consent session (POST /api/v1/apps/{app_id}/consent-sessions) with your live key and sends the signed-in user to the returned session_url. Scope selection, duration, intent, existing-grant review, the passkey ceremony, and the one-time token all happen there. Direct token issuance (POST /api/v1/apps/{app_id}/token, and this SDK's issue-token helpers and any SDK-mounted generate-token route) is a sandbox facility for aa_test_ keys only; a live key receives 403 hosted_consent_required. Verification (/verify) is unchanged and is the core of this SDK. Full walkthrough: App Owner Guide, Step 4.

Quick Start

composer require agentadmit/laravel
php artisan vendor:publish --tag=agentadmit-config

Add your credentials to .env:

AGENTADMIT_APP_ID=app_yourappid
AGENTADMIT_API_KEY=aa_test_yourkey

Add scope enforcement to any route:

// routes/api.php
Route::middleware('agentadmit.scope:read:orders')->get('/orders', [OrderController::class, 'index']);

Your app now supports AI agent connections with:

  • Scoped access control (you define the scopes)
  • User-controlled connection duration
  • Token generation and exchange
  • Mandatory introspection (every agent request validated through AgentAdmit)
  • Revocation and remote audit logging (via the AgentAdmit hosted service)

How It Works

  1. User clicks "AgentAdmit" in your app
  2. Selects scopes and connection duration
  3. Gets a token to give to their AI agent
  4. Agent exchanges the token for scoped API access
  5. User revokes anytime

The token goes to the human, not the agent. No automated delivery = no prompt injection surface.

Important

Mandatory introspection. All token validation goes through api.agentadmit.com. There is no self-hosted mode. No local JWT validation. No bypass. This is required for security, audit logging, and scope enforcement.

Admin revocation. As the app operator, you can revoke any user's agent connection by calling TokensClient::revoke($connectionId) from your backend (requires your operator API key).

Embeddable admin panel. Drop the <AgentAdmitAdminPanel> React component into your admin section to view all agent connections, usage metrics, billing status, and revoke any connection without leaving your app. See the React SDK for details.

In-app AI scopes. If your app has built-in AI features (analysis, plan generation, photo recognition), do not expose those as agent scopes. The user's AI agent can read the raw data and do the analysis itself. Exposing in-app AI endpoints to agents creates double cost.

Consent Ledger (Caller-Identity Consent)

AgentAdmit can host per-user consent switches for three independent caller classes: human_session, in_app_ai, and external_agent. No class's setting implies another's.

External agents: the verify result already carries the verdict:

$result = $introspectionClient->verify($token);
if (!$result->consentGranted()) {
    abort(403, 'The data owner has not enabled external agent access.');
}

consentGranted() fails closed when the verdict is absent (the hosted service omits it while its consent store is unreadable). To keep serving during that degraded mode, resolve an absent verdict authoritatively with ConsentClient::checkConsent($result->userId, 'external_agent') — the CallerConsent middleware does this for you.

Human sessions and in-app AI never hold AgentAdmit tokens, so ask directly:

use AgentAdmit\ConsentClient;

$consent = new ConsentClient(config('agentadmit'));
$verdict = $consent->checkConsent('user_8842', ConsentClient::CALLER_CLASS_IN_APP_AI);
if (!$verdict['granted']) {
    // do not run AI over this user's data
}

Consent is orthogonal to revocation: a denied verdict means your app returns its own 403; the connection and token stay valid so the user can flip consent back on without re-connecting. Write switches through PUT /api/v1/consent/settings from your backend; export the audit trail with GET /api/v1/consent/export (every plan).

One-middleware drop-in. Instead of wiring the three paths by hand, the agentadmit.caller_consent middleware classifies the caller from the credential and evaluates the right independent path:

// config/agentadmit.php
'caller_consent' => [
    // derive the class from your own credential structure, never caller input
    'classify_non_agent' => fn ($request) => $request->headers->get('x-internal-ai') === env('INTERNAL_AI_SECRET')
        ? 'in_app_ai' : 'human_session',
    'resolve_data_owner_id' => fn ($request) => $request->route('owner_id'),
],

// routes: the middleware parameter is the required scope for external agents
Route::middleware('agentadmit.caller_consent:read:records')
    ->get('/api/records/{owner_id}', ...);
// Downstream: $request->attributes->get('agentadmit.caller_class' / 'agentadmit.consent')

External agents are checked via hosted introspection (consent verdict plus scope); in-app AI via the Consent Ledger (fail closed); the human path defers to your own permission model unless caller_consent.gate_human is true. It is a consent gate, not an authenticator, so register it after your own authentication.

Presence Verification (WebAuthn Step-Up)

AgentAdmit can require the human behind a connection to complete a WebAuthn presence ceremony on the consent page. The verify result carries the outcome as an additive presence block, and the SDK surfaces it next to the consent verdict:

$result = $introspectionClient->verify($token);
if (!$result->presenceVerified()) {
    abort(403, 'This action requires a connection authorized with human presence verification.');
}

Or enforce it per route with the fail-closed middleware:

// routes/api.php
Route::middleware('agentadmit.presence')->post('/orders', [OrderController::class, 'store']);

presenceVerified() is strict: it returns true only when the platform reports verified: true. Connections minted without a ceremony, malformed blocks, and older servers that omit the block entirely all count as not verified, so guarded routes return a 403 with error: presence_required. Unlike consent, absence does not mean allowed: presence fails closed because a missing block means no ceremony was ever proven.

Declared Purpose

Declared purpose: the user-facing reason recorded on the grant at the consent moment. Review-time record only, never an enforcement input; authorization decisions ride scopes, connection status, and consent.

Pass an optional purpose (max 300 characters) when issuing a connection token. AgentAdmit shows it to the human on the consent page ("Declared purpose: …"), records it on the grant, stamps it into every audit log row, and returns it from /verify introspection:

$issued = $tokens->issueToken(
    'user_42',
    ['read:orders'],
    purpose: 'Reorder the usual weekly groceries',
);

The SDK rejects purposes longer than 300 characters client-side with an InvalidArgumentException; when you pass no purpose, the field is omitted from the request entirely.

On the verify side, the result carries the nullable purpose for display and review:

$result = $introspectionClient->verify($token);
$result->purpose; // 'Reorder the usual weekly groceries', or null when none was declared

purpose is null for connections minted without one and on older servers that omit the field. Do not branch authorization on it - /verify never gates on the purpose, and neither should your app.

User-Declared Intent

User-declared intent: the user's own words, typed by the human at the consent moment. It is distinct from purpose - purpose is the app's words, user_intent is the user's. Like the declared purpose, it is a review-time record only, never an enforcement input; authorization decisions ride scopes, connection status, and consent.

Pass an optional user_intent (1-300 characters) when issuing a connection token. It flows exactly like the declared purpose: recorded on the grant, returned from /verify introspection, stamped into audit log rows, and carried on ledger events. When the hosted presence ceremony runs, the user-declared intent is included in the verifiable-consent-evidence commitment.

$issued = $tokens->issueToken(
    'user_42',
    ['read:orders'],
    purpose: 'Reorder the usual weekly groceries', // the app's words
    userIntent: 'get my usual Tuesday order',      // the user's own words
);

Outbound validation mirrors purpose: a user_intent longer than 300 characters, or any non-string, non-null value, throws InvalidArgumentException client-side before any request is sent - the user's own typed words are never silently discarded. null and the empty string are simply omitted from the request. (Verify-side parsing stays tolerant: a malformed user_intent in the /verify response is normalized to null, never a failure.)

On the verify side, the result carries the nullable user-declared intent for display and review:

$result = $introspectionClient->verify($token);
$result->userIntent; // 'get my usual Tuesday order', or null when none was declared

userIntent is null for connections minted without one and on older servers that omit the field. Do not branch authorization on it - like the purpose, /verify never gates on it, and neither should your app.

App-Attested Presence

If your app gates token minting behind its own embedded passkey/WebAuthn ceremony, AgentAdmit never witnesses that ceremony (it is origin-bound), so by default the hosted service reports presence.verified: false for those connections. Attest the ceremony fact at issuance to close that gap - AFTER verifying and consuming your own fresh, purpose-bound attestation:

use AgentAdmit\AppAttestedPresence;

$issued = $tokensClient->issueToken(
    'user_42',
    ['read:orders'],
    presence: new AppAttestedPresence('my_webauthn', $attestation->createdAt)
);

The SDK sends it as presence: {verified: true, uv: true, method, verified_at} - verified/uv are literal true by construction and the class cannot represent anything else. The hosted service validates freshness (10-minute window, 60 s future clock-skew slack) and stores the method provenance-marked app:<method> so app-attested facts stay distinct from ceremonies AgentAdmit witnessed itself. Introspection, the grant-event ledger, and the evidence API then carry presence.verified: true for the connection.

Honesty ceiling: this is your app's attestation, recorded and provenance-marked. It is not witnessed by AgentAdmit and not independently verifiable. Only attest a ceremony that verified the user with UV (biometric or PIN user verification); a ceremony without UV carries no presence fact, so pass null (the default). An out-of-contract method (^[a-z0-9_]+$, 1-60) throws InvalidArgumentException at construction, before any request; verified_at serializes RFC 3339 with an explicit offset because DateTimeInterface always carries a timezone.

Per-Call Audit Telemetry

Every verified call reports what it actually exercised. On each introspection the SDK sends, alongside the token, the scope the middleware enforced for that call (scope_used), the request path (endpoint), and the HTTP method (method), and the hosted service records them in the app's tamper-evident audit log — so an audit row answers "which scope, which endpoint, which method" per call, not just "a token was checked".

The scope middlewares (agentadmit.scope, agentadmit.scope_if_agent) report all three automatically — the middleware parameter is the enforced scope:

Route::middleware('agentadmit.scope:read:orders')->get('/orders', ...);
// verify body: {token, scope_used: "read:orders", endpoint: "/orders", method: "GET"}

agentadmit.presence reports endpoint and method only. agentadmit.caller_consent:<scope> reports all three and sets the hosted consent-first guard automatically, so denied caller classes receive no scope-state disclosure. Direct client calls send whatever you provide — every telemetry argument is optional and the signature stays backward-compatible:

$result = $introspectionClient->verify($token, 'read:orders', $request->getPathInfo(), $request->method());

The verify result also exposes the hosted audit row id when the service returns it:

$result->auditRowId; // e.g. "019..."

If the hosted service returns a replay diagnostic, the SDK surfaces it as $result->consumedReceipt only when it is a strict already_consumed receipt. This is a diagnostic record for review and debugging; it is not authorization to run the downstream action again.

Honesty rules:

  • Omitted means omitted. A field that is not known is left out of the request entirely (never null or an empty string), and the hosted audit row honestly records it as "not reported" rather than inventing a value.
  • No query strings. The endpoint is the path only - the SDK strips everything from the first ? or # client-side before sending, because query strings can carry PII. Paths are truncated to 500 characters; methods are uppercased and capped at 20.
  • scope_used is the single declared scope the integration point enforced for that call, never a joined list of everything granted.

Hosted refusals fail closed

As of 1.10.0 the SDK also treats an active: true introspection response that carries a string error field as a refusal of that call, never a pass-through:

  • insufficient_scope → the middleware returns 403 {error, required_scope, granted_scopes} (the standard step-up shape).
  • bound_exceeded → 403 with the hosted error_description, bound, and renewal fields passed through verbatim (the connection's bounded capability is exhausted; the token itself stays valid).
  • confirmation_required → 403 with the hosted error_description and the strictly parsed confirmation block (attestation_status, attestation_description, and renewal ride along when sent); surfaced to custom gates as the typed ConfirmationRequiredException. See Confirm Each Time below.
  • confirmation_declined → 403 with the strictly parsed declined block (the user said no on the hosted page; hold until declined.hold_until); typed ConfirmationDeclinedException for custom gates.
  • Any refusal code this SDK version does not recognize → 403 {error: <code>, error_description: "Call refused by the authorization service."} — unknown hosted verdicts never become an allow.

IntrospectionClient::verify() surfaces these as VerificationDeniedException (a 403 AgentAdmitException subclass); getDenialBody() is the exact JSON body the middlewares return.

Outcome reporting

After your app handles a verified request, you can append the observed downstream outcome to the AgentAdmit audit trail:

$client->reportOutcome(
    $result->auditRowId,
    'executed', // executed, failed, or unknown
    '2xx',      // optional status class: 1xx, 2xx, 3xx, 4xx, 5xx, or null
);

This posts to POST {api_url}/api/v1/audit/{row}/outcome with:

{"outcome":"executed","status_class":"2xx"}

executed means your app observed the downstream operation complete. failed means your app observed a downstream failure. unknown is never inferred by the middleware; use it only in explicit custom code when your app cannot tell whether the operation completed.

Laravel middleware can report automatically after the route returns:

AGENTADMIT_OUTCOME_REPORTING=true

With that opt-in enabled, agentadmit.scope, agentadmit.scope_if_agent, agentadmit.presence, and the external-agent path of agentadmit.caller_consent report only after $next($request) returns a Symfony/Laravel response and the verify result contains audit_row_id. HTTP status <400 maps to executed; status >=400 maps to failed; status_class is reported as 1xx through 5xx. Aborted requests, exceptions before a response, missing responses, and older verify responses without audit_row_id are not reported. If the outcome report itself fails, the SDK logs a warning and returns your app's original response unchanged.

Confirm Each Time (Exercise-Time Human Confirmation)

Some actions should never run on a standing grant alone: moving money, sending or publishing on the user's behalf, deleting data, or touching production. Mark those scopes confirm_each_time: true when you register them in the AgentAdmit dashboard — this SDK publishes no scope catalog, so the flag lives on the hosted scope definition and the hosted service enforces it. Every call that exercises one then requires a fresh human confirmation, even inside a valid connection.

Optionally describe the action for the human. Configure a plain-language summary hook and name it as the second middleware parameter (agentadmit.scope, agentadmit.scope_if_agent, and agentadmit.caller_consent all accept it):

// config/agentadmit.php
'confirm_each_time' => [
    'action_summary' => null, // app-wide default hook, or null for none
    'summaries' => [
        'pay' => fn ($request) => 'Pay ' . $request->input('trainer')
            . ' $' . $request->input('amount'),
    ],
],

// routes/api.php
Route::middleware('agentadmit.scope:write:payments,pay')->post('/payments', ...);

How a call flows:

  1. The agent calls your route. The middleware verifies the token as usual, carrying the exercised scope. With a summary hook configured it also sends a sha256: digest of the raw request body (request_digest) and the summary (action_summary), so the ceremony commits to the exact payload and the words the human sees.
  2. The hosted service refuses the first call with confirmation_required and stages a one-time ceremony for exactly that action. Your route returns 403 with a confirmation block; the agent gives confirmation.action_session_url to the user.
  3. The user confirms on AgentAdmit's hosted page with their passkey. The signature commits to the scope, method, endpoint, request digest, and the summary they saw. Only a user-verified ceremony produces an attestation; the agent cannot complete it.
  4. The agent retries the same request with the header X-AgentAdmit-Action-Attestation: <action_session_id>. Every AgentAdmit middleware (including agentadmit.presence) forwards it as action_attestation_id; the hosted service consumes the attestation once (exact action only) and the call proceeds. The audit row names the confirmation.

The 403 body an agent receives on the first call:

{
  "error": "confirmation_required",
  "error_description": "Scope \"write:payments\" requires a fresh human confirmation for each call. ...",
  "confirmation": {
    "action_session_id": "asess_...",
    "action_session_url": "https://agentadmit.com/confirm/action/asess_...",
    "expires_at": "2026-09-02T18:30:00.000Z",
    "scope": "write:payments",
    "method": "POST",
    "endpoint": "/api/payments",
    "request_digest": "sha256:...",
    "summary": "Pay Alex $50"
  }
}

Notes:

  • The summary is yours. AgentAdmit shows it as the headline of the confirmation page and commits to the text shown; it does not verify the description against the request. A hook that throws or returns an empty value never blocks the call — the summary is simply omitted and the digest still rides along.
  • A confirmation covers exactly one call. A retry with a different body, route, method, or summary is refused again with attestation_status: "action_mismatch".
  • Custom gates: IntrospectionClient::verify() throws ConfirmationRequiredException (a VerificationDeniedException, so existing fail-closed handlers already return the right 403) exposing getActionSessionUrl(), getActionSessionId(), getConfirmation(), and getAttestationStatus(); getDenialBody() is the exact 403 body above. Direct callers pass the new optional arguments after $consentFirst: verify($token, $scope, $endpoint, $method, false, $actionAttestationId, $requestDigest, $actionSummary) — each is trimmed, capped (120 / 128 / 200 chars), and omitted when empty.
  • Strict parsing, fail closed: a confirmation block missing any of action_session_id, action_session_url, expires_at, or scope as strings degrades to a plain VerificationDeniedException 403 with no confirmation key. The SDK never relays a half-parsed ceremony to a human.
  • The user can decline. If the user taps Decline on the hosted page, the hosted service answers the agent's retry with confirmation_declined and holds that answer until declined.hold_until; no new ceremony is staged and the user is not notified again. Middlewares return 403 with the strictly typed declined block (action_session_id, declined_at, hold_until, scope, plus nullable method, endpoint, request_digest, summary) and renewal; custom gates get ConfirmationDeclinedException (a VerificationDeniedException) exposing getDeclined(), getActionSessionId(), getHoldUntil(), and getAttestationStatus(). Agents should relay the decline to the user and not retry unless the user asks; only the user can lift a decline, and after the hold ends a retry stages a fresh confirmation. A malformed declined block degrades to a plain 403 with no declined key.
  • On an accepted retry, $result->actionConfirmation (['action_session_id' => ..., 'consumed' => true]) and the agentadmit.action_confirmation request attribute expose the consumed ceremony, so your own transaction step-up can avoid asking the human twice. Absent or malformed blocks are null, never partially populated.

Rate Limiting

The AgentAdmit introspection endpoint enforces rate limits. The PHP SDK handles HTTP 429 responses automatically with exponential backoff and jitter - no changes needed in your middleware code.

Retry behavior

Parameter Default Description
Initial delay 1 second First retry wait
Backoff multiplier 2× Doubles each retry
Cap 30 seconds Maximum wait per retry
Jitter 0–500 ms Random addition to each delay
Max retries 3 Configurable

The SDK also respects the Retry-After response header - if present, it overrides the computed backoff delay.

Configuring max retries

In config/agentadmit.php or .env:

// config/agentadmit.php
'max_retries' => 5, // default: 3
AGENTADMIT_MAX_RETRIES=5

Handling exhausted retries

When all retries are exhausted, IntrospectionClient::verify() throws RateLimitException:

use AgentAdmit\RateLimitException;

try {
    $result = $client->verify($token);
} catch (RateLimitException $e) {
    return response()->json(['error' => 'rate_limited'], 429)
        ->header('Retry-After', $e->getRetryAfter() ?? 60);
}

RateLimitException methods:

  • getRetryAfter() - seconds from Retry-After header (null if absent)
  • getLimit() - X-RateLimit-Limit header value (null if absent)
  • getRemaining() - X-RateLimit-Remaining header value (null if absent)
  • getReset() - X-RateLimit-Reset Unix timestamp (null if absent)

Documentation

Full integration guide: https://agentadmit.com/docs/app-owner-guide

Data Collection & Privacy

The AgentAdmit PHP SDK runs server-side and does not interact with app stores or end-user devices directly.

What the SDK does

  • Validates AgentAdmit tokens by calling AgentAdmit's hosted introspection endpoint (https://api.agentadmit.com/api/v1/verify) on every agent request - this is mandatory introspection; there is no local or offline validation mode
  • Enforces scope-based access control on your API routes
  • Manages connection lifecycle (issue, exchange, revoke) via the AgentAdmit hosted service

What the SDK does NOT do

  • Does not transmit raw end-user PII (such as name, email, or device identifiers) - each introspection request sends the opaque access token, your API key, and the per-call audit telemetry described above (the enforced scope, the request path with the query string stripped client-side, and the HTTP method)
  • Does not perform passive background telemetry or analytics - network calls occur only during active token validation
  • Does not maintain its own persistent storage; connection state and audit logs are held by the AgentAdmit hosted service

What the AgentAdmit hosted service records

On every token validation, AgentAdmit's /api/v1/verify endpoint receives the access token and API key, resolves the token to its user_id, connection_id, granted scopes, and agent_label, and records per-call metadata (including the endpoint and timestamp) for billing, audit logging, the security alerts engine, and usage metering. This is integral to how AgentAdmit works and applies to both test and live keys. See the "Mandatory introspection" notes above and the compliance guide for the full data-handling description.

Privacy impact

Since this SDK runs on your server, it has no direct App Store or Play Store compliance surface. Your client-side integration (e.g., the AgentAdmit React SDK) handles privacy manifest and data safety requirements.

For complete compliance guidance, see our compliance guide.

License

All rights reserved. Patent pending.

Security Alerts

use AgentAdmit\AlertsClient;
$alerts = new AlertsClient(config('agentadmit'));

Six alert type constants on AlertsClient.

Configure

$alerts->configureAlerts('app_abc123', AlertsClient::ALERT_TYPE_VOLUME_SPIKE, [
    'enabled' => true, 'threshold_value' => 100, 'threshold_window_minutes' => 5,
    'kill_switch_enabled' => true,
]);

List Events

$events = $alerts->listAlerts(appId: 'app_abc123', alertType: AlertsClient::ALERT_TYPE_VOLUME_SPIKE);

Get Config

$config = $alerts->getAlertConfig(appId: 'app_abc123');

Notifying Your Users

AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes connections. How you notify your own users is up to you. AgentAdmit provides the data - you deliver it through your own system (in-app notifications, email, push, etc.).

  • Poll alerts - Use the SDK methods above from your backend to check for new events, then notify users through your existing system.

  • Webhook delivery - Configure a webhook URL in your AgentAdmit dashboard. When an alert fires, AgentAdmit POSTs the payload to your server, signed with your whsec_… secret. The payload carries alert_id, alert_type, severity, the connection's agent_label, and the grant's declared purpose; the full shape is documented in the Webhook Delivery section of the MCP guide at https://agentadmit.com/docs/mcp-guide. Always verify the signature against the raw request body before trusting the payload:

    use AgentAdmit\Webhook;
    use AgentAdmit\AgentAdmitException;
    
    Route::post('/agentadmit/alerts', function (Request $request) {
        try {
            Webhook::verifySignature(
                $request->getContent(),
                $request->header('X-AgentAdmit-Signature', ''),
                config('agentadmit.webhook_secret'), // whsec_… from AGENTADMIT_WEBHOOK_SECRET
            );
        } catch (AgentAdmitException $e) {
            return response()->json(['error' => 'invalid_signature'], 400);
        }
        $event = $request->json()->all();
        // ...
    });

    The header format is t=<unix_ts>,v1=<hex> - an HMAC-SHA256 of {t}.{rawBody} keyed with your signing secret. Verification uses hash_equals() (constant time) and rejects timestamps more than 5 minutes off (replay protection).

  • React SDK - Embed the <AlertsPanel> component so users can view their own alert history and tighten thresholds.

Issuing & Exchanging Tokens

use AgentAdmit\TokensClient;

$tokens = app(TokensClient::class);

// Duration is tri-state:
//   omit the argument                     → AgentAdmit default (30 days)
//   null                                  → until the user revokes
//   int seconds (60–31536000)             → explicit duration
// The optional purpose (max 300 chars) is shown to the human at the consent
// moment and recorded on the grant — see "Declared Purpose" above. The
// optional userIntent (1-300 chars) is the user's own words — see
// "User-Declared Intent" above.
$issued = $tokens->issueToken(
    'user_42',
    ['read:orders'],
    role: 'user',
    durationSeconds: null,
    purpose: 'Reorder the usual weekly groceries',
    userIntent: 'get my usual Tuesday order',
);
$connectionToken = $issued['token']; // ag_ct_…

// Agent side  -  no API key needed; the connection token is the credential.
$granted = $tokens->exchange($connectionToken, agentLabel: 'MyAssistant');

// Revoke when the user disconnects the agent.
$tokens->revoke($granted['connection_id'], reason: 'user_requested');