> Section "Use with AI agents (MCP)" of the AthanLab Developer API documentation (API version 1.0.0). On the web: https://athanlab.com/docs#agents. Whole documentation as Markdown: https://athanlab.com/docs.md # Use with AI agents (MCP) `@athanlab/mcp` is an [MCP](https://modelcontextprotocol.io) server that lets AI agents in Claude Code, Codex (the CLI and the desktop app), Claude Desktop, Cursor and other MCP clients that run local (stdio) servers create, poll and download speech through this API with your key. Ask for audio in plain words: the agent creates the job, waits for it and saves the file in a local folder. A script becomes one audio file per line plus a `manifest.json` timeline, ready for subtitles and video. Or the agent sends the audio to [AthanInbox](https://athanlab.com/docs#athaninbox), to listen to on athanlab.com. The server runs on your machine, calls the same endpoints as the [Quickstart](https://athanlab.com/docs#quickstart) and spends the same balance. ## Requirements - Node.js 20 or later on the machine that runs the agent. - An API key (see [Get access](https://athanlab.com/docs#access)), ideally [one made for the agent](https://athanlab.com/docs#agent-keys). ## Install **Claude Code** (terminal) ```sh claude mcp add athanlab -e ATHANLAB_API_KEY=ak_live_your_key_here -- npx -y @athanlab/mcp ``` - Options go before `--`, the server command after it. The server is added to the current project. Add `--scope user` to use it in every project. Either way audio goes to an `athanlab-audio` folder in the project you are working in. - `-e` stores the key in plain text in `~/.claude.json`, and in your shell history unless you start the command with a space (zsh `HIST_IGNORE_SPACE`, bash `HISTCONTROL=ignorespace`). - To share a setup with a team, commit a `.mcp.json` that reads `${ATHANLAB_API_KEY}` from each person's environment, never one with a key in it. Check with `claude mcp list`. **Codex** (~/.codex/config.toml) ```toml [mcp_servers.athanlab] command = "npx" args = ["-y", "@athanlab/mcp"] env_vars = ["ATHANLAB_API_KEY"] startup_timeout_sec = 30 tool_timeout_sec = 960 ``` - For the Codex desktop app, use the Codex desktop tab: the app reads this same file, but `env_vars` finds no key there. - With this entry, `env_vars` is required. Codex starts MCP servers with a filtered environment, so without it the key never arrives. It forwards `ATHANLAB_API_KEY` only if the variable is set in the shell that launches Codex. Export the key in that shell, or in its profile, before you start Codex. - Codex stops waiting for a tool after 60 seconds unless told otherwise, but `athanlab_speak` and `athanlab_speak_script` wait up to 15 minutes by default, hence `tool_timeout_sec = 960`. Keep any `timeout_seconds` you give them below it. If a call is still cut off, finish its job rather than start it again (see [Tools](https://athanlab.com/docs#mcp-tools)). - `startup_timeout_sec = 30` leaves room for the first `npx` download. - Codex starts the server in the session folder, so audio goes to an `athanlab-audio` folder there. - `codex mcp add athanlab --env ATHANLAB_API_KEY=ak_live_your_key_here -- npx -y @athanlab/mcp` also works, but it stores the key in plain text in `config.toml` and still needs `tool_timeout_sec` added by hand, and `startup_timeout_sec` too, since Codex otherwise waits only 10 seconds for the server to start. **Codex desktop** (~/.codex/config.toml) ```toml [mcp_servers.athanlab] command = "npx" args = ["-y", "@athanlab/mcp"] startup_timeout_sec = 30 tool_timeout_sec = 960 [mcp_servers.athanlab.env] ATHANLAB_API_KEY = "ak_live_your_key_here" ATHANLAB_OUTPUT_DIR = "/Users/you/Documents/athanlab-audio" PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" ``` - In the app, open Settings → MCP servers → Add server → STDIO, give the command, Save, then Restart. Editing `~/.codex/config.toml` works too: the app shares it with Codex CLI. Either way the entry needs the `env` table and both timeouts above. - The key goes in `env`. The app does not load your shell profile, so the servers it starts get none of the variables `~/.zshrc` exports, and `env_vars = ["ATHANLAB_API_KEY"]` finds nothing. - `PATH` must include the folder that holds `node`. The app's own PATH does not, and `npx` runs on node, so without it the server never starts (`env: node: No such file or directory`). Find the folder with `dirname "$(command -v node)"` and put the absolute path it prints first in `PATH`: `/opt/homebrew/bin` for Homebrew on Apple Silicon, `/usr/local/bin` for Homebrew on Intel (both already in the PATH above), `/Users/you/.nvm/versions/node//bin` with nvm. - Give the output folder as an absolute path. The app starts the server in the thread's project folder, but one server can serve several threads. - The timeouts are the CLI's: `tool_timeout_sec = 960` outlasts the speaking tools' 15-minute wait, and `startup_timeout_sec = 30` leaves room for the first `npx` download. - The key sits in this file in plain text. Keep the file private (`chmod 600 ~/.codex/config.toml`) and never commit or share it. - Codex CLI reads the same file and the same `[mcp_servers.athanlab]` entry, so keep only this one: it works for the CLI too. The Codex tab's `env_vars` entry keeps the key out of the file, but finds no key in the app. **Claude Desktop** (claude_desktop_config.json) ```json { "mcpServers": { "athanlab": { "command": "npx", "args": ["-y", "@athanlab/mcp"], "env": { "ATHANLAB_API_KEY": "ak_live_your_key_here", "ATHANLAB_OUTPUT_DIR": "/Users/you/Documents/athanlab-audio" } } } } ``` - Open Settings → Developer → Edit Config, then quit and restart the app. - Claude Desktop does not read your shell profile, so the key sits in this file in plain text. Keep the file private. - Give the output folder as an absolute path. **Cursor** (~/.cursor/mcp.json) ```json { "mcpServers": { "athanlab": { "type": "stdio", "command": "npx", "args": ["-y", "@athanlab/mcp"], "env": { "ATHANLAB_API_KEY": "${env:ATHANLAB_API_KEY}", "ATHANLAB_OUTPUT_DIR": "${userHome}/athanlab-audio" } } } } ``` - `${env:ATHANLAB_API_KEY}` reads the key from your environment, so it stays out of the file. - For one project, put the same entry in that project's `.cursor/mcp.json` with `${workspaceFolder}/athanlab-audio` as the output folder. ## Agent skill The package ships the [athanlab-voice](https://unpkg.com/@athanlab/mcp/skill/athanlab-voice/SKILL.md) skill. It teaches an agent when to use these tools, how to write text for speech, cast voices, quote before spending and turn a manifest into subtitles. The skill needs the MCP server and adds no tools of its own. **Claude Code** (terminal) ```sh mkdir -p ~/.claude/skills/athanlab-voice curl -fsSL https://unpkg.com/@athanlab/mcp/skill/athanlab-voice/SKILL.md \ -o ~/.claude/skills/athanlab-voice/SKILL.md ``` **Codex** (terminal) ```sh mkdir -p ~/.agents/skills/athanlab-voice curl -fsSL https://unpkg.com/@athanlab/mcp/skill/athanlab-voice/SKILL.md \ -o ~/.agents/skills/athanlab-voice/SKILL.md ``` The Codex desktop app uses the same skill folder as Codex CLI. For one project, put it in `.claude/skills/athanlab-voice/` (Claude Code) or `.agents/skills/athanlab-voice/` (Codex) inside that project instead. Start a new session to load it. ## Tools The server registers these tools. Those marked “no charge” spend no characters, but their API requests still count toward the [rate limits](https://athanlab.com/docs#limits). | Tool | What it does | Scopes | Characters | | --- | --- | --- | --- | | `athanlab_list_voices` | The voices the key can use, official and from your library, and the default voice. | voices:read | no charge | | `athanlab_preview_voice` | A signed sample URL of one voice, valid about an hour; with save: true it also downloads the sample. | voices:read | no charge | | `athanlab_quote` | A dry run: what a text would cost, how many pieces it becomes and whether the balance covers it. | speech:write | no charge | | `athanlab_speak` | One text to one audio file: creates the job, polls it and downloads the audio. | speech:write, speech:read | spends characters | | `athanlab_speak_script` | A script of lines to one file per line, manifest.json and optionally combined.wav. Quotes first; re-running it resumes. | speech:write, speech:read (with usage:read: runs several lines at once) | spends characters | | `athanlab_send_to_inbox` | A title and its text, or your own chapters, to your AthanInbox on athanlab.com. Charged up front, failed chapters refunded; quotes first above the thresholds; returns at once. | speech:write (with speech:read: waits for it with wait_seconds) | spends characters | | `athanlab_get_delivery` | One delivery: status, each chapter, characters charged and refunded, and its AthanInbox link. | speech:read | no charge | | `athanlab_get_job` | One job: status, progress, characters charged and refunded, audio details. | speech:read | no charge | | `athanlab_list_jobs` | A page of jobs, newest first, optionally only those in one status. | speech:read | no charge | | `athanlab_cancel_job` | Stops a job that is still processing; the unprocessed share is refunded. | speech:write | refunds characters | | `athanlab_download_audio` | Saves a finished job’s audio, as MP3 or WAV, into the output folder. | speech:read | no charge | | `athanlab_usage` | Plan, balance, this key’s monthly budget and the jobs running against the limit. | usage:read | no charge | It also serves the `athanlab-writing-guide` prompt (and the resource `athanlab://guides/writing`): the rules of [Writing text for speech](https://athanlab.com/docs#writing-text), for the agent to read before it writes text, or to rewrite a draft you give it. > [!WARNING] > **Finish a job, don’t repeat it** > > A job keeps running after the client stops waiting for it. Finish it with `athanlab_get_job` and `athanlab_download_audio`, or by calling the same script again, and check a delivery with `athanlab_get_delivery` instead of sending it again. Speaking the same text again pays twice. ## AthanInbox Send audio to your own AthanInbox, and listen on athanlab.com. AthanInbox is the inbox of your AthanLab account, on the web at [athanlab.com/dashboard/inbox](https://athanlab.com/dashboard/inbox). AthanInbox comes to the AthanLab mobile app in a later update. Instead of saving files, an agent with `athanlab_send_to_inbox`, or your own code with `POST /deliveries`, sends a title and its text there. You listen when it suits you, chapter by chapter, with a transcript that follows along and the source links that came with it. A delivery always goes to AthanInbox of the account that owns the key, never to anyone else. **1. Charged up front** The whole delivery, every chapter, is charged when it is accepted, and the response is `202 Accepted` at once. A chapter that cannot be made is refunded automatically, to the balance it was charged from (a monthly allowance that has reset since is not carried over), and the other chapters still arrive. **2. Made in the background** The chapters are made one at a time, in order, after the response. A chapter still waiting after 7 days expires and is refunded. **3. Listen on athanlab.com** Sign in to athanlab.com with the account that owns the key and open AthanInbox. Progress shows there while the chapters are made. `app_url`, `https://athanlab.com/app/inbox/`, opens the delivery in AthanInbox for the signed-in owner only. Open it on a computer for now: on a phone with the AthanLab app installed, the link opens the app, which gets AthanInbox in a later update. On a phone, open [athanlab.com/dashboard/inbox](https://athanlab.com/dashboard/inbox) in the browser. **4. Kept until you delete it** A job's audio is deleted after 30 days, but a delivery stays in AthanInbox until you delete it there or delete your account. Deleting one while it is being made refunds every chapter not yet made and the unmade share of the one in progress. > [!WARNING] > **A delivery is charged when it is accepted** > > Every chapter is paid for before any of its audio exists, and only a chapter that fails is refunded. Quote a large delivery first with `"dry_run": true`, and give an agent a key with a monthly budget (see [A key for each agent](https://athanlab.com/docs#agent-keys)). | Status | Meaning | Characters | | --- | --- | --- | | `queued` | Accepted and charged. No chapter has started yet. | Charged | | `processing` | The chapters are being made. `progress` rises from 0 toward 1. | Charged | | `succeeded` | Every chapter is ready in AthanInbox. | Charged | | `partially_succeeded` | Some chapters are ready. Each failed chapter's `error.code` says why (see the codes below). | Failed chapters refunded | | `failed` | No chapter could be made. | Refunded, automatically | | `deleted` | You deleted it in AthanInbox. Only its charge and refunds stay readable. | Chapters not yet made were refunded | A delivery takes up to 50 chapters of at most 5,000 characters each, 100,000 in total, a title of up to 120 characters and up to 20 https source links, which AthanInbox shows and AthanLab never fetches. Each chapter is checked like the text of a job before anything is charged (see [Writing text for speech](https://athanlab.com/docs#writing-text)). An account can have 3 deliveries unfinished at once. Send an `Idempotency-Key` with every `POST /deliveries` (see [Safe retries](https://athanlab.com/docs#idempotency)), and add `"dry_run": true` for a free [DeliveryQuote](https://athanlab.com/docs#schema-DeliveryQuote) (see [Quote first with dry_run](https://athanlab.com/docs#dry-run)). `GET /deliveries/{id}` reports each chapter and what the delivery cost. Polling is optional, since AthanInbox shows the progress; if you poll, wait at least the `Retry-After` of the create response (30 seconds) between polls. A failed chapter reports one of these codes in its `error.code`, and its characters are refunded: | Code | Retryable | Meaning | | --- | --- | --- | | `input_rejected` | No | The chapter text could not be turned into speech. Change it before sending it again. The characters were refunded. | | `capacity_unavailable` | Yes | No speech capacity became available for this chapter. Send it again later. The characters were refunded. | | `generation_failed` | Yes | Speech generation failed for this chapter. Send it again. The characters were refunded. | | `interrupted` | Yes | The chapter was interrupted by server restarts. Send it again. The characters were refunded. | | `voice_unavailable` | No | The voice was no longer available when the chapter ran. Send it again with another voice. The characters were refunded. | | `expired` | Yes | The chapter waited too long to be generated. Send it again. The characters were refunded. | **From an agent.** `athanlab_send_to_inbox` takes a title and the text, and splits long text into chapters of at most 5,000 characters at paragraph and sentence ends, or takes your own chapters. Above 2,000 characters or 20 chapters it returns a free quote and sends nothing until it is called again with `confirm: true`. It returns at once with the delivery's id, status and `app_url`, and `athanlab_get_delivery` checks on it. The same arguments within 24 hours return the first delivery and charge nothing, while `send_again: true` sends a second copy, charged again. Nothing is saved on your machine: you listen in AthanInbox on athanlab.com. Sending needs a key with the `speech:write` scope (`speech:read` to read a delivery back) and the same access as creating a job: an approved application and the Max plan (see [Get access](https://athanlab.com/docs#access)). Full details: [POST /deliveries](https://athanlab.com/docs#endpoint-post-deliveries) and [GET /deliveries/{id}](https://athanlab.com/docs#endpoint-get-deliveries-id) in the endpoint reference. ## Configuration The server is configured with these environment variables. For its default output folder it also reads `CLAUDE_PROJECT_DIR`, which Claude Code sets itself. | Variable | Required | Meaning | | --- | --- | --- | | `ATHANLAB_API_KEY` | Yes | Your API key. It is never logged or returned to the model. | | `ATHANLAB_OUTPUT_DIR` | No | Where audio and manifests are written, and nothing goes outside it. The default is an athanlab-audio folder in Claude Code’s project, or else in the server’s working directory (for Codex CLI, the session folder). A single file never overwrites another (a clashing name gets a suffix), but a script re-run updates the files in its own out_subdir. | | `ATHANLAB_MAX_CHARS_PER_CALL` | No | An optional cap: a speak, script or send call with more characters is rejected before anything is sent. | | `ATHANLAB_API_BASE` | No | The API base URL. Default https://api.athanlab.com/api/v1. | | `ATHANLAB_LOG_LEVEL` | No | debug for more detail in the server’s log (stderr, which the client keeps). The key is removed from every log line. | ## A key for each agent An agent spends real characters on its own, so give it a key you can cap and switch off. When you create it on the [Developer API page](https://athanlab.com/dashboard/api): - **A separate key** for each agent or machine, named for where it runs, so you can see what it spends and revoke it without touching your servers. - **Only the scopes it needs.** All four for everything the server does. Without `speech:write` it can list voices, read jobs and deliveries, download audio and check usage, but not create or cancel jobs or send to AthanInbox. - **A low monthly character budget**, for example 20,000. Past it, creating a job returns [429 key_budget_exceeded](https://athanlab.com/docs#errors-key_budget_exceeded) and nothing is charged, so a looping agent cannot drain your balance. - **A short expiry**: 30 days, the shortest there is. Create a new key when it runs out. - **Keep it out of prompts and repositories.** The key belongs in the server's environment. The model never needs to see it, so never paste it into a chat. - **Rotate or revoke.** If a key leaks, revoke it at once, or rotate it and choose **Stop the old secret immediately** (see [Authentication](https://athanlab.com/docs#authentication)). The server checks the key's format on your machine before any request, so a malformed key (a missing prefix, a truncated paste, the placeholder) never reaches the API or counts toward the per-network limit on failed key checks. A well-formed but wrong key is sent once. After one [401 invalid_api_key](https://athanlab.com/docs#errors-invalid_api_key), the server sends nothing more until it restarts with a new key. **Spending brakes.** `athanlab_speak_script` returns its quote and creates nothing above 2,000 characters or 20 lines until it is called again with `confirm: true`. `athanlab_send_to_inbox` asks the same way above 2,000 characters or 20 chapters. `ATHANLAB_MAX_CHARS_PER_CALL` caps any single call. ## Example: a two-speaker scene Ask the agent in plain words, with the script: > Voice this scene into scene01 with a female voice for Aye and a male voice for Min, combine it into one track, then write scene01.srt from the manifest. > > Aye: မင်္ဂလာပါ။ နေကောင်းလား။ > > Min: နေကောင်းပါတယ်။ ကျေးဇူးတင်ပါတယ်။ > > Min: ဒီနေ့ ရာသီဥတု သာယာတယ်နော်။ > > Aye: ဟုတ်တယ်။ လမ်းလျှောက်ကြရအောင်။ It calls `athanlab_speak_script` with one entry per line and a voice for each speaker: athanlab_speak_script arguments: ```json { "lines": [ { "id": "l1", "speaker": "Aye", "text": "မင်္ဂလာပါ။ နေကောင်းလား။" }, { "id": "l2", "speaker": "Min", "text": "နေကောင်းပါတယ်။ ကျေးဇူးတင်ပါတယ်။" }, { "id": "l3", "speaker": "Min", "text": "ဒီနေ့ ရာသီဥတု သာယာတယ်နော်။" }, { "id": "l4", "speaker": "Aye", "text": "ဟုတ်တယ်။ လမ်းလျှောက်ကြရအောင်။" } ], "voices": { "Aye": "athanlab-default-female-v1", "Min": "athanlab-default-male-v1" }, "out_subdir": "scene01", "combine": true } ``` > [!WARNING] > **Re-runs match lines by position** > > A re-run with the same `out_subdir` skips finished lines without paying again. Lines are matched by position: inserting or removing a line regenerates, and charges, every line after it, and changing the output format regenerates them all. Add new lines at the end, or give a new version its own `out_subdir`. In `scene01/` inside the output folder (`athanlab-audio` by default) it writes one WAV file per line (`001-l1.wav` to `004-l4.wav`), `manifest.json` and, with `combine`, `combined.wav`. The manifest puts every line on one timeline: manifest.json (trimmed): ```json { "version": 1, "output_format": "wav", "gaps": { "sentence": 0.4, "turn": 0.55 }, "timeline_complete": true, "total_duration_seconds": 10.8, "combined_file": "combined.wav", "lines": [ { "index": 0, "id": "l1", "speaker": "Aye", "voice_id": "athanlab-default-female-v1", "text": "မင်္ဂလာပါ။ နေကောင်းလား။", "file": "001-l1.wav", "characters_charged": 23, "duration_seconds": 2.1, "start_seconds": 0, "end_seconds": 2.1 }, { "index": 1, "id": "l2", "speaker": "Min", "voice_id": "athanlab-default-male-v1", "text": "နေကောင်းပါတယ်။ ကျေးဇူးတင်ပါတယ်။", "file": "002-l2.wav", "characters_charged": 31, "duration_seconds": 2.4, "start_seconds": 2.65, "end_seconds": 5.05 }, { "index": 2, "id": "l3", "speaker": "Min", "voice_id": "athanlab-default-male-v1", "text": "ဒီနေ့ ရာသီဥတု သာယာတယ်နော်။", "file": "003-l3.wav", "characters_charged": 26, "duration_seconds": 2.2, "start_seconds": 5.45, "end_seconds": 7.65 }, { "index": 3, "id": "l4", "speaker": "Aye", "voice_id": "athanlab-default-female-v1", "text": "ဟုတ်တယ်။ လမ်းလျှောက်ကြရအောင်။", "file": "004-l4.wav", "characters_charged": 29, "duration_seconds": 2.6, "start_seconds": 8.2, "end_seconds": 10.8 } ] } ``` Each line starts `gaps.turn` seconds (0.55 by default) after the previous one ends when the speaker changes, and `gaps.sentence` seconds (0.4) when the same speaker goes on. `combined.wav` has exactly these gaps as silence, so the manifest's times are times in it. The real file also gives each line its `job_id` and status. > [!NOTE] > **Gaps are measured between files** > > The API does not report speech timing yet, so gaps are measured file edge to file edge. A file can begin and end with a little silence, which makes the pause you hear slightly longer than the gap, and a subtitle cue starts with its file rather than its first word. From the manifest the agent writes one subtitle cue per line, from `start_seconds` to `end_seconds`: scene01.srt: ```srt 1 00:00:00,000 --> 00:00:02,100 မင်္ဂလာပါ။ နေကောင်းလား။ 2 00:00:02,650 --> 00:00:05,050 နေကောင်းပါတယ်။ ကျေးဇူးတင်ပါတယ်။ 3 00:00:05,450 --> 00:00:07,650 ဒီနေ့ ရာသီဥတု သာယာတယ်နော်။ 4 00:00:08,200 --> 00:00:10,800 ဟုတ်တယ်။ လမ်းလျှောက်ကြရအောင်။ ``` Use `combined.wav` as the voice track, or place each line's file at its `start_seconds` in your editor. This ffmpeg command puts the track under a video and keeps the video's full length: Shell: ```sh ffmpeg -i video.mp4 -i combined.wav -map 0:v -map 1:a -c:v copy -af apad -c:a aac -shortest out.mp4 ``` Burned-in subtitles need a font with Burmese glyphs.