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 },
"configRegeneration": { "ok": 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.

configRegeneration reports the outcome of the most recent boot-time OpenClaw config regeneration. It is { "ok": true } in normal operation. When a startup regeneration fails — most commonly because an image-only upgrade dropped the openclaw-secrets volume (see Upgrading Pinchy) — it becomes { "ok": false, "error": "<actionable message>", "at": "<ISO timestamp>" }. This is the one signal that catches a frozen config: Pinchy still marks itself healthy so the OpenClaw container can start and hot-reload a fixed config later, so status stays "ok" and openclaw.connected can be true while new providers, agents, and model changes silently never take effect. error carries operator guidance only — never secret material. Alert on configRegeneration.ok == false. A later successful regeneration (for example after you fix the compose file and restart) clears it back to { "ok": true }.

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:

FieldTypeRequiredDescription
namestringYesAdmin display name
emailstringYesAdmin email address
passwordstringYesAdmin 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:

FieldTypeRequiredDescription
providerstringYesanthropic, openai, google, ollama-cloud, ollama-local
apiKeystringCond.Required for cloud providers
urlstringCond.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": ["knowledge_search"],
"pluginConfig": {
"pinchy-files": { "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:

FieldTypeRequiredDescription
namestringYesDisplay name for the agent
templateIdstringYesTemplate to use. Call GET /api/templates for the full live list (Knowledge Base, custom, document-analysis, Odoo, and email families).
taglinestringNoShort subtitle shown under the agent name. Falls back to the template's default tagline.
pluginConfigobjectNoPlugin configuration, e.g. { "pinchy-files": { "allowed_paths": ["/data/hr"] } }. Required for file-access templates.
connectionIdstringNoIntegration connection to attach. Required for Odoo and email templates.
defaultAllowedToolsstring[]NoExtra 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:

FieldTypeRequiredDescription
namestringNoNew display name
modelstringNoNew model identifier
allowedToolsstring[]NoList of tool IDs the agent can use (admin only)
pluginConfigobjectNoPlugin 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:

HeaderRequiredDescription
x-pinchy-draft-idYesUUID 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

See Create a Knowledge Base Agent for the end-to-end setup flow.

POST /api/agents/:agentId/knowledge/reindex

Section titled “POST /api/agents/:agentId/knowledge/reindex”

Queue a (re)index of the agent's granted knowledge-base folders. Admin only. Reads the agent's pinchy-files allowed_paths, extracts text from each PDF, chunks and embeds it with the fixed bge-m3 model, and stores the result in Pinchy's Postgres knowledge-base index. Idempotent: unchanged files are skipped, changed files are replaced, and files removed from disk are dropped from the index.

The request returns as soon as the job is queued — indexing runs in the background, because a real corpus takes hours to embed. Poll GET on the same path for progress and results.

Request body:

FieldTypeRequiredDescription
pathsstring[]NoSubset of the agent's granted folders to reindex. Can only narrow the granted set — any path not already granted is ignored. Omit to reindex every granted folder.

Response — 202 Accepted:

{
"jobId": "6f1c2b64-6c3e-4a2f-9c19-5c1d3a1f2e77",
"status": "pending",
"pathCount": 2
}
FieldDescription
jobIdThe queued job. Pass nothing — GET on the same path reports this agent's most recent job.
statuspending (queued) — or noop when the agent has no granted folders, in which case jobId is null.
pathCountNumber of granted folders the job will reindex.

Errors:

  • 401 — not authenticated
  • 403 — not an admin
  • 404 — agent not found
  • 409 — a reindex is already running. One index job runs at a time across the whole instance, so the run in your way may belong to a different agent. The response carries the running job's jobId, status, and agentGET that agent's reindex endpoint to watch it, rather than retrying.
  • 503 — the local Ollama embedding endpoint isn't configured (see Set Up Local Ollama)

GET /api/agents/:agentId/knowledge/reindex

Section titled “GET /api/agents/:agentId/knowledge/reindex”

The agent's most recent index run — in flight or last finished. Admin only. Returns { "job": null } if the agent has never been reindexed.

Response:

{
"job": {
"id": "6f1c2b64-6c3e-4a2f-9c19-5c1d3a1f2e77",
"status": "succeeded",
"processed": 18,
"total": 18,
"counts": {
"indexed": 3,
"skipped": 12,
"removed": 1,
"unsearchable": 2,
"failed": 0
},
"error": null,
"createdAt": "2026-07-17T10:00:00.000Z",
"startedAt": "2026-07-17T10:00:05.000Z",
"finishedAt": "2026-07-17T10:04:11.000Z"
}
}
FieldDescription
statuspending, running, succeeded, or failed.
processed / totalDocuments done, and documents found. total is null until discovery has walked every folder; it never changes after that. processed advances per whole document, so a long one can hold it still for minutes.
countsThe run's findings so far, updated as it goes. null before the first document.
counts.indexedDocuments added, updated, or rebuilt — and searchable afterwards.
counts.skippedUnchanged content hash, chunks already present.
counts.removedSource file no longer on disk; the document and its chunks were deleted. Stays 0 until the run's final pass.
counts.unsearchableParsed without error but yielded no text, so the document exists in the index yet can never be retrieved (typically a scanned PDF).
counts.failedThe file could not be read or parsed. The run continues — one bad file never aborts the reindex — and a previously indexed version of the file stays searchable.
errorWhy the run failed. null unless status is failed.

unsearchable and failed can be non-zero on a succeeded run: the reindex itself worked, and these are its findings about the corpus.

status: "failed" means something systemic stopped the run — the embedding endpoint or the database — not that a file was broken. A broken file is a counts.failed, and the run keeps going. Failed runs keep the counts they reached before dying.

A run interrupted by a restart is picked up again automatically: indexing is idempotent, so the resumed run skips everything already done.

Every run writes two knowledge.reindex audit entries, correlated by jobId: the admin's request, and the outcome (recorded by the background worker, with the counts).

Errors:

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

Internal — authenticated with the shared OpenClaw gateway token, not a user session. Called by the pinchy-knowledge plugin's knowledge_search tool, never by the browser.

Runs a hybrid (vector + full-text) search over the requesting agent's indexed, granted documents and returns numbered, citable passages. The raw query is never persisted — only a one-way hash, since a knowledge-base question can itself carry PII.

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:

FieldTypeRequiredDescription
keystringYesSetting key
valuestringYesSetting 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:

FieldTypeRequiredDescription
providerstringYesProvider identifier
apiKeystringCond.API key (required for cloud providers)
baseUrlstringCond.URL (required for ollama-local)
isDefaultbooleanNoMark 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, together with the hostname and scheme this request arrived on — which is what the UI compares against to tell you whether locking is safe right now.

Response:

{
"domain": "pinchy.example.com",
"currentHost": "pinchy.example.com",
"isHttps": true
}

domain is null when no domain is locked.

Lock Pinchy to a domain. Takes no request body. The domain is the hostname the request itself arrived on (the outermost hop of X-Forwarded-Host, falling back to Host), and that is deliberate: you can only lock Pinchy to an address you have just proven reachable, so a typo can't lock you out of your own instance. See Lock Pinchy to a Domain for the full flow and safety guarantees.

The request must arrive over HTTPS (X-Forwarded-Proto: https).

Response: { "domain": "pinchy.example.com", "restart": true }

restart: true is not advisory. The process exits half a second later so the secure-cookie settings take effect, and your container runtime restarts it — expect a few seconds of downtime and one dropped WebSocket.

Errors:

  • 400 — the request did not arrive over HTTPS, or no hostname could be determined
  • 401 — not authenticated
  • 403 — not an admin

Unlock the domain. Same restart behaviour as POST.

Response: { "removed": true, "restart": true }

Errors:

  • 400 — no domain is currently locked
  • 401 — not authenticated
  • 403 — not an admin

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:

FieldTypeRequiredDescription
contentstringYesOrganization 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:

FieldTypeRequiredDescription
rolestringYesNew 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:

FieldTypeRequiredDescription
rolestringYesRole for the invited user (admin or member)
emailstringNoEmail address of the invited user (optional, for reference)
groupIdsstring[]NoGroup 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:

FieldTypeRequiredDescription
tokenstringYesThe invite/reset token from the URL
namestringYesDisplay name for the new user
passwordstringYesPassword (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:

FieldTypeRequiredDescription
botTokenstringYesBot 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:

FieldTypeRequiredDescription
codestringYesPairing 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:

FieldTypeRequiredDescription
connectionIdstringYesThe connection this permission set applies to
permissionsarrayYes{ 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:

FieldTypeRequiredDescription
reconnectConnectionIdstringYesThe 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, pinchy-odoo and pinchy-web with the gateway token as Bearer auth; the plugin caches the response, typically with a 5-minute TTL, and invalidates on 401.

The calling plugin must name its agent: ?agentId=<id> is required, and the request is refused unless that agent was actually granted the connection. The gateway token is one shared secret written into every plugin's config block, so on its own it identifies the container, not the caller — without this check any plugin could name any connectionId and receive a decrypted password. Odoo and mailbox connections are checked against the agent's integration grants; the instance-wide web-search connection is checked against the agent's pinchy_web_search / pinchy_web_fetch tool grant, because it is deliberately shared by every agent and has no per-agent grant rows.

Query parameterRequiredDescription
agentIdyesThe agent the calling plugin is acting for
StatusMeaning
200Credentials returned
400agentId missing
401Gateway token missing or wrong
403The agent is not granted this connection, or the connection is still pending
404The connection no longer exists — the body says so in words an admin can act on, because plugins surface it into the tool error
500The stored credentials could not be decrypted under the current encryption key
503An OAuth access token expired and the app's OAuth settings are gone, so it cannot be refreshed. Deliberately not a 200 carrying stale tokens

A refusal for a missing grant writes an integration.credentials_denied audit row naming the agent and the connection — see Audit Trail. A successful fetch is not audited: plugins re-fetch on every cache miss, so a row per success would be volume without signal.

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:

ParameterTypeDefaultDescription
daysnumber30Look-back window. 0 means all-time
agentIdstringFilter 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
}
FieldTypeDescription
enterprisebooleanWhether an active enterprise license is present
typestring | null"trial" or "paid". null when no license is active.
orgstring | nullOrganisation name encoded in the license token. null when no license is active.
expiresAtstring | nullISO 8601 expiry timestamp. null for non-expiring tokens or when no license is active.
daysRemainingnumber | nullDays until the key expires. null when the key has no expiry or no license is active.
managedByEnvbooleantrue when the key comes from the PINCHY_ENTERPRISE_KEY environment variable (read-only in the Settings UI)
seatsUsednumberNumber of seats currently in use: active users + valid pending invitations
maxUsersnumberMaximum 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:

FieldTypeRequiredDescription
keystringYesThe 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:

FieldTypeRequiredDescription
namestringYesGroup name
descriptionstringNoGroup 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:

FieldTypeRequiredDescription
namestringNoNew group name
descriptionstringNoNew 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:

FieldTypeRequiredDescription
userIdsstring[]YesUser 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:

ParameterTypeDefaultDescription
pagenumber1Page number
limitnumber50Entries per page
eventTypestringFilter by event type (e.g., auth.login, tool.pinchy_read)
actorIdstringFilter by user ID
fromstringStart date (ISO 8601)
tostringEnd 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:

ParameterTypeDefaultDescription
fromIdstringStart verification from this entry ID (optional)
toIdstringEnd 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",
"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.

Report that a chat run finished while the user was away from the chat page. Called by the chat UI, not by agents — it writes one chat.background_run_completed audit entry for the named agent.

Request body:

FieldTypeRequiredDescription
agentIdstringYesAgent the run belongs to. The caller must have access to it.
durationMsnumberYesWall-clock run duration, in milliseconds. Capped at 10 minutes.

Response: 204 No Content. Returns 400 if durationMs is negative or above the cap, 404 if the agent doesn't exist, and 403 if the caller has no access to it.

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

Query parameters:

ParameterTypeDefaultDescription
eventTypestringFilter by event type
actorIdstringFilter by user ID
fromstringStart date (ISO 8601)
tostringEnd 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:

FieldTypeRequiredDescription
namestringYesNew 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:

FieldTypeRequiredDescription
contentstringYesYour personal context (Markdown)

Response: { "success": true }

Errors:

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

Change your own password.

Request body:

FieldTypeRequiredDescription
currentPasswordstringYesCurrent password
newPasswordstringYesNew password (min 8 characters)

Response: { "success": true }

Errors:

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