polaris / agents
AI agents as principals for Polaris for PHP: registration and host enrolment, delegated and autonomous agents over OAuth (token exchange with act, client credentials, private_key_jwt), signed request JWTs, capability grants with constraints and budgets, approvals, signed receipt chains and a kill sw
Requires
- php: >=8.3
- ext-openssl: *
- firebase/php-jwt: ^7.0
- lcobucci/jwt: ^5.0
- opis/json-schema: ^2.4
- polaris/admin: ^0.11
- polaris/audit: ^0.11
- polaris/core: ^0.11
- polaris/mcp: ^0.11
- polaris/oauth-provider: ^0.11
- psr/clock: ^1.0
- psr/event-dispatcher: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-middleware: ^1.0
- psr/simple-cache: ^3.0
- symfony/uid: ^7.0 || ^8.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-13 21:31:02 UTC
README
AI agents as principals of Polaris for PHP: registered by
their owners or enrolled by hosts, each an OAuth client of polaris/oauth-provider (client credentials
for an autonomous agent, token exchange with act for a delegated one; private_key_jwt, a secret or
the user's own token), signed request JWTs, capability grants with constraints and budgets approved by
a person or by policy, one execution path with a signed receipt chain, and a kill switch. Every
capability is an MCP tool when polaris/mcp is registered. The protocol is
docs/better/agent-auth-compat.md: what Polaris serves, and
the subset of Better Auth's Agent Auth Protocol an agent can rely on without a compatibility switch.
composer require polaris/agents
use Polaris\Admin\AdminPlugin; use Polaris\Agents\AgentsPlugin; use Polaris\Agents\Capability; use Polaris\Agents\ExecutionContext; use Polaris\Audit\AuditPlugin; use Polaris\OAuth\OAuthPlugin; $polaris = Polaris::create(new Config(..., plugins: [ new AuditPlugin(), new AdminPlugin(), new OAuthPlugin(baseUrl: 'https://app.example.com/auth'), new AgentsPlugin( baseUrl: 'https://app.example.com/auth', approvalUrl: 'https://app.example.com/agents/approve', capabilities: [ new Capability( name: 'deploy.staging', description: 'Deploy a build to staging', input: ['type' => 'object', 'properties' => ['build' => ['type' => 'string']], 'required' => ['build']], permission: 'deploy.staging', risk: 'low', handler: fn(array $arguments, ExecutionContext $context) => $deployer->deploy($arguments['build']), ), ['name' => 'billing.refund', 'description' => 'Refund an order', 'input' => [...], 'permission' => 'billing.refund', 'risk' => 'high', 'location' => 'https://app.example.com/api/refunds'], ], ), ]));
polaris/audit, polaris/admin and polaris/oauth-provider are required and must be registered first:
every agent event is an agent.* record in the audit store, /admin/agents runs on the admin
plugin's principals, and an agent is an OAuth client. baseUrl is where Polaris is mounted, prefix
included: the discovery document's endpoints derive from it. In Laravel, Symfony and Yii the instance
goes in the adapter's plugins configuration; the five tables join polaris:install, schema:create
and schema:diff, the routes join the route table.
Agents, hosts and capabilities
An agent (polaris_agent) belongs to a user or to an organization (through the member who
registered it), acts for its owner (delegated) or as its owner (autonomous), and authenticates
with a key (private_key_jwt: an EC P-256 or Ed25519 public JWK), a secret (client_secret, a pcs_
secret shown once) or the user's own token (delegated_token, for the exchange only). Its id is the
client_id of the OAuth client the plugin registers for it: an autonomous agent's client may use
client_credentials, a delegated agent's the token exchange; the client's scopes follow the agent's
live grants, so a token is never minted beyond what was granted.
A host (polaris_agent_host) is a runtime that enrols agents (a Claude Code install, a worker): its
owner creates it (POST /agents/hosts) and gets an enrolment token (peh_..., shown once, stored as a
keyed hash); POST /agents/enroll with it as the bearer creates an agent under the host's owner and
asks for the host's default_capabilities at once.
A capability is declared in the configuration: name, description, input (a JSON Schema of
the arguments), permission (a catalog name the owner must hold; the plugin contributes the ones core
lacks), risk (low, medium, high) and exactly one of a handler (run by POST /agents/execute
and the MCP tool) or a location (a host URL guarded by AgentRequestMiddleware). GET /agents/capabilities lists them (with grant_status when an agent asks); GET /.well-known/agent-configuration is the discovery document.
The three ways an agent authenticates
- Client credentials at
/oauth2/token(client_secret_basic, or aprivate_key_jwtassertion signed with the agent's key and itskid) for an autonomous agent: the token'ssubis the agent. On every Polaris route the plugin's middleware makes it act as its owner (a user's agent as the user, an organization's as the member who registered it, in that organization), with the permissions of its live grants as the delegated authority, andactor_type: agent,agent_id,actin the token. - Token exchange (RFC 8693) for a delegated agent: its client exchanges the user's access token
(
subject_token) for one whosesubis the user andactis{sub: <agent>, client_id: <agent>}, within the user's permissions and the agent's grants; core's RBAC, the delegation rule (no permissionless write, no step-up route), the audit log and the admin plugin all see the user and the agent. A grant revoked takes effect at the next request, not at the token's expiry. Only the user the agent acts for can be the subject: the token endpoint exchanges any session, so a token naming an agent's client whosesubis anyone else (adelegated_tokenagent's client is public) is refused on every route,401 agents/invalid_jwt. - Signed request JWTs on the agent-facing routes (
POST /agents/execute,/agents/grants/request,GET /agents/session,GET /agents/capabilities) and at capability locations, sent asAuthorization: Bearer <jwt>: headertyp: agent+jwt,algES256orEdDSA, verified with the agent's registered key only (nojku,x5uorjwkheader),sub= the agent,aud= the exact URL called (scheme, host, port, path; no query),iatandexpat mostrequestJwtMaxAge(300 s) apart with 60 s of leeway,jtiused once untilexp, optionalhtm,htuandbody_hash(base64url SHA-256 of the raw body) checked when present, optionalcapabilitiesrestricting the grants the JWT may use. A JWT listing several capabilities cannot use a location as itsaud(agents/aud_mismatch).
The agent-facing routes are auth: public and authenticate the agent themselves, so core's delegation
rule never applies to them; the owner-facing routes are auth: bearer self-service writes, so no
delegated credential (a key, a token, an agent) can register an agent or approve a grant. An
organization's agents need org.update in the caller's active organization, checked by the endpoint.
Grants, approvals and strengths
POST /agents/grants/request (the agent) names capabilities, each a name or {name, constraints}:
AAP's operators per argument (max, min, in, not_in) plus pattern, and budget ({count: N}
or {amount: N, field: argument}, with an optional window in seconds that becomes the grant's
lifetime); an unknown operator is 400 agents/unknown_constraint_operator. The answer is AAP's
{agent_id, agent_capability_grants, approval?}.
What the policy auto-approves is active at once: autoApprove (default ['low' => ['count' => 100]])
maps a risk tier to the budget it gets (or true for none) and applies when the owner holds the
capability's permission and the asked budget, if any, stays within the policy's (the same kind and
field, no larger, and no longer a window when the policy sets one): the policy's budget is a ceiling
the asker cannot raise. The rest waits in one approval (polaris_agent_approval), whose method
only changes how the owner finds it: device_authorization (the agent shows the user_code; the owner
types it on the host's approvalUrl page, which loads GET /agents/approvals/code?user_code=), ciba
(the host pushes the binding_message from the agent.grant.requested event, through
polaris/messaging for instance) or in_app (the owner's own client lists GET /agents/approvals).
All decide at POST /agents/approvals/{id}/decide with {decision: approve|deny, constraints?}, once:
approving needs a session of the strength the riskiest capability requires (strengths, default
low and medium session, high step_up: core's step-up freshness; passkey: a recent passkey
sign-in) and the owner must hold every permission at that moment. DELETE /agents/grants/{id} revokes
a grant; GET /agents/session shows the agent its grants and remaining budgets while it waits. Asking
again for a capability that already waits answers the same pending grant and its open approval (a new
one once that approval expired); an approval past approvalTtl reads as expired; an agent may have at
most five approvals waiting (409 agents/conflict beyond that, until one is decided or expires).
Execution, budgets and receipts
One path for POST /agents/execute {capability, arguments} (input is an alias), the capability
locations and the MCP tools: the grant must be live, named by the JWT's capabilities when it
restricts them and carried as a permission by an access token's scopes; the owner must still hold the
permission; the arguments must match the capability's schema and the grant's constraints
(403 agents/constraint_violated with violations: [{field, constraint, actual}]). Then, in one
transaction, the budget is spent by a conditional update (budget_remaining >= cost; one per call for a
count, the rounded-up value of the budget's argument for an amount), the handler runs and a receipt is
appended. A budget that cannot cover the call is 403 agents/budget_exhausted; a grant that reaches
zero is exhausted (an agent.grant.exhausted event) and a refill is a new request. The answer is
200 {data: <the handler's result>, receipt: {id, seq, hash}}.
Receipts (polaris_agent_receipt) form a chain per agent: seq, prev_hash, hash (SHA-256 over
the canonical JSON — sorted keys, unescaped slashes — of id, agent_id, seq, grant_id,
capability, input_hash, outcome, budget_before, budget_after, prev_hash, created_at) and
signature, a compact JWS (typ: receipt+jwt, claims receipt_hash, receipt_id, agent_id, seq)
signed with core's key and kid, verifiable with /auth/.well-known/jwks.json. Inputs are stored as a
hash only. GET /agents/{id}/receipts exports the chain (after, limit); GET /agents/receipts/{id}/verify recomputes the hash, verifies the signature and checks the link to the
previous receipt ({valid, checks: {hash, signature, chain}}); a receipt changed in the database fails
its own check, and the next one's chain check.
The kill switch
POST /agents/{id}/revoke (the owner) and POST /admin/agents/{id}/revoke (an operator with own),
in one transaction: the agent is revoked, its OAuth client disabled and every token of it revoked, its
grants revoked, its pending approvals cancelled, and a final receipt with the outcome sealed closes
the chain; nothing is chained after it and every credential of the agent is refused
(401 agents/agent_inactive, error: agent_revoked). An execution holds the agent's row for its
transaction, so the switch and an execution in flight serialise: an agent revoked or suspended
meanwhile runs nothing. suspend and resume pause an agent without sealing anything
(agent_suspended). A member who leaves an organization takes the agents and the hosts they registered
for it (the agents revoked and sealed, the hosts revoked); an erased user or organization leaves no
agent or host behind. The agent's OAuth client belongs to no organization: the plugin manages it (the
kill switch, its scopes, erasure), so the organization's self-service client routes
(/orgs/{id}/oauth/clients) never list, rewrite or delete it; operators see it under
/admin/oauth/clients.
Capabilities with a location
A capability whose location is a host URL runs at the host, behind AgentRequestMiddleware, a PSR-15
middleware: the request must authenticate an agent (a JWT whose aud is the URL called, or an access
token) holding a live grant whose constraints the JSON body satisfies; the budget, the host's handler
and the receipt (outcome success for a 2xx, failed otherwise; the handler's exception rolls back and
is rethrown) run in one transaction. The handler reads the evaluated grant from the request attribute
polaris.agents.grant (and the agent from polaris.agents.agent, the whole context from
polaris.agents.context); the response carries X-Polaris-Receipt. POST /agents/execute refuses a
location capability: it cannot be reached without passing the middleware. Two ways to attach it:
- Bound to the capability (preferred):
AgentsPlugin::of($graph)->requestMiddleware($graph, 'billing.refund')on that capability's route. Every request through it is an execution of that capability, whatever URL the application sees; nothing passes through. - Unbound:
requestMiddleware($graph)in front of all routes. A request is matched against the locations by host, port and path, normalised (lower-case scheme and host, default port dropped,//collapsed, dot segments resolved, percent-encoded unreserved characters decoded, trailing slash dropped; the scheme is ignored), so no spelling of a location passes unguarded; any other URL passes through untouched.
In both forms the JWT's aud must be the URL called, compared normalised the same way (query and
fragment excluded). Behind a TLS-terminating proxy the application must see the external scheme, host
and port in PSR-7 (X-Forwarded-* handled before this middleware): a location requested as http://
is guarded but every agent JWT then fails with aud_mismatch, and in the unbound form a URL seen with
another host or port is not a location at all.
MCP tools
With polaris/mcp registered, every capability with a handler the calling agent holds a live grant for
is a tool named agents.<capability> whose input schema is the capability's input; a call is an
execution with the same checks and receipt, answered as the HTTP route would ({data, receipt}, or the
problem document). The discovery document then advertises endpoints.mcp.
Routes
| Route | Who | What |
|---|---|---|
GET /.well-known/agent-configuration |
public | The discovery document (cached an hour). |
GET /agents/capabilities |
public, or an agent | The capabilities, grant_status for an agent. |
POST /agents/register |
the owner | An agent (name, mode, auth, jwk, organization_id, capabilities); the secret once. |
POST /agents/enroll |
a host (Bearer peh_...) |
An agent under the host's owner with its default capabilities. |
GET /agents, GET /agents/{id} |
the owner | The agents, one with its grants. |
POST /agents/{id}/suspend, /resume, /revoke |
the owner | Pause, resume, the kill switch. |
GET /agents/{id}/receipts |
the owner | The chain (after, limit). |
GET /agents/receipts/{id}/verify |
the owner, or the agent | Hash, signature and chain checks. |
POST /agents/hosts, GET /agents/hosts, DELETE /agents/hosts/{id} |
the owner | Hosts; the enrolment token once. |
POST /agents/grants/request |
the agent | Grants, auto-approved or waiting in an approval. |
DELETE /agents/grants/{id} |
the owner | Revoke a grant. |
GET /agents/approvals, GET /agents/approvals/code?user_code= |
the owner | The pending approvals; the one behind a code. |
POST /agents/approvals/{id}/decide |
the owner | Approve or deny, once, with the strength required. |
POST /agents/execute |
the agent | Execute a capability with a handler. |
GET /agents/session |
the agent | The agent, owner, host, grants and budgets. |
GET /admin/agents, POST /admin/agents/{id}/revoke |
operators | Every agent; the kill switch. |
Rate groups: register for /agents/register; token_refresh for /agents/enroll, /agents/execute,
/agents/grants/request and /agents/session; mfa_confirm for /agents/approvals/{id}/decide.
Configuration
| Option | Default | What |
|---|---|---|
baseUrl |
required | Where Polaris is mounted, prefix included. |
capabilities |
[] |
Capability objects or arrays (name, description, input, permission, risk, handler or location). |
autoApprove |
['low' => ['count' => 100]] |
Risk tiers approved by policy when the owner holds the permission, with their budget (true: none). |
strengths |
low/medium session, high step_up |
The approval strength per risk tier (session, step_up, passkey). |
approvalUrl |
null | The host's approval page (verification_uri); without it the default method is in_app. |
approvalTtl |
600 | Seconds an approval stays pending. |
providerName, description |
Polaris, ... |
The discovery document's provider. |
requestJwtMaxAge |
300 | The most exp - iat a request JWT may span (at most 300). |
responses |
null | A PSR-17 response factory for the middleware's problem documents (discovered when absent). |
Problem types: agents/unauthorized, agents/invalid_jwt, agents/aud_mismatch,
agents/agent_inactive (error agent_revoked or agent_suspended), agents/capability_not_found,
agents/capability_not_granted, agents/constraint_violated (with violations),
agents/budget_exhausted, agents/unknown_constraint_operator, agents/invalid_arguments,
agents/invalid_input, agents/not_found, agents/forbidden, agents/conflict,
agents/step_up_required, agents/permission_not_held, agents/sealed. Where the protocol document
defines a code (the first eight), error carries it bare (capability_not_granted); the others carry the
plugin's prefix as every plugin's do (agents_not_found). A 401 answers WWW-Authenticate: Bearer
without a credential and Bearer error="invalid_token" with one that does not authenticate.
Events (PSR-14 Polaris\Agents\Event\AgentEvent, audited as agent.* with the agent as the actor where
it acts and the owner as the subject): agent.registered, agent.enrolled, agent.suspended,
agent.resumed, agent.revoked, agent.grant.requested, agent.grant.approved, agent.grant.denied,
agent.grant.revoked, agent.grant.exhausted, agent.executed, agent.execution_denied,
agent.receipt.sealed.
License
MIT. Polaris for PHP is created and maintained by 2am.tech.