Skip to content

API Reference

All API endpoints require authentication unless noted otherwise. Unauthenticated requests receive a 401 Unauthorized response. Authenticate by including a valid session cookie (set during login via the web UI).

Some endpoints are admin only — non-admin users receive a 403 Forbidden response.

Agent chat is handled over WebSocket at /api/ws, not via REST. The browser sends JSON messages and receives streaming responses. For details on the WebSocket protocol, message types, and session management, see the Architecture page.

Service health check. Public — no authentication required. Suitable for load-balancer probes and uptime monitoring.

Response: 200 OK whenever the Pinchy web process is running.

{
"status": "ok",
"secrets": {
"encryption_key": "envvar",
"auth_secret": "envvar",
"audit_hmac_secret": "envvar",
"db_password": "default"
},
"openclaw": { "connected": true }
}

openclaw.connected reflects whether the web process currently has a live WebSocket connection to the OpenClaw gateway — the dependency chat relies on. Top-level status deliberately stays "ok" even when openclaw.connected is false: brief disconnects during OpenClaw restarts (for example while applying a config change) are expected and self-heal, and flipping status would make the Docker healthcheck restart-loop the container during normal operation. Monitor openclaw.connected directly (or poll /api/health/openclaw) if you need alerting on gateway reachability specifically.

OpenClaw gateway reachability check. Public — no authentication required.

Useful for monitoring the WebSocket gateway separately from the web process.

Response:

{
"status": "ok",
"connected": true,
"configPushesPending": 0
}

configPushesPending is the number of configuration updates Pinchy has accepted but not yet confirmed inside the OpenClaw runtime (for example while OpenClaw's config-apply rate limit defers an update). A value of 0 means the runtime reflects every change you've made — useful for automation that needs to act right after a settings or permission change. While the gateway restarts, the response is { "status": "restarting", "connected": false, "since": <timestamp> } instead.

Setup endpoints handle the initial installation and first-admin account. They are accessible only before setup has completed — once an admin exists, they return 409 Conflict.

Check whether setup is required.

Response:

{
"setupComplete": false,
"providerConfigured": false
}

Create the first admin account.

Request body:

| Field | Type | Required | Description | | ---------- | ------ | -------- | ---------------------------- | | name | string | Yes | Admin display name | | email | string | Yes | Admin email address | | password | string | Yes | Admin password (min 8 chars) |

Response: 201 Created. A session cookie is set — the admin is logged in immediately.

Configure the first LLM provider as part of the setup flow.

Request body:

| Field | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------------------------------------- | | provider | string | Yes | anthropic, openai, google, ollama-cloud, ollama-local | | apiKey | string | Cond. | Required for cloud providers | | baseUrl | string | Cond. | Required for ollama-local |

Response: { "success": true }

List agents visible to the current user. Admins see all agents. Non-admin users see shared agents and their own personal agents.

Response:

[
{
"id": "uuid",
"name": "HR Policy Assistant",
"model": "anthropic/claude-haiku-4-5-20251001",
"templateId": "knowledge-base",
"allowedTools": ["pinchy_ls", "pinchy_read"],
"pluginConfig": {
"allowed_paths": ["/data/hr-policies"]
},
"isPersonal": false,
"ownerId": "user-uuid",
"createdAt": "2025-01-15T10:00:00.000Z",
"updatedAt": "2025-01-15T10:00:00.000Z"
}
]

Create a new agent. Admin only. The agent inherits default tool permissions from its template. Use PATCH /api/agents/:id to configure permissions after creation.

Request body:

| Field | Type | Required | Description | | --------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | name | string | Yes | Display name for the agent | | templateId | string | Yes | Template to use. Call GET /api/templates for the full live list (Knowledge Base, custom, document-analysis, Odoo, and email families). | | tagline | string | No | Short subtitle shown under the agent name. Falls back to the template's default tagline. | | pluginConfig | object | No | Plugin configuration, e.g. { "pinchy-files": { "allowed_paths": ["/data/hr"] } }. Required for file-access templates. | | connectionId | string | No | Integration connection to attach. Required for Odoo and email templates. | | defaultAllowedTools | string[] | No | Extra tool IDs to allow on creation, merged with the template's defaults. |

Example — Knowledge Base agent:

{
"name": "HR Policy Assistant",
"templateId": "knowledge-base"
}

Example — Custom agent:

{
"name": "General Assistant",
"templateId": "custom"
}

Response: 201 Created with the created agent object.

Errors:

  • 400 — missing or invalid name or templateId, or a template requirement is unmet (for example, a file template without a selected directory, or an Odoo/email template without a connectionId)
  • 401 — not authenticated
  • 403 — not an admin
  • 422template_capability_unavailable: the default provider has no model with the capabilities the template requires. The body lists what's missing:
{
"error": "template_capability_unavailable",
"message": "Template requires vision but provider ollama-local has no matching model.",
"missingCapabilities": ["vision"],
"docsUrl": "https://docs.heypinchy.com/guides/ollama-setup#models-for-agent-templates"
}

Get a single agent by ID.

Response: The agent object, or 404 if not found.

Update an agent's settings. Any combination of fields can be included.

Request body:

| Field | Type | Required | Description | | -------------- | -------- | -------- | ------------------------------------------------------------------------------- | | name | string | No | New display name | | model | string | No | New model identifier | | allowedTools | string[] | No | List of tool IDs the agent can use (admin only) | | pluginConfig | object | No | Plugin configuration, e.g. { "allowed_paths": ["/data/hr"] } (admin only) |

Example — update permissions:

{
"allowedTools": ["pinchy_ls", "pinchy_read"],
"pluginConfig": {
"allowed_paths": ["/data/hr-policies"]
}
}

Response: The updated agent object.

Errors:

  • 400 — cannot change permissions for personal agents
  • 401 — not authenticated
  • 403 — only admins can change allowedTools or pluginConfig
  • 404 — agent not found

Delete an agent. Admin only. Personal agents (auto-created Smithers) cannot be deleted.

Response: 200 OK

{
"success": true
}

Errors:

  • 400 — attempting to delete a personal agent
  • 401 — not authenticated
  • 403 — not an admin
  • 404 — agent not found

Stage a file for an upcoming chat message. The file is held in the agent's staging area; the chat sends the returned id as one of its attachmentIds rather than inlining the file. Anyone with access to the agent can upload.

Headers:

| Header | Required | Description | | ------------------- | -------- | ---------------------------------------------------- | | x-pinchy-draft-id | Yes | UUID grouping uploads for the message being composed |

Request body: multipart/form-data with a single file field.

Accepted types: images (image/jpeg, image/png, image/webp, image/gif, image/heic, image/heif), application/pdf, and text formats (text/plain, text/csv, text/markdown, application/json, text/yaml). The detected content type must match the declared one. Maximum file size is 15 MB.

Response: 201 Created

{
"id": "upload-uuid",
"filename": "report.pdf",
"mimeType": "application/pdf",
"sizeBytes": 51234
}

Send id in the chat message's attachmentIds (up to 10 attachments per message). Staged files expire after 24 hours and are removed by an hourly garbage-collection sweep if never attached.

Errors:

  • 400 — missing or invalid x-pinchy-draft-id, malformed form data, missing file field, or invalid filename
  • 401 — not authenticated
  • 403 / 404 — no access to the agent
  • 413 — file exceeds the 15 MB limit
  • 415 — unsupported or mismatched file type

List available agent templates.

Response:

{
"templates": [
{
"id": "knowledge-base",
"name": "Knowledge Base",
"description": "Answer questions from your docs"
},
{
"id": "custom",
"name": "Custom Agent",
"description": "Start from scratch"
},
{
"id": "odoo-crm-sales-assistant",
"name": "CRM & Sales Assistant",
"description": "Pipeline management, quotation building with fiscal positions"
}
]
}

The full registry includes one Knowledge Base template, one Custom template, five document-analysis templates, three email templates, and 20+ Odoo templates. The response above is truncated — call the endpoint for the live list. Template IDs are stable across releases for templates that ship in stock builds.

List directories available under /data/ for agent configuration.

Response:

{
"directories": [
{ "path": "/data/hr-policies", "name": "hr-policies" },
{ "path": "/data/engineering-docs", "name": "engineering-docs" }
]
}

Returns an empty array if /data/ does not exist or contains no subdirectories. Hidden directories (starting with .) are excluded.

Get all application settings. Encrypted values (such as API keys) are masked in the response.

Response:

[
{ "key": "default_provider", "value": "anthropic", "encrypted": false },
{ "key": "anthropic_api_key", "value": "--------", "encrypted": true }
]

Update a setting.

Request body:

| Field | Type | Required | Description | | ------- | ------ | -------- | ------------- | | key | string | Yes | Setting key | | value | string | Yes | Setting value |

Settings with api_key in the key name are automatically encrypted at rest.

Response: { "success": true }

List configured LLM providers. Credentials are masked.

Response:

[
{ "provider": "anthropic", "configured": true, "isDefault": true },
{ "provider": "openai", "configured": true, "isDefault": false }
]

Add or update a provider.

Request body:

| Field | Type | Required | Description | | ----------- | ------- | -------- | ------------------------------------------- | | provider | string | Yes | Provider identifier | | apiKey | string | Cond. | API key (required for cloud providers) | | baseUrl | string | Cond. | URL (required for ollama-local) | | isDefault | boolean | No | Mark as the default provider for new agents |

Pinchy validates the credentials by calling the provider's /models endpoint before persisting. API keys are encrypted at rest with AES-256-GCM.

DELETE /api/settings/providers?provider=<id>

Section titled “DELETE /api/settings/providers?provider=<id>”

Remove a provider. Any agent still using a model from this provider will fail to start chats until reassigned.

List available models across all configured providers. Populates the model dropdown in agent settings.

Response: an array of providers, each with its available models.

{
"providers": [
{
"id": "anthropic",
"name": "Anthropic",
"models": [
{ "id": "anthropic/claude-sonnet-4-6", "name": "Claude Sonnet 4.6" }
]
}
]
}

Each model carries an optional compatible boolean and an incompatibleReason string when the model cannot be used (for example, a missing capability). The response is cached for one hour for cloud providers. Local Ollama is always fetched live.

Capability flags for every model in the catalog. Used to gate model selection against the capabilities a template needs.

Response: a map keyed by <provider>/<modelId>. Each value lists the model's capabilities as booleans.

{
"anthropic/claude-sonnet-4-6": {
"vision": true,
"documents": true,
"audio": false,
"video": false,
"longContext": true,
"tools": true
}
}

Backed by the models table, so it reflects the catalog seed and any provider the admin has added or removed. The response is sent with Cache-Control: private, max-age=60.

Get the current domain lock status.

Response:

{
"lockedDomain": "pinchy.example.com",
"insecureMode": false
}

Set or clear the locked domain. See Lock Pinchy to a Domain for the full flow and safety guarantees.

Request body:

| Field | Type | Required | Description | | -------------- | ------------ | -------- | ---------------------------------------------------------------- | | lockedDomain | string|null | Yes | Canonical HTTPS hostname. Pass null to disable domain locking. |

Get the organization context. Admin only.

Response:

{
"content": "Acme Corp is a SaaS company focused on..."
}

Returns { "content": "" } if no org context has been set.

Update the organization context. This is synced to all shared agent workspaces and triggers a runtime restart.

Request body:

| Field | Type | Required | Description | | --------- | ------ | -------- | ------------------------------- | | content | string | Yes | Organization context (Markdown) |

Response: { "success": true }

Errors:

  • 400 — content is not a string
  • 401 — not authenticated
  • 403 — not an admin

List all users.

Response:

{
"users": [
{
"id": "uuid",
"name": "Alice",
"email": "alice@example.com",
"role": "admin",
"banned": false,
"groups": [{ "id": "group-uuid", "name": "Engineering" }]
}
]
}

Deactivate a user (soft delete — sets banned status). Their personal agents are removed from the shared view but the account record is retained and can be reactivated via POST /api/users/:id/reactivate.

Response: 200 OK

{
"success": true
}

Errors:

  • 400 — attempting to delete yourself
  • 401 — not authenticated
  • 403 — not an admin
  • 404 — user not found

Update a user's role. Admin only.

Request body:

| Field | Type | Required | Description | | ------ | ------ | -------- | ------------------------------ | | role | string | Yes | New role (admin or member) |

Response:

{
"success": true
}

Errors:

  • 400 — invalid role, attempting to change your own role, or demoting the last admin
  • 401 — not authenticated
  • 403 — not an admin
  • 404 — user not found

Reactivate a deactivated user. Admin only.

Response:

{
"success": true
}

Errors:

  • 401 — not authenticated
  • 403 — not an admin
  • 404 — user not found or user is not deactivated

Generate a password reset token for a user. The admin constructs the reset URL from the returned token: {origin}/invite/{token}.

Response: 201 Created

{
"token": "abc123..."
}

Errors:

  • 401 — not authenticated
  • 403 — not an admin
  • 404 — user not found

Create an invite for a new user. Returns the full invite object including a plaintext token (shown only once).

Request body:

| Field | Type | Required | Description | | ---------- | -------- | -------- | ----------------------------------------------------------- | | role | string | Yes | Role for the invited user (admin or member) | | email | string | No | Email address of the invited user (optional, for reference) | | groupIds | string[] | No | Group IDs to assign the user to on account creation |

Response: 201 Created

{
"id": "uuid",
"token": "abc123...",
"email": "bob@example.com",
"role": "member",
"type": "invite",
"groups": [{ "id": "group-uuid", "name": "Engineering" }],
"expiresAt": "2025-01-22T10:00:00.000Z",
"createdAt": "2025-01-15T10:00:00.000Z"
}

The admin constructs the invite URL from the returned token: {origin}/invite/{token}.

Errors:

  • 400 — missing or invalid role
  • 401 — not authenticated
  • 403 — not an admin, or seat cap reached (enterprise license maxUsers limit exceeded — see GET /api/enterprise/status)

List all invites and their status.

Response:

[
{
"id": "uuid",
"email": "bob@example.com",
"role": "member",
"status": "pending",
"groups": [{ "id": "group-uuid", "name": "Engineering" }],
"expiresAt": "2025-01-22T10:00:00.000Z",
"createdAt": "2025-01-15T10:00:00.000Z"
}
]

Revoke a pending invite.

Response: 200 OK

{
"success": true
}

Errors:

  • 401 — not authenticated
  • 403 — not an admin
  • 404 — invite not found

Claim an invite token to create a new account or reset a password. This endpoint does not require authentication.

Request body:

| Field | Type | Required | Description | | ---------- | ------ | -------- | ----------------------------------- | | token | string | Yes | The invite/reset token from the URL | | name | string | Yes | Display name for the new user | | password | string | Yes | Password (min 8 characters) |

Response: 201 Created

{
"success": true
}

Errors:

  • 400 — missing fields, invalid token, or token expired

Connect agents to Telegram bots so users can chat with them from their phone. See Set Up Telegram for the end-to-end flow.

GET /api/agents/:agentId/channels/telegram

Section titled “GET /api/agents/:agentId/channels/telegram”

Get the Telegram bot configuration for an agent. Admin only.

Response (not configured):

{ "configured": false, "mainBotConfigured": true }

Response (configured):

{ "configured": true, "hint": "8a2f", "mainBotConfigured": true }

hint is the last 4 characters of the bot token for visual verification. mainBotConfigured indicates whether the global Smithers/main bot is set up — a prerequisite before any other agent can connect to Telegram.

POST /api/agents/:agentId/channels/telegram

Section titled “POST /api/agents/:agentId/channels/telegram”

Connect an agent to a Telegram bot. Admin only. The main bot must be configured first (exception: the main bot itself).

Request body:

| Field | Type | Required | Description | | ---------- | ------ | -------- | --------------------------- | | botToken | string | Yes | Bot token from @BotFather |

Response:

{ "botUsername": "support_pinchy_bot", "botId": 123456789 }

Errors:

  • 400 — invalid or non-working bot token
  • 409telegram_not_configured (main bot missing), or the bot token is already used by another agent

DELETE /api/agents/:agentId/channels/telegram

Section titled “DELETE /api/agents/:agentId/channels/telegram”

Disconnect a Telegram bot from an agent. Admin only. Personal (Smithers) agents cannot be disconnected individually — use the "Remove Telegram for everyone" action instead.

Get the current user's Telegram link status. Available to any authenticated user.

Response:

{ "linked": true, "channelUserId": "123456789" }

Link the current user's Telegram account using a pairing code received from the bot.

Request body:

| Field | Type | Required | Description | | ------ | ------ | -------- | ----------------------------------------------- | | code | string | Yes | Pairing code shown in Telegram after DM-ing bot |

Response: { "linked": true, "telegramUserId": "..." }

Errors:

  • 400 — invalid or expired pairing code

Unlink the current user's Telegram account.

List all Telegram bots connected across agents, with the agent each bot belongs to. Admin only.

Remove Telegram from every agent at once. Admin only. This is the backend for the "Remove Telegram for everyone" flow in Settings → Telegram.

External system integrations — Odoo, email (Gmail and Microsoft 365 via OAuth), and Web Search. See Integrations for the conceptual overview, Connect Odoo for the Odoo setup guide, and Connect Email for the email setup guide.

List all integration connections with masked credentials.

Create a new integration connection.

Request body (Odoo):

{
"type": "odoo",
"name": "Production Odoo",
"description": "Main company instance",
"credentials": {
"url": "https://odoo.example.com",
"db": "production",
"login": "api-user@example.com",
"apiKey": "..."
}
}

Credentials are encrypted at rest with AES-256-GCM.

Response: 201 Created — the created connection with masked credentials.

Errors:

  • 400 — validation failed (bad URL, missing fields) or the URL resolves to a disallowed address (SSRF guard)

Get a single connection (credentials masked).

Update a connection's name, description, or credentials.

Delete a connection. Agents that reference it lose access immediately.

Re-probe the external system: rediscover available models and recheck access rights. Permissions that no longer apply are removed.

Test credentials against the external system without persisting changes. Used by the setup wizard for live feedback.

Get an agent's integration permissions: which connections are enabled, access level (read-only, read-write, full, custom), and which data models are allowed.

Replace all permissions for this agent on a connection.

Request body:

| Field | Type | Required | Description | | -------------- | ------ | -------- | ------------------------------------------------------------------------ | | connectionId | string | Yes | The connection this permission set applies to | | permissions | array | Yes | { model: string, operation: string } entries — the full set to persist |

This is a full replace, not a merge: the previous permission rows for this agent/connection pair are deleted and the submitted permissions array is inserted in their place. Pass an empty array to revoke every permission on that connection without clearing the connection selection itself.

Remove all integration permissions for this agent, across every connection. Used when a connection is cleared from the agent's Permissions tab.

GET /api/integrations/oauth/start?provider=<google|microsoft>

Section titled “GET /api/integrations/oauth/start?provider=<google|microsoft>”

Browser-driven OAuth entry point. Redirects the admin to the provider's consent screen. Reached by clicking Connect Google / Connect Microsoft in the Add Integration wizard — not intended to be called via fetch.

Programmatic reconnect entry point for a google or microsoft connection that has entered the auth_failed state.

Request body:

| Field | Type | Required | Description | | ----------------------- | ------ | -------- | ------------------------------------ | | reconnectConnectionId | string | Yes | The existing connection to reconnect |

Response: { "url": "<provider consent screen URL>" } for the client to navigate to.

OAuth redirect target for both Google and Microsoft. Exchanges the authorization code for tokens, fetches the mailbox's email address, and persists the connection (creating it on first connect, or updating it in place on reconnect so existing agent permissions stay attached). Not called directly — the provider redirects the browser here after consent. On success, redirects to /settings?tab=integrations&created=<connectionId>; on failure, redirects to /settings?tab=integrations&error=<reason>.

GET /api/settings/oauth?provider=<google|microsoft>

Section titled “GET /api/settings/oauth?provider=<google|microsoft>”

Get whether a provider's OAuth app is configured, its clientId (and tenantId for Microsoft), and how many mailbox connections depend on it. The client secret is never returned.

Create or update a provider's OAuth app credentials (Client ID, Client Secret, and for Microsoft, an optional Tenant ID).

Request body (Google):

{ "provider": "google", "clientId": "...", "clientSecret": "..." }

Request body (Microsoft):

{
"provider": "microsoft",
"clientId": "...",
"clientSecret": "...",
"tenantId": "..."
}

Credentials are encrypted at rest with AES-256-GCM.

DELETE /api/settings/oauth?provider=<google|microsoft>

Section titled “DELETE /api/settings/oauth?provider=<google|microsoft>”

Reset a provider's OAuth app credentials. Existing mailbox connections for that provider are left untouched but will need to be reconnected once a new app is configured.

Summary counts of integration connection health (active, auth_failed, and connections whose credentials cannot be decrypted under the current encryption key). Backs the red ! badge on the Settings sidebar entry.

GET /api/internal/integrations/:connectionId/credentials

Section titled “GET /api/internal/integrations/:connectionId/credentials”

Internal — authenticated with the shared OpenClaw gateway token (Pattern C in AGENTS.md), not a user session. Called by Pinchy plugins at runtime, never by the browser.

Returns the decrypted credentials for a connection, auto-refreshing an expired OAuth access token first (Google and Microsoft rotate refresh tokens differently — see the plugin cache/refetch behavior in the Secret Handling section of AGENTS.md). Called by pinchy-email and pinchy-odoo with the gateway token as Bearer auth; the plugin caches the response, typically with a 5-minute TTL, and invalidates on 401.

POST /api/internal/integrations/:connectionId/report-auth-failure

Section titled “POST /api/internal/integrations/:connectionId/report-auth-failure”

Internal — authenticated with the shared OpenClaw gateway token, not a user session. Called by Pinchy plugins at runtime, never by the browser.

Reports a permanent authentication failure (expired API key, revoked OAuth grant) detected by a plugin at call time. Requires an X-Plugin-Id header naming a known Pinchy plugin, in addition to the gateway token. Transitions the connection to the auth_failed state shown in Settings → Integrations.

Request body: { "reason": string } — a human-readable description of the failure, shown on the failed connection card.

Token usage and estimated cost aggregates. See Usage & Costs Dashboard for the UI.

Per-agent usage totals within a time window.

Query parameters:

| Parameter | Type | Default | Description | | --------- | ------ | ------- | ------------------------------------ | | days | number | 30 | Look-back window. 0 means all-time | | agentId | string | — | Filter by a single agent |

Response:

{
"agents": [
{
"agentId": "uuid",
"agentName": "Smithers",
"totalInputTokens": "15432",
"totalOutputTokens": "3210",
"totalCacheReadTokens": "120",
"totalCacheWriteTokens": "45",
"totalCost": "0.08",
"deleted": false
}
],
"totals": {
"chat": {
"inputTokens": "...",
"outputTokens": "...",
"cacheReadTokens": "...",
"cacheWriteTokens": "...",
"cost": "..."
},
"system": {
"inputTokens": "...",
"outputTokens": "...",
"cacheReadTokens": "...",
"cacheWriteTokens": "...",
"cost": "..."
},
"plugin": {
"inputTokens": "...",
"outputTokens": "...",
"cacheReadTokens": "...",
"cacheWriteTokens": "...",
"cost": "..."
}
}
}

Source buckets classify where the tokens were consumed:

  • chat — direct user ↔ agent conversation
  • system — background work such as Smithers onboarding or summarization
  • plugin — work driven by a plugin subagent call

Usage broken down by user. Enterprise license required.

Usage over time — used to render the dashboard charts. Query parameters: days, agentId, and interval (day or hour).

Export usage records as CSV. Enterprise license required.

Get the current enterprise license status and seat usage. Any authenticated user can call this endpoint (non-admins see it too, so the UI can show licence-gated features correctly).

Response:

{
"enterprise": true,
"type": "paid",
"org": "Acme Corp",
"expiresAt": "2027-01-15T00:00:00.000Z",
"daysRemaining": 263,
"managedByEnv": false,
"seatsUsed": 7,
"maxUsers": 10
}

| Field | Type | Description | | --------------- | -------------- | -------------------------------------------------------------------------------------------------------------- | | enterprise | boolean | Whether an active enterprise license is present | | type | string | null | "trial" or "paid". null when no license is active. | | org | string | null | Organisation name encoded in the license token. null when no license is active. | | expiresAt | string | null | ISO 8601 expiry timestamp. null for non-expiring tokens or when no license is active. | | daysRemaining | number | null | Days until the key expires. null when the key has no expiry or no license is active. | | managedByEnv | boolean | true when the key comes from the PINCHY_ENTERPRISE_KEY environment variable (read-only in the Settings UI) | | seatsUsed | number | Number of seats currently in use: active users + valid pending invitations | | maxUsers | number | Maximum seats allowed by the license. 0 means unlimited. |

Seat cap behaviour: the seat cap is a soft cap with a 20% grace window. When maxUsers > 0, invites keep working up to floor(1.2 * maxUsers) seats (for example 12 on a 10-seat license); in the 100–120% grace band admins see a notice but invites still succeed and nothing is blocked. Only when seatsUsed >= floor(1.2 * maxUsers) is the Invite User button disabled and the API rejects new invitations with 403 (the response includes graceCap). Existing users are never affected. See Enterprise Setup — Seat cap enforcement for the full explanation.

Errors:

  • 401 — not authenticated

Save or update the enterprise license key. Admin only. Not available when the key is managed via the PINCHY_ENTERPRISE_KEY environment variable (managedByEnv: true).

Request body:

| Field | Type | Required | Description | | ----- | ------ | -------- | --------------------- | | key | string | Yes | The JWT license token |

Response: { "success": true } — the new license status takes effect immediately without a restart.

Errors:

  • 400 — key is missing or the JWT signature is invalid
  • 401 — not authenticated
  • 403 — not an admin, or key is managed by env var

All group endpoints require an enterprise license. Requests without a valid license receive a 403 Forbidden response.

List all groups with member counts. Admin only.

Response:

[
{
"id": "uuid",
"name": "Engineering",
"description": "Backend and frontend engineers",
"memberCount": 5
}
]

Create a new group. Admin only.

Request body:

| Field | Type | Required | Description | | ------------- | ------ | -------- | ----------------- | | name | string | Yes | Group name | | description | string | No | Group description |

Response: 201 Created with the created group object.

Errors:

  • 400 — missing or invalid name
  • 401 — not authenticated
  • 403 — not an admin or no enterprise license

Update a group. Admin only.

Request body:

| Field | Type | Required | Description | | ------------- | ------ | -------- | --------------------- | | name | string | No | New group name | | description | string | No | New group description |

Response: The updated group object.

Errors:

  • 400 — invalid fields
  • 401 — not authenticated
  • 403 — not an admin or no enterprise license
  • 404 — group not found

Delete a group. Admin only.

Response:

{
"success": true
}

Errors:

  • 401 — not authenticated
  • 403 — not an admin or no enterprise license
  • 404 — group not found

List members of a group. Admin only.

Response:

[
{
"userId": "user-uuid",
"groupId": "group-uuid"
}
]

Errors:

  • 401 — not authenticated
  • 403 — not an admin or no enterprise license
  • 404 — group not found

Replace the members of a group. Admin only.

Request body:

| Field | Type | Required | Description | | --------- | -------- | -------- | -------------------------------- | | userIds | string[] | Yes | User IDs to set as group members |

Response:

{
"success": true
}

Errors:

  • 400 — invalid or missing userIds
  • 401 — not authenticated
  • 403 — not an admin or no enterprise license
  • 404 — group not found

Retrieve a paginated, filterable audit log. Admin only.

Query parameters:

| Parameter | Type | Default | Description | | ----------- | ------ | ------- | ------------------------------------------------------------- | | page | number | 1 | Page number | | limit | number | 50 | Entries per page | | eventType | string | — | Filter by event type (e.g., auth.login, tool.pinchy_read) | | actorId | string | — | Filter by user ID | | from | string | — | Start date (ISO 8601) | | to | string | — | End date (ISO 8601) |

Response:

{
"entries": [
{
"id": 1,
"timestamp": "2026-02-21T10:00:00.000Z",
"actorType": "user",
"actorId": "user-uuid",
"actorName": "Alice",
"actorDeleted": false,
"eventType": "auth.login",
"resource": "user:user-uuid",
"resourceName": "Alice",
"resourceDeleted": false,
"outcome": "success",
"error": null,
"detail": {},
"rowHmac": "sha256-hex-string"
}
],
"total": 142,
"page": 1,
"limit": 50
}

outcome is one of "success" or "failure". error is null for successes and a short error message for failures (e.g. "seat_cap" on a blocked invite). Both fields are part of the HMAC-signed v2 row shape.

Errors:

  • 401 — not authenticated
  • 403 — not an admin

Verify the integrity of audit log entries by recomputing HMAC signatures. Admin only.

Query parameters:

| Parameter | Type | Default | Description | | --------- | ------ | ------- | ------------------------------------------------ | | fromId | string | — | Start verification from this entry ID (optional) | | toId | string | — | End verification at this entry ID (optional) |

Response:

{
"valid": true,
"totalChecked": 142,
"invalidIds": []
}

If tampered entries are found, valid is false and invalidIds contains the IDs of entries with mismatched signatures.

Errors:

  • 401 — not authenticated
  • 403 — not an admin

List the distinct event types present in the current audit log. Useful for populating filter dropdowns without enumerating the full schema. Admin only.

Response:

{
"eventTypes": [
"auth.login",
"auth.failed",
"auth.csrf_blocked",
"auth.password_reset_completed",
"agent.created",
"agent.memory_changed",
"agent.model_unavailable",
"agent.upstream_format_error",
"channel.created",
"chat.agent_error",
"chat.retry_triggered",
"chat.silent_stream",
"chat.background_run_completed",
"chat.run_timed_out",
"chat.run_completed_after_disconnect",
"chat.run_aborted",
"file.upload.staged",
"file.upload.attached",
"file.upload.expired",
"tool.pinchy_read",
"tool.odoo_read",
"tool.denied"
]
}

The response only contains event types that actually appear in the audit log — it's distinct values from the table, not the full enumerated set. See Audit Trail for the complete schema and detail shapes.

Export the audit log as a CSV file. Supports the same filters as GET /api/audit. Admin only.

Query parameters:

| Parameter | Type | Default | Description | | ----------- | ------ | ------- | --------------------- | | eventType | string | — | Filter by event type | | actorId | string | — | Filter by user ID | | from | string | — | Start date (ISO 8601) | | to | string | — | End date (ISO 8601) |

Response: 200 OK with Content-Type: text/csv and Content-Disposition: attachment; filename="audit-log.csv".

Errors:

  • 401 — not authenticated
  • 403 — not an admin

Update your own display name.

Request body:

| Field | Type | Required | Description | | ------ | ------ | -------- | ---------------- | | name | string | Yes | New display name |

Response:

{
"success": true
}

Errors:

  • 400 — missing or empty name
  • 401 — not authenticated

Get your personal context.

Response:

{
"content": "I'm Alice, a product manager at Acme Corp..."
}

Returns { "content": "" } if no context has been set.

Update your personal context. This is synced to your Smithers agent's workspace and triggers a runtime restart.

Request body:

| Field | Type | Required | Description | | --------- | ------ | -------- | -------------------------------- | | content | string | Yes | Your personal context (Markdown) |

Response: { "success": true }

Errors:

  • 400 — content is not a string
  • 401 — not authenticated

Change your own password.

Request body:

| Field | Type | Required | Description | | ----------------- | ------ | -------- | ------------------------------- | | currentPassword | string | Yes | Current password | | newPassword | string | Yes | New password (min 8 characters) |

Response: { "success": true }

Errors:

  • 400 — missing fields or new password too short
  • 401 — not authenticated or current password incorrect