# Lero API Speech with a Brazilian accent in your product: Text-to-Speech (TTS) in MP3, Speech-to-Text (STT) for spoken Brazilian Portuguese, with timestamps for each sentence and ready subtitles, and a live Conversational Agent over WebRTC. Voice Clone is coming soon. The API is on the Creator and Dev plans: TTS uses 1 credit per character and STT, 150 credits per minute of audio. - MCP: Lero's voice inside Claude, Codex and Cursor, with one command. See https://lero.studio/docs/en/ai#mcp - Build with AI: paste one prompt into your coding agent and it integrates the API into your product. See https://lero.studio/docs/en/ai#prompt ## Direct integration Use Lero in Claude, Codex or Cursor without writing code. Create a key at [app.lero.studio/desenvolvedores](https://app.lero.studio/desenvolvedores?aba=chaves) and run your agent's command, with your key in place of `lero_your_key`: Claude Code: ```bash claude mcp add --env LERO_API_KEY=lero_your_key lero -- npx -y lero-mcp ``` Codex: ```bash codex mcp add lero --env LERO_API_KEY=lero_your_key -- npx -y lero-mcp ``` Cursor: ```json { "mcpServers": { "lero": { "command": "npx", "args": [ "-y", "lero-mcp" ], "env": { "LERO_API_KEY": "lero_your_key" } } } } ``` Claude Desktop: ```json { "mcpServers": { "lero": { "command": "npx", "args": [ "-y", "lero-mcp" ], "env": { "LERO_API_KEY": "lero_your_key" } } } } ``` In Cursor, the JSON goes in `~/.cursor/mcp.json`. In Claude Desktop, in Settings > Developer > Edit Config. Needs Node 18 or newer. Done: ask for audio in the chat, for example *make an audio in the Rio voice saying "bom dia, tudo certo?"*, and the agent saves the MP3 in `~/Downloads/Lero`. More about the MCP in [Build with AI](https://lero.studio/docs/en/ai#mcp). To put Lero in your product's code, paste the [integration prompt](https://lero.studio/docs/en/ai#prompt) into your coding agent, or follow the quickstart below. ## Quickstart 1. Create a key at [app.lero.studio/desenvolvedores](https://app.lero.studio/desenvolvedores?aba=chaves). The API is on the Creator and Dev plans. 2. Keep the key on your server, in the `LERO_API_KEY` variable. 3. Ask for your first speech: cURL: ```bash curl -X POST "https://app.lero.studio/api/v1/tts" \ -H "Authorization: Bearer $LERO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"texto": "Fala, tudo bem? Aqui é o Lero, direto do Rio.", "voz": "rio"}' \ --output fala.mp3 ``` Python: ```python import os import requests r = requests.post( "https://app.lero.studio/api/v1/tts", headers={"Authorization": f"Bearer {os.environ['LERO_API_KEY']}"}, json={"texto": "Fala, tudo bem? Aqui é o Lero, direto do Rio.", "voz": "rio"}, timeout=300, ) r.raise_for_status() with open("fala.mp3", "wb") as f: f.write(r.content) print("credits left:", r.headers["X-Creditos-Restantes"]) ``` JavaScript: ```javascript import { writeFile } from "node:fs/promises"; const res = await fetch("https://app.lero.studio/api/v1/tts", { method: "POST", headers: { "Authorization": `Bearer ${process.env.LERO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ texto: "Fala, tudo bem? Aqui é o Lero, direto do Rio.", voz: "rio" }), }); if (!res.ok) throw new Error(`${res.status} ${(await res.json()).erro}`); await writeFile("fala.mp3", Buffer.from(await res.arrayBuffer())); ``` Base URL for every route: `https://app.lero.studio/api/v1`. Requests and responses are JSON (UTF-8), except the audio: speech returns MP3 and [transcription](https://lero.studio/docs/en#transcricao) takes the file as `multipart/form-data`. Route names are in English: `/v1/tts`, `/v1/stt`, `/v1/voices`, `/v1/balance`, `/v1/agents` and `/v1/agents/conversation`. The old Portuguese paths (`/v1/fala`, `/v1/transcricao`, `/v1/vozes`, `/v1/saldo`, `/v1/agentes` and `/v1/agentes/conversa`) keep working the same way. ## Key Every route needs the key in the `Authorization: Bearer ` header (or in the `X-Api-Key` header). The key starts with `lero_` and shows in full only once, when you create it. After that, the app shows only the last 4 characters. - Use the key only on your server. Never in the browser, in a mobile app or in a repository: whoever has the key spends your credits. - Each key can have a name, a monthly credit cap and an expiry (1, 7, 30 or 90 days, or never). - Leaked? Revoke the key in the app. It stops at once, and you create another one. - Up to 10 active keys per account. ## Errors Every error comes back as JSON, with a stable code in the `erro` field and a message in Portuguese. Handle errors by `erro`, never by the `mensagem` text. Speech and transcription errors also carry `pedido` and, when a field is wrong, `campo`. ```json {"erro": "sem_credito", "mensagem": "Os créditos da conta acabaram.", "pedido": "3f9c1e0a7b2d4c6e8a1b3d5f7e9c0a2b"} ``` | status | erro | when | |---|---|---| | 400 | `texto_invalido` | The text is empty, longer than 500 characters, or the voice did not accept it (emojis and symbols). | | 400 | `voz_invalida` | Unknown voice. Use `rio`, `sp` or `niteroi`. | | 400 | `audio_invalido` | Transcription: the file is empty, has no readable audio, or the format is not supported. | | 400 | `url_invalida` | Transcription: `url` is not a public https link, did not answer 200, or could not be downloaded. | | 400 | `formato_invalido` | Transcription: `formato` is not `json`, `texto`, `srt` or `vtt`. | | 400 | `idioma_invalido` | Transcription: `idioma` is not `pt-BR`. | | 400 | `modelo_invalido` | `model` is not `Lero v1`. | | 400 | `pedido_invalido` | The body is not JSON. On transcription: the body is neither multipart nor JSON, or it has neither `audio` nor `url`, or both. | | 401 | `sem_chave` | No key in the request. | | 401 | `chave_invalida` | The key is wrong or was revoked. | | 401 | `chave_vencida` | The key expired. | | 402 | `sem_credito` | The account credits do not cover the request. On transcription, the JSON carries `custo` (the credits the audio needs) and `saldo`. | | 402 | `teto_da_chave` | The key reached its monthly credit cap. | | 403 | `plano` | The account is not on a plan with the API (Creator or Dev). | | 403 | `escopo` | The key does not have the Fala scope (it covers speech and transcription). | | 403 | `bloqueado` | The account is blocked. | | 413 | `audio_grande` | Transcription: the file in the body is over 4 MB (send it by `url`) or the one at `url` is over 500 MB. | | 413 | `audio_longo` | Transcription: the audio is longer than 2 h. | | 429 | `muitos_pedidos` | More than 60 requests per minute on the account. Wait for `Retry-After`. | | 429 | `simultaneas` | The plan is already at its limit of concurrent requests (Creator 5, Dev 10). Wait for one to finish. | | 429 | `ocupado` | The voice or the transcription is busy. Wait for `Retry-After`. | | 503 | `carregando` | The voice is starting up. Wait for `Retry-After`. | | 503 | `motor_desligado` | The voice or the transcription is offline. Wait for `Retry-After`. | | 502 | `falhou` | Speech generation or transcription failed. Try again. | | 504 | `demorou` | Speech generation or transcription timed out. Try again. | | 500 | `interno` | An error on our side. Try again; if it persists, send us the `X-Pedido-Id`. | | 400 | `oferta_invalida` | The SDP offer is missing, does not start with `v=0`, or is longer than 20,000 characters. | | 400 | `versao_invalida` | `versao` is not in the `GET /v1/agents` list for your key. | | 400 | `llm_invalido` | `llm` is not in the `GET /v1/agents` list. | | 400 | `roteiro_invalido` | A custom agent field is over its limit. The field name comes in `campo`. | | 502 | `sem_bot` | The agent did not answer the offer. Try again. | | 503 | `desligado` | The agents are offline now. Wait for `Retry-After`. | When speech generation fails after the charge, Lero refunds the credits at once: the `cobrado` field is `0` and the message ends with "Nada foi cobrado." ("Nothing was charged."). Transcription charges only on success: a transcription error never spends credits. ## Credits and limits ### Text-to-Speech (TTS) - 1 credit per character of the text, charged before generating and refunded if generation fails. - Up to 500 characters per request. For longer text, split it into sentences and join the MP3s. - Each request generates a new audio. Keep the MP3 if you will use it again. ### Speech-to-Text (STT) - 150 credits per minute of audio (2.5 per second), billed by the second and rounded up, only when the transcription succeeds. The whole file counts, silence included. - Up to 2 h of audio per request. A file in the body can be up to 4 MB; by `url`, up to 500 MB. - About 30 times real time: 1 h of audio comes back in about 2 min. Use a 300 s timeout. - Lero does not keep the audio or the transcript. Keep the response if you will use it again. ### The account - Up to 60 requests per minute per account, across all keys and routes. - Concurrent requests (speech and transcription): 5 on Creator and 10 on Dev. Above that, `429 simultaneas`. - Live conversation uses 1,000 credits per minute of call, charged per second at the end; starting a call needs at least 1 minute of balance. - The plan allowance arrives on the first use of each month (Brasília time). Unused credits roll over up to 2 times the plan quota; anything above that expires at the turn of the month. - Extra credits, Dev only, at the plan rate: valid for 6 months. ## Text-to-Speech (TTS) Text to speech, in MP3, with a Brazilian accent. ### Generate speech `POST /v1/tts` Turns the text into speech and returns the MP3 (`audio/mpeg`). Costs 1 credit per character of the text, charged before generating and refunded if generation fails. Prefer JSON? Send `Accept: application/json` (without `*/*`) and the audio comes as base64 in the `audio` field. Authentication: key with the Fala (speech) scope. Request body (JSON): - `texto` (string, required): What Lero will say, 1 to 500 characters. Leading and trailing spaces do not count. - `voz` (string, required): The voice: `rio` (Rio de Janeiro), `sp` (São Paulo) or `niteroi` (Niterói). - `model` (string, optional): `Lero v1`, the only speech model for now. Response headers: - `X-Pedido-Id` (string): The request id. Send it if you need support. - `X-Creditos-Custo` (integer): The credits charged for this request. - `X-Creditos-Restantes` (integer): The account balance after the request. - `X-Duracao` (number): The audio duration, in seconds. Responses: - `200`: The speech MP3, with the headers above. With `Accept: application/json`, the JSON on the side. - `400`: `texto_invalido` - `400`: `voz_invalida` - `400`: `modelo_invalido` - `400`: `pedido_invalido` - `401`: `sem_chave` - `401`: `chave_invalida` - `401`: `chave_vencida` - `402`: `sem_credito` - `402`: `teto_da_chave` - `403`: `plano` - `403`: `escopo` - `403`: `bloqueado` - `429`: `muitos_pedidos` - `429`: `simultaneas` - `429`: `ocupado` - `503`: `carregando` - `503`: `motor_desligado` - `502`: `falhou` - `504`: `demorou` - `500`: `interno` cURL: ```bash curl -X POST "https://app.lero.studio/api/v1/tts" \ -H "Authorization: Bearer $LERO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"texto": "Fala, tudo bem? Aqui é o Lero, direto do Rio.", "voz": "rio"}' \ --output fala.mp3 ``` Python: ```python import os import requests r = requests.post( "https://app.lero.studio/api/v1/tts", headers={"Authorization": f"Bearer {os.environ['LERO_API_KEY']}"}, json={"texto": "Fala, tudo bem? Aqui é o Lero, direto do Rio.", "voz": "rio"}, timeout=300, ) r.raise_for_status() with open("fala.mp3", "wb") as f: f.write(r.content) print("credits left:", r.headers["X-Creditos-Restantes"]) ``` JavaScript: ```javascript import { writeFile } from "node:fs/promises"; const res = await fetch("https://app.lero.studio/api/v1/tts", { method: "POST", headers: { "Authorization": `Bearer ${process.env.LERO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ texto: "Fala, tudo bem? Aqui é o Lero, direto do Rio.", voz: "rio" }), }); if (!res.ok) throw new Error(`${res.status} ${(await res.json()).erro}`); await writeFile("fala.mp3", Buffer.from(await res.arrayBuffer())); ``` Response · Accept: application/json (200): ```json { "pedido": "3f9c1e0a7b2d4c6e8a1b3d5f7e9c0a2b", "custo": 45, "saldo": 1499955, "model": "Lero v1", "voz": "rio", "duracao": 3.12, "formato": "mp3", "audio": "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYx..." } ``` ## Voices The Lero voices and their accents. ### List voices `GET /v1/voices` The list of voices, with id and name. Free. Authentication: key. Responses: - `200`: The voices. - `401`: `sem_chave` - `401`: `chave_invalida` - `401`: `chave_vencida` - `403`: `plano` - `403`: `bloqueado` cURL: ```bash curl "https://app.lero.studio/api/v1/voices" \ -H "Authorization: Bearer $LERO_API_KEY" ``` Python: ```python import os import requests r = requests.get( "https://app.lero.studio/api/v1/voices", headers={"Authorization": f"Bearer {os.environ['LERO_API_KEY']}"}, timeout=30, ) r.raise_for_status() print(r.json()) ``` JavaScript: ```javascript const res = await fetch("https://app.lero.studio/api/v1/voices", { headers: { "Authorization": `Bearer ${process.env.LERO_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.erro}`); console.log(data); ``` Response (200): ```json { "vozes": [ { "id": "rio", "nome": "Rio de Janeiro" }, { "id": "sp", "nome": "São Paulo" }, { "id": "niteroi", "nome": "Niterói" } ] } ``` ## Speech-to-Text (STT) Audio to text, in spoken Brazilian Portuguese (slang, acronyms and numbers), with timestamps for each sentence and ready subtitles. ### Transcribe audio `POST /v1/stt` Turns audio into text, in spoken Brazilian Portuguese: slang, acronyms, numbers and names come out right. Returns the text with paragraphs and the sentences with start and end times; on request, the time of each word or ready subtitles (SRT or VTT). Send the file in the `audio` field as `multipart/form-data` (up to 4 MB), or a public https link in `url` (up to 500 MB; a signed S3, GCS or R2 link works). Files over 4 MB go by `url`, in a JSON body with the same options. Formats: MP3, WAV, M4A, OGG, OPUS, WEBM, FLAC and MP4 video (Lero uses the video's audio). Costs 150 credits per minute of audio (2.5 per second), billed by the second and rounded up, only when the transcription succeeds: if it fails, nothing is charged. The whole file duration counts, silence included. Example: 11 min of audio cost 1,650 credits. Up to 2 h of audio per request. Transcription runs at about 30 times real time (1 h of audio in about 2 min): use a 300 s timeout. Lero does not keep the audio or the transcript: it deletes both when it responds. | `formato` | response | |---|---| | `json` (default) | JSON with the text, the sentences and the cost (on the side) | | `texto` | `text/plain`: just the text, with paragraphs | | `srt` | `application/x-subrip`: SRT subtitles | | `vtt` | `text/vtt`: WebVTT subtitles | In the JSON, `texto` has paragraphs (`\n\n`) at long pauses and `segmentos` has the sentences, with `inicio` and `fim` in seconds. With `timestamps=palavra`, each sentence also carries `palavras`: `[{inicio, fim, palavra}]`. `formatado` is `true` when the punctuation and capitalization pass covered the whole text. The `texto`, `srt` and `vtt` formats come in UTF-8. Authentication: key with the Fala scope (the same as speech). Request body (multipart/form-data or application/json): - `audio` (file, one of two): The audio file, up to 4 MB: MP3, WAV, M4A, OGG, OPUS, WEBM, FLAC or MP4 video. Send `audio` or `url`, never both. - `url` (string, one of two): Public https link to the audio, up to 500 MB; Lero downloads it. For files over 4 MB, use `url` (a signed S3, GCS, R2 etc. link works). - `formato` (string, optional): `json` (default), `texto` (text/plain), `srt` or `vtt` (ready subtitles). - `timestamps` (string, optional): `frase` (sentence, default) or `palavra` (word): each segment gets `palavras`, with the start and end of each word. Only with `json`. - `palavras_chave` (string[], optional): Keyterms: up to 50 names, brands, acronyms and terms to recognize and keep spelled as given, e.g. `["Lero", "Unflat", "CNPJ"]`. In multipart, as a comma-separated list: `Lero,Unflat,CNPJ`. - `formatar` (boolean, optional): The punctuation and capitalization pass, default `true`. With `false`, it returns faster, with the recognizer's punctuation; same price. - `idioma` (string, optional): `pt-BR`, the only one for now. - `model` (string, optional): `Lero v1`, the only transcription model for now. Response headers: - `X-Pedido-Id` (string): The request id. Send it if you need support. - `X-Creditos-Custo` (integer): The credits charged for this request. - `X-Creditos-Restantes` (integer): The account balance after the request. - `X-Duracao` (number): The audio duration, in seconds. Responses: - `200`: The transcription: the JSON on the side (`formato=json`) or the file itself (`texto`, `srt`, `vtt`), with the headers above. - `400`: `audio_invalido` - `400`: `url_invalida` - `400`: `formato_invalido` - `400`: `idioma_invalido` - `400`: `modelo_invalido` - `400`: `pedido_invalido` - `401`: `sem_chave` - `401`: `chave_invalida` - `401`: `chave_vencida` - `402`: `sem_credito` - `402`: `teto_da_chave` - `403`: `plano` - `403`: `escopo` - `403`: `bloqueado` - `413`: `audio_grande` - `413`: `audio_longo` - `429`: `muitos_pedidos` - `429`: `simultaneas` - `429`: `ocupado` - `503`: `motor_desligado` - `502`: `falhou` - `504`: `demorou` - `500`: `interno` cURL: ```bash # local file (up to 4 MB) curl -X POST "https://app.lero.studio/api/v1/stt" \ -H "Authorization: Bearer $LERO_API_KEY" \ -F "audio=@meeting.mp3" \ -F "palavras_chave=Lero,Unflat,CNPJ" \ --max-time 300 # public https link (up to 500 MB) and ready subtitles curl -X POST "https://app.lero.studio/api/v1/stt" \ -H "Authorization: Bearer $LERO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/meeting.mp3", "formato": "srt"}' \ --max-time 300 \ --output subtitles.srt ``` Python: ```python import os import requests H = {"Authorization": f"Bearer {os.environ['LERO_API_KEY']}"} # local file (up to 4 MB) with open("meeting.mp3", "rb") as f: r = requests.post( "https://app.lero.studio/api/v1/stt", headers=H, files={"audio": f}, data={"palavras_chave": "Lero,Unflat,CNPJ"}, timeout=300, ) r.raise_for_status() t = r.json() print(t["texto"]) for s in t["segmentos"]: print(s["inicio"], s["fim"], s["texto"]) # public https link (up to 500 MB) and ready subtitles r = requests.post( "https://app.lero.studio/api/v1/stt", headers=H, json={"url": "https://example.com/meeting.mp3", "formato": "srt"}, timeout=300, ) r.raise_for_status() with open("subtitles.srt", "wb") as f: f.write(r.content) ``` JavaScript: ```javascript import { readFile, writeFile } from "node:fs/promises"; const headers = { "Authorization": `Bearer ${process.env.LERO_API_KEY}` }; // local file (up to 4 MB) const form = new FormData(); form.append("audio", new Blob([await readFile("meeting.mp3")], { type: "audio/mpeg" }), "meeting.mp3"); form.append("palavras_chave", "Lero,Unflat,CNPJ"); const res = await fetch("https://app.lero.studio/api/v1/stt", { method: "POST", headers, body: form, signal: AbortSignal.timeout(300_000), }); const t = await res.json(); if (!res.ok) throw new Error(`${res.status} ${t.erro}`); console.log(t.texto); for (const s of t.segmentos) console.log(s.inicio, s.fim, s.texto); // public https link (up to 500 MB) and ready subtitles const srt = await fetch("https://app.lero.studio/api/v1/stt", { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://example.com/meeting.mp3", formato: "srt" }), signal: AbortSignal.timeout(300_000), }); if (!srt.ok) throw new Error(`${srt.status} ${(await srt.json()).erro}`); await writeFile("subtitles.srt", await srt.text()); ``` Response · formato=json (200): ```json { "pedido": "3f9c1e0a7b2d4c6e8a1b3d5f7e9c0a2b", "custo": 1650, "saldo": 1498350, "model": "Lero v1", "idioma": "pt-BR", "duracao": 659.4, "formatado": true, "texto": "Bom dia, pessoal. Hoje a pauta é o lançamento da campanha de verão.\n\nPerfeito. Então...", "segmentos": [ { "inicio": 0.0, "fim": 3.2, "texto": "Bom dia, pessoal." }, { "inicio": 3.4, "fim": 7.9, "texto": "Hoje a pauta é o lançamento da campanha de verão." } ] } ``` ## Voice Clone **Coming soon.** Your own voice from recordings of the voice owner, with their consent. ## Conversational Agent Real-time voice agent over WebRTC, with your system prompt, knowledge base and live context. In beta. ### List versions and LLMs `GET /v1/agents` Conversational Agent discovery: the agent versions your key can use, the LLMs, the voices and the recommended setup. Call it before opening a call: the list changes. Free. Authentication: key. Responses: - `200`: The current options. - `401`: `sem_chave` - `401`: `chave_invalida` - `401`: `chave_vencida` cURL: ```bash curl "https://app.lero.studio/api/v1/agents" \ -H "Authorization: Bearer $LERO_API_KEY" ``` Python: ```python import os import requests r = requests.get( "https://app.lero.studio/api/v1/agents", headers={"Authorization": f"Bearer {os.environ['LERO_API_KEY']}"}, timeout=30, ) r.raise_for_status() print(r.json()) ``` JavaScript: ```javascript const res = await fetch("https://app.lero.studio/api/v1/agents", { headers: { "Authorization": `Bearer ${process.env.LERO_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.erro}`); console.log(data); ``` Response (200): ```json { "ligado": true, "recomendado": { "versao": "v0.4", "llm": "oss120b", "voz": "rio" }, "versoes": [ { "id": "v0.4", "nome": "Agente v0.4", "lingua": "pt", "vozes": true } ], "llms": [ { "id": "oss120b", "nome": "..." } ], "vozes": [ "rio", "sp", "niteroi" ] } ``` ### Open a call (WebRTC) `POST /v1/agents/conversation` Live call signaling: you send the SDP offer of your WebRTC client and get the SDP answer. After that, audio and the `chat` data channel flow peer-to-peer between your client and the agent (streaming STT, LLM and TTS, with barge-in and turn detection). Without `versao`, `llm` and `voz`, the recommended setup applies. With `instrucoes`, the agent becomes yours: the system prompt replaces the Lero persona. Step by step in [Conversational Agent: step by step](#agente-guia). Authentication: key. Request body (JSON): - `type` (string, required): Always `offer`. - `sdp` (string, required): The full SDP offer, with complete ICE (non-trickle), up to 20,000 characters. - `versao` (string, optional): The agent version, an id from `GET /v1/agents`. Recommended: `v0.4`. - `llm` (string, optional): The LLM, an id from `GET /v1/agents`. Recommended: `oss120b`. - `voz` (string, optional): `rio`, `sp` or `niteroi`. Ignored on single-voice versions (`vozes: false`). - `instrucoes` (string, optional): System prompt, up to 30,000 characters. Replaces the Lero persona; voice rules (no markdown, code or URLs in speech) are appended. - `conhecimento` (string | array, optional): Knowledge base (RAG): text or `[{titulo, texto}]`, up to 50 items and 60,000 characters. The agent consults it and never reads it aloud. - `abertura` (string, optional): First message: how the agent opens the call. An instruction, not the literal sentence. - `palavras` (string[], optional): STT keywords: up to 40 names and terms to recognize. - `silencio_s` (false | number[], optional): Silence prompts. Off with `instrucoes`; `[20, 40, 60]` turns them on (2 nudges, then goodbye). - `pesquisa` (boolean, optional): Web search tool. Off with `instrucoes`. - `max_tokens` (integer, optional): Max length of each reply, 60 to 1000 (default 260). Responses: - `200`: The SDP answer: apply it with `setRemoteDescription`. - `400`: `voz_invalida` - `400`: `pedido_invalido` - `401`: `sem_chave` - `401`: `chave_invalida` - `401`: `chave_vencida` - `400`: `oferta_invalida` - `400`: `versao_invalida` - `400`: `llm_invalido` - `400`: `roteiro_invalido` - `502`: `sem_bot` - `503`: `desligado` cURL: ```bash curl -X POST "https://app.lero.studio/api/v1/agents/conversation" \ -H "Authorization: Bearer $LERO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "offer", "sdp": "v=0\r\no=- ...", "versao": "v0.4", "llm": "oss120b", "voz": "rio", "instrucoes": "Você é a recepção da Clínica Exemplo. Marca consultas e tira dúvidas de preço.", "conhecimento": [{ "titulo": "Preços", "texto": "Consulta: R$ 250..." }] }' ``` JavaScript: ```javascript // browser: mic and speaker. In production, send the offer to YOUR backend, which calls Lero with the key. const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }); const chat = pc.createDataChannel("chat"); // 1. data channel BEFORE the offer chat.onmessage = (e) => console.log(JSON.parse(e.data)); // events: estado, fala, latencia const mic = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true } }); mic.getTracks().forEach((t) => pc.addTrack(t, mic)); // 2. audio up const audio = new Audio(); audio.autoplay = true; pc.ontrack = (e) => { audio.srcObject = e.streams[0]; }; // agent voice down await pc.setLocalDescription(await pc.createOffer()); // 3. offer with complete ICE await new Promise((ok) => { if (pc.iceGatheringState === "complete") return ok(); pc.onicegatheringstatechange = () => pc.iceGatheringState === "complete" && ok(); setTimeout(ok, 2500); }); const res = await fetch("https://app.lero.studio/api/v1/agents/conversation", { // 4. signaling: offer → answer method: "POST", headers: { "Authorization": `Bearer ${LERO_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ type: "offer", sdp: pc.localDescription.sdp, versao: "v0.4", llm: "oss120b", voz: "rio" }), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.erro}`); await pc.setRemoteDescription(data); // done: the call starts // hang up: pc.close(); mic.getTracks().forEach((t) => t.stop()); ``` Python: ```python # headless (QA bots, tests, AI agents): pip install aiortc requests import asyncio, json, os import requests from aiortc import RTCPeerConnection, RTCSessionDescription from aiortc.contrib.media import MediaPlayer, MediaRecorder async def main(): pc = RTCPeerConnection() chat = pc.createDataChannel("chat") chat.on("message", lambda m: print(json.loads(m))) # transcript and state pc.addTrack(MediaPlayer("pergunta.wav").audio) # what the "person" says rec = MediaRecorder("resposta.wav") # what the agent says pc.on("track", lambda t: rec.addTrack(t)) await pc.setLocalDescription(await pc.createOffer()) # returns with complete ICE r = requests.post( "https://app.lero.studio/api/v1/agents/conversation", headers={"Authorization": f"Bearer {os.environ['LERO_API_KEY']}"}, json={"type": "offer", "sdp": pc.localDescription.sdp, "versao": "v0.4", "llm": "oss120b"}, timeout=20, ) r.raise_for_status() await pc.setRemoteDescription(RTCSessionDescription(**r.json())) await rec.start() await asyncio.sleep(30) await rec.stop() await pc.close() asyncio.run(main()) ``` Response (200): ```json { "type": "answer", "sdp": "v=0\r\no=- ..." } ``` ## Account The account credits. ### Credits left `GET /v1/balance` The account credits right now, with this month's allowance, and the plan. Free. Authentication: key. Response headers: - `X-Creditos-Restantes` (integer): The account balance. Responses: - `200`: The balance and the plan. - `401`: `sem_chave` - `401`: `chave_invalida` - `401`: `chave_vencida` - `403`: `plano` - `403`: `bloqueado` cURL: ```bash curl "https://app.lero.studio/api/v1/balance" \ -H "Authorization: Bearer $LERO_API_KEY" ``` Python: ```python import os import requests r = requests.get( "https://app.lero.studio/api/v1/balance", headers={"Authorization": f"Bearer {os.environ['LERO_API_KEY']}"}, timeout=30, ) r.raise_for_status() print(r.json()) ``` JavaScript: ```javascript const res = await fetch("https://app.lero.studio/api/v1/balance", { headers: { "Authorization": `Bearer ${process.env.LERO_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.erro}`); console.log(data); ``` Response (200): ```json { "saldo": 1499955, "plano": "Dev" } ``` ## Webhooks Webhooks are Lero calling **your** server when something happens, instead of you polling. Example: a Conversational Agent call ended, and your system gets the transcript, the duration and the end reason right away, to save in the CRM, score an interview or open a ticket. 1. At [app.lero.studio/desenvolvedores](https://app.lero.studio/desenvolvedores?aba=webhooks), Webhooks tab, add your server's `https` URL and choose the events (up to 5 webhooks per account). 2. Copy the webhook secret (`whsec_...`) to your server. 3. Click **Enviar teste** (send test): the `webhook.teste` event arrives. 4. On your server, verify the signature and answer `2xx` within 10 s. | event | when | |---|---| | `agente.conversa.encerrada` | A Conversational Agent call of your account ended. Carries version, LLM, voice, start, end, duration, end reason and the transcript. | | `webhook.teste` | You clicked Enviar teste. | Speech-to-Text answers in the same call, with no webhook. Voice Clone events arrive when that product launches. Each delivery is a `POST` with `Content-Type: application/json` and the headers `Lero-Evento`, `Lero-Evento-Id` and `Lero-Assinatura: t=,v1=`. `v1` is the HMAC-SHA256 of `"."` with the webhook secret. Reject when the signature does not match or when `t` is older than 5 minutes. ```json { "id": "6f1c2a9e-3b7d-4e58-9a0c-2d4f6b8e1a37", "evento": "agente.conversa.encerrada", "criado": "2026-11-11T15:42:10.512Z", "dados": { "conversa_id": "2eecec700b63", "versao": "v0.4", "llm": "oss120b", "voz": "rio", "lingua": "pt", "inicio": "2026-11-11T15:31:07Z", "fim": "2026-11-11T15:42:09Z", "duracao_s": 662.4, "motivo": "a pessoa desligou", "transcript": [ { "papel": "agente", "texto": "Oi! Aqui é a Bia, da Acme.", "t": 1.2 }, { "papel": "pessoa", "texto": "Oi, tudo bem?", "t": 4.8 } ] } } ``` Verify the signature (Node): ```javascript import crypto from "node:crypto"; // Next.js route: POST /api/lero-webhook. LERO_WEBHOOK_SECRET = o segredo do webhook (whsec_...) export async function POST(req) { const corpo = await req.text(); // o corpo cru, antes do JSON.parse const [t, v1] = (req.headers.get("lero-assinatura") || "").split(",").map((p) => p.split("=")[1]); const esperado = crypto.createHmac("sha256", process.env.LERO_WEBHOOK_SECRET).update(`${t}.${corpo}`).digest("hex"); const ok = v1 && v1.length === esperado.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(esperado)); if (!ok || Math.abs(Date.now() / 1000 - Number(t)) > 300) return new Response("assinatura inválida", { status: 401 }); const evento = JSON.parse(corpo); if (evento.evento === "agente.conversa.encerrada") { // salve evento.dados.transcript, avalie a entrevista, abra o ticket... } return new Response("ok"); // responda 2xx rápido; o trabalho pesado vai para uma fila } ``` Verify the signature (Python): ```python import hashlib, hmac, json, os, time from flask import Flask, request app = Flask(__name__) @app.post("/lero-webhook") def lero_webhook(): corpo = request.get_data(as_text=True) # o corpo cru partes = dict(p.split("=", 1) for p in request.headers.get("Lero-Assinatura", "").split(",") if "=" in p) esperado = hmac.new(os.environ["LERO_WEBHOOK_SECRET"].encode(), f"{partes.get('t')}.{corpo}".encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(partes.get("v1", ""), esperado) or abs(time.time() - int(partes.get("t", 0))) > 300: return "assinatura inválida", 401 evento = json.loads(corpo) if evento["evento"] == "agente.conversa.encerrada": pass # salve evento["dados"]["transcript"]... return "ok" ``` - Without a `2xx`, Lero retries after 2 s and 8 s (3 attempts). The event `id` is the same across attempts: use it to avoid processing twice. - Redirects are not followed: use the final URL. - The latest deliveries, with status and time, show in the Webhooks tab. ## Conversational Agent: step by step The Conversational Agent is a real-time voice call. HTTP only does discovery and signaling; then audio and events flow peer-to-peer over WebRTC. Typical latency ~1 s, with barge-in (the person interrupts and the agent stops). 1. **Discover.** `GET /v1/agents` returns the versions, LLMs and voices your key can use. 2. **Connect.** In the WebRTC client, create the `chat` data channel before the offer, add 1 audio track (the microphone) and wait for ICE gathering. 3. **Signaling.** Send the SDP offer to `POST /v1/agents/conversation` (from your backend, with the key) and apply the SDP answer. The call starts and the agent greets. 4. **Custom agent (optional).** In the same POST, `instrucoes` (system prompt), `conhecimento` (knowledge base), `abertura`, `palavras` and the settings make the agent yours. 5. **Live context (optional).** During the call, send on `chat` what the agent needs to see: the code in a live coding, the cart, the screen. 6. **Events.** Read the transcript, the state and the latency of each turn on `chat`. 7. **Hang up.** Close the `RTCPeerConnection`. Live context and control, over the data channel (every message needs the `type` field): ```javascript // live context: one panel per key, always the latest version (send the full text on change, ~every 2 s) chat.send(JSON.stringify({ type: "lero-contexto", chave: "codigo", texto: editor.getValue() })); chat.send(JSON.stringify({ type: "lero-contexto", chave: "testes", texto: "3/5 testes passaram", responder: true })); // system note: the person does not hear it; the agent acts on the next free turn (responder: false only stores it) chat.send(JSON.stringify({ type: "lero-instrucao", texto: "O tempo da fase 2 acabou. Passe para a fase 3." })); // exact utterance, bypasses the LLM chat.send(JSON.stringify({ type: "lero-falar", texto: "Faltam 5 minutos." })); ``` | message | fields | what it does | |---|---|---| | `lero-contexto` | `chave` (up to 60), `texto` (up to 20,000), `responder` | One live panel per key (up to 8). Only the latest version enters the next reply. Empty `texto` removes it. | | `lero-instrucao` | `texto` (up to 4,000), `responder` | System note the person does not hear: phase change, timer, hint. | | `lero-falar` | `texto` (up to 2,000) | The agent says exactly the text, bypassing the LLM. | Events on `chat` (field `tipo`): | tipo | fields | |---|---| | `estado` | `estado`: conectado, na_fila, ouvindo, pensando, falando, aviso_fim, encerrada; `motivo` at the end | | `fala` | `papel` (pessoa or agente), `texto`, `final`, `interrompido`: the live transcript | | `latencia` | `total_ms` and the turn stages | | `ignorado` | a short backchannel ("uhum") that did not interrupt the agent | The call ends when you close the connection, on the silence goodbye (if `silencio_s` is on) or after 10 minutes without speech. Every call is recorded (audio, transcript and agent config) for quality review. ## Examples A complete client, with a timeout and waits on `429` and `503`: JavaScript: ```javascript import { writeFile } from "node:fs/promises"; const espera = s => new Promise(ok => setTimeout(ok, s * 1000)); // generates the speech and returns the MP3; waits Retry-After on 429 and 503 async function falar(texto, voz = "rio", tentativas = 3) { for (let i = 0; i < tentativas; i++) { const res = await fetch("https://app.lero.studio/api/v1/tts", { method: "POST", headers: { "Authorization": `Bearer ${process.env.LERO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ texto, voz }), signal: AbortSignal.timeout(300_000), }); if (res.status === 429 || res.status === 503) { await espera(Number(res.headers.get("Retry-After")) || 10); continue; } if (!res.ok) { const { erro, mensagem } = await res.json(); throw new Error(`${res.status} ${erro}: ${mensagem}`); } return Buffer.from(await res.arrayBuffer()); } throw new Error("the voice stayed busy"); } await writeFile("fala.mp3", await falar("Bom dia! Seu pedido saiu pra entrega.", "sp")); ``` Python: ```python import os import time import requests def falar(texto, voz="rio", tentativas=3): """Generates the speech and returns the MP3; waits Retry-After on 429 and 503.""" for _ in range(tentativas): r = requests.post( "https://app.lero.studio/api/v1/tts", headers={"Authorization": f"Bearer {os.environ['LERO_API_KEY']}"}, json={"texto": texto, "voz": voz}, timeout=300, ) if r.status_code in (429, 503): time.sleep(int(r.headers.get("Retry-After", "10"))) continue if not r.ok: e = r.json() raise RuntimeError(f"{r.status_code} {e['erro']}: {e['mensagem']}") return r.content raise RuntimeError("the voice stayed busy") with open("fala.mp3", "wb") as f: f.write(falar("Oi! Aqui é o Lero, de Niterói.", voz="niteroi")) ``` ## Support Questions and problems: oi@lero.studio. Send the `X-Pedido-Id` of the request that failed. # Build with AI Two ways to use Lero with AI. The MCP puts Lero's voice inside Claude, Codex and Cursor. The prompt makes your coding agent integrate the API into your product. ## MCP The MCP puts Lero inside your agent. It gets 3 tools and starts generating audio straight from the chat, without you writing code. Run your agent's command, with your key in place of `lero_your_key`: Claude Code: ```bash claude mcp add --env LERO_API_KEY=lero_your_key lero -- npx -y lero-mcp ``` Codex: ```bash codex mcp add lero --env LERO_API_KEY=lero_your_key -- npx -y lero-mcp ``` Cursor: ```json { "mcpServers": { "lero": { "command": "npx", "args": [ "-y", "lero-mcp" ], "env": { "LERO_API_KEY": "lero_your_key" } } } } ``` Claude Desktop: ```json { "mcpServers": { "lero": { "command": "npx", "args": [ "-y", "lero-mcp" ], "env": { "LERO_API_KEY": "lero_your_key" } } } } ``` In Cursor, the JSON goes in `~/.cursor/mcp.json`. In Claude Desktop, in Settings > Developer > Edit Config. Needs Node 18 or newer. Then just ask, for example: *make an audio in the São Paulo voice saying "seu pedido saiu para entrega"*. | tool | what it does | cost | |---|---|---| | `lero_fala` | Turns text into speech and saves the MP3. Up to 500 characters per call. | 1 credit per character | | `lero_vozes` | Lists the voices. | free | | `lero_saldo` | Shows the account credits and the plan. | free | The MP3s go to `~/Downloads/Lero`. To change the folder, add `LERO_PASTA` to the config, the same way as the key. The key stays only in your agent's config, on your computer, and goes straight to the Lero API. ## The prompt Want Lero in your product's code? Paste this prompt into your coding agent (Claude Code, Codex, Cursor or another one that reads the web). It reads the docs and writes the integration. The same text is in [prompt.md](https://lero.studio/docs/en/prompt.md). ```markdown # Integrate the Lero API You will integrate the Lero voice API into my project. Follow these instructions. ## What Lero is Lero turns text into speech in Brazilian Portuguese, with a regional accent. You send the text and the voice and get back an MP3. Voices: `rio` (Rio de Janeiro), `sp` (São Paulo), `niteroi` (Niterói). Model: `Lero v1`. Lero also transcribes Brazilian Portuguese audio (Speech-to-Text): you send the file or a link and get back the text, the sentences with their timestamps, or ready subtitles (SRT or VTT). ## Read before writing code 1. https://lero.studio/docs/en/llms-full.txt: the complete reference in Markdown. 2. https://lero.studio/docs/en/openapi.json: the OpenAPI 3.1 contract of the routes. Follow the contract. Do not invent routes, fields or error codes. ## Connection - Base URL: https://app.lero.studio/api/v1 - Every request carries the header `Authorization: Bearer `. - Read the key from the `LERO_API_KEY` environment variable. Never write the key in the code. - Call the API only from the server. Never from the browser or a mobile app: whoever has the key spends the credits. ## Routes 1. `POST /v1/tts` with `{"texto": "...", "voz": "rio"}`: returns the MP3 (`audio/mpeg`). With `Accept: application/json` (without `*/*`), returns JSON with the audio as base64 in the `audio` field. 2. `POST /v1/stt` with the file in the `audio` field as `multipart/form-data` (up to 4 MB), or JSON with `url` (public https link, up to 500 MB): returns JSON with `texto` and `segmentos` (the sentences with `inicio` and `fim` in seconds). With `formato` `texto`, `srt` or `vtt`, returns the file itself. Optional: `timestamps` (`palavra`), `palavras_chave`, `formatar`. Only if the request involves transcription, dictation or subtitles. 3. `GET /v1/voices`: the list of voices. Free. 4. `GET /v1/balance`: the account credits and the plan. Free. 5. Conversational Agent (live voice over WebRTC): `GET /v1/agents` and `POST /v1/agents/conversation`. Only if the request is a live voice agent; follow the "Conversational Agent: step by step" section of llms-full.txt. ## Rules - Speech: 1 credit per character of the text, charged before generating and refunded if generation fails. - Speech: up to 500 characters per request. For longer text, split it into sentences (never mid-word) and join the MP3s in order. - Transcription: 150 credits per minute of audio, charged only on success. Up to 2 h per request. Files over 4 MB go by `url`. - Up to 60 requests per minute per account. On `429` or `503`, wait the seconds in the `Retry-After` header and try again. - Speech takes a few seconds and transcription a few minutes (1 h of audio in about 2 min): use a 300 s timeout. - Handle errors by the `erro` field (stable code), never by the `mensagem` text. - Keep the MP3 and the transcript if you will use them again: each request spends credits, and Lero does not keep the audio or the text. - Log the `X-Pedido-Id`, for support. ## Deliverables - An API client with a timeout, waits on `Retry-After` and error handling by `erro`. - Tests with mocked responses built from the `openapi.json` examples. - A short summary of what was done and how to run it. ``` ## Conversational Agent For your coding agent to integrate the live voice agent (for example, a technical interviewer that sees the code editor, or a support agent with your knowledge base), paste this prompt with your goal: ```text Integrate the Lero Conversational Agent into my project. Read https://lero.studio/docs/en/llms-full.txt (sections Conversational Agent and "Conversational Agent: step by step") and https://lero.studio/docs/en/openapi.json (/agents and /agents/conversation). Rules: the WebRTC client creates the "chat" data channel before the offer; signaling goes through my backend, which keeps LERO_API_KEY; versao, llm and voz come from GET /v1/agents; my system prompt goes in "instrucoes" and my knowledge base in "conhecimento"; what the agent must see during the call goes through "lero-contexto". Goal: . ``` The contract has the full request (`instrucoes`, `conhecimento`, `abertura`, `palavras`, `silencio_s`, `pesquisa`, `max_tokens`), the events and the errors. For automated tests without a browser, the agent can use the headless Python client of the [call route](https://lero.studio/docs/en#conversa). ## Docs for agents The agent can learn the API on its own by reading these files. Nothing to set up. - [llms.txt](https://lero.studio/docs/en/llms.txt): Short index of the docs, with one link per section and per route. - [llms-full.txt](https://lero.studio/docs/en/llms-full.txt): All the docs in one Markdown file. It is the best context for the agent. - [openapi.json](https://lero.studio/docs/en/openapi.json): OpenAPI 3.1 contract with the routes. Use it to generate a client and check examples. - [skill.md](https://lero.studio/docs/en/skill.md): The `lero-api` Agent Skill for coding agents, with the routes and the rules. ## Install the skill The `lero-api` skill teaches the API routes and rules to your coding agent. In Claude Code, install it with one command: ```bash mkdir -p ~/.claude/skills/lero-api && curl -fsSL https://lero.studio/docs/en/skill.md -o ~/.claude/skills/lero-api/SKILL.md ``` Then just ask, for example: *add the Lero voice to my backend*. The skill kicks in on its own when the request matches it. In other agents, point to [llms-full.txt](https://lero.studio/docs/en/llms-full.txt). ## Key Create the key at [app.lero.studio/desenvolvedores](https://app.lero.studio/desenvolvedores?aba=chaves), on the Creator and Dev plans. It shows in full only once. - Keep the key in an environment variable (`LERO_API_KEY`) or in the MCP config. - Never paste the key into the prompt, the chat or the code. - For the agent, create a key just for it, with a monthly credit cap and a short expiry. If it leaks, revoke it in the app.