Thun MurexDocs
Contents
Documentation

Thun Murex API

One endpoint for three cloud AI providers and four AI coding tools running on this machine. Send the same JSON to any of them, and every answer comes back in the same shape.

Overview

Thun Murex is a gateway running on thun-latitude-7280. Apps send it one kind of request, and it answers from any of seven providers: three cloud APIs, and four coding CLIs (command-line AI tools) running on the machine itself.

Requests and responses are JSON in the OpenAI chat format, so most OpenAI-style code works once you change the address and the key. The gateway can only be reached from devices on your Tailscale network.

Providerprovider valueKindDefault model
SEA-LIONsea-lionCloud APIaisingapore/Qwen-SEA-LION-v4.5-27B-IT
CerebrascerebrasCloud APIqwen-3.8-27b
GroqgroqCloud APIopenai/gpt-oss-120b
Claude CodeclaudeLocal CLIThe CLI’s own default
CodexcodexLocal CLIThe CLI’s own default
Gemini CLIgeminiLocal CLIThe CLI’s own default
AntigravityantigravityLocal CLIThe CLI’s own default
ChatGPT (web)chatgptWebsiteauto
Grok (web)grokWebsitefast
MiMo (web)mimoWebsitemimo-v2.6-pro

Leave provider out and the request goes to SEA-LION. To see every model you can choose, call GET /api/models.

Authentication

Every request needs a key in the Authorization header:

Header
Authorization: Bearer <your key>

There are two kinds of key:

  • Owner key (APP_API_KEY): can call every endpoint and sign in to the console. Keep it to yourself.
  • Access tokens (sk-ai-thun-…): created on the console’s API keys page, one per app or person, or by people with an account on their own account page. They can only send requests to providers and read their own balance at /api/me. Each one has limits, an expiry date and providers: its own, or for an account’s keys, the account’s plan, which they all share.
EndpointOwner keyAccess token
POST /api/chat, /api/chat/streamYesYes
POST /api/image, /api/transcribe, /api/speechYesYes
GET /api/models, /api/speech, /api/meYesYes
GET /api/usage, /api/quotasYes—
GET, PATCH /api/providersYes—
GET, POST, PATCH, DELETE /api/tokensYes—
GET, PATCH /api/usersYes—
GET, POST, PATCH, DELETE /api/codesYes—
GET, POST, PATCH, DELETE /api/shopYes—
GET /api/local-agent/health, /api/local-agent/limitsYes—

A missing or wrong key gets 401 with {"error": "Unauthorized"}. Keys are secrets: call the gateway from your own server or scripts, not from code that runs in someone’s web browser.

  • Wrong keys lock you out for a while. After 10 wrong keys in 10 minutes from one address, every request from there gets 429 for 15 minutes, with a Retry-After header, even with the right key.
  • The console sends your owner key once, to sign in. The browser then keeps a sign-in cookie for 7 days, which the page’s scripts can’t read, never the key itself. A new APP_API_KEY signs everyone out.

Quickstart

1. Put the gateway’s address and your key in two variables. The examples on this page use them.

Shell
export AI_THUN_URL="https://your-ai-thun-host"
export AI_THUN_KEY="sk-ai-thun-…"   # an access token, or your APP_API_KEY

2. Send a message:

curl "$AI_THUN_URL/api/chat" \
  -H "Authorization: Bearer $AI_THUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "groq",
    "messages": [{"role": "user", "content": "Write a haiku about Phnom Penh"}]
  }'

3. The answer is in choices[0].message.content:

Response (shortened)
{
  "id": "chatcmpl-…",
  "object": "chat.completion",
  "model": "openai/gpt-oss-120b",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "River light at dawn…" },
      "finish_reason": "stop"
    }
  ],
  "usage": { "prompt_tokens": 82, "completion_tokens": 141, "total_tokens": 223 }
}

Chat

POST/api/chatOwner key or access token

Sends a message or a whole conversation to any provider and returns the complete answer as one JSON response. For text that appears while it’s being written, use /api/chat/stream with the same body.

Request body

FieldTypeDescription
providerstringWhich provider answers: sea-lion, cerebras, groq, claude, codex, gemini or antigravity. Default sea-lion.
modelstringA model id from GET /api/models. Default: the provider’s default model.
messagesarrayThe conversation so far: [{"role", "content"}] with roles system, user and assistant. Send this or prompt.
promptstringOne user message: a shorter way to send messages with a single message in it.
session_idstringLocal CLIs: carry on an earlier conversation, using the session_id from its answer. See Conversations.
workdirstringLocal CLIs: the folder the CLI works in, as an absolute path inside the folders the gateway allows. Default: the gateway’s default folder.
no_toolsbooleanLocal CLIs, claude and codex only: run the CLI with none of its tools (no files, shell or web), as a plain model.
max_tokens, temperature, …Cloud APIs: any other OpenAI field (top_p, stop, response_format, reasoning_effort, …) goes to the provider unchanged. Local CLIs ignore them.
chat_template_kwargsobjectSEA-LION: {"enable_thinking": false} turns its reasoning off, for much faster answers.

Response

Cloud APIs answer in their own OpenAI format, passed on unchanged (see the Quickstart). Local CLI answers are converted to the same shape, with session_id and duration_ms added. usage is null for Codex and Gemini CLI, which don’t report tokens.

Response from a local CLI
{
  "id": "local-7352154e-6436-4025-b807-eea57954a1ff",
  "object": "chat.completion",
  "provider": "claude",
  "model": "claude-sonnet-5-5",
  "session_id": "7352154e-6436-4025-b807-eea57954a1ff",
  "duration_ms": 8106,
  "choices": [
    {
      "index": 0,
      "finish_reason": "stop",
      "message": { "role": "assistant", "content": "It's an AI gateway that…" }
    }
  ],
  "usage": { "prompt_tokens": 21480, "completion_tokens": 312, "total_tokens": 21792 }
}

Streaming

POST/api/chat/streamOwner key or access token

Takes the same body as /api/chat, and sends the answer as Server-Sent Events while it’s being written. Every provider sends the same events:

EventDataMeaning
start{"provider"}The provider has started on the request.
thinking{"text"}Reasoning, as it’s generated. Only from cloud models that think before answering (SEA-LION, gpt-oss).
delta{"text"}The next piece of the answer.
done{"provider", "model", "result", "finish_reason", "usage", "duration_ms"}The whole answer is in result. Local CLIs add session_id and leave out finish_reason. Always the last event.
error{"status", "detail", …}The request failed after the stream started, with the same fields as an error response. Always the last event.
What the stream looks like
event: start
data: {"provider":"groq"}

event: thinking
data: {"text":"Just list the numbers."}

event: delta
data: {"text":"1, 2, 3"}

event: delta
data: {"text":", 4, 5"}

event: done
data: {"provider":"groq","model":"openai/gpt-oss-120b","result":"1, 2, 3, 4, 5","finish_reason":"stop","usage":{"prompt_tokens":79,"completion_tokens":38,"total_tokens":117},"duration_ms":412}
curl -N "$AI_THUN_URL/api/chat/stream" \
  -H "Authorization: Bearer $AI_THUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider": "groq", "prompt": "Count from 1 to 5"}'
  • While nothing new comes for 20 seconds (a CLI at work), the stream sends a comment line, : keep-alive, so nothing between you and the gateway closes it. Skip lines that start with :, as SSE readers do.
  • A problem found before the stream starts (a wrong key, an unknown provider, a paused provider, a provider’s own error such as 402) comes back as an ordinary JSON error response, not as a stream.
  • Closing the connection stops the work: a cloud request is cancelled, and a local CLI is stopped.
  • finish_reason: "length" means max_tokens ran out. Thinking models can use it all on reasoning and leave result empty: raise max_tokens, or for SEA-LION turn thinking off.

Models

GET/api/modelsOwner key or access token

Lists every provider’s models, with its default and whether it’s paused.

Request
curl "$AI_THUN_URL/api/models" -H "Authorization: Bearer $AI_THUN_KEY"
Response (shortened)
{
  "providers": {
    "groq": {
      "kind": "cloud",
      "default": "openai/gpt-oss-120b",
      "models": [{ "id": "openai/gpt-oss-120b", "name": "openai/gpt-oss-120b" }, …],
      "note": null,
      "error": null,
      "paused": false
    },
    "claude": {
      "kind": "local",
      "default": null,
      "models": [{ "id": "sonnet", "name": "Claude Sonnet (latest)" }, …],
      "note": "aliases for the latest model of each family …",
      "error": null,
      "paused": false
    },
    …
  }
}
Provider fieldTypeDescription
kindstringcloud or local.
defaultstring | nullThe model used when a request names none. null: the CLI’s own default.
modelsarrayEach model’s id (the value for model) and name.
notestring | nullExtra advice about the list, from the CLI.
errorstring | nullWhy the list couldn’t be fetched, if it couldn’t.
pausedbooleanPaused providers turn requests away. See Pause & resume.

Cloud lists come live from each provider, keep only chat models, and are cached for 10 minutes. Gemini CLI can’t list its models, so its list is empty and any model name is passed through. A paused cloud provider lists only its default model.

Image generation

POST/api/imageOwner key or access token

Codex draws one image with its built-in image tool, using its ChatGPT sign-in (no API key). One image takes about 75 seconds.

FieldTypeDescription
promptrequiredstringWhat to draw.
imagesarrayUp to 5 pictures to edit or follow, in order: {"data_base64", "label"}. PNG, JPEG or WebP, at most 20 MB each. label says what each one is for, e.g. style reference.
modelstringThe Codex model that drives the image tool.
workdirstringAs for chat.
Request: saves the image as lotus.png
curl "$AI_THUN_URL/api/image" \
  -H "Authorization: Bearer $AI_THUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "A lotus flower, flat vector illustration"}' \
  | jq -r .image_base64 | base64 -d > lotus.png
Response
{
  "provider": "codex",
  "model": null,
  "image_base64": "iVBORw0KGgoAAAANSUhEUgAA…",
  "mime_type": "image/png",
  "message": "Created the lotus on a plain background.",
  "session_id": "01a11183-…",
  "duration_ms": 76000
}

Speech to text

POST/api/transcribeOwner key or access token

Turns audio into text with Groq’s Whisper, using the Groq keys. It takes the same form as OpenAI’s /audio/transcriptions, so OpenAI-compatible clients only need the address and key changed. Send multipart/form-data:

FieldTypeDescription
filerequiredfileThe audio.
modelstringDefault whisper-large-v3-turbo, the fastest.
languagestringThe spoken language, e.g. en. Optional, but it helps.
promptstringWords or spellings to expect.
response_formatstringjson (default), verbose_json or text.
temperaturenumberAs in OpenAI’s API.
providerstringgroq (default) or mimo: MiMo Studio’s MiMo-V2.5-ASR, below.
Request
curl "$AI_THUN_URL/api/transcribe" \
  -H "Authorization: Bearer $AI_THUN_KEY" \
  -F file=@meeting.m4a \
  -F language=en
Response
{ "text": " Let's start with the budget.", "x_groq": { "id": "req_…" } }

With provider=mimo the file goes to MiMo Studio’s speech recognition page through chatgpt-web-gateway instead (noisy audio, several speakers, dialects; up to 10 MB and 90 seconds), and the answer is {"provider": "mimo", "model": "mimo-v2.5-asr", "text": "…"}. Its other fields are ignored, and pausing MiMo pauses it.

Groq allows 20 transcriptions a minute and 2,000 a day per key. A key that’s at its limit is skipped for the next one; if every key is, the answer is 429 with a Retry-After header. Pausing Groq pauses this endpoint too.

Text to speech

POST/api/speechOwner key or access token

Speaks text through MiMo Studio’s speech pages, via chatgpt-web-gateway. It takes JSON like OpenAI’s /audio/speech and answers with the audio file (WAV, 24 kHz mono). GET /api/speech lists the models and voices.

FieldTypeDescription
inputrequiredstringThe text, up to 1,024 characters.
modelstringmimo-v2.5-tts (default): one of its voices. mimo-v2.5-tts-voicedesign: a voice you describe. mimo-v2.5-tts-voiceclone: the voice of a recording.
voicestringFor mimo-v2.5-tts: Bingtang (冰糖, default), Mia, Moli (茉莉), Chloe, Suda (苏打), Milo, Baihua (白桦) or Dean.
instructionsstringThe speaking style, or for voicedesign the voice to create.
reference_audio_base64stringFor voiceclone: a 5–30 s recording under 10 MB, base64-encoded.
voice_consentbooleanFor voiceclone, required true: ticks MiMo’s consent to uploading the recording’s voiceprint. Only copy voices you have the right to use.
response_formatstringjson answers the audio base64-encoded, with its type and voice, instead of the file.
Request: saves the audio as evening.wav
curl "$AI_THUN_URL/api/speech" \
  -H "Authorization: Bearer $AI_THUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"input": "Good evening!", "voice": "Dean", "instructions": "Calm, like a radio host."}' \
  -o evening.wav

The file’s response carries X-Model, X-Voice (URL-encoded) and X-Duration-Ms headers. Each takes about 5–15 seconds, and speech, MiMo transcription and MiMo chat take turns on one MiMo tab. Every one is saved to the MiMo account’s history; pausing MiMo pauses this endpoint.

Your key

GET/api/meOwner key or access token

The access token you call with: its status (active, disabled, expired or limited), its limits, what it has used and has left this period, when the period starts over, and when the key expires. It answers even for a key that’s turned off, expired or used up, so you can see why requests are refused. With the owner key it answers {"owner": true}.

Request
curl "$AI_THUN_URL/api/me" -H "Authorization: Bearer $AI_THUN_KEY"
Response (shortened)
{
  "name": "Dara",
  "hint": "sk-ai-thun-3f9a…9c1e",
  "status": "active",
  "expiresAt": "2026-11-30T23:59:59.999Z",
  "limits": { "period": "month", "tokens": 1000000, "requests": 1000, "perMinute": 20 },
  "providers": ["groq", "cerebras"],
  "used": { "requests": 212, "errors": 3, "tokens": 412880, "estimatedTokens": 0 },
  "remaining": { "tokens": 587120, "requests": 791 },
  "resetsAt": "2026-11-01T00:00:00.000Z",
  "total": { "requests": 212, "errors": 3, "tokens": 412880, "estimatedTokens": 0 },
  …
}
  • period is day or month (UTC), or total for a prepaid amount that doesn’t start over. null means no limit, and providers: null means every provider.
  • Failed requests (errors) don’t count against the limit. Tokens are what the provider reported; where it doesn’t say (the web chats, Codex, Gemini CLI), they’re estimated from the prompt’s and answer’s length, about 4 characters a token, and counted in estimatedTokens too. Speech, transcription and images count as requests only.

Usage

GET/api/usage?month=YYYY-MMOwner key only

One month’s usage across every provider: totals, every model with its requests and tokens (most tokens first), and a total for each day. month defaults to the current one. This is what the console’s Monthly usage panel shows.

Request
curl "$AI_THUN_URL/api/usage?month=2026-10" -H "Authorization: Bearer $APP_API_KEY"
Response (shortened)
{
  "month": "2026-10",
  "months": ["2026-10", "2026-09"],
  "totals": {
    "requests": 31,
    "errors": 0,
    "promptTokens": 122819,
    "completionTokens": 12995,
    "totalTokens": 135814
  },
  "models": [
    {
      "provider": "claude",
      "model": "claude-haiku-5-5",
      "requests": 9,
      "errors": 0,
      "promptTokens": 54753,
      "completionTokens": 404,
      "totalTokens": 55157,
      "tokensReported": true,
      "avgDurationMs": 3900,
      "timed": 9,
      "lastUsedAt": "2026-10-15T15:03:13.000Z"
    },
    …
  ],
  "days": [
    { "day": "2026-10-01", "requests": 0, "totalTokens": 0 },
    …
  ],
  "generatedAt": "2026-10-16T02:30:00.000Z"
}
  • Days are UTC days. months lists the months that have usage.
  • tokensReported: false means none of that model’s requests said how many tokens they used (Codex, Gemini CLI, Whisper), so its zeros mean “unknown”.
  • Usage is saved on the server, so restarts and updates don’t reset it. It’s kept for about 13 months.

Quotas

GET/api/quotasOwner key only

Today’s usage in detail: every cloud API key with its last-minute and daily numbers next to the provider’s published limits, every local CLI and model used today, and the paused providers. This is what the console’s Analytics page shows.

Request
curl "$AI_THUN_URL/api/quotas" -H "Authorization: Bearer $APP_API_KEY"
Response (shortened)
{
  "keys": [
    {
      "provider": "groq",
      "keyName": "GROQ_API_KEY3",
      "model": "openai/gpt-oss-120b",
      "requestsLast60s": 1,
      "tokensLast60s": 127,
      "requestsToday": 12,
      "tokensToday": 48210,
      "limits": { "tokensPerDay": 500000 }
    },
    …
  ],
  "local": [
    {
      "provider": "claude",
      "model": "claude-sonnet-5-5",
      "requestsToday": 4,
      "errorsToday": 0,
      "promptTokensToday": 86020,
      "completionTokensToday": 1210,
      "tokensToday": 87230,
      "tokensReported": true,
      "avgDurationMs": 8100,
      "finishedToday": 4,
      "lastUsedAt": "2026-10-16T02:11:40.000Z"
    }
  ],
  "paused": ["cerebras", "codex"],
  "generatedAt": "2026-10-16T02:30:00.000Z"
}

Keys are shown by the name of the setting that holds them, never by their value. The last-minute numbers start again from zero when the server restarts; the daily ones don’t.

Pause & resume

GET/api/providersOwner key only
PATCH/api/providersOwner key only
POST/api/providersOwner key only

A paused provider turns every request away with 503, from the console and from access tokens alike, until you resume it. Its keys and CLI sign-in stay as they are. Pausing Groq also pauses speech to text, and pausing Codex pauses image generation. You can also do this on the console’s Manage page.

A provider that isn’t set up is held the same way, and can’t be resumed: a cloud API with no API key, a CLI that isn’t installed, or one the gateway says isn’t signed in. Resuming it answers 409 with the reason. Once it’s set up, it goes by your own pause switch again. If the gateway can’t be reached to ask, nothing is held.

Being set up isn’t being able to answer, so each provider is also tested with a real, tiny chat (“Reply with exactly: ok”) when the server starts: a few tokens on a cloud API, a small run on a CLI’s plan. The websites (ChatGPT, Grok, MiMo) aren’t tested at start, because they share one Chrome window and MiMo saves every chat to its history. A provider that fails is held like one that isn’t set up, with the reason, and tried again after 30 seconds, a minute and two minutes in case the gateway was only starting. A real request that fails because of the provider (a refused key, a server error) starts a test too. After that, a held provider waits for you to test it again. A provider at its rate or usage limit still counts as answering. Test chats aren’t counted as traffic and cost no one’s credit.

Only an answer is a verdict. If the gateway (or a cloud API) can’t be reached at all, the test says nothing about the provider: it isn’t held, and the Manage page says what couldn’t be reached. To keep a provider out of the automatic tests and out of the holds, for one that’s used with a model the test chat doesn’t use, list it in PROVIDER_CHECK_SKIP (for example antigravity,groq). Pressing Test on it still shows how the test went.

POST /api/providers tests one provider now, which is what the Manage page’s Test button does, and is the way to test a website. It answers 202 at once (checks[provider].testing stays true until it’s over; GET shows when), or with "wait": true after the test, which can take a minute or more. Resuming a provider you paused starts a test the same way. Not for a provider that isn’t set up: 409. Set PROVIDER_CHECKS=off to turn the test at start off.

Test Groq and wait for the answer
curl -X POST "$AI_THUN_URL/api/providers" \
  -H "Authorization: Bearer $APP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider": "groq", "wait": true}'
FieldTypeDescription
providerrequiredstringWhich provider to change.
pausedrequiredbooleantrue to pause, false to resume.
Pause Cerebras
curl -X PATCH "$AI_THUN_URL/api/providers" \
  -H "Authorization: Bearer $APP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider": "cerebras", "paused": true}'

# → {"paused": ["cerebras"]}

These calls return the providers that turn requests away now, as {"paused": [...], "manual": [...], "setup": {...}, "checks": {...}}: paused has every one that does, manual the ones you paused. Each provider’s setup is {"status": "ready" | "no-key" | "not-installed" | "signed-out" | "failing" | "unknown", "reason": "…" | null}, failing being a failed test chat. Each checks entry is {"status": "ok" | "failing" | "unverified", "checkedAt", "source": "test" | "traffic", "reason", "limited", "testing"}.

Access tokens

GET/api/tokensOwner key only
POST/api/tokensOwner key only
PATCH/api/tokens?id=…Owner key only
DELETE/api/tokens?id=…Owner key only

Make, change and delete the keys other apps and people use instead of your owner key, and read what each one has used. Changes take effect on the next request. The console’s API keys page does the same. Only a hash of each key is stored, so a lost key can’t be shown again: give it a new secret instead.

FieldTypeDescription
namestringWho or what it’s for.
notestringYour own note (contact, plan, payment). The key’s holder never sees it.
limits.periodstringmonth (default) or day, UTC, or total: a prepaid amount that doesn’t start over.
limits.tokensnumberTokens per period. null (default): no limit.
limits.requestsnumberRequests per period; failed ones don’t count. null: no limit.
limits.perMinutenumberRequests in any 60 seconds. null: no limit.
expiresAtstringWhen it stops working. A date (2026-11-30) means the end of that day, UTC. null: never.
providersstring[]The providers it can use. null (default): all of them.
agentToolsbooleanDefault false: Claude Code and Codex answer without their tools, in the gateway’s own folder, and Gemini CLI, Antigravity and /api/image are refused. true lets the CLIs read files on the machine, in a workdir the caller picks. Only for keys you’d trust with that.
disabledbooleanPATCH: turn it off (true) or on again.
addTokens, addRequestsnumberPATCH: raise the limit by this much (a top-up).
extendDaysnumberPATCH: move the expiry this many days later, from now if it’s already past.
rotatebooleanPATCH: a new secret, sent back in token. The old one stops working; limits and usage stay.
Requests
# Create one: 1M tokens and 1,000 requests a month, 20 a minute, until the end
# of Nov 30 (UTC), Groq and Cerebras only. The key is shown only in this response.
curl -X POST "$AI_THUN_URL/api/tokens" \
  -H "Authorization: Bearer $APP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Dara", "note": "paid 5 USD on Oct 26",
       "limits": {"period": "month", "tokens": 1000000, "requests": 1000, "perMinute": 20},
       "expiresAt": "2026-11-30", "providers": ["groq", "cerebras"]}'
# → {"id": "9f2c41d07ab35e18", "token": "sk-ai-thun-3f9a…", "name": "Dara", "status": "active", …}

# List them, with each one's status and use this period
curl "$AI_THUN_URL/api/tokens" -H "Authorization: Bearer $APP_API_KEY"

# One key's use in a month, by day and model
curl "$AI_THUN_URL/api/tokens?id=9f2c41d07ab35e18&month=2026-10" -H "Authorization: Bearer $APP_API_KEY"

# Top it up, give it 30 more days, turn it off, or make it a new secret
curl -X PATCH "$AI_THUN_URL/api/tokens?id=9f2c41d07ab35e18" \
  -H "Authorization: Bearer $APP_API_KEY" -H "Content-Type: application/json" \
  -d '{"addTokens": 500000, "extendDays": 30}'
#    -d '{"disabled": true}'   or   -d '{"rotate": true}'   (the new key comes back in "token")

# Delete one
curl -X DELETE "$AI_THUN_URL/api/tokens?id=9f2c41d07ab35e18" \
  -H "Authorization: Bearer $APP_API_KEY"
# → {"revoked": "9f2c41d07ab35e18"}

Users

GET/api/usersOwner key only
PATCH/api/users?id=…Owner key only

The people who signed up at /account (with Supabase), each with their account: its plan, how many keys it has, and what it has used. A new account is pending and its keys don’t work until you activate it. GET /api/users?id=…&month=2026-10 adds the user’s keys and that month’s use by day and model. The console’s Users page does the same.

FieldTypeDescription
activatebooleantrue: the account’s keys can work. Send it with the plan.
limits, expiresAt, providers, agentTools, disabled, noteThe account’s plan, which all its keys share: the same fields as an access token’s (see Access tokens), and addTokens, addRequests, extendDays.
maxKeysnumberHow many keys the user may make. Default 5.
confirmEmailbooleantrue: confirm their email address, when Supabase’s email didn’t reach them.

Redeem codes

GET/api/codesOwner key only
POST/api/codesOwner key only
PATCH/api/codes?id=…Owner key only
DELETE/api/codes?id=…Owner key only

Codes people redeem on their account page. A plan code activates an account with its plan, or replaces and renews it (its days count from the end of the current plan); a topup code adds to an active plan. Each account can use a code once. GET lists every code with its status, what it gives and who used it; PATCH takes disabled and note. The console’s Redeem codes page does the same.

FieldTypeDescription
kindrequiredstringplan or topup.
limits, providers, agentTools, maxKeysA plan code’s plan, like an account’s (see Users). A prepaid (total) amount is that much more from redeeming.
daysnumberA plan code: how many days the plan lasts. Empty: no end.
addTokens, addRequests, addDaysnumberA top-up code: what it adds. Only what the plan has a limit or end date for is added.
countnumberHow many codes to make, 1 to 500. Default 1.
maxUsesnumberHow many accounts can use each code. Default 1.
expiresAtstringWhen the code stops working, like 2026-12-31. Default: never.
codestringYour own text for a single code, like WELCOME-2026. Default: a random one, like K7QM-2XHD-9WPB.
notestringYour own note.

Shop

GET/api/shopOwner key only
POST/api/shopOwner key only
PATCH/api/shop?pack=…Owner key only
DELETE/api/shop?pack=…Owner key only

Prepaid token packs people buy on their account page with PayPal. GET returns the packs with their pricing, the market prices, every order and whether payments are set up. A pack’s price can be at most a tenth of its tokens at the market price of the cheapest provider it covers. PATCH /api/shop with {"market": {"groq": {"input": 0.15, "output": 0.6}}} changes market prices (US dollars per million tokens). The console’s Shop page does the same.

FieldTypeDescription
namerequiredstringWhat buyers see it as.
pricerequirednumberIn US dollars, like 0.26. At most a tenth of the market value.
tokensrequirednumberHow many tokens it gives, prepaid.
providersstring[]The providers they work on. null: all of them (priced by the cheapest).
daysnumberHow long they last. Empty: no end.
perMinute, maxKeys, agentToolsAs for an account’s plan (see Users).
descriptionstringA line buyers see under the name.
activebooleanfalse takes it off sale.

CLI health

GET/api/local-agent/healthOwner key only

Which coding CLIs are installed on the machine, their versions, and what each one supports. Installed doesn’t mean signed in: each CLI needs its own sign-in on the machine.

Response (shortened)
{
  "status": "ok",
  "providers": {
    "claude": {
      "installed": true,
      "path": "/home/thun/.nvm/versions/node/v24.22.0/bin/claude",
      "version": "2.1.300 (Claude Code)",
      "supports_resume": true,
      "supports_images": false,
      "supports_no_tools": true
    },
    …
  }
}

CLI plan limits

GET/api/local-agent/limitsOwner key only

How much of each CLI subscription’s usage limits is left, and when each one is fully available again, as the CLI itself reports it. Checking uses none of it. Only CLIs that can say are filled in (Claude Code and Antigravity); the others have supported: false. The limits belong to the account, so use outside Thun Murex counts too. They refill in rolling windows (5h, weekly), not daily. Claude Code’s 5-hour session has no resets_at until it starts; Antigravity keeps separate limits for its Gemini models and for its Claude and GPT models. Answers are cached for a few minutes; a signed-out CLI comes back with error set.

Response (shortened)
{
  "providers": {
    "antigravity": {
      "installed": true,
      "supported": true,
      "checked_at": "2026-10-24T10:24:36Z",
      "error": null,
      "limits": [
        {
          "id": "gemini-5h",
          "window": "5h",
          "remaining_fraction": 0.92,
          "resets_at": "2026-10-24T14:08:32Z",
          "name": "Five Hour Limit Remaining",
          "group": "Gemini Models",
          "models": "Gemini Flash, Gemini Pro"
        },
        …
      ]
    },
    "claude": {
      "installed": true,
      "supported": true,
      "limits": [
        {
          "id": "five_hour",
          "window": "5h",
          "remaining_fraction": 0.93,
          "resets_at": "2026-10-24T23:50:00+07:00",
          "name": "Current session",
          "group": null,
          "models": null
        },
        { "id": "seven_day", "window": "weekly", "remaining_fraction": 0.16, … }
      ],
      …
    },
    "codex": { "installed": true, "supported": false, "limits": [], … },
    …
  }
}

Conversations

Cloud APIs don’t remember anything between requests. To carry on a conversation, send all of it in messages every time, including the earlier answers:

Cloud API: send the whole conversation
curl "$AI_THUN_URL/api/chat" \
  -H "Authorization: Bearer $AI_THUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "sea-lion",
    "messages": [
      {"role": "system", "content": "Answer in one short sentence."},
      {"role": "user", "content": "What is the capital of Cambodia?"},
      {"role": "assistant", "content": "Phnom Penh is the capital of Cambodia."},
      {"role": "user", "content": "How many people live there?"}
    ]
  }'

Local CLIs keep their own history. Every answer has a session_id; send it back with the next message, and the same workdir, to carry on. If you send messages to a CLI, only the last user message is used.

Local CLI: send the session_id back
# The first answer includes a session_id …
curl "$AI_THUN_URL/api/chat" \
  -H "Authorization: Bearer $AI_THUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "claude",
    "model": "sonnet",
    "prompt": "What does this project do?",
    "workdir": "/home/thun/Desktop/documents/Project/ai-thun"
  }'

# … send it back, with the same workdir, to carry on the conversation
curl "$AI_THUN_URL/api/chat" \
  -H "Authorization: Bearer $AI_THUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "claude",
    "session_id": "7352154e-6436-4025-b807-eea57954a1ff",
    "prompt": "How is it deployed?",
    "workdir": "/home/thun/Desktop/documents/Project/ai-thun"
  }'

Cloud providers

Each cloud provider has several API keys. Requests take turns across all of them, so together they handle many more requests than one key could. A key that answers 429 (rate limited) is skipped for the next one; if every key is, the request fails with 429, and its Retry-After header says how many seconds until the first key can be used again, when the provider said.

ProviderLimitsGood to know
SEA-LION10 requests a minute per keyThinks before answering, shown as thinking events. Turn it off with "chat_template_kwargs": {"enable_thinking": false}.
CerebrasDifferent for each modelVery fast. The account needs billing set up, or it answers 402.
Groq500,000 tokens a day per key and model (our own limit)When a key reaches it, the next key is used until the UTC day ends; when every key has, requests get 429 until then. gpt-oss models send thinking events.

Local agent CLIs

claude, codex, gemini and antigravity are real AI coding tools running on the machine, started for each request by local-agent-gateway. They work in the workdir folder: by default they can read the files there, but not change them or run commands.

CLISessionsno_toolsImagesReports tokensModels
Claude CodeYesYes—Yesfable, opus, sonnet, haiku, or a full claude-… name
CodexYesYesYes—From GET /api/models
Gemini CLIYes———Any model name
AntigravityYes——YesFrom GET /api/models
  • Each CLI runs at most 2 requests at a time. Others wait up to 30 seconds, then get 429.
  • A request that takes longer than 5 minutes is stopped, with 504.
  • When a CLI reaches its plan’s usage limit (Claude Code’s 5-hour or weekly limit, Codex’s, Gemini’s or Antigravity’s quota), the answer is 429, with retry_after when the CLI said when the limit resets.
  • Antigravity stops if it needs a permission nobody can grant while it runs on its own, such as running a command. The answer is 502, with the permissions in denied and the conversation in session_id: allow them in agy’s settings.json, then send that session_id to carry on.
  • Token counts are for that request alone, even in a long session, and input includes cached tokens, which are most of a Claude Code request.
  • Each CLI has to be signed in on the machine before it can answer.

Errors

Errors come back with an HTTP error status and a JSON body. Read its error field; errors from a local CLI use detail instead, and a cloud provider’s own errors are passed on in the provider’s format.

Example: 503
{ "error": "Provider \"cerebras\" is paused. Resume it on the Manage page of the Thun Murex console." }
StatusMeaning
400Something’s wrong with the request: invalid JSON, an unknown provider, no prompt or messages, or a bad workdir, session_id or model for a CLI.
401The key is missing or wrong, or this key can’t call this endpoint.
402The cloud provider wants payment (passed on from the provider).
403The access token is turned off or expired, or can’t use that provider (or images); workdir is outside the folders the gateway allows; or a request reached the gateway’s public door without coming through its address.
404No access token with that id.
429Too many requests: the access token has used its tokens or requests for the period, or its requests a minute (/api/me says which), 10 wrong keys from your address in 10 minutes (for 15 minutes), every key of a cloud provider is rate limited or at its daily limit, a CLI reached its plan’s usage limit, a CLI already has 2 running, or every Groq key is at its transcription limit. Retry-After says how many seconds to wait, when that’s known.
502The provider failed: it couldn’t be reached, or the CLI failed or isn’t signed in.
503The provider is paused. See Pause & resume.
504A CLI took longer than 5 minutes.

Some errors carry more fields, so a program can recover on its own:

FieldTypeDescription
retry_afternumberWith 429: seconds until the limit resets, the same as the Retry-After header. Only there when the provider or CLI said.
deniedarrayAntigravity: the permissions it needed and couldn’t get, e.g. ["command"].
session_idstringAntigravity, with denied: the conversation to carry on once the permissions are allowed.
partial_responsestringA CLI’s text from before it stopped.
stderrstringThe end of a CLI’s error output, with secrets hidden.
Example: a CLI at its usage limit
HTTP/1.1 429 Too Many Requests
Retry-After: 7140

{
  "detail": "claude exited with status 1: You've hit your limit · resets 3pm (Asia/Bangkok)",
  "retry_after": 7140
}
During a stream, a failure arrives as an error event instead, with the same fields and the status in status; the HTTP status stays 200.

VS Code & phone

The Thun Murex extension for VS Code (duchpanhathun.ai-thun) is a chat panel for this gateway: every provider and model, CLI sessions that carry on, teams of models that split up a task, approval of the CLI’s actions, and scheduled tasks and workflows.

  • Set the gateway’s address in the aiThun.baseUrl setting, and the key with the command Thun Murex: Set API Key. VS Code keeps the key in its secret storage.
  • It uses the error fields above: a team stopped by a usage limit carries on by itself once retry_after has passed, and an Antigravity run stopped for a permission picks up the same session_id.
  • Phone app: run Thun Murex: Connect a Phone and scan the code. From the phone you can chat, approve the agent’s actions from a notification, change models, and run schedules and workflows. The phone only talks to VS Code, over Tailscale; VS Code sends the requests here with its own key.