Service accounts are machine identities for AgentOS. Coding agents, chat apps, and CI pipelines authenticate with opaque agno_pat_... tokens instead of JWTs.
Mint a token with a credential that can create service accounts: a JWT holding service_accounts:write (or the admin scope), or the OS security key:
curl -X POST http://localhost:7777/service-accounts \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{"name": "claude-code"}'
import httpx
response = httpx.post(
"http://localhost:7777/service-accounts",
headers={"Authorization": f"Bearer {admin_jwt}"},
json={"name": "claude-code"},
)
token = response.json()["token"]
agno tokens create claude-code
The 201 response is the only time you’ll see the plaintext token, so store it somewhere safe. There’s no way to get it back later. agno tokens wraps this API for the terminal.
The machine sends the token as a standard bearer header:
curl -X POST http://localhost:7777/agents/my-agent/runs \
-H "Authorization: Bearer agno_pat_..." \
-d "message=hello" -d "stream=false"
Service accounts require a database on your AgentOS (AgentOS(db=...)). Tokens are stored there, next to sessions and memories. Minting always requires a real credential, so anonymous requests on an open instance get a 401.
Token Properties
| Property | Behavior |
|---|
| Format | agno_pat_<base62>. The fixed prefix makes leaked tokens easy for secret scanners to find. |
| Storage | SHA-256 hash only. The plaintext is returned once, at creation. |
| Name | Lowercase slug (letters, digits, _, -; max 63 chars), e.g. claude-code. |
| Expiry | 90 days by default. Set expires_in_days (1 to 3650) or never_expires: true. |
| Rotation | Names are unique among active accounts. Revoke, then mint again under the same name. |
Scopes
A token minted without scopes gets run and read access:
agents:run, teams:run, workflows:run, sessions:read, config:read
config:read lets the token discover what it can run (GET /config).
Custom scopes are passed as {scope, effect} objects. Token scopes are grants, so only effect: "allow" is accepted:
curl -X POST http://localhost:7777/service-accounts \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "ci-runner",
"scopes": [
{"scope": "agents:run", "effect": "allow"},
{"scope": "sessions:write", "effect": "allow"}
],
"allow_privileged_scopes": true,
"expires_in_days": 30
}'
agno tokens create ci-runner -s agents:run -s sessions:write --privileged --expires 30d
Privileged Scopes
Some scopes require allow_privileged_scopes: true at mint time, so a privileged token is always deliberate:
| Scope class | Examples |
|---|
Any write or delete action | sessions:write, knowledge:delete |
| The admin scope | agent_os:admin |
Any service_accounts scope | service_accounts:write (tokens that mint tokens) |
A scoped caller can only grant scopes it already holds. A caller with service_accounts:write but without knowledge:delete cannot mint a token carrying knowledge:delete, so minting never escalates privileges. Admin callers and the OS security key (an unscoped root) can grant anything.
List and Revoke
curl http://localhost:7777/service-accounts \
-H "Authorization: Bearer $ADMIN_JWT"
curl -X DELETE http://localhost:7777/service-accounts/<id> \
-H "Authorization: Bearer $ADMIN_JWT"
import httpx
headers = {"Authorization": f"Bearer {admin_jwt}"}
base = "http://localhost:7777"
accounts = httpx.get(f"{base}/service-accounts", headers=headers).json()["data"]
httpx.delete(f"{base}/service-accounts/{accounts[0]['id']}", headers=headers)
agno tokens list
agno tokens revoke claude-code
Listing is paginated (limit, page, sort_by, sort_order, include_revoked) and returns metadata plus a display prefix (token_prefix) only. Neither the hash nor the plaintext is ever returned.
Revocation is one-way and idempotent. A revoked name can be reused by minting a new account.
Revocation Timing
Successful verifications are cached in-process, so token auth does not hit the database on every request. Revocation takes effect immediately on the worker that processes the DELETE, and within service_account_cache_ttl_seconds (default 30) on other workers. Token expiry is always honored, even on a cache hit.
For strict instant revocation, disable the cache. Every request then verifies against the database:
from agno.os import AgentOS
from agno.os.settings import AgnoAPISettings
agent_os = AgentOS(
agents=[agent],
db=db,
settings=AgnoAPISettings(service_account_cache_ttl_seconds=0),
)
Attribution and Data Access
Requests authenticated with a token run as the principal sa:<name>. Sessions, memories, and traces created through a claude-code token show sa:claude-code as the user.
sa: is a reserved namespace. A JWT whose sub claims an sa: identity is rejected with 401, so a human token can never impersonate a machine identity.
Service accounts always self-scope: they read and write only data attributed to their own principal, even when user isolation is off. For a cross-user debugging token, grant the admin scope; agent_os:admin bypasses self-scoping.
Enforcement Surfaces
Tokens pass through the same auth layer as JWTs and cover the full AgentOS surface:
| Surface | Behavior |
|---|
| REST API | Every route is checked against the token’s scopes. |
MCP (/mcp) | Tool calls are checked against the scopes of their equivalent REST route. |
| Interfaces (A2A) | A2A routes map to agents/teams/workflows scopes. See Scopes. |
| WebSockets | Same verification and scope enforcement as REST. |
Service account scopes are ACL data stored in your database, so AgentOS enforces them in every authentication mode, including security_key and none. JWT scopes are enforced only when authorization is enabled.
Failure Modes
| Status | Cause |
|---|
401 | Unknown, expired, or revoked token. Also: minting without a credential, sending a token to an AgentOS without a database, or a JWT claiming an sa: subject. |
403 | The token verified but lacks a scope the route requires (the detail lists the required scopes). Also: minting scopes the caller does not hold. |
400 | Invalid scope strings, or privileged scopes without allow_privileged_scopes: true. |
409 | An active account with that name already exists. Revoke it to rotate. |
429 | Too many failed token lookups from one client address. |
503 | The database is unreachable or does not support service accounts. |
On an instance with no auth configured, a token that cannot be verified is ignored and the request proceeds anonymously. A token that verifies always attributes the request, and its scopes always apply.
Next Steps
| Task | Guide |
|---|
| Mint tokens from the terminal | agno tokens |
| See the full scope reference | Scopes |
| Understand per-user data scoping | User Isolation |
| Run the cookbook example | agent_os_with_service_accounts.py |