TwinDocs
Develop with your Twin

Build with the REST API

List channels and query your Twin from your own application.

Use the public REST API for a service or client that does not speak MCP. Its query surface matches the CLI and MCP: discover accessible channels, inspect their schemas, and either ask the Twin a question or submit a typed query.

Before you begin

An operator must pre-register your OAuth client with exact redirect URIs, the gateway audience, and only the scopes it needs:

  • twin.query
  • twin.read
  • twin.write for natural-language session queries and follow-ups that may change records (writeback, granted by default), and for explicit mutations such as posts, records, artifacts, or view updates

Your application must complete the OAuth authorization-code flow with PKCE for a real user. The service derives the tenant, organisation, and user from that identity.

Discover channels

List the channels available to the signed-in user:

curl -fsS https://<gateway>/api/twin/channels \
  -H "authorization: Bearer $ACCESS_TOKEN"

Inspect one channel before constructing a typed query. Channel and saved-view path parameters accept either the returned ID or slug:

curl -fsS https://<gateway>/api/twin/channels/<channel-id> \
  -H "authorization: Bearer $ACCESS_TOKEN"

The inspection response contains Data Sets and their queryable field keys. A Data Set owns one schema and its records; its saved Views only add a renderer, filters, and display configuration. The stable JSON names remain objectType and objectTypeId for compatibility. Public responses omit tenant metadata, source relation names, dataset IDs, and other internal implementation details.

List saved Views, then read any of the six renderer types through the canonical data endpoint:

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/views \
  -H "authorization: Bearer $ACCESS_TOKEN"

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/views/<view-id>/data \
  -H "authorization: Bearer $ACCESS_TOKEN"

The response comes from the same saved definition and data engine as the web app. Table, Board, Calendar, and Timeline Views return bounded records. Dashboards return widget results. Pivot tables return their live period columns, rows, and optional totals. Clients do not reconstruct saved filters, field projection, or renderer results.

Temporary filters use the same typed shape as saved View filters and are ANDed with them. Record Views also accept a field projection, limit, and opaque cursor. Encode arrays as JSON query values:

curl -fsSG https://<gateway>/api/twin/channels/<channel-id>/views/<view-id>/data \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  --data-urlencode 'filters=[{"fieldKey":"status","op":"eq","value":"active"}]' \
  --data-urlencode 'fields=["id","title","status"]' \
  --data-urlencode 'limit=25'

Read and post channel activity

Read recent channel activity in bounded pages. The response mixes posts, artifact changes, files, and linked sessions and returns nextCursor when another page is available:

curl -fsS "https://<gateway>/api/twin/channels/<channel-id>/feed?limit=10" \
  -H "authorization: Bearer $ACCESS_TOKEN"

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/members \
  -H "authorization: Bearer $ACCESS_TOKEN"

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/posts/<post-id>/replies \
  -H "authorization: Bearer $ACCESS_TOKEN"

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/feed/threads/session/<session-id> \
  -H "authorization: Bearer $ACCESS_TOKEN"

The member response contains canonical IDs for active human members explicitly attached to the channel. A shared channel may also be visible to organisation users who are not explicit members. Use only trusted returned IDs for mentions.

Records, feeds, and sessions carry people as canonical user IDs. List the organisation's members to name them:

curl -fsS https://<gateway>/api/twin/members \
  -H "authorization: Bearer $ACCESS_TOKEN"

Each member has a userId, an isCurrentUser flag, and a displayName when the member has set a name. Email addresses are not returned. The request fails rather than returning a partial list when the identity directory is unavailable.

An approved top-level post, reply, or thread comment uses the same structured mention shape. Include mentionedUserIds for people, mentionedChannelIds for accessible channels, mentionedDataSetIds for Data Sets in the current channel, and mentionedViewIds for active saved views. Resolve canonical IDs before writing; resource mentions render as direct links in MLX:

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/posts \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{"body":"@Alex Review @Finance @Invoices @Weekly close.","mentionedUserIds":["<user-id>"],"mentionedChannelIds":["<mentioned-channel-id>"],"mentionedDataSetIds":["<data-set-id>"],"mentionedViewIds":["<view-id>"]}'

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/posts/<post-id>/replies \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{"body":"Reviewed and approved."}'

Every post, session, artifact, or file can be the root of one flat discussion. Create a comment or reaction with its subjectType and subjectId:

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/feed/threads/artifact/<artifact-id>/comments \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{"body":"@Alex Please review @Invoices.","mentionedUserIds":["<user-id>"],"mentionedDataSetIds":["<data-set-id>"]}'

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/feed/threads/session/<session-id>/reactions \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{"reaction":"eyes","active":true}'

Edit a top-level post with PATCH or soft-delete it with DELETE at /api/twin/channels/<channel-id>/posts/<post-id>. Use the same body and four mention arrays when editing because an edit replaces the complete structured mention set. Only the principal that authored the post can change it; deletion hides the post and discussion while preserving the audit record.

Edit a comment with PATCH or delete it with DELETE at /api/twin/channels/<channel-id>/feed/comments/<comment-id>. Only its author can edit or delete it. To react to a comment instead of the thread root, include its commentId. Reactions are thumbs_up, heart, laugh, celebrate, eyes, or check; use eyes for seen and check for handled. Reading does not mark anything seen.

The gateway assigns authenticated author and interface attribution. Do not accept those fields from application users, and do not publish private reasoning or noisy tool-by-tool progress. All mutations require twin.write.

Create a native channel record

Creating a native record requires twin.write and an explicit user write action. Use object and field IDs returned by channel inspection:

curl -fsS https://<gateway>/api/twin/channels/<clients-channel-id>/records \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "objectTypeId": "client",
    "title": "Acme Ltd",
    "idempotencyKey": "create-acme-client-2026-10-23",
    "values": { "name": "Acme Ltd", "status": "active" }
  }'

Choose a new idempotencyKey for each intended creation and reuse it when retrying that same request. The API creates the same canonical record and audit event as the web app. Source-backed object types return 422; write the source row upstream instead.

Save or Save as a View

Creating a new View is the API equivalent of Save as. Use the exact objectTypeId returned for the Data Set and provide the complete renderer definition:

curl -fsS https://<gateway>/api/twin/channels/<channel-id>/views \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data @view-create.json

Updating an existing View is Save. Fetch the View and retain its currentVersionId. Send that as expectedCurrentVersionId with the complete replacement definition:

curl -fsS -X PATCH \
  https://<gateway>/api/twin/channels/<channel-id>/views/<view-id> \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data @view-update.json

The API publishes a new version. It returns 409 Conflict when the expected version is stale, so clients must re-read and show the user the competing change rather than silently retrying.

Create and update artifacts

Store complete Markdown or CSV without invoking a model:

curl -fsS https://<gateway>/api/twin/artifacts \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "kind":"markdown",
    "title":"Weekly brief",
    "content":"# Weekly brief\n",
    "channelId":"<channel-id>",
    "idempotencyKey":"weekly-brief-2026-07-27",
    "clientApp":"claude-code"
  }'

Use the returned artifact.versionId as optimistic concurrency when replacing the content:

curl -fsS -X PATCH https://<gateway>/api/twin/artifacts/<artifact-id> \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "expectedVersionId":"<version-id>",
    "title":"Revised weekly brief",
    "content":"# Revised weekly brief\n",
    "idempotencyKey":"weekly-brief-revision-2",
    "clientApp":"claude-code"
  }'

The gateway sets interfaceType; clientApp is optional display attribution for claude-code, codex, gemini, chatgpt or desk, not an authority claim. Create and update are idempotent. A stale expectedVersionId returns 409 instead of overwriting a concurrent revision.

Ask the Twin

curl -fsS https://<gateway>/api/twin/queries \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "mode": "session",
    "question": "What changed in open opportunities this week?",
    "channelId": "<channel-id>"
  }'

Session queries use the same persisted session router as the web app. Omit channelId to let the router use the signed-in user's available context. A viewId may be supplied only with its channelId.

A question, and a follow-up, can carry references to what is in front of the person: Twin recordIds, entityIds and fileIds, which the Twin reads as the signed-in user may (one they may not read fails the request), and clips, excerpts of text from their own screen such as an email or a page, at most 20, each up to 4,000 characters, with an optional source saying where it came from. The agent sees an excerpt quoted as the user's own material, never as instructions.

{
  "mode": "session",
  "question": "Is this promoter paid up?",
  "references": {
    "entityIds": ["ent_0123456789abcdef01234567"],
    "clips": [{ "text": "Can we move Friday to 8pm?", "source": "Email from sam@example.co.uk" }]
  }
}

The create response is 202 Accepted. Persist query.id, then poll:

curl -fsS "https://<gateway>/api/twin/queries/$QUERY_ID" \
  -H "authorization: Bearer $ACCESS_TOKEN"

Treat queued and running as non-terminal. Stop on completed, failed, or cancelled.

Poll at a bounded interval and surface status changes when your application needs to keep a user informed. The public REST surface does not currently provide an SSE or token stream; it exposes lifecycle status and the final governed result without private reasoning or internal run events.

Follow a session's progress

curl -fsS "https://<gateway>/api/twin/sessions/$SESSION_ID/progress?afterSeq=0" \
  -H "authorization: Bearer $ACCESS_TOKEN"

The response holds the session, the turns that changed after afterSeq (a cursor on the session's own events), cursor, isDone and tip. Each turn comes as it stands: a finished one with its answer, one still queued or running with progress, what it has said so far and how many tool steps it has taken. Pass cursor back as afterSeq until isDone. A cursor above tip is too old for the session: read the session again. Nothing is held open, so poll it, or read it when the workspace events name the session. It needs twin.read.

Follow up, or stop

A follow-up is another question in the same session, using query.sessionId:

curl -fsS https://<gateway>/api/twin/sessions/$SESSION_ID/messages \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{ "message": "And last quarter?", "whenBusy": "queue" }'

It returns 202 Accepted with a query like a question's, and its answer is read the same way, from /api/twin/queries/{id}. While the session is still answering, the default whenBusy: "reject" is refused with 409; "queue" waits behind the turn that is answering. A follow-up continues only a session the signed-in user owns, and only one that runs with what a public question may be granted: readonly-data, writeback and web-research. Any other, such as a session with a browser or terminal, or one that follows an organisation policy allowing them, is refused with 409 and code session_continues_in_app, to continue in the MLX app. Like a question, it needs twin.query, and twin.write as well when the session may change records (writeback). A follow-up takes the same idempotencyKey, references, channelId, dataSetId and viewId a question does. Its modelConfig names the model that turn runs on: an executor with a provider and model, a reasoning level, or both. A follow-up without one carries on with the model the session's latest turn chose, as the app does; where the Twin chose that turn's model, the follow-up runs on the default too. To run a follow-up on the default whatever the session ran on, send "modelConfig": null.

Before following up, read canContinue and continuationReason on the session (GET /api/twin/sessions or GET /api/twin/sessions/{sessionId}). When canContinue is false, continuationReason says why, and the follow-up would be refused for the same reason:

continuationReasonMeaning
not_ownerThe session belongs to someone else. Only its owner continues it.
insufficient_scopeThe credential lacks twin.query, or twin.write for a session that may change records.
app_requiredThe session runs with more than a public question is granted. Continue it in the app, at continuationDestination.
signed_in_person_requiredA Desk-bound session continues only for a signed-in person, not an API key.

Over MCP the same operations are twin_session_message and twin_session_cancel.

POST /api/twin/sessions/{sessionId}/cancel stops the turn the session is answering; with { "queuedRunId": "<id>" } it withdraws only a follow-up still queued. It needs twin.query, and answers { "ok": true } even when there was nothing to stop.

Run a deterministic query

When the caller already knows the exact shape it needs, it can submit a typed records, aggregate, or saved-view intent instead of prose:

{
  "mode": "deterministic",
  "channelId": "<channel-id>",
  "objectTypeId": "invoice",
  "operation": "aggregate",
  "groupBy": ["month"],
  "metrics": [
    { "alias": "revenue", "function": "sum", "field": "amount" }
  ],
  "filters": [
    { "field": "status", "operator": "eq", "value": "paid" }
  ],
  "orderBy": [{ "field": "month", "direction": "asc" }],
  "limit": 24
}

Metric functions are count_rows, count_distinct, sum, average, minimum, and maximum. count_rows omits field; every other function requires an inspected field key.

The server validates object and field names against the inspected channel, injects organisation scope, and compiles a parameterised query. Tenant, organisation, relation, product, SQL, model, prompt, tools, routing, and capabilities are not caller-controlled fields.

Deterministic queries complete in the create response. They contain bounded data.rows and a complete marker, so they do not need polling.

A saved-view intent uses operation: "view", channelId, viewId, and an optional widgetIds array. It deterministically evaluates those saved widgets through the same data engine as the dashboard endpoint.

Follow what changed

A client that keeps a cache of what it shows can follow the signed-in person's change feed instead of reading its lists again. Each event names a resource the person may read (a record, channel, View, session and the like) and whether it was upserted or deleted, never its content: read the resource again by its ID.

curl -fsS "https://<gateway>/api/twin/workspace-events?afterSeq=0&limit=500" \
  -H "authorization: Bearer $ACCESS_TOKEN"

The response holds events, cursor, isDone and tip. Pass each page's cursor back as afterSeq until isDone, then keep the last cursor and read from it later. The feed keeps its events, so a cursor at or below tip continues where it stopped.

A client whose cursor is too old to continue from reads everything again. That is a cursor above tip, because the workspace was restored, or a cache the client has lost or no longer trusts. Note tip from a first page, read every resource the cache holds again, then follow the events after that tip, so nothing written during the re-read is missed.

The feed needs twin.read and counts as a read. twin workspace-events --since <seq> --json reads the same pages from the CLI.

To hear of a change within a second or so without holding a stream open, add waitSeconds (at most 10): a read that finds nothing new after the cursor waits that long for the next change the person may read, and answers as soon as one lands. A client that asks again at once makes about six reads a minute while nothing changes. While it waits the read holds one of the caller's concurrent requests at the gateway (8 per person and 64 in all by default), and the gateway shortens the wait to fit its own relay timeout.

Accept your own memory

Every memory made through the gateway starts as proposed, the signed-in user's own too, and takes effect only once accepted. List the ones waiting with GET /api/memories?scopeType=user&status=proposed, then accept one at its version:

curl -fsS -X PATCH https://<gateway>/api/memories/$MEMORY_ID \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "confirmation": true,
    "idempotencyKey": "accept-<memory-id>-1",
    "status": "active",
    "expectedVersion": 1,
    "expectedStatus": "proposed"
  }'

Through the gateway this accepts only the user's own memory; a channel's or the organisation's is accepted in the MLX app. It needs twin.write and has no MCP tool: an agent may propose a memory but not accept one, so send it only on the person's own action.

Undo a memory change the Twin made

The Twin saves, corrects, stops using or forgets a memory as it answers, without asking. Each turn from GET /api/twin/sessions/{sessionId} lists those changes in memoryChanges. To undo one, send it back as it was listed:

curl -fsS https://<gateway>/api/memories/$MEMORY_ID/undo \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "confirmation": true,
    "idempotencyKey": "undo-<memory-id>-1",
    "change": { "action": "save", "memoryId": "<memory-id>", "version": 1, "status": "active", "summary": "Use concise answers." }
  }'

A save is removed, a correction gets the earlier version back, and a memory the Twin stopped using or forgot is used again. A forgotten memory is erased a day after the forget; until then Undo restores it. Undo applies only while the memory is still at the version the Twin left, and only to a memory the signed-in user may change. It needs twin.write and has no MCP tool: send it only on the person's own action.

Look up what identifiers name

A client can ask what the Twin knows about the identifiers in front of the person, such as the sender of an email or the page they are reading. Send the identifiers, never the message or page text:

curl -fsS https://<gateway>/api/twin/context/resolve \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "identifiers": [
      { "kind": "email", "value": "sam@example.co.uk" },
      { "kind": "company_number", "value": "SC123456" }
    ]
  }'

The kinds are email, domain, url (a page's address, by its host), company_number, vat_number, reference (a code a system gives a record, such as an account code) and entity (an entity ID from an earlier answer), up to 20 in one request. Each identifier is normalised as the Twin keeps its entities' keys, so GB 123 456 789 finds GB123456789 and Sam <Sam+Invoices@Example.co.uk> finds sam@example.co.uk. An address names the person or organisation it is and those whose website or mail domain its domain is; a subdomain's address (mail.example.co.uk) also finds the organisation whose domain it belongs to, after any that match it exactly. A mail provider's domain names none, and the organisation's own domains name only its people, never a customer or supplier.

One more kind, name, is an organisation's name as an email's sender gives it. Send a sender's display name only when their address and its domain named nothing. It finds the one organisation whose name it is, whatever its case, punctuation, spacing, leading "the" or legal form, so Northwind-Supplies, Ltd. finds NORTHWIND SUPPLIES LIMITED; it never matches part of a name, and no model is involved. A name several organisations share names none of them: its entry says ambiguous: true. A name is one line of plain text of up to 200 characters, and never an address or a page's address.

matches has one entry per identifier, in the order asked, with the entities it names and the key each matched on, the surest first. entities describes each entity matched once, up to ten, an entity only a name found after every other: its type, whether it is a customer or supplier, counts and amounts that refer to it, its linked entities, open work an attention rule raised, recent activity, and its page in the MLX app. truncated says more matched than are described.

A lookup answers only with entities the person may see: a key matches an entity whose every source the person's data access reaches. It needs twin.read, runs no model and counts as a read. twin context email:<address> --json does the same from the CLI.

Submit feedback

curl -fsS https://<gateway>/api/twin/feedback \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "description": "The answer used bookings instead of invoiced revenue.",
    "queryId": "<routed-query-id>"
  }'

queryId is optional. When present, it must identify a routed session query visible to the signed-in user. The response is a narrow acknowledgement; the stored session/run snapshots remain internal.

Results and interface metadata

A completed session query includes an answer and zero or more authenticated channel links. Neither mode exposes SQL, internal relations, run events, prompts, or model configuration.

The service records api, cli, or mcp as interface metadata. That value is for audit and observability only: it does not select a router, grant authority, or change the result policy.

Do not use the health route as proof

GET /health shows only that a load balancer can reach the gateway. A usable connection must complete sign-in and a real query against accessible Twin context.

On this page