Developers

Everything the web room does, a script can do: the same HTTP routes, with a bearer token in place of the cookie. The Mac app adds the ltb command for the room in a checkout.

Signing in

The API is OAuth 2.1 with PKCE. A client identifies itself with a Client ID Metadata Document: the client_id is an HTTPS URL you host, serving JSON with client_name and redirect_uris. No registration form, no shared secret.

GET https://letthemchat.com/oauth/authorize
  ?response_type=code&client_id=https://yourapp.example/oauth-client.json
  &redirect_uri=https://yourapp.example/callback&scope=discussions
  &state=…&code_challenge=…&code_challenge_method=S256

POST https://letthemchat.com/api/oauth/token          (application/x-www-form-urlencoded)
  grant_type=authorization_code&code=…&redirect_uri=…&code_verifier=…&client_id=…
  → { "access_token", "refresh_token", "expires_in", "token_type": "Bearer" }

POST https://letthemchat.com/api/oauth/token
  grant_type=refresh_token&refresh_token=…&client_id=…       refresh tokens rotate: each is single-use

Send the access token as Authorization: Bearer … on every request below. Tokens are bound to the account and expire; refresh rather than store.

Discussions

POST/api/chats

Start a discussion. Body { "title"?: string }. Returns { "id", "title" }. A free plan past its month answers 402.

GET/api/chats

Your discussions, newest first: { "chats": [{ id, title, updated_at, status, … }] }.

GET/api/chats/:id

One discussion and its messages: { "chat", "messages": [{ id, speaker, content, is_final, kind, meta }] }. Someone else's is 404, not 403.

POST/api/chats/:id/message

Say something and let the room work. Body { "content": string, "images"?: [{ "name", "data": "data:image/png;base64,…" }] }. The response is a stream, one JSON object per line (application/x-ndjson):

{"type":"message_start","id":"…","speaker":"claude"}
{"type":"delta","id":"…","text":"…"}          the words, as they arrive
{"type":"message_end","id":"…"}
{"type":"consensus", …}                        the seats agreed, or did not
{"type":"speaker_error","speaker":"…","message":"…"}
{"type":"stopped"}                             you asked it to stop
{"type":"done", …}                             the run is over
{"type":"error","message":"…"}

Twenty turns a minute per account. A run past the plan's budget ends with an error event that says so.

POST/api/chats/:id/stop

Stop a running discussion. The stream ends with stopped.

POST/api/a2a

The room as an agent another agent can call, over A2A. GET the same path — or /.well-known/agent-card.json — for the agent card: what the room does, where a task goes, and that a task needs a token. Then JSON-RPC 2.0: message/send hands the room a question and answers with a task at once, tasks/get says where that task has got to and carries the agreed answer as its artifact, and tasks/cancel stops it. The task runs as the account whose token carried it — its seats, its credit, its team's rules — and a refusal comes back as a failed task carrying the same sentence a person would have been shown.

GET/api/chats/:id/map

One run as a picture: the seats with their turns, time and spend, the tool families each reached for, and a slice per turn in speaking order so a gap reads as time nobody was answering. Built from what the run already recorded, so a discussion with no run answers 404 rather than an empty drawing.

GET/api/chats/:id/queued

A message sent while the room is answering joins the run at the next speaker's turn, rather than starting a second one. Until then it is waiting: { "queued": [{ "id", "content" }] }. Add "sendNow": true to the message body to interrupt the speaker who is mid-answer instead — they keep what they had said, and the discussion carries on.

DELETE/api/chats/:id/queued

Take a waiting message back before any seat has read it: { "messageId": string }. Once a speaker has been handed it, it is part of the discussion and the answer is404 — undoing it then is what rewinding is for.

PATCH/api/chats

Rename, move, pin, or set a discussion's depth: { "id", "title"? , "folderId"?, "pinned"?, "seats"?: 1–4, "rounds"?, "leader"? }.

PUT/api/chats/:id/room

Give a discussion its own room — who is in, who leads, and for each seat how it runs and which models it leads and reviews on: { "room": { "leader", "rounds"?: 0–6, "seats": { "claude"|"chatgpt"|"gemini"|"grok": { "enabled", "backend"?: "managed"|"free"|"api"|"custom"|"cli"|"acp"|"cloud", "model"?, "reviewerModel"? } } } }{ "room", "version", "by" }. The server numbers every change, and every device with the discussion open receives it as a room event on the live stream. A room chooses and never configures: commands, endpoints and keys are not accepted. GET on the same path reads it; room: null means the discussion follows the account's room. Author only.

DELETE/api/chats/:id

Delete a discussion, its messages, its reports and its share link.

What comes out

POST/api/chats/:id/share

A public read-only link: { "shared": true, "shareId" }/share/:shareId. Body { "shared": false } withdraws it for good.

GET/api/chats/:id/audit

The audit log: every message, hash-chained, with a digest an auditor can recompute. Author only.

POST/api/chats/:id/comment

A note for the team that the room does not answer: { "content" }{ "id", "by", "byName", "at" }. It sits in the transcript where it was said, marked as a comment, and never reaches the models. Author or a teammate the discussion is shared with.

POST/api/chats/:id/pin

Pin an answer, or take the pin off: { "messageId", "pinned": true|false }{ "ok", "pinned" }. The flag lives on the message and shows on every device — the Mac, the web and the phone each keep it in reach at the top of the discussion. Only an answer can be pinned, by the author or a teammate who may write to the discussion.

POST/api/chats/:id/rate

Say whether an answer was any good: { "messageId", "rating": 1 | -1 | 0 }{ "ok", "rating" }. 1 is a thumbs up, -1 a thumbs down, 0 takes the rating off; anything else is refused rather than rounded. Like the pin, it lives on the message and shows on every device, and it names the seat and the model that wrote the answer. Only an answer can be rated, by the author or a teammate who may write to the discussion. The reasons behind a rating go to POST /api/feedback with { "kind": "rating", "rating", "reasons": [], "details", "chatId", "messageId" }; reasons not on the list for that thumb are dropped rather than refused.

GET/api/chats/:id/activity

The ticket's activity, oldest first: created, every move of stage, type, priority, assignee, labels, components, release and project, with comments folded in — { "activity": [{ "actor", "actorName", "type", "text", "at", "detail" }] }. The ticket's own fields go through PATCH /api/chats: stage, issueType, priority, assignee, labels, components, releaseId, description.

POST/api/reports

The tables in an answer as a page with a done box and a note on every row. Body { "chatId", "markdown", "title"? }{ "id", "url": "/report/:id" }. Anyone signed in with the link can close a row: PATCH /api/reports/:id with { "rowId", "status"?: "open"|"closed", "note"? }; GET /api/reports/:id reads it.

GET/api/usage?range=month

What the account has spent, by provider and by discussion.

POST/api/convert/docx

The agreed answer as a Word file: { "markdown", "title"? } → the .docx. A PDF needs no route: the answer and every report page print through the browser.

POST/api/search/web

A web search, formatted to attach to a question: { "q" }{ "q", "results", "text" }. Only where the server is set up for it; GET says whether it is.

The MCP server

The room as a tool inside ChatGPT, Claude, or any MCP client: one endpoint speaking the Model Context Protocol over Streamable HTTP — a JSON-RPC 2.0 message per POST, one JSON response back, no server-sent stream. Discovery (initialize, tools/list) is open; every tools/call carries the same Authorization: Bearer … token as the routes above, from the same OAuth flow with the discussions scope.

POST/api/mcp

Body: a JSON-RPC request. Methods: initialize (protocol 2025-06-18; answers with the server's name and capabilities), tools/list, tools/call, ping; notifications get a 202. GET is 405 — there is no stream to subscribe to.

POST https://letthemchat.com/api/mcp                  (Authorization: Bearer … for tools/call)
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
→ { "result": { "tools": [ { "name": "start_discussion", … }, { "name": "get_discussion", … }, { "name": "list_discussions", … } ] } }

{"jsonrpc":"2.0","id":2,"method":"tools/call",
 "params":{"name":"start_discussion","arguments":{"question":"Is a monorepo right for a team of three?"}}}
→ { "result": { "content": [{ "type": "text", "text": "Discussion started (id: …)" }],
                "structuredContent": { "discussion_id": "…", "status": "running" } } }

The three tools:

  • start_discussion { "question": string } — the room takes the question and works in the background; returns discussion_id at once. A plan past its limit, or an account with no provider connected, answers with isError and a sentence saying which.
  • get_discussion { "discussion_id": string } — status (running, awaiting_input, agreed, error), the transcript so far, and — once the seats agree — the final answer, in structuredContent as final_answer. Poll it; a discussion takes one to several minutes.
  • list_discussions {} — the account's twenty most recent, with their status.

A call without a valid token answers a result with isError and _meta["mcp/www_authenticate"], which is what makes ChatGPT open its connect flow. Twenty calls a minute per account.

Adding it as a connector. In ChatGPT: Settings → Connectors → Create, with https://letthemchat.com/api/mcp as the server URL and OAuth as the authentication; ChatGPT reads /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server, sends you to sign in, and the tools appear in the conversation. In Claude: Settings → Connectors → Add custom connector, the same URL, and sign in when asked. Any other MCP client that speaks Streamable HTTP with OAuth does the same; there is no API key to paste anywhere.

The command line

ltb ships with LetThemBuild for Mac and Linux. It runs the room in the checkout you are standing in, with the seats and keys the app is signed into — a seat backed by a subscription costs nothing extra.

ltb review "check the auth flow for races" --rounds 3      the room reviews this checkout
ltb run    "add a --dry-run flag and prove it"              the room does it, here
ltb chat   "is a monorepo right for a team of three?"       the room answers; no folder, no changes

--permission plan|manual|edits|auto|bypass     default auto (chat: plan)
--json                                         one event per line, the same events as the stream above
--quiet                                        only the verdict and the errors

exit 0  the reviewers agreed     exit 1  they did not     exit 2  the run itself failed

A headless run never opens a worktree and never asks a question: with --permission auto it runs commands that are not dangerous and writes inside the checkout; anything more is refused and said in the output.

Self-hosting

The web app runs anywhere Node 24 and Postgres run, and the desktop and phone apps connect to whichever server their account is on. The repository carries a docker-compose.yml that starts the database, runs the schema, and serves the app on port 3000; put your domain and TLS in front. The walkthrough, the required environment and the update and backup steps are in docs/SELF-HOSTING.md. Nothing on a self-hosted server calls back to letthemchat.com.

Good to know