Agent Users
Per-endpoint reference for the Agent Users resource. An agent user is a special users row with user_type = 'agent' plus a matching org_users membership, scoped to the API key's org. Agents act on behalf of an organization: they receive long-lived API keys (credentials) that can be passed to a third-party AI / automation worker, and they can be rotated or revoked independently of any human user. Credential, token, and invitation endpoints are admin-gated (org_users.role = 1). Webhook GET/PUT is self-serve: the caller may manage their own org membership and direct reports. Each section below shows the exact path, every parameter, a full request example, and the response shape with a worked example.
Quickstart
Base URL
https://api.tasklife.com/v1/
Auth (all endpoints)
Every request must include a bearer token in the Authorization header. The token is an API key bound to a specific organization — endpoints return only data scoped to that org. Create-agent, credentials, token, and invitation endpoints require that the API key's user has org_users.role = 1 in the API key's org (non-admin keys receive 403 — Admin access required from AgentUsersService::assertOrgAdmin()). Webhook GET/PUT is not admin-only: a non-admin key may read and write webhook settings for its own org_user_id and for direct reports (reports_to_org_user_id equals the caller, one level only). Peers get 403 — Not allowed to manage webhook settings. Org admins keep full access.
Authorization: Bearer ***
Content-Type: application/json
Response envelope
All successful responses share a common envelope. Error responses are described in Errors below.
{
"success": true,
"data": { /* endpoint-specific payload */ }
}
Note: the create-credential and rotate-credential endpoints both return two top-level data keys: data.credential.<field> (the row metadata — key id, label, scope, expiry, rotated flag, created timestamp) and data.payload.<field> (a self-contained bootstrap document intended to be handed off to the agent worker). The bootstrap payload carries the raw plaintext api_key under data.payload.auth.api_key — that is the only time the plaintext key is returned by the API. After the response is delivered, the only way to recover the plaintext is the reveal endpoint, which is admin-gated and audit-logged. The create-agent endpoint nests the new agent under data.agent.<field>, and the reveal endpoint returns just data.credential.{ key_id, api_key }.
Errors
Failed responses always have success: false and an error object with a stable code and a human-readable message:
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "Admin access required"
}
}
| HTTP status | When | What to do |
|---|---|---|
400 | Validation failed: missing name on create-agent, expires_at not parseable / not in the future on create-credential, role outside {1,2,3}, agent user is not actually an agent on a credential call, or an older key (pre-ciphertext storage) is being revealed. | Read error.message; fix the offending field. |
401 | Missing or invalid Authorization header. | Check the API key; confirm the header is present. |
403 | On credential / token / invitation endpoints: API key is valid but the user is not an org admin (org_users.role = 1). On webhook GET/PUT: the caller is not an org admin, is not the target org_user_id, and is not the target's direct manager (reports_to_org_user_id). Cross-org targets are also 403. | For credentials/token/invitation, use an org-admin key. For webhooks, use the agent's own key, their manager's key, or an org-admin key. |
404 | {id} does not resolve to an active agent user in the API key's org, or {key_id} does not resolve to a credential row scoped to that agent and org. | Verify the id values and that the agent / key haven't been deleted. |
500 | Unexpected server error (e.g. api_key_crypto decrypt failed on reveal, missing API_KEY_ENCRYPTION_KEY env var, DB write failed). | Retry with backoff; report if persistent. |
501 | Reveal attempted against a key that was created before ciphertext was stored — the older api_keys row has no key_ciphertext / key_iv / key_tag columns populated. | Rotate the credential to mint a fresh, revealable key. |
Agent Users
Agent Users let an organization mint long-lived API keys that are bound to a non-human users row (user_type = 'agent'). The flow is three steps: create the agent user (POST /v1/agents) → mint its first credential (POST /v1/agents/{id}/credentials) → hand the returned data.payload.auth.api_key plus a link to the human-facing docs (https://tasklife.com/main/pages/77) to the worker process. Use POST /v1/agents/{id}/credentials/rotate to issue a new key and immediately deactivate the old one, and GET /v1/agents/{id}/credentials/{key_id}/reveal as a last-resort admin tool to recover the plaintext of a specific credential when the worker has lost it. All four endpoints are admin-gated.
⚠️ Agent Users gotchas
- Every endpoint is admin-gated —
AgentUsersService::assertOrgAdmin()runs at the top ofcreateAgent(),createOrRotateCredential(), andrevealCredential()and probesorg_users.role = 1for the callingusers.idin the API key's org. A valid bearer token that belongs to a non-admin (role = 2or3) gets403 — Admin access requiredon all four endpoints. - The plaintext
api_keyis only returned at creation / rotation —createOrRotateCredential()returns the raw key underdata.payload.auth.api_keyexactly once, alongside a self-contained bootstrap payload (issuer,environment,org.id,principal,auth.scopes[],auth.expires_at). Once that response is delivered, the plaintext is gone from the wire — only the SHA-256key_hashand AES-GCMkey_ciphertextsurvive. Plan to hand the bootstrap document to the worker process immediately and store it client-side; otherwise you'll need the admin-only reveal endpoint to recover it later. - The reveal endpoint returns plaintext credentials — it is sensitive —
GET /v1/agents/{id}/credentials/{key_id}/revealdecrypts the stored ciphertext and returns the rawapi_key. Anyone with admin access to the org can read the plaintext key for any active credential in that org. Every call is audit-logged toapi_key_reveal_audit(requesting user id, source IP, user-agent, success / failure reason) when that table exists, but you should still treat reveals as a privileged operation and gate them behind your own admin UI. - Rotation revokes the old key immediately —
POST/PUT /v1/agents/{id}/credentials/rotateissues a new credential and runsUPDATE api_keys SET is_active = 0 WHERE org_id = ? AND user_id = ? AND is_active = 1in the same transaction, so any worker still authenticating with the previous key will start failing auth as soon as the rotate transaction commits. Coordinate rotation with the worker (stop → rotate → re-handoff the new plaintext → restart). expires_atis optional on credential creation — when omitted, the service storesexpires_at = NULLand the credential has no built-in expiry (it lives until you rotate it). When supplied, the value must be a valid ISO-8601 datetime that resolves to a future timestamp; past or unparseable values return400 — expires_at must be a valid future datetime. The value is stored as MySQLdatetime(Y-m-d H:i:s, UTC) and echoed back as that string indata.credential.expires_at.- Older installs may not have
users.user_typeorapi_keys.{key_owner_type, agent_user_id}— the service probes each of these withSHOW COLUMNSat runtime. On installs withoutuser_type,data.agent.user_typeis returned as"unknown"and theis not an agentcheck is skipped. On installs without the api-key owner columns, thekey_owner_type = 'agent'tag is dropped from the insert. The four documented endpoints behave identically either way. namealias accepts bothnameanddisplay_name— the create-agent handler reads$data['display_name'] ?? $data['name'](display_name wins). If both are missing or empty after trim, the service returns400 — display_name is required. Note the error message refers todisplay_name, notname, even though the field is callednamein the request.- Rate-limit defaults are
120req/min —createOrRotateCredential()defaultsrate_limit = 120andrate_limit_burst = max(rate_limit, <input>)(ornullwhen not supplied). The values are stored on theapi_keysrow but the wire response surfaces onlydata.credential.{key_id, label, scope, expires_at, created_at, rotated}— you cannot read the actual rate-limit values back through this endpoint family. - Bootstrap payload uses
tl_prod_/tl_dev_prefixes —$envPrefix = APP_ENV === 'prod' ? 'tl_prod_' : 'tl_dev_', so a key minted against production looks liketl_prod_<48 hex chars>and against any non-prod environment looks liketl_dev_<48 hex chars>. This is purely cosmetic on the wire (the auth layer does not key off the prefix), but it's a useful sanity check when reading keys back off a worker's config. - Token + invitation stay admin-gated; webhook GET/PUT is self-serve — the router also accepts
POST /v1/agents/{id}/token/mint,POST /v1/agents/{id}/token/revoke,GET /v1/agents/{id}/webhook, andPUT/PATCH/POST /v1/agents/{id}/webhook. Token mint/revoke and invitation still callassertOrgAdmin(). Webhook GET/PUT callassertCanManageWebhookSettings(): org admin, the target themselves, or a direct manager (org_users.reports_to_org_user_id= caller'sorg_user_id, one level only — not a recursive subtree). Anyone else gets403. MCP toolsget_agent_webhook/set_agent_webhookhit the same REST endpoints and inherit this gate.
Endpoints at a glance
| Action | Method + Path | Summary |
|---|---|---|
| Create agent user | POST /agents | Create a new user_type = 'agent' row + matching org_users membership. Required: name (alias: display_name). Returns 201 with the created agent. |
| Create credential | POST /agents/{id}/credentials | Mint a new bearer API key for the agent. Optional body: expires_at (ISO datetime), scope, rate_limit, rate_limit_burst, label. Returns 201 with the credential metadata plus a bootstrap payload that contains the plaintext api_key. |
| Rotate credential | POST or PUT /agents/{id}/credentials/rotate | Deactivate every active credential for the agent and issue a new one in the same transaction. Returns 200 with the new credential metadata + bootstrap payload (data.credential.rotated: true). |
| Reveal credential | GET /agents/{id}/credentials/{key_id}/reveal | Decrypt and return the plaintext api_key for a previously-issued credential. Admin-only, audit-logged. Sensitive endpoint — treat as privileged. |
| Get webhook settings | GET /agents/{org_user_id}/webhook | Read webhook config for an agent membership. Allowed for org admins, the target themselves, or their direct manager. Path id is org_user_id. |
| Set webhook settings | PUT / PATCH / POST /agents/{org_user_id}/webhook | Update webhook URL, events, throttle, and secret. Same self / direct-report / admin gate as GET. Peers receive 403. |
POST /agents
Create a new agent user. Inserts a row into users (with user_type = 'agent' on installs that have the column) and a matching org_users membership in the API key's org. Returns 201 with the created agent nested under data.agent.
name (or display_name). Empty / missing throws 400 — display_name is required. Note the error message references display_name because that's the column the service writes to users.name.
Body parameters
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string |
yes | — | Display name for the agent. Alias: display_name. display_name wins if both are sent. Trimmed; empty after trim returns 400. |
display_name |
string |
no | — | Alias for name. Takes precedence when both are present. |
description |
string |
no | — | Free-form description. Not persisted by the service — accepted on the wire for forward compatibility, but no field on the created data.agent object reflects it. |
role |
integer |
no | 2 |
Org-membership role (org_users.role). Must be one of {1, 2, 3}; out-of-range values silently fall back to 2. 1 = admin, 2 = member, 3 = read-only / custom. |
Request example
curl -sS -X POST https://api.tasklife.com/v1/agents \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{
"name": "Stracker Build Agent",
"description": "CI worker that picks up tasks in the Build column.",
"role": 2
}'
Response 201 Created
The created agent is nested under data.agent:
| Field | Type | Description |
|---|---|---|
data.agent.id | integer | New users.id. Use this as the {id} path parameter for the credential / reveal endpoints. |
data.agent.display_name | string | The trimmed name you passed in. |
data.agent.role | integer | Stored org_users.role for this membership. |
data.agent.org_id | integer | Org the membership was created in (always equal to the API key's org_id). |
data.agent.user_type | string | "agent" on installs that have the users.user_type column; "unknown" otherwise. |
data.agent.created_at | string | RFC 3339 / ISO 8601 timestamp (date('c')) generated at insert time. |
{
"success": true,
"data": {
"agent": {
"id": 9187,
"display_name": "Stracker Build Agent",
"role": 2,
"org_id": 42,
"user_type": "agent",
"created_at": "2026-07-03T14:21:48+00:00"
}
}
}
Errors
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "Admin access required"
}
}
POST /agents/{id}/credentials
Mint a new bearer API key for the agent and return it once in a self-contained bootstrap payload. The response carries two top-level keys: data.credential (the row metadata you can persist — key id, label, scope, expiry, rotated flag, created timestamp) and data.payload (a handoff document for the worker that contains the raw api_key). Save the payload immediately — the plaintext is not recoverable without reveal, which is admin-gated and audit-logged.
api_key is returned by the API. The plaintext lives only in data.payload.auth.api_key in this response. After that, only the SHA-256 key_hash (used to validate incoming Authorization: Bearer headers) and the AES-GCM key_ciphertext (used by the reveal endpoint) survive on the server. Plan to deliver the bootstrap document to the worker process in the same request that mints it.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Agent users.id returned by POST /agents. Must be an active membership in the API key's org with user_type = 'agent'. |
Body parameters
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
expires_at |
string |
no | null (no expiry) |
ISO-8601 datetime at which the credential should expire. Must parse via strtotime() and resolve to a future timestamp — past or unparseable values return 400 — expires_at must be a valid future datetime. Stored as MySQL datetime (Y-m-d H:i:s, UTC) and echoed back as that string. |
scope |
string |
no | "agent" |
Single scope token written to api_keys.scope and echoed back as data.credential.scope / data.payload.auth.scopes[0]. Trimmed; empty string falls back to "agent". |
label |
string |
no | "Agent key" |
Free-form label stored on api_keys.label. Trimmed; empty falls back to the default. |
rate_limit |
integer |
no | 120 |
Per-minute request budget. Clamped to a minimum of 1. Not surfaced in the response — stored on the api_keys row only. |
rate_limit_burst |
integer |
no | null (or max(rate_limit, value) when supplied) |
Burst budget. When supplied, clamped to max(rate_limit, value); otherwise null. Also stored only — not in the response. |
Request example
curl -sS -X POST https://api.tasklife.com/v1/agents/9187/credentials \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{
"expires_at": "2027-01-01T00:00:00Z",
"scope": "agent",
"label": "Stracker Build Agent — key 1",
"rate_limit": 240,
"rate_limit_burst": 480
}'
Response 201 Created
Two top-level keys under data — data.credential (metadata) and data.payload (handoff document with the plaintext api_key):
| Field | Type | Description |
|---|---|---|
data.credential.key_id | integer | New api_keys.id. Use as {key_id} for the reveal endpoint. |
data.credential.label | string | The (possibly defaulted) label stored on api_keys.label. |
data.credential.scope | string | The resolved scope token ("agent" by default). |
data.credential.expires_at | string | null | MySQL Y-m-d H:i:s string in UTC, or null when no expiry was supplied. |
data.credential.created_at | string | RFC 3339 / ISO 8601 timestamp generated at insert time. |
data.credential.rotated | boolean | Always false from this endpoint; true on the rotate endpoint. |
data.payload.version | string | Bootstrap document schema version. Currently "v1". |
data.payload.issuer | string | Always "strackerapp". |
data.payload.environment | string | APP_ENV value at mint time — "prod" on production, "dev" elsewhere. Determines the key prefix (tl_prod_ vs tl_dev_). |
data.payload.org.id | integer | API key's org id. |
data.payload.principal.type | string | Always "agent" for this endpoint. |
data.payload.principal.agent_user_id | integer | Echo of the path {id}. |
data.payload.principal.display_name | string | Agent's users.name at mint time. |
data.payload.auth.scheme | string | Always "bearer". |
data.payload.auth.api_key | string | Plaintext API key — the only time the raw key is returned. Format: tl_prod_<48 hex chars> on prod, tl_dev_<48 hex chars> elsewhere. Hand this off to the worker immediately. |
data.payload.auth.key_id | integer | Same as data.credential.key_id. |
data.payload.auth.scopes[] | string[] | Single-element array mirroring data.credential.scope. |
data.payload.auth.expires_at | string | null | Same value as data.credential.expires_at. |
{
"success": true,
"data": {
"credential": {
"key_id": 4218,
"label": "Stracker Build Agent — key 1",
"scope": "agent",
"expires_at": "2027-01-01 00:00:00",
"created_at": "2026-07-03T14:22:11+00:00",
"rotated": false
},
"payload": {
"version": "v1",
"issuer": "strackerapp",
"environment": "prod",
"org": {
"id": 42
},
"principal": {
"type": "agent",
"agent_user_id": 9187,
"display_name": "Stracker Build Agent"
},
"auth": {
"scheme": "bearer",
"api_key": "tl_prod_8f3a1c9b2e7d6045f1a8b3c6e9d2f4a7b8c1d5e0f2a4b6c8d0e1f3a5b7c9d763",
"key_id": 4218,
"scopes": ["agent"],
"expires_at": "2027-01-01 00:00:00"
}
}
}
}
Errors
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "expires_at must be a valid future datetime"
}
}
POST /agents/{id}/credentials/rotate (PUT is also accepted)
Issue a new credential for the agent and, in the same DB transaction, set is_active = 0 on every previously-active credential for that agent in the API key's org. The plaintext of the new key is returned once under data.payload.auth.api_key — same envelope as the create-credential endpoint, but with data.credential.rotated: true. Accepts both POST and PUT at the router.
401. Coordinate rotation: stop the worker → call rotate → hand the new plaintext to the worker → restart. Active credentials for the agent that were minted against a different scope are also deactivated.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Agent users.id. |
Body parameters
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
expires_at |
string |
no | null (no expiry) |
Same rules as the create-credential endpoint. Future-only ISO-8601 datetime, or omit for an open-ended key. |
scope |
string |
no | "agent" |
Scope for the new credential. |
label |
string |
no | "Agent key (rotated)" |
Label for the new credential. Default flips from "Agent key" to "Agent key (rotated)" on this endpoint. |
rate_limit |
integer |
no | 120 |
Per-minute request budget for the new credential. Stored only. |
rate_limit_burst |
integer |
no | null (or max(rate_limit, value) when supplied) |
Burst budget for the new credential. Stored only. |
Request example
curl -sS -X POST https://api.tasklife.com/v1/agents/9187/credentials/rotate \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{
"label": "Stracker Build Agent — rotated after incident",
"expires_at": "2027-01-01T00:00:00Z"
}'
Response 200 OK
Same envelope as the create-credential endpoint. data.credential.rotated is true, and the prior api_keys row for this agent has been deactivated:
| Field | Type | Description |
|---|---|---|
data.credential.<field> | mixed | Same shape as create-credential, with rotated: true. |
data.payload.<field> | mixed | Same shape as create-credential, including the plaintext data.payload.auth.api_key for the newly minted key. |
{
"success": true,
"data": {
"credential": {
"key_id": 4219,
"label": "Stracker Build Agent — rotated after incident",
"scope": "agent",
"expires_at": "2027-01-01 00:00:00",
"created_at": "2026-07-03T14:48:02+00:00",
"rotated": true
},
"payload": {
"version": "v1",
"issuer": "strackerapp",
"environment": "prod",
"org": {
"id": 42
},
"principal": {
"type": "agent",
"agent_user_id": 9187,
"display_name": "Stracker Build Agent"
},
"auth": {
"scheme": "bearer",
"api_key": "tl_prod_4b7e2d1a9f6c8053e2b4d7a0c1f3e5b8a9d2c4f6e0b1a3c5d7f9e1b3a5c7d318",
"key_id": 4219,
"scopes": ["agent"],
"expires_at": "2027-01-01 00:00:00"
}
}
}
}
Errors
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "Agent user not found for org"
}
}
GET /agents/{id}/credentials/{key_id}/reveal (sensitive — admin-only, audit-logged)
Decrypt the stored AES-GCM ciphertext for a previously-issued credential and return the plaintext api_key. This is the last-resort path for recovering a key when the worker has lost it — it is the only endpoint besides the create / rotate endpoints that exposes the raw api_key. Treat as privileged: the call is audit-logged to api_key_reveal_audit (requesting user id, source IP, user-agent, success / failure reason) when that table exists on the install.
- Anyone with admin access to the org can read the plaintext
api_keyfor any active credential for any agent in that org. Treat the call as a privileged operation and gate it behind your own admin UI; consider requiring an additional confirmation step (MFA, signed audit note, etc.) before invoking it. - Every call writes a row to
api_key_reveal_audit(when the table exists) withwas_success,failure_reason(not_found_or_wrong_org/legacy_not_revealable/decrypt_failed_or_missing_secret; thelegacy_not_revealableconstant name is historical and refers to credentials created before ciphertext storage was added), the calling user's id, source IP, and user-agent. The audit row is written even on failure paths. - Credentials created before
api_keys.key_ciphertext / key_iv / key_tagcolumns existed will not be revealable — the service returns400 — Credential is not revealable. The fix is to rotate the credential, which mints a fresh, revealable key. - If
API_KEY_ENCRYPTION_KEYis missing or the ciphertext is corrupt, the service returns500 — Unable to reveal credentialrather than partially-decrypting. Audit row is still written withfailure_reason = decrypt_failed_or_missing_secret.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Agent users.id. |
key_id | integer | yes | api_keys.id to reveal. Must belong to this agent in the API key's org (WHERE id = ? AND org_id = ? AND user_id = ?). |
Request example
curl -sS https://api.tasklife.com/v1/agents/9187/credentials/4218/reveal \
-H "Authorization: Bearer ***"
Response 200 OK
Returns just the credential id and the decrypted plaintext key. No metadata (label, scope, expiry) is included:
| Field | Type | Description |
|---|---|---|
data.credential.key_id | integer | Echo of the {key_id} path parameter. |
data.credential.api_key | string | Plaintext API key for the credential — same value originally returned at mint time. |
{
"success": true,
"data": {
"credential": {
"key_id": 4218,
"api_key": "tl_prod_8f3a1c9b2e7d6045f1a8b3c6e9d2f4a7b8c1d5e0f2a4b6c8d0e1f3a5b7c9d763"
}
}
}
Errors
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "Credential is not revealable"
}
}