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://sg-origin.cloud.xagent.co/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 task is waiting on a pending question (interaction_response_required — this REST surface has no reply endpoint; answer through the real-time WebSocket's input.submit frame instead of appending), 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.

Interaction-State Refusals

A message sent while a task is parked waiting on a question — or a stale/duplicate answer to one — produces a task.error whose socket stays open on both real-time endpoints, unlike the policy closes above. The client's next move (answer the pending question, or re-read it first) travels on the same connection, so nothing here should trigger a reconnect:

task.error codeMeaningClient remedy
interaction_response_requiredThe task is parked on a pending question; a plain message cannot start a new turn against it. Backend socket only — see below for what the widget socket does with the same situation.Submit an input response instead of a new message.
interaction_staleThe task is not waiting on a response at all — it completed, failed, was paused, or has not started.Re-read the task's current state; do not retry this body.
interaction_payload_conflictThe pending request is unchanged, but the turn id is already spent — a different body was recorded under it, or its delivery is attributed to another input request.Resend under a new message id. Refreshing shows the same question, unchanged.
continuation_unavailableThe task is parked on a pending question, but the runtime has no live execution context and no loadable checkpoint to resume it from.Retry the identical payload — the declined delivery claim is released server-side, so the retry re-runs the full authorization fence safely.

task_busy stays reserved for a genuinely in-flight turn (task status running) — the one case where "retry after it completes" is actually true.

On the widget session socket (/v1/external/chat/sessions/ws) specifically, a plain chat message sent while the bound task is parked on a pending question is not rejected with interaction_response_required. It is routed to the interaction continuation and delivered as the answer to that question instead — there is no separate answer frame on this transport, so the same frame shape means "new message" or "answer" depending on the task's current state. Which pending question it answers is inferred server-side from whatever is currently pending; a client that needs to be sure it is answering the question it is currently showing the visitor should re-read the task's state before sending if there is any chance the question rotated. The delivery still follows the ordinary message_accepted / message_rejected ack — a rejected delivery whose remedy is a fresh id carries retry_with_new_id: true on that ack.

A widget chat frame that starts a new turn (not an answer to a pending question) may also carry context.timezone — an IANA zone name the agent uses to render its clock and interpret relative wording like "tomorrow at 09:00" for that turn:

{ "type": "chat", "message": "Schedule this for tomorrow at 9am", "context": { "timezone": "Australia/Perth" } }

The bundled embed populates this automatically from the visitor's browser (or the embed's data-timezone attribute, if set). Each frame carries only its own value — a later frame that omits it does not inherit an earlier one — and a blank, non-string, or over-64-character value is treated as absent and degrades to UTC rather than rejecting the message. An answer to a pending question does not consume it; the resumed run keeps its original context.

Wire-contract change for existing SDK clients

Backend-socket (/v1/external/chat/ws) changes: a stale or malformed answer used to be reported as invalid_message and closed the connection with policy code 1008; it is now interaction_stale or interaction_payload_conflict, and the socket is kept open. Separately, a declined dispatch — no live execution context and no loadable checkpoint — used to report task_busy; it now reports continuation_unavailable. This is the more breaking of the two: it is reachable today independent of any widget work, and a client that branches on task_busy to mean "the task is running, retry later" will stop matching this case even though the remedy (retry the identical payload) is unchanged. If your integration switches exhaustively on task.error.code and treats unknown codes as fatal, add these codes before upgrading — there is no protocol-version gate that degrades them gracefully.

Widget-socket (/v1/external/chat/sessions/ws) change: a chat message sent against a parked task no longer produces a task.error at all. It went from a generic busy-text rejection, to briefly reporting interaction_response_required, to now being delivered as the answer to the pending question (see above). A widget client that was branching on interaction_response_required to detect "the visitor needs to answer a question first" should switch to reading the delivery ack instead.

Widget client_message_id: a reserved id shape closes the socket

An id of the exact shape external-input: followed by 48 lowercase hexadecimal characters is reserved for server-generated delivery ids. A widget chat frame whose client_message_id exactly matches that shape is rejected before any processing and closes the socket with policy code 1008 — discard the id and reconnect with a freshly generated, differently-shaped one. An ordinary id that merely starts with the same prefix, such as external-input:client-1, is unaffected and remains valid.

Idempotent Task Creation (Command Recovery)

On the backend socket (/v1/external/chat/ws), task.create may include an optional data.command_id — a client-chosen identifier matching [A-Za-z0-9._:-]{1,64} that stays the same across retries of one logical create:

{
  "v": 1,
  "type": "task.create",
  "data": {
    "agent_id": 42,
    "message": {"role": "user", "content": "List my open tickets."},
    "command_id": "create-2026-08-11-001"
  }
}

This closes a gap where a task could be durably accepted and billed before the client learned its identity — a naive reconnect-and-retry then created a duplicate task. With a command_id supplied, the server persists the create binding, and a client that loses its connection before seeing the task id can safely resend the identical task.create:

  • Same command_id, same payload: the server recovers the original task instead of creating a second one. The retry receives stream.resync_required (reason reconnected) followed by the task's current task.status; no new task is created, scheduled, or billed. The recovered connection is a full session — message.create and input.submit work as usual.
  • Same command_id, different payload: the connection closes with policy code 1008 and reason create_command_conflict. This is stable — retrying the same pair conflicts again; use a new command_id to start a genuinely new task.

Recovery requires the deployment to configure a signing key set (XAGENT_EXTERNAL_CREATE_COMMAND_KEYS). On a deployment without keys configured, or once a binding's sealing key has been withdrawn, a task.create that carries a command_id closes with policy code 1008 and reason create_command_unverifiable; plain creates with no command_id are unaffected either way.

Do not work around create_command_unverifiable by changing the command_id

An earlier create may already have been accepted and billed under the original id, so issuing a new one to dodge the error risks a duplicate task. Contact your account contact to confirm no prior accepted binding exists before retrying with a different id.

command_id is only supported on the backend socket. The widget socket (/v1/external/chat/sessions/ws) has its own delivery-probe idempotency via client_message_id and does not accept a create command id.

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