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.
For machines, the same routes as an OpenAPI 3.1 document: GET /api/openapi.json, with an operationId, parameters and schemas per route. This page and that document are built from one table, so they cannot disagree.
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-useSend 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/chatsstartDiscussion
Start a discussion.
Start a discussion. Body { "title"?: string }. Returns { "id", "title" }. A free plan past its month answers 402.
GET/api/chatslistDiscussions
Your discussions, newest first.
Your discussions, newest first: { "chats": [{ id, title, updated_at, status, … }] }.
GET/api/chats/:idgetDiscussion
One discussion and its messages.
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/messagesendMessage
Say something and let the room work; the answer streams back one JSON object per line.
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/stopstopDiscussion
Stop a running discussion.
Stop a running discussion. The stream ends with stopped.
POST/api/chats/:id/free-keycontinueOnFreeKey
Move the seats a discussion was spending credit on to Free Key, after its credit ran out. Author only.
After a discussion stops because the credit ran out: moves the seats it was spending credit on to Free Key and returns { ok, seats }. Send the next message to carry on. Refused, with the reason, when Free Key isn't available on the account or no seat spends credit. The discussion's author only.
POST/api/chats/:id/workspace-reportkeepWorkspaceReport
Keep a report a run on your Mac wrote to artifacts/, so your phone and the web can open it. Owner only; private to the discussion's readers.
{ "path": "artifacts/report.html", "html" } — keep a report a run on your Mac wrote, so your phone and the web can open it. Owner only; an HTML file under artifacts/, up to 4 MB, replacing the copy at that path. Private: only people who can read the discussion open it.
POST/api/chats/:id/ceilingliftDiscussionCeiling
Allow one more ceiling's worth for this discussion — the Keep going on a stop at your own ceiling per discussion. Owner only.
Allow one more ceiling's worth for this discussion — the “Keep going” on a stop at your own ceiling per discussion. Owner only; the team's ceiling is raised in Settings → Team. Answers with what is allowed in all and what has been spent.
GET/api/chats/similarsimilarDiscussions
Recent discussions, yours and your team's, that read like a question, with the line a new discussion is told.
?q=<the question>&exclude=<chat id> — the discussions you and your team started in the last 30 days that read like the question: id, title, who, when and a score, with the one line a new discussion is told. Plain word overlap, no model call. The Mac asks it before a discussion's first run; the server asks for itself on the web and the phone. Switched off by the account's “Point out a similar recent discussion”.
POST/api/chats/reachreachDiscussions
Search, read or message your other discussions — what you may read and write yourself — for a seat in the Mac's room.
A seat reaching your other discussions, for the Mac's room. { op: "search", query, limit?, scope? } and { op: "open", id | key, maxChars? } read only what you could open yourself — your own and what your team shared with you, never a temporary one — as text to wrap as outside material. { op: "message", id | key, text, from: { id, key, title }, hop } sends a message into one you may write in: it starts that discussion's room, or joins its run, and answers delivered or queued. A chain of messages stops after two further hops.
GET/api/chats/sharedsharedDiscussions
Your teammates' discussions shared with your team that you can read, newest first, with each owner's name and whether the discussion is live.
Your teammates' discussions shared with your team, newest first: id, title, owner and their name, when it last changed, where it runs, whether its owner lets teammates drive it, whether it is running, and its ticket key and project. Your own are not in it, nor any in a project your team keeps you out of. With them, readOnlyReason (null when you may write, else why not — the team's plan ended, or you are an auditor) and notice (a pause that stops anything being sent). Open one with GET /api/chats/:id and write with /message or /comment.
POST/api/chats/:id/files-undoundoRenames
Ask the Mac that renamed files for an answer to put the old names back. Owner only; refused while that Mac is offline.
{ "messageId": "…" } — put back the files an answer's run renamed on the Mac. The record of the renames is kept on that Mac, so this asks it, and it is woken to act; the answer's meta.fileBatch then says undone, or what was in the way. Owner only; refused with 409 while that Mac is offline or once the renames were undone.
POST/api/chats/:id/backgroundstopBackgroundJob
Stop one of the discussion's background jobs (a helper, a command or a merge chain), or one step of it, on the Mac that runs it. Owner only.
{ "action": "stop", "job": "<id>", "step": "<id>" } — stop one of the discussion's background jobs on the Mac that runs it, or one step of it: a helper, a background command or a merge chain, as the Mac last reported them. The Mac is woken and stops it as its own Stop does. Owner only, and only for a job the Mac reported. GET on the same path answers with the jobs (background: clocks, model, tokens, step counts and the last steps) and which stops are still stopping.
POST/api/chats/:id/jobcontrolJob
Pause, resume or stop the discussion's job, switch its merge policy, raise its budget or retry a failed task, on the Mac that runs it. Owner only.
{ "action": "pause" | "resume" | "stop" }, { "action": "merge_policy", "policy": "leave" | "merge_green" }, { "action": "budget", "usd": 25 } or { "action": "retry_task", "taskId": "<id>" } — steer the discussion's job: a big task its Mac split into sub-tasks and runs side by side for hours or days. The control is written down for that Mac, which is woken and acts on it; a control the job does not offer now (resume a running job, lower the budget, retry a task that did not fail) is refused with 409. Owner only. GET on the same path answers with the job as the Mac last reported it — status, budget and spend, deadline, merge policy and each task's state, branch and pull request — the controls still waiting, and whether you may press them. GET /api/jobs lists your jobs across discussions, active first (?active=1 for only those).
POST/api/chats/:id/looploopDiscussion
Start, stop or run now a loop: a prompt the discussion repeats on its own, on the server or on the Mac that runs it. Owner only.
{ "command": "/loop 10m <what to check>" } or { "action": "stop" | "run-now" }, with "on": "mac" when the discussion is sent to its Mac — a prompt the discussion repeats on its own, every so often or at a pace its lead chooses, until the lead says it is done, it is stopped, it reaches the most runs the account allows, or a week has passed. A discussion the server runs loops at once; one a Mac runs is asked of that Mac, which is woken and acts on it as its own composer would. Owner only. Answers with a message, the loop as it stands, and how many asks are waiting for the Mac. GET on the same path, and GET /api/chats/:id as loop, carry it too.
POST/api/chats/:id/pr-watchsetPullRequestSwitch
Switch fixing, merge when ready or archiving for one of the discussion's open pull requests. The Mac looking after it acts on it. Owner only.
{ "url", "key": "fix" | "merge" | "archive", "on": boolean } — switch one of an open pull request's switches: fix failing checks and review comments, merge when ready, or archive the discussion once it merges or closes. Nothing runs on the server. The Mac looking after the discussion is woken, acts on it under the same rules as its own menu (or refuses, if its owner turned that off), and reports back. Owner only; refused with 409 until a Mac has reported on the discussion, and with 403 where the team takes approvals on the Mac only. Answers with every open pull request's switches, a switch not yet acted on listed under waiting.GET /api/chats/:id carries the same list as prWatch.
GET/api/cloud-runslistCloudRuns
Your cloud runs, or one discussion's, with the start form's defaults, the machines' rates, the plan's offer (the free plan's one trial run) and whether the discussion's files hold an iPhone app.
?chatId= for one discussion's. Each run carries its state, spend, last steps, what waits for approval and, once a Mac has built it, build. Also: offer, the plan's cloud offer — on the free plan, its one trial run and whether it is available, running or used — and xcode, whether the discussion's project files hold an iPhone app the cloud cannot build.
POST/api/cloud-runsstartCloudRun
Start a cloud run from a discussion; what is sent is the approval, and the free plan's one trial run is held to its own limits.
{ "chatId", "objective", "model", "tokenBudget", "minutes", "dollars", "machine", "room" } — registered as your approval with this sign-in. On the free plan this is its one trial run: a small machine, a $1 ceiling and 30 minutes at most, and at least $1 of credit; anything more is refused with the reason.
POST/api/cloud-runs/:id/buildreportCloudRunBuild
The Mac's report of building a cloud run's iPhone app when its work came back, kept on the run and sent to the discussion's other devices.
{ "state": "built" | "failed" | "tests-passed" | "tests-failed" | "no-xcode" | …, "errors", "passed", "failed", "scheme", "branch" } — what the Mac reports after building a run's iPhone app on its cloud/… branch. Kept on the run and published on the discussion's live channel. Your own runs only.
POST/api/a2aa2a
The room as an agent another agent can call: JSON-RPC 2.0 message/send, tasks/get and tasks/cancel.
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/mapgetRunMap
One run as a picture: seats, turns, time, spend and tool families.
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/treegetRunTree
One run as a tree: rounds, the seats' turns, their tool calls and what came back, the discussions a turn delegated to — with the outline as lines.
One run as a tree: its rounds, each seat's turn inside them, every tool call a turn made with what came back cut to size, and the discussions a turn delegated to as branches of their own. The same outline comes as lines, ready to paste, and as a one-sentence brief. Built from what the run recorded, so a discussion with no run answers 404.
GET/api/chats/:id/queuedlistQueued
Messages waiting to join the run at the next speaker's turn.
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/queuedwithdrawQueued
Take a waiting message back before any seat has read it.
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/chatsupdateDiscussion
Rename, move, pin, or set a discussion's depth.
Rename, move, pin, or set a discussion's depth: { "id", "title"? , "folderId"?, "pinned"?, "seats"?: 1–4, "rounds"?, "leader"? }.
PUT/api/chats/:id/roomsetRoom
Give a discussion its own room: who is in, who leads, and how each seat runs.
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.
PUT/api/chats/:id/run-targetsetRunTarget
Choose where a discussion's next message runs: here, or on the computer it runs on.
Choose where a discussion's next message runs: { "runTarget": "here"|"mac" } → { "runTarget", "effective" }. here is answered on the server, on your account's seats — a seat the room runs on a Mac is your account's seat for that chair there; mac sends it to the computer the discussion runs on. Every device with the discussion open receives a run_target event on the live stream. GET on the same path reads it; runTarget: null means nobody has chosen, and effective is the default in force (a room with a seat only a Mac can run goes to the Mac). The author, or a teammate the author lets drive.
DELETE/api/chats/:iddeleteDiscussion
Delete a discussion, its messages, its reports and its share link.
Delete a discussion, its messages, its reports and its share link.
What comes out
POST/api/chats/:id/shareshareDiscussion
A public read-only link, or withdraw it for good.
A public read-only link: { "shared": true, "shareId" } → /share/:shareId. Body { "shared": false } withdraws it for good.
GET/api/chats/:id/auditgetAudit
The audit log: every message, hash-chained, with a digest an auditor can recompute.
The audit log: every message, hash-chained, with a digest an auditor can recompute. Author only.
POST/api/chats/:id/commentaddComment
A note for the team; the room answers it only when it @mentions a seat or @room.
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/compactcompactChat
Fold the earlier turns into a note the seats read instead; nothing is deleted.
Fold the earlier turns into a note the seats read instead (row 256): no body → { "ok", "message": { "id", "speaker": "moderator", "content", "kind": "compact", "folded" }, "context": { "tokens", "window", "percent", "model", "canCompact" } }. Nothing is deleted — the folded turns stay in the transcript under the marker. Author only; refused while a run is writing (409), and when there is not enough to fold (400). GET returns the meter alone.
POST/api/chats/:id/asideaskAside
A side chat: one seat answers a question about the discussion beside it; nothing is stored.
The side chat (row 257): { "passage"?, "question", "history"?: [{ "question", "answer" }] } → { "answer", "provider", "model" }. One seat answers a question about the discussion beside it, with the discussion so far and the passage in hand; nothing is stored and nothing enters the discussion. Anyone who may read the discussion may ask; the asker pays, as Ask does.
POST/api/chats/:id/comparecompareRound
Compare, in a side-by-side discussion: the lead writes how one round's answers agree and differ, added at the end of the transcript.
Compare, in a side-by-side discussion: { "round": 1 } → { "comparison": { "id", "speaker", "content", "round", "model", "label"? } }. The room's lead reads that round's answers and writes three short paragraphs — Shared view, Different bets, Still unanswered — added at the end of the transcript as a turn with role: "comparison" and the round it compares; asked again, a newer one is added and is the one shown. Anyone who may write the discussion may ask; the asker's lead answers and the asker pays, as for a run. Refused for any other kind of discussion, and for a round with fewer than two answers (400).
POST/api/chats/:id/combinecombineRound
Combine, in a side-by-side discussion: the lead merges one round's answers into one, saying who gave each item and where they disagree, added at the end of the transcript.
Combine, in a side-by-side discussion: { "round": 1 } → { "combined": { "id", "speaker", "content", "round", "model", "label"? } }. The room's lead merges that round's answers into one complete answer in Markdown: each item once, with the seats that gave it and the sources they cited, disagreements stated rather than settled, and nothing added. Saved at the end of the transcript as a turn with role: "combined" and the round it reads; asked again, a newer one is shown. The same gates, billing and refusals as Compare.
GET/api/chats/:id/filesgetQuestionFile
One file a question was asked with, whole: its name, type and base64 data.
?message=…&n=… — one file a question was asked with, a picture or a PDF, whole: { "name", "mediaType", "data" }, the data in base64. message is the question's id and n the file's place among its files, as GET /api/sync lists them on the question (files: [{ "n", "name", "mediaType", "size", "text"? }], a PDF with its text and never the bytes). Anyone who can read the discussion; 404 for a file the question does not have.
POST/api/chats/:id/pinpinMessage
Pin an answer, or take the pin off.
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.
PUT/api/chats/:id/peoplesetChatSharing
Share a discussion with nobody, the whole team, or chosen teammates.
Who on the team a discussion is shared with: { "mode": "private" | "team" | "people", "emails"?: [...] } → { "mode", "people": [{ "email", "name" }], "team": [...], "teamName", "canChange", "shareBlocked", "inTeamProject" }. GET on the same path answers how it stands, to anyone who can read the discussion. Only the author, or the team's owner or an admin, changes it; only people on the team can be chosen; a new share needs the Team plan. Someone taken off loses access at once.
POST/api/chats/:id/hostrunOnComputer
Run a new discussion on one of your computers that is online, before anything is asked; its next message goes there.
Run a new discussion on one of your computers: { "hostId" } (an id from GET /api/hosts) → { "hostId", "hostName", "noun": "Mac" | "PC" | "computer" }. The discussion becomes one that computer runs, as one started from Devices is; its next message goes there. Only before anything has been asked, only your own discussion, never a temporary one, and only to a computer checking in now — otherwise 409, with the reason.
POST/api/chats/:id/movemoveToComputer
Move a discussion to another of your computers, your Cloud Computer included; one tied to a computer is handed over as a pushed branch first.
Move a discussion to another of your computers, your Cloud Computer included: { "hostId" } (an id from GET /api/hosts) → { "status": "moved" | "handing_off" | "here", "hostId", "hostName" }. One not yet tied to a computer moves at once. One tied to a computer is handed over first: that computer commits the discussion's files as they stand to a branch of their own, pushes it to the repository's remote, and the new computer carries on from it; every device with the discussion open receives host_move events on the live stream. Only your own discussion, never while it is running, and only for a discussion in a git repository with a remote — otherwise 409, with the reason. Your own checkout and branches are not changed.
POST/api/chats/:id/move/donecompleteMove
The computer a discussion is on reports where it pushed the work; for the LetThemBuild app, not scripts.
The other half of a move, for the LetThemBuild app only: the computer a discussion is on reports where it pushed the discussion's work, { "fromHostId", "remote", "branch", "head" }. Accepted only from the computer the discussion is tied to, and only with a remote address that carries no credential. A script has no use for it.
POST/api/chats/:id/reactreactToMessage
React to a message in a channel; the same reaction again takes yours off.
React to a message in a channel: { "messageId", "emoji" } → { "ok", "reactions": { "👍": ["you@…"] } }. Sending the same reaction again takes yours off. Only in a channel you may write to; at most eight kinds on one message.
PUT/api/chats/:id/notifysetChannelNotify
Your bell for a channel: all, mentions, off, or null for the default.
Your bell for a channel: { "level": "all" | "mentions" | "off" | null } → { "ok", "level" }. all brings every new post to your Inbox, mentions only @mentions and replies in threads you are in, off nothing; null goes back to the default, mentions in a channel. It is yours alone and travels with sync.
POST/api/chats/:id/buildbuildFromThread
Start a build session from a channel thread: an In-turns discussion in the channel's project.
Start a build session from a channel thread: { "thread" } (the post's message id) → { "id", "title", "key", "draft" }. It makes an ordinary In-turns discussion in the channel's project, with the thread as its description and draft as the words for its composer; it numbers the way any discussion there does. A thread that already has one returns it with existing: true. A channel itself never takes a ticket number.
POST/api/chats/:id/ticketticketFromThread
Draft, then make, a ticket from a channel thread or one message, with no run.
Make a ticket from a channel thread, or from one message: { "thread" | "message", "draft": true } → { "title", "description", "drafted" }, a cheap model's draft (drafted: false when none could be asked, and the message's own words stand in). Then { "thread" | "message", "title", "description"? } makes it → { "id", "title", "key" }, with no run.
POST/api/chats/:id/raterateMessage
Say whether an answer was any good: 1, -1, or 0 to take the rating off.
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/activitygetActivity
The ticket's activity, oldest first, with comments folded in.
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/reportscreateReport
The tables in an answer as a page with a done box and a note on every row.
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=monthgetUsage
What the account has spent, by provider and by discussion.
What the account has spent, by provider and by discussion.
POST/api/convert/docxconvertDocx
The agreed answer as a Word file.
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/websearchWeb
A web search, formatted to attach to a question.
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.
OpenAI-compatible
POST/api/v1/chat/completionschatCompletions
An OpenAI-compatible completion on the account's own credit, with an API key from Settings.
The one shape every client already speaks, on the account's own credit: Authorization: Bearer with an API key minted under Settings → API Keys. Body { "model", "messages": [{ "role", "content" }], "stream"?, "max_tokens"? }; the answer is OpenAI's. A Pro and Team perk for credit somebody already has, dearer than going direct by the margin taken when the credit was bought, and never Free Key's models. Refused without credit, and under the team's model and retention rules.
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/mcpmcp
The room as an MCP server over Streamable HTTP: initialize, tools/list, tools/call, ping.
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", … }, … ] } }
{"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 eight tools. Three run the room:
start_discussion{ "question": string }— the room takes the question and works in the background; returnsdiscussion_idat once. A plan past its limit, or an account with no provider connected, answers withisErrorand 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, instructuredContentasfinal_answer. Poll it; a discussion takes one to several minutes.list_discussions{}— the account's twenty most recent, with their status.
Five answer what you would otherwise open the app to see. Each goes through the same code as the web routes, and the team ones through the same role check, so a connector can do nothing the account could not:
list_running_rooms{}— discussions running now or active in the last two hours, newest first.spend_summary{ "period"?: "today" | "week" | "month", "project"?: string }— spending by project, this week unless told otherwise; the account's own, or the whole team's for its owner, an admin or an auditor.projecttakes a key or a name.list_schedules{}— the account's schedules: what each runs, when, where (this server or one of its Macs), whether it is paused, and how its last run ended.run_schedule{ "schedule_id": string }— starts a schedule now, as if its time had come. Only one that runs on this server; a schedule that runs on a Mac fires on that Mac.recent_audit_events{ "hours"?: number, "limit"?: number, "type"?: string }— the team's last audit events, for its owner, an admin or an auditor. 24 hours back by default, up to 168; 50 events by default, up to 100.
Five reach the pull-request reviewer on the GitHub installations the account linked, or its team did:
list_review_repos{}— where the reviewer is installed and linked, the repositories it covers, and whether reviews are on.list_pr_reviews{ "repo"?: string, "pr_number"?: number, "limit"?: number }— the newest reviews, 20 by default, up to 50.get_pr_review{ "review_id"?: string, "repo"?: string, "pr_number"?: number }— one review's state, confidence and findings, with links to its summary on GitHub and the discussion that wrote it.request_pr_review{ "repo": string, "pr_number": number, "mode"?: "review" | "summarize" }— reviews an open pull request now, under the installation's switches and the paying account's limits; the review is posted on GitHub.ignore_pr_finding{ "repo": string, "finding": string, "path"?: string, "reason"?: string }— settles a point for the repository, as replyingignoreon GitHub does; for whoever linked the installation, or the team's owner or admins.
Every tool carries readOnlyHint, destructiveHint and openWorldHint.
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 run "add a --dry-run flag and prove it" the room does it, here
ltb review "check the auth flow for races" --rounds 3 the room reviews this checkout
ltb review --staged reviews what is staged; edits nothing
ltb chat "is a monorepo right for a team of three?" the room answers; no folder, no changes
ltb "build an iPhone app that reminds me of pills" the same as ltb run
ltb ask "which queue fits this?" one question, no folder; the answer prints
ltb chat a conversation; Ctrl-C stops a turn, /quit ends it
ltb continue GPU-12 "now add the tests" a follow-up in a discussion the app has
ltb continue "now add the tests" the same, in the newest discussion in this folder
ltb logs GPU-12 --follow its turns as they come
ltb stop GPU-12 stop its run
ltb cloud start "make the tests pass" · ltb cloud status · ltb cloud approve HASH · ltb cloud stop RUN
ltb list [--project GPU] [--running] · ltb show GPU-12 · ltb open GPU-12 · ltb status
ltb projects · ltb tickets [--project GPU] · ltb ticket GPU-12
ltb seats · ltb seat gemini off · ltb seat 2 model gpt-6-sol · ltb models · ltb guest <model> "…"
ltb channels · ltb post #release "…" · ltb loops · ltb schedule [run NAME] · ltb pr 431
ltb whoami · ltb usage [--today|--month|--all] [--team] · ltb login · ltb logout
ltb mcp list · ltb mcp add NAME COMMAND… · ltb mcp remove NAME · ltb update
ltb rc [--cwd DIR] remote control: run discussions from your phone here
ltb acp the room as an agent inside an editor that speaks ACP
ltb doctor [--json] what on this machine would stop the room working
--permission plan|manual|edits|smart|auto|bypass default auto (chat: plan)
--cwd PATH work in exactly this folder
--new [NAME] work in a new project folder under ~/Documents/LetThemBuild
--model ID --effort L --dollars N for this run only: the lead's model, every seat's effort, a ceiling
--attach PATH --add-dir PATH a file with the message; another folder to work in
--append-prompt TEXT --schema FILE words for the lead; an answer that ends in JSON fitting the schema
--allow RULE --deny RULE tool rules for this run, as in Settings → Policies
--verbose --debug-file FILE every event; every event written to a file
--json one event per line, the same events as the stream above;
every other command answers in JSON with it
--quiet only the verdict and the errors
exit 0 the reviewers agreed exit 1 they did not exit 2 the run itself failedOutside a project (your home folder, or a folder that is not a git checkout) ltb run does not work where you stand: it makes a new project folder under ~/Documents/LetThemBuild, runs git init there and says where, or, when the task is not to build something new, asks first; a script gets exit 2 and the option to add. A headless run never opens a worktree and never asks about the work itself: with --permission auto it runs commands that are not dangerous and writes inside the checkout; anything more is refused and said in the output. With smart it writes inside the checkout and refuses the rest: in the app, Smart puts anything beyond an edit to a fast model first, but with nobody there to answer it does not let a model widen what the run may do.
ltb rc opens the app in the folder you are standing in, and this machine appears under Devices on your phone within a minute; a discussion started there runs here, with this checkout and these seats. ltb acp speaks the Agent Client Protocol on stdin and stdout, so an editor that hosts external agents, such as Zed, can put the room in its agent panel; point the editor's agent settings at the command rather than running it by hand. ltb doctor looks at this machine (signed-out CLIs, a proxy the app is not using, a missing certificate bundle, a quarantined app) and says what it found and what to do. It changes nothing, and exits 1 when something would stop the room.
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
- Rate limits answer 429 with
retry-after. Discussion turns: 20 a minute. Everything else is more generous. - Errors are
{ "error": "a sentence" }with the status that fits: 400 for a bad body, 401 signed out, 402 the plan, 404 not yours, 422 nothing to do. - A discussion in a team is readable by the team; writing it is the author's. Managed seats bill the inviter.
- The enterprise page covers audit logs, team policies and where data lives.