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

CallerHow it authenticatesCredential it starts with
Partner backendExchanges client credentials for a bearer token at POST /v1/external/oauth/tokenclient_id + client_secret
Embedded browser widgetExchanges a signed, encrypted context grant for a session token at POST /v1/external/chat/sessionsA grant your own backend mints

Get a Token — POST /v1/external/oauth/token

Auth: No bearer authStripe signature verified

Standard OAuth 2.0 client-credentials exchange. Send it as form-encoded data (application/x-www-form-urlencoded) — JSON is rejected.

FieldRequiredNotes
grant_typeYesMust be client_credentials.
scopeNoSpace-separated agent:<id> entries. Omit to receive every agent granted to your application.
client_id / client_secretConditionalSupply 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." }
StatuserrorCause
400invalid_requestMalformed form body, wrong content type, duplicate fields, or credentials supplied in both places.
400unsupported_grant_typegrant_type is not client_credentials.
400invalid_scopeRequested scope is malformed or exceeds what your application was granted.
401invalid_clientUnknown 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.json

Published 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

Auth: Runtime keyxag_<prefix>_<secret>

Returns 204. The body takes one of two shapes and must match exactly one of them:

FormFieldsUse it when
Resourceresource (URI, ≤1000 chars), access_token (≤16384), expires_atYou know the OAuth resource URI yourself.
Connector referenceconnector_ref ({ "connector_type": "mcp", "connector_id": <int> }), access_token, expires_atYou 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

Auth: Runtime keyxag_<prefix>_<secret>

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=7
resourceconnector_type + connector_id
What it revokesThe single credential at that exact resourceEvery credential that connector wrote for this actor, plus the one at the resource it derives now
Actor never seen beforeCreates the actor and records a revoked entryWrites nothing at all, still returns 204
Typical useYou hold the resource string yourselfOrdinary “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

Auth: Runtime keyxag_<prefix>_<secret>

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}/steps

Two 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.

StatusMeaning
402Quota exhausted — either quota_exceeded (the team's allowance) or client_quota_exceeded (your application's share of it). Handle both.
403The agent is not authorised for your application.
404Task or connector not visible to this application and end user.
409Task busy, or the runtime context changed on an append.
503Connector runtime temporarily unavailable.

Files

Auth: Runtime keyxag_<prefix>_<secret>

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

Auth: No bearer authStripe signature verified

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 sessions

A 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.

EndpointForAuthentication
/v1/external/chat/wsPartner backendsAuthorization and X-Actor-Id handshake headers. Credentials in the query string are rejected.
/v1/external/chat/sessions/wsEmbedded widgetSession 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:

EndpointShape
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/sessions works while POST on the same path returns 404. Confirm this is intended, since a 404 otherwise reads as a wrong path.

Next Steps