External / Partner API
The /v1/external surface is for applications that act on behalf of their own end users — a partner backend integrating Xagent, or an Xagent chat widget embedded in someone else's product. It is a different audience from the Workspace SDK: that surface authenticates your workspace with an xag_… key, whereas this one authenticates an application plus the end user it is acting for.
Availability is per-tenant
This surface is made up of two capabilities that are enabled independently — external tasks (tasks, files and delegated OAuth credentials) and widget sessions (embedded browser chat). Ask your account contact which are enabled for your tenant. If one is off, its endpoints return 404.
The Two Callers
| Caller | How it authenticates | Credential it starts with |
|---|---|---|
| Partner backend | Exchanges client credentials for a bearer token at POST /v1/external/oauth/token | client_id + client_secret |
| Embedded browser widget | Exchanges a signed, encrypted context grant for a session token at POST /v1/external/chat/sessions | A grant your own backend mints |
Get a Token — POST /v1/external/oauth/token
Standard OAuth 2.0 client-credentials exchange. Send it as form-encoded data (application/x-www-form-urlencoded) — JSON is rejected.
| Field | Required | Notes |
|---|---|---|
grant_type | Yes | Must be client_credentials. |
scope | No | Space-separated agent:<id> entries. Omit to receive every agent granted to your application. |
client_id / client_secret | Conditional | Supply either HTTP Basic auth or both body fields — never both at once. |
curl -X POST https://your-domain.com/v1/external/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&scope=agent:42"Response (200): the token is valid for two hours.
{
"access_token": "eyJhbGciOiJFZERTQSIs...",
"token_type": "Bearer",
"expires_in": 7200,
"scope": "agent:42"
}This endpoint alone uses the OAuth error format, not the envelope used elsewhere on this surface:
{ "error": "invalid_client", "error_description": "Client authentication failed." }| Status | error | Cause |
|---|---|---|
| 400 | invalid_request | Malformed form body, wrong content type, duplicate fields, or credentials supplied in both places. |
| 400 | unsupported_grant_type | grant_type is not client_credentials. |
| 400 | invalid_scope | Requested scope is malformed or exceeds what your application was granted. |
| 401 | invalid_client | Unknown client, wrong secret, revoked application, or no usable agent grant. |
Do not branch on error text
error_description is diagnostic text that may change, and several distinguishable causes deliberately return identical messages so credentials cannot be probed. Branch on the status and the error code only. The 413 and 429 responses on this surface are produced by the proxy and their bodies are not stable — branch on the status alone.
Calling the API
Every other /v1/external request carries two headers — the token, and the end user you are acting for:
Authorization: Bearer <access_token>
X-Actor-Id: <your own opaque user id>X-Actor-Id is your identifier for the user, up to 255 characters — never an Xagent user id. Xagent creates the actor record the first time it sees one. The older X-End-User-Id header is rejected.
Verifying Signatures — JWKS
GET /.well-known/jwks.jsonPublished key set, cacheable for five minutes. It contains two kinds of key: use: "sig" (Ed25519) for verifying signatures on refresh webhooks Xagent sends you, and use: "enc" (RSA) for encrypting widget grants before you send them. Select a signing key by matching the kid from the signature header — never by position in the array. For encryption, any listed enc entry is valid; copy its kid into your header.
Delegated OAuth Credentials
If your users connect third-party accounts, you push their short-lived access tokens to Xagent so agents can act with them. Xagent encrypts each token and stores it against the actor. These endpoints never return a token.
Push a credential — PUT /v1/external/oauth-tokens
Returns 204. The body takes one of two shapes and must match exactly one of them:
| Form | Fields | Use it when |
|---|---|---|
| Resource | resource (URI, ≤1000 chars), access_token (≤16384), expires_at | You know the OAuth resource URI yourself. |
| Connector reference | connector_ref ({ "connector_type": "mcp", "connector_id": <int> }), access_token, expires_at | You want Xagent to derive the resource from a connector. |
expires_at is an RFC 3339 timestamp and must be no more than 400 days in the future. Sending both resource and connector_ref returns 422. Bodies are limited to 64 KiB.
When a connector reference is refused
If the connector cannot be used — unknown, not shared with your team, not an MCP OAuth connector, or one whose resource could move at runtime — you get a single 422: connector_ref is not an authorized MCP OAuth connector. The message is deliberately identical for every cause. Use the explicit resource form for any connector that PUT will not accept.
Revoke a credential — DELETE /v1/external/oauth-tokens
Returns 204. Choose one form — the two behave differently, and the difference matters:
DELETE /v1/external/oauth-tokens?resource=<uri-encoded>
DELETE /v1/external/oauth-tokens?connector_type=mcp&connector_id=7resource | connector_type + connector_id | |
|---|---|---|
| What it revokes | The single credential at that exact resource | Every credential that connector wrote for this actor, plus the one at the resource it derives now |
| Actor never seen before | Creates the actor and records a revoked entry | Writes nothing at all, still returns 204 |
| Typical use | You hold the resource string yourself | Ordinary “disconnect this connector for this user” |
Supplying only one half of the connector pair returns 422; supplying both forms together also returns 422.
204 is not a confirmation that anything was revoked
A 204 comes back even when that connector id has never been used or does not exist. It is not an acknowledgement that the connector exists, is shared with your team, or that any credential was actually removed. To close out a user completely, send one connector-form deletion for every connector you have ever pushed or authorised through for that actor. Revocation by connector is best-effort against concurrent writes — reissue the deletion if you need certainty.
Two further behaviours worth knowing: two connectors that derive the same resource for one actor share a single credential, so a later push replaces the earlier token and deleting through either revokes it; and revoking a credential causes a widget re-authorisation against it to be refused until you push a token again. If you ever mix the two forms for the same resource, keep the resource string on your side.
Tasks
Mirrors the Workspace SDK task endpoints and returns the same response shapes, scoped to the acting end user.
POST /v1/external/chat/tasks # 202 Accepted
GET /v1/external/chat/tasks/{task_id}
POST /v1/external/chat/tasks/{task_id}/messages # 202 Accepted
GET /v1/external/chat/tasks/{task_id}/stepsTwo differences from the Workspace SDK: the request body also accepts oauth_tokens (an array of { resource, access_token, expires_at } — the connector-reference form is not accepted here) and connector_runtime_context; and /steps returns a deliberately narrower, external-safe view of each step. Bodies are limited to 64 KiB, and unknown top-level fields are rejected with 422.
| Status | Meaning |
|---|---|
| 402 | Quota exhausted — either quota_exceeded (the team's allowance) or client_quota_exceeded (your application's share of it). Handle both. |
| 403 | The agent is not authorised for your application. |
| 404 | Task or connector not visible to this application and end user. |
| 409 | Task busy, or the runtime context changed on an append. |
| 503 | Connector runtime temporarily unavailable. |
Files
Files belong to the acting end user; no request names a user or application id.
POST /v1/external/files # 201 Created, multipart field "file"
GET /v1/external/files
GET /v1/external/files/{file_id}/content
DELETE /v1/external/files/{file_id} # 204 No Content{
"file_id": "3f9a1c...",
"filename": "invoice.pdf",
"file_size": 248311,
"mime_type": "application/pdf",
"created_at": "2026-08-10T09:15:00Z"
}Uploads are capped at 100 MB by default and rejected with 413 as soon as the limit is passed. Expect 401 for a bad token and 404 for a file that is not this user's.
Widget Sessions
For an Xagent chat widget embedded in your product. Your backend mints a signed, encrypted context grant; the browser exchanges it for a session token.
POST /v1/external/chat/sessions # exchange a grant for a session
POST /v1/external/chat/sessions/reconnect # rotate tokens and resume
DELETE /v1/external/chat/sessions?actor_id=… # 204, revoke all of one user's sessionsA session represents one browser conversation for a given application, end user and agent. It lives for 8 hours from creation — idle time does not extend it, and reconnecting does not either. Send the grant as { "encrypted_context": "…" }; no other fields are accepted. Both exchange and reconnect return the same shape:
{
"session_token": "...",
"session_token_expires_at": "2026-08-10T11:15:00Z",
"reconnect_token": "...",
"session": {
"absolute_expires_at": "2026-08-10T17:15:00Z",
"agent": { "id": 42, "name": "Support Bot", "description": "...", "logo_url": null, "suggested_prompts": [] }
}
}Reconnect rotates tokens on use and doubles as renewal — there is no separate renew call. Re-presenting a token inside its grace window replays the identical response; exceeding the reuse limit revokes the session.
Logout uses a query parameter rather than the usual header: DELETE /v1/external/chat/sessions?actor_id=…, authenticated with your client-credentials token. It revokes every one of that user's sessions regardless of the token's agent scope, and returns 404 actor_not_found for a user this application has never seen. Note that this form places user ids in URLs, so they may appear in proxy logs.
Grant and session errors use their own fixed code set, including grant_malformed (400), encryption_required (400), signature_invalid (401), grant_expired (401), grant_already_used (401), reconnect_invalid (401), session_expired (401), agent_not_granted (403), identity_mismatch (403), agent_not_available (409), widget_disabled (409), and rate_limited (429, with Retry-After).
Messages are intentionally ambiguous
Several distinct causes return byte-identical messages so that credentials and configuration cannot be enumerated — signature_invalid covers unknown, invalid and revoked applications, and reconnect_invalid covers unknown, rotated, over-used, revoked and wrong-origin tokens. Never branch on message text.
Rate Limits
Grant exchange is limited to 1000 requests per minute per application, counted after signature verification, returning 429 rate_limited with a Retry-After header. A per-IP limit also applies at the proxy. Replaying an already-used grant still counts against the limit.
Real-Time Streaming
Two WebSocket endpoints stream a task as it runs.
| Endpoint | For | Authentication |
|---|---|---|
/v1/external/chat/ws | Partner backends | Authorization and X-Actor-Id handshake headers. Credentials in the query string are rejected. |
/v1/external/chat/sessions/ws | Embedded widget | Session token offered via Sec-WebSocket-Protocol — never a header or query string. |
On the backend socket, connect with no parameters and send task.create as the first frame, or attach to an existing task. Client frames are task.create, message.create and input.submit; server events include task.status, input.required, step.thinking, step.tool_call, step.agent_delegation, message.delta, message.completed, task.error and stream.resync_required. Frames are capped at 64 KiB and each application may hold 20 concurrent sockets; a widget session may hold 8.
Sequence numbers restart on every reconnect and there is no event replay, so when you receive stream.resync_required, fetch the task's steps over REST, reconcile, and resume. Disconnecting does not cancel a turn that is already running.
For the widget socket, only two close codes are terminal — 4403 (session or application revoked) and 4408 (session lifetime reached). Stop retrying on those. 4401, 4409, 1008 and 1011 are all recoverable by reconnecting.
Error Format
This surface carries three different error shapes. Pick the right parser per endpoint:
| Endpoint | Shape |
|---|---|
POST /v1/external/oauth/token | { "error": "<code>", "error_description": "<text>" } — OAuth 2.0 |
| Grant exchange and reconnect | { "error": { "code", "reason", "message", "details" } } |
| Everything else | { "error": { "code", "message", "details" } } — the same envelope as the Workspace SDK |
When a capability is switched off for your tenant, the 404 uses { "detail": "…" } instead — the one exception to the above.
What Needs Confirming
Open items
These are unresolved between the implementation and the internal specification, and are flagged rather than guessed:
- WebSocket attach path. This page documents attaching by query parameter on
/v1/external/chat/ws, which is what the implementation provides. The internal specification instead describes a path form,/v1/external/chat/ws/{task_id}, which does not exist in the current build. Confirm which is intended before integrating against either. - Widget endpoint schemas. Grant exchange and reconnect are hand-serialised, so they publish no schema in the generated OpenAPI document. The shapes above were read from the implementation and should be confirmed as the contract.
- Same path, different availability. On a tenant with only external tasks enabled,
DELETE /v1/external/chat/sessionsworks whilePOSTon the same path returns404. Confirm this is intended, since a404otherwise reads as a wrong path.
Next Steps
- Workspace SDK → — the API for your own workspace
- API Reference → — surfaces, authentication and errors