* fix(gateway): serve Responses API under /api/v1/ prefix (#2201) The OpenAI Responses API was only reachable at `/v1/responses`, which broke the otherwise consistent `/api/...` prefix used by every other IronClaw HTTP surface. Callers expecting `/api/v1/responses` got a 404. This routes both paths through the same handlers: - `/api/v1/responses` + `/api/v1/responses/{id}` — canonical paths - `/v1/responses` + `/v1/responses/{id}` — retained as backward-compat aliases for clients already configured against the legacy path Also updates the web gateway CLAUDE.md route table, the USER_MANAGEMENT_API.md reference, and the module docstring for responses_api.rs so documentation points at the canonical prefix. Regression test: tests/responses_api_path_prefix.rs drives the full router via `start_server` and asserts that POST/GET on both the canonical and legacy paths reach the handler (400 from the handler for bad inputs, not 404 from the router) and that both paths enforce bearer auth (401 without a token). This follows the "Test Through the Caller, Not Just the Helper" rule so a future router edit that drops either path fails the test rather than silently regressing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gateway): address PR #2748 review feedback - Extend both_paths_require_auth to cover GET /responses/{id} on both canonical and legacy paths. - Align USER_MANAGEMENT_API.md Responses API examples with the current handler behavior (only "default" model accepted). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: address PR #2748 reviewer nits - Change the "Go ahead with the transfer" Responses API request example to use "model": "default". The handler rejects any other value, so copying the old example verbatim would 400. - Expand the Error Format section to document that the Responses API returns an OpenAI-compatible JSON envelope ({"error": {...}}) rather than the plain-text body used by every other endpoint. Add 429 to the status-code table for Responses API rate limiting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR #2748 Copilot review nits on docs + test cleanup - Correct the documented Responses API 429 error type from `rate_limit_exceeded` to `rate_limit_error` to match what `create_response_handler` actually emits. - Clarify that the JSON error envelope covers handler-generated errors; missing/invalid bearer token (401) and auth-path 503 are returned by the shared gateway auth middleware as plain text. - Add a `ServerGuard` RAII helper in the Responses API path-prefix integration test that takes `state.shutdown_tx` on startup and sends `()` on drop, so each test tears its `axum::serve` task down instead of leaking it for the rest of the process. Update the six test callers to bind the guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
User Management API
DB-backed user management for multi-tenant IronClaw deployments. Covers admin user CRUD, per-user secrets provisioning, self-service profile, API token management, and usage reporting.
Authentication
All endpoints require Authorization: Bearer <token>. Tokens are either:
- Env-var tokens — configured via
GATEWAY_AUTH_TOKEN(single-user) at startup - DB-backed tokens — created via
POST /api/tokensorPOST /api/admin/users
DB tokens are SHA-256 hashed at rest; plaintext is returned exactly once at creation time.
Auth is cached in a bounded LRU (1024 entries, 60s TTL). Suspending a user or revoking a token may take up to 60s to take effect.
Roles
| Role | Scope |
|---|---|
admin |
Full access to all endpoints |
member |
Self-service profile + own token management only |
Endpoints marked Admin return 403 Forbidden for member role.
Admin: Users
POST /api/admin/users
Create a new user. Returns the user record and a one-time plaintext API token.
Auth: Admin
Request body:
{
"display_name": "Alice Smith",
"email": "alice@example.com",
"role": "member"
}
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
display_name |
string | yes | ||
email |
string | no | null |
Must be unique if provided |
role |
string | no | "member" |
"admin" or "member" |
Response: 200 OK
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "alice@example.com",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"token": "a1b2c3d4e5f6...64-char hex...",
"created_at": "2026-03-25T12:00:00+00:00",
"created_by": "admin-user-id"
}
The token field is the plaintext API token. It is shown only once — store it securely.
Errors: 400 (missing display_name, invalid role), 403 (not admin), 503 (no database)
GET /api/admin/users
List all users.
Auth: Admin
Response: 200 OK
{
"users": [
{
"id": "550e8400-...",
"email": "alice@example.com",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"updated_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00",
"created_by": "admin-user-id"
}
]
}
GET /api/admin/users/{id}
Get a single user by ID.
Auth: Admin
Response: 200 OK
{
"id": "550e8400-...",
"email": "alice@example.com",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"updated_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00",
"created_by": "admin-user-id",
"metadata": {}
}
Errors: 404 (user not found), 403 (not admin)
PATCH /api/admin/users/{id}
Update a user's display name and/or metadata. Omitted fields are left unchanged.
Auth: Admin
Request body:
{
"display_name": "Alice Johnson",
"metadata": {"department": "engineering"}
}
| Field | Type | Required | Notes |
|---|---|---|---|
display_name |
string | no | |
role |
string | no | "admin" or "member" |
metadata |
object | no | Replaces entire metadata object (full replacement; keys not included are removed) |
Response: 200 OK — returns the full updated user record (same shape as GET detail, without last_login_at/created_by).
Errors: 404 (user not found), 403 (not admin)
POST /api/admin/users/{id}/suspend
Suspend a user. Suspended users cannot authenticate (DB auth checks user status).
Auth: Admin
Response: 200 OK
{
"id": "550e8400-...",
"status": "suspended"
}
Errors: 404 (user not found), 403 (not admin)
POST /api/admin/users/{id}/activate
Re-activate a suspended user.
Auth: Admin
Response: 200 OK
{
"id": "550e8400-...",
"status": "active"
}
Errors: 404 (user not found), 403 (not admin)
DELETE /api/admin/users/{id}
Permanently delete a user and all associated data (tokens, jobs, conversations, memory, routines, settings, secrets).
Auth: Admin
Response: 200 OK
{
"id": "550e8400-...",
"deleted": true
}
Errors: 404 (user not found), 403 (not admin)
Cascade: Deletes from api_tokens, agent_jobs, conversations, memory_documents, routines, secrets, settings, wasm_tools, and related tables. On PostgreSQL this uses FK cascades; on libSQL it uses explicit deletes.
Admin: Per-User Secrets
Provision secrets on behalf of individual users. The primary use case is an application backend (acting as admin) that configures per-user credentials so each user's IronClaw agent can call back to external services.
Secrets are encrypted at rest with AES-256-GCM using a per-secret HKDF-derived key. Plaintext values are never returned by any endpoint — they can only be used by the agent's tool system at runtime.
PUT /api/admin/users/{user_id}/secrets/{name}
Create or update a secret for the specified user. If a secret with the same name already exists, it is overwritten.
Auth: Admin
Path parameters:
| Param | Type | Notes |
|---|---|---|
user_id |
string | The user's ID |
name |
string | Secret name (normalized to lowercase) |
Request body:
{
"value": "sk-live-abc123...",
"provider": "my-app-backend",
"expires_in_days": 90
}
| Field | Type | Required | Notes |
|---|---|---|---|
value |
string | yes | The secret value (encrypted at rest, never returned) |
provider |
string | no | Tag for grouping (e.g. "stripe", "my-app") |
expires_in_days |
integer | no | Auto-expire after N days; null = never |
Response: 200 OK
{
"user_id": "550e8400-...",
"name": "my_app_callback_token",
"status": "created"
}
Errors: 400 (missing value), 403 (not admin), 503 (secrets store not available)
Example — application backend provisioning a callback token:
# Admin creates a user
curl -X POST https://ironclaw.example.com/api/admin/users \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"display_name": "Alice", "role": "member"}'
# Response includes: {"id": "alice-uuid", "token": "alice-bearer-token", ...}
# Admin provisions a per-user callback secret
curl -X PUT https://ironclaw.example.com/api/admin/users/alice-uuid/secrets/app_callback_token \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"value": "per-user-jwt-for-alice", "provider": "my-app"}'
# Now Alice's IronClaw agent can use the "app_callback_token" secret
# when calling tools that need to authenticate back to the app backend.
GET /api/admin/users/{user_id}/secrets
List a user's secrets. Returns names and providers only — never values or hashes.
Auth: Admin
Response: 200 OK
{
"user_id": "550e8400-...",
"secrets": [
{"name": "app_callback_token", "provider": "my-app"},
{"name": "openai_api_key", "provider": "openai"}
]
}
DELETE /api/admin/users/{user_id}/secrets/{name}
Delete a specific secret for a user.
Auth: Admin
Response: 200 OK
{
"user_id": "550e8400-...",
"name": "app_callback_token",
"deleted": true
}
Errors: 404 (secret not found), 403 (not admin), 503 (secrets store not available)
Admin: Usage
GET /api/admin/usage
Per-user LLM usage statistics aggregated from llm_calls via agent_jobs.user_id.
Auth: Admin
Query parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
user_id |
string | all users | Filter to a single user |
period |
string | "day" |
"day" (24h), "week" (7d), or "month" (30d) |
Response: 200 OK
{
"period": "week",
"since": "2026-03-18T12:00:00+00:00",
"usage": [
{
"user_id": "alice-id",
"model": "claude-sonnet-4-5-20250514",
"call_count": 42,
"input_tokens": 150000,
"output_tokens": 30000,
"total_cost": "1.23"
}
]
}
Self-Service: Profile
GET /api/profile
Get the authenticated user's own profile.
Auth: Any authenticated user
Response: 200 OK
{
"id": "550e8400-...",
"email": "alice@example.com",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00"
}
PATCH /api/profile
Update the authenticated user's own display name and/or metadata.
Auth: Any authenticated user
Request body:
{
"display_name": "Alice Johnson",
"metadata": {"theme": "dark"}
}
Response: 200 OK
{
"id": "550e8400-...",
"display_name": "Alice Johnson",
"updated": true
}
Self-Service: Tokens
POST /api/tokens
Create a new API token for the authenticated user. Admins can optionally create tokens for other users by including user_id.
Auth: Any authenticated user
Request body:
{
"name": "CI pipeline",
"expires_in_days": 90,
"user_id": "other-user-id"
}
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Human-readable label |
expires_in_days |
integer | no | null = never expires |
user_id |
string | no | Admin-only; create token for another user |
Response: 200 OK
{
"token": "a1b2c3d4...64-char hex...",
"id": "token-uuid",
"name": "CI pipeline",
"token_prefix": "a1b2c3d4",
"expires_at": "2026-06-23T12:00:00+00:00",
"created_at": "2026-03-25T12:00:00+00:00"
}
The token field is shown only once.
GET /api/tokens
List the authenticated user's tokens. Token hashes are never returned.
Auth: Any authenticated user
Response: 200 OK
{
"tokens": [
{
"id": "token-uuid",
"name": "CI pipeline",
"token_prefix": "a1b2c3d4",
"expires_at": "2026-06-23T12:00:00+00:00",
"last_used_at": "2026-03-25T14:00:00+00:00",
"created_at": "2026-03-25T12:00:00+00:00",
"revoked_at": null
}
]
}
DELETE /api/tokens/{id}
Revoke one of the authenticated user's tokens. Users can only revoke their own tokens.
Auth: Any authenticated user
Path: id — UUID of the token to revoke
Response: 200 OK
{
"status": "revoked",
"id": "token-uuid"
}
Errors: 400 (invalid UUID), 404 (token not found or belongs to another user)
Responses API
Routes through the full agent loop — tools, memory, safety, and server-side conversation state are all active. Compatible with any standard OpenAI SDK via client.responses.create().
Path prefix: The canonical path is /api/v1/responses, matching the rest of IronClaw's HTTP surface. The older /v1/responses path is retained as an alias for backward compatibility with clients that were already configured against it (see ironclaw#2201). Both paths accept identical request bodies and return identical responses; new integrations should target /api/v1/responses.
Auth: Any authenticated user (Authorization: Bearer <token>)
POST /api/v1/responses
Alias: POST /v1/responses (legacy; kept for backward compatibility).
Create a response.
Request body:
{
"model": "default",
"input": "What's a good time to send money to India?",
"stream": true,
"previous_response_id": null
}
| Field | Type | Notes |
|---|---|---|
model |
string | Currently only "default" is accepted; the server-selected model is determined by gateway configuration. Any other value returns 400. |
input |
string or array | User message as a string, or a messages array (see below) |
stream |
boolean | true for SSE, false for blocking (120s timeout) |
previous_response_id |
string | Pass the previous id to continue a conversation thread |
Messages array input: input may be passed as an array instead of a string:
{
"model": "default",
"input": [
{ "role": "user", "content": "What is 2+2? Reply with just the number." }
]
}
Response (non-streaming): 200 OK
{
"id": "resp_<uuid>",
"object": "response",
"created_at": 1743000000,
"model": "claude-sonnet-4-5-20250514",
"status": "completed",
"output": [
{
"type": "message",
"id": "msg_<uuid>",
"role": "assistant",
"content": [{ "type": "output_text", "text": "Based on today's rate..." }]
}
],
"usage": {
"input_tokens": 320,
"output_tokens": 95,
"total_tokens": 415
}
}
Streaming: When stream: true, IronClaw returns SSE events:
| Event | Description |
|---|---|
response.created |
Response object created, stream begins |
response.output_item.added |
New output item started (message or tool call) |
response.output_text.delta |
Text chunk |
response.output_item.done |
Output item finalized |
response.completed |
Full response done |
response.failed |
Error or tool approval required |
Multi-turn: Pass previous_response_id from the previous response's id to continue in the same thread. IronClaw decodes the thread UUID statelessly from the response ID — no lookup table required.
Status values: completed | failed
Structured context
The input field accepts a structured context object alongside the user message. Use this to pass machine-readable state that the agent should act on, without relying solely on natural language.
Request:
{
"model": "default",
"input": "Go ahead with the transfer",
"previous_response_id": "resp_...",
"x_context": {
"notification_response": {
"notification_id": "msg_123",
"action": "approved",
"original_signal": "convert_now",
"score": 72,
"rate": 85.42,
"amount_usd": 1000
}
}
}
The agent receives the context prepended to the user message:
[Context: notification_response — notification_id: msg_123, action: approved,
original_signal: convert_now, score: 72, rate: 85.42, amount_usd: 1000]
Go ahead with the transfer
The context payload is persisted in message metadata for auditability.
GET /api/v1/responses/{id}
Alias: GET /v1/responses/{id} (legacy; kept for backward compatibility).
Retrieve a historical response reconstructed from conversation messages in the database. Users can only retrieve their own responses.
Auth: Any authenticated user
Response: 200 OK — same shape as the create response object above.
Errors: 400 (empty input), 401 (missing or invalid bearer token), 404 (response ID not found or not owned by caller), 500 (internal error)
Error Format
Most endpoints (admin, profile, tokens, settings, memory, jobs, routines, skills, extensions) return a plain text body with the error message and the corresponding HTTP status code.
The Responses API (POST /api/v1/responses, GET /api/v1/responses/{id}, and their legacy /v1/... aliases) returns an OpenAI-compatible JSON envelope for errors generated by the handler itself:
{
"error": {
"message": "human-readable description",
"type": "invalid_request_error"
}
}
An optional code string field may appear when the server can attach a machine-readable subtype; it is omitted when absent. Error type values used by the current handler: invalid_request_error (400), rate_limit_error (429), server_error (500), service_unavailable (503). Clients should be tolerant of additional OpenAI-conventional types appearing in the future.
Middleware exception: requests rejected before the handler runs — missing or invalid bearer token (401), service-unavailable during auth setup (503) — are emitted by the shared gateway auth middleware as plain-text bodies, not JSON envelopes. Clients should therefore treat non-JSON 401/503 responses as expected and handler-level failures as JSON.
| Code | Meaning |
|---|---|
400 |
Bad request (missing fields, invalid input) |
401 |
Missing or invalid bearer token |
403 |
Authenticated but insufficient role (member accessing admin endpoint) |
404 |
Resource not found |
429 |
Rate limit exceeded (Responses API) |
503 |
Database or secrets store not available |
500 |
Internal server error |
Security Model
Secrets Encryption
- Algorithm: AES-256-GCM with per-secret HKDF-SHA256 derived keys
- Master key: 32+ bytes, resolved from
SECRETS_MASTER_KEYenv var or OS keychain - Storage format:
nonce (12B) || ciphertext || tag (16B)inencrypted_valuecolumn - Per-secret salt: 32 random bytes stored alongside the ciphertext
- Zero-exposure: Plaintext never appears in logs, debug output, API responses, or LLM conversations
Auth Cache
- Bounded LRU cache (1024 entries max)
- 60-second TTL per entry
- Suspending a user or revoking a token takes up to 60s to propagate
Database Schema
users
| Column | Type (PG / libSQL) | Notes |
|---|---|---|
id |
TEXT / TEXT |
Primary key; typically UUID v4 strings (bootstrap admin may use a custom ID) |
email |
TEXT UNIQUE |
Nullable |
display_name |
TEXT NOT NULL |
|
status |
TEXT NOT NULL |
"active" or "suspended" |
role |
TEXT NOT NULL |
"admin" or "member" |
created_at |
TIMESTAMPTZ / TEXT |
|
updated_at |
TIMESTAMPTZ / TEXT |
|
last_login_at |
TIMESTAMPTZ / TEXT |
Nullable |
created_by |
TEXT |
Nullable, references users.id |
metadata |
JSONB / TEXT |
Default {} |
api_tokens
| Column | Type (PG / libSQL) | Notes |
|---|---|---|
id |
UUID / TEXT |
Primary key |
user_id |
TEXT NOT NULL |
FK to users.id (PG cascades; libSQL explicit cleanup) |
token_hash |
BYTEA / BLOB |
SHA-256 of hex-encoded plaintext |
token_prefix |
TEXT NOT NULL |
First 8 chars for identification |
name |
TEXT NOT NULL |
Human-readable label |
expires_at |
TIMESTAMPTZ / TEXT |
Nullable |
last_used_at |
TIMESTAMPTZ / TEXT |
Nullable |
created_at |
TIMESTAMPTZ / TEXT |
|
revoked_at |
TIMESTAMPTZ / TEXT |
Nullable; set on revocation |
secrets
| Column | Type (PG / libSQL) | Notes |
|---|---|---|
id |
UUID / TEXT |
Primary key |
user_id |
TEXT NOT NULL |
Scoped to user |
name |
TEXT NOT NULL |
Unique per user (lowercase normalized) |
encrypted_value |
BYTEA / BLOB |
AES-256-GCM (nonce + ciphertext + tag) |
key_salt |
BYTEA / BLOB |
Per-secret HKDF salt |
provider |
TEXT |
Optional grouping tag |
expires_at |
TIMESTAMPTZ / TEXT |
Nullable |
last_used_at |
TIMESTAMPTZ / TEXT |
Audit: last injection time |
usage_count |
BIGINT / INTEGER |
Audit: total injections |
created_at |
TIMESTAMPTZ / TEXT |
|
updated_at |
TIMESTAMPTZ / TEXT |