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.querytwin.readtwin.writefor 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.jsonUpdating 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.jsonThe 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:
continuationReason | Meaning |
|---|---|
not_owner | The session belongs to someone else. Only its owner continues it. |
insufficient_scope | The credential lacks twin.query, or twin.write for a session that may change records. |
app_required | The session runs with more than a public question is granted. Continue it in the app, at continuationDestination. |
signed_in_person_required | A 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.