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 only if users can publish channel posts or replies, create records, manage artifacts, or publish 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.

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

Request twin.write only for applications that expose 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",
    "values": { "name": "Acme Ltd", "status": "active" }
  }'

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, or chatgpt, 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.

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.

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.

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