# AO Router > One API key for AI models, tools and infrastructure. Call hundreds of capabilities (text and image models, video, audio, avatars, web scraping and search, SEO data, social networks, Google Analytics and Search Console, public research), buy and manage domain names, and deploy apps, all through one REST API or one MCP server. Everything is paid from a single prepaid wallet in USD. This file is written for AI agents and developers. It is enough to use the whole platform: read it once, then discover exact schemas and prices live from the catalog. ## Essentials - Base URL: `https://apis.ao.me` - Authentication: `Authorization: Bearer ` on every request. Keys are created by the account owner in the dashboard (https://llmrouter-dashboard.rbhxbe.easypanel.host). Never put a key in a URL or a query string. - MCP server: `https://apis.ao.me/mcp` (Streamable HTTP), same key in the `Authorization` header. - OpenAI-compatible models: `https://apis.ao.me/v1` (chat completions, embeddings, model list) with the same key. - Every price is in USD and written `1.80 USD`. Prices shown to your key already include your account's pricing. - A key only reaches the capability groups its owner enabled. A call outside them returns `403`. ## The one mechanic: discover, call, follow Every capability, whatever it does, works the same way. 1. **Discover.** `GET /catalog/search?q=&offset=0` returns matching capabilities, best matches first, 20 per page: id, name, a one-line summary and the price. Pass `next_offset` for more, and `category` (from `categories`) to narrow. `GET /catalog/endpoints/` returns the exact JSON input schema, method, price and an example. Image and video models: `GET /catalog/endpoints/?model=` returns the schema of that model only. Discovery is free and never executes anything. 2. **Call.** `POST /call/` with the JSON input as the body. Read calls answer directly. Long jobs answer `202` with a call id. 3. **Follow.** `GET /calls/` returns the status, the output and the settled cost. Poll it, every few seconds for short jobs and less often for long ones. The actual cost of a call is returned in the `X-Router-Cost-USD` response header, and in `call_status` for jobs. ```bash curl -s "https://apis.ao.me/catalog/search?q=scrape%20a%20web%20page" -H "Authorization: Bearer $KEY" curl -s "https://apis.ao.me/catalog/endpoints/web.scrape" -H "Authorization: Bearer $KEY" curl -s -X POST "https://apis.ao.me/call/web.scrape" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"url":"https://example.com"}' ``` ## MCP: connect an assistant Any MCP client (Claude, Cursor, custom agents) can use the platform. Configuration: ```json { "mcpServers": { "ao-router": { "type": "http", "url": "https://apis.ao.me/mcp", "headers": { "Authorization": "Bearer " } } } } ``` The MCP server exposes a few generic tools, which reach every capability of the catalog: - `catalog_search` `{query?: string, offset?: integer, category?: string}`: Find capabilities and models by keyword. Returns ranked one-line summaries with their price, 20 per page (pass next_offset for more). Read the full input schema with catalog_get before calling. Discovery never executes an operation. - `catalog_get` `{endpoint_id: string, model?: string}`: Full description of one operation: every input field explained, method, price and an example. Read it before calling. - `connections_list` `{}`: List only accounts explicitly authorized for this project key. - `call` `{endpoint_id: string, query?: object, body?: any, connection_id?: string, idempotency_key?: string}`: Execute an operation. May spend money or publish/change provider data depending on the selected endpoint. Use an authorized connection_id for personal accounts. - `call_status` `{call_id: string}`: Read the status and settlement of a previous call from this project. Typical MCP flow: `catalog_search` → `catalog_get` → `call` (with `idempotency_key` for paid operations) → `call_status` until the status is final. With MCP, a personal account (social network, Google, Slack) is selected with `connection_id` from `connections_list`. Over HTTP, use the `X-Router-Connection` header. ## Money and safety rules - **Prepaid wallet.** The account owner tops up the wallet. Each call is debited at the price in force when it started. A price is never recomputed afterwards. - **No spending below zero.** When the balance reaches zero, paid calls return `402 insufficient_balance` and apps are suspended until the next top-up. Free reads keep working. - **Idempotency-Key.** Every paid operation requires an `Idempotency-Key` header (MCP: `idempotency_key`). Use a new unique value for each intent, and reuse the same value when you retry the same intent after a network error. The same key with a different body returns `409`. - **Confirmed writes.** Operations that spend money or change something outside (publish, send, buy, deploy, delete) require `"confirmed": true` in the input. Only set it after the user agreed. - **Pending or unknown is not failed.** A `202`, `pending` or `unknown` status means the work may still happen or may already be paid. Never resubmit it. Poll `GET /calls/` (or the operation's own status call) until the status is final. The platform reconciles unknown outcomes automatically. - **Price caps.** Some operations accept a maximum (`max_price_usd`, `max_cost_usd`). The call is refused instead of exceeding it. - **Limits.** Request bodies up to 8 MiB, responses up to 8 MiB. Streaming is not available through `/call` (use `/v1` for streamed model output). ## Capabilities Always read the exact schema and price with `catalog_get` before calling. The families below are a map, not the full list. ### AI models and text - `/v1/*`: OpenAI-compatible chat completions, with fallback between equivalent deployments handled for you. `GET /v1/models` lists the models your key may use. - `text.models` lists the public model aliases enabled for your key. `text.generate` takes `{model, prompt, system?, max_tokens?}`. Always use those aliases, never a vendor prefix. - `ai.judge` answers structured questions about evidence you provide: `{state, questions}`, where each question has a `type` (`choice`, `score` or `noul`), `instructions` and `criteria`. At most 10 questions and 10 KB of evidence. ### Media - `image.generate`, `image.design`, `video.generate`: search models by name with `catalog_search`, then read one model's schema with `catalog_get(endpoint_id, model)`. Recent models use `{model, parameters}`. Generations are jobs: poll `/calls/`. - `audio.models`, `audio.voices` (needs `model`, returns compatible voices), `audio.generate` (MP3 in `output.audio_base64`). - `avatar.list`, `avatar.voices`, `avatar.generate`, `avatar.animate`: talking avatars and videos. ### Web - `web.search`, `web.images`, `web.extract`: search results and page extracts. - `web.scrape`, `web.screenshot`, `web.structured`, `web.branding`, `web.summarize`, `web.pdf`, `web.map`, `web.search-content`: one page or one site, as Markdown, JSON, screenshot or brand data. - `web.crawl`, `web.batch`, `web.agent`: long jobs. Poll `/calls/`, read pages of results with `web.job-status {call_id, cursor?}`, stop with `web.job-cancel {call_id}`. Work already done stays billed. ### SEO and search data - `seo.*`: search engine results (organic, news, images, shopping), keyword volumes and ideas, backlinks, AI search mentions and answers, on-page analysis, business and merchant data. - Jobs require `confirmed: true` and an `Idempotency-Key`. Poll `seo.job-status {call_id}`, read results with `seo.job-result {call_id, cursor?}`. `status` is about completion, `cost_status` is about settlement. ### Social networks - **Your own accounts.** `social.connect {network, label, capability}` returns a `consent_url` for the account holder to open, then poll `social.connection-status`. Once connected, use `social.profile`, `social.stats`, `social.posts`, `social.inbox`, `social.messages`, `social.send` and `social.compose` with the connection. Sends require `confirmed: true` and are never retried automatically. - **Public data** (no login): `public..*` for Instagram, TikTok, X, YouTube, LinkedIn, Reddit, Facebook and Linktree: profiles, enriched profiles, posts, post stats, comments, transcripts, trending content, hashtag and creator search, audiences. `public.posts-page {call_id, cursor}` reads more pages of a stored result for free. Missing counters are `null`, never `0`. - **Ads, commerce and reviews:** `ads.*` (ad libraries), `commerce.*` (product pages), `reviews.*` (app store, marketplace and review site collections). ### Google Analytics and Search Console `ga.connect` / `gsc.connect` return an OAuth `consent_url`. Then poll `*.connection-status`, list properties with `*.resources`, pick one with `*.select-resource`, and call the report endpoints with the connection. The selected property is injected server-side. Reads cost `0.00 USD`. ### Communities - Slack: `community.slack.connect` with a bot or user token, then read channels, messages, threads and users, or send messages and reactions (`confirmed: true`). - Telegram public channels: `community.telegram.*` (channel info, posts, comments, search, similar channels). ## Domain names Search, buy, renew and manage domain names. The price is debited from the wallet. 1. `domain.search {query, tlds?}`: availability plus purchase and renewal price per year. Premium domains are not sold. 2. `domain.register {domain, years, contact, max_price_usd, auto_renew?, nameservers?, confirmed: true}` with an `Idempotency-Key`. The domain is registered in the name of `contact`. The phone number uses the `+CC.NUMBER` format, for example `+33.612345678`. `max_price_usd` protects you against a price change. If registration is refused, the amount is refunded. If the answer is `unknown`, poll `domain.order-status {order_id}` and **never order again**. 3. Manage: `domain.list`, `domain.get`, `domain.dns.list`, `domain.dns.set` (replaces all the values of one name and type; MX and SRV values start with the priority, as in `"10 mail.example.com"`), `domain.dns.delete`, `domain.nameservers` (delegate to external name servers), `domain.contact`. 4. Renew: `domain.renew {domain, years, max_price_usd, confirmed: true}`, or leave `auto_renew` on. The wallet is then debited 30 days before expiry. Reference, generated from the live contract (`paid` = requires an `Idempotency-Key`, `?` = optional): - `domain.search`: `{query: string, tlds?: [string]}` - `domain.register` · paid: `{domain: string, years?: integer, contact: {type?: "individual"|"company"|"association"|"public_body", first_name: string, last_name: string, organization?: string, email: string, phone: string, address: string, city: string, postal_code: string, state?: string, country: string}, max_price_usd: number, auto_renew?: boolean, nameservers?: [string], confirmed: true}` - `domain.renew` · paid: `{domain: string, years?: integer, max_price_usd: number, confirmed: true}` - `domain.list`: `{}` - `domain.get`: `{domain: string}` - `domain.order-status`: `{order_id: string}` - `domain.auto-renew`: `{domain: string, enabled: boolean, confirmed: true}` - `domain.dns.list`: `{domain: string}` - `domain.dns.set`: `{domain: string, name: string, type: "A"|"AAAA"|"ALIAS"|"CAA"|"CNAME"|"MX"|"NS"|"SRV"|"TXT", values: [string], ttl?: integer, confirmed: true}` - `domain.dns.delete`: `{domain: string, name: string, type: "A"|"AAAA"|"ALIAS"|"CAA"|"CNAME"|"MX"|"NS"|"SRV"|"TXT", confirmed: true}` - `domain.nameservers`: `{domain: string, nameservers: [string], confirmed: true}` - `domain.contact`: `{domain: string, contact: {type?: "individual"|"company"|"association"|"public_body", first_name: string, last_name: string, organization?: string, email: string, phone: string, address: string, city: string, postal_code: string, state?: string, country: string}, confirmed: true}` ## App hosting Deploy a website or an app in one call. You never pick servers or regions: send the project and get a URL. 1. **Send the project.** - Large projects: `app.upload` returns `{upload_id, url}`, then send a zip, tar or tar.gz with `curl -T project.zip ""` (100 MB max, URL valid 1 hour). - Small projects: skip the upload and pass `files: [{path, content_base64}]` directly to `app.deploy` (8 MiB max). 2. **Deploy.** `app.deploy {upload_id | files, confirmed: true}` with an `Idempotency-Key` creates an app and returns its identifier `app` (12 characters, generated by the platform). The app is served at `https://.ao.page`. To publish a new version, call `app.deploy` again with `app`. 3. **Follow.** Static sites go live immediately. Server apps are built in the background: poll `app.get {app}` until `deployment.status` is `live` (or `failed`, with a reason). What you can deploy (detected automatically): - **A built static site:** `index.html` at the root, or in `dist/`, `build/` or `out/`. Single-page apps work as expected. - **An edge function:** a bundled JavaScript module exporting `fetch` (`_worker.js`, or a project with `wrangler.toml` / `wrangler.jsonc` and a built entry point). Bundle it before sending: nothing is built on the edge. - **Anything with a `Dockerfile`:** any language or server. It listens on the port of its `EXPOSE` line (8080 if none), sleeps when idle and wakes on the first request. Choose `memory_mb` (256, 512, 1024 or 2048). Anything else returns `unsupported_project` with what to provide. - **Custom domain:** `app.domain.attach {app, domain, confirmed: true}`. HTTPS is automatic. A domain bought here is configured for you, including the apex. For another domain, the answer lists the DNS records to create, then HTTPS turns on by itself. - **Environment variables:** `app.secrets.set {app, secrets: {NAME: value}}` and `app.secrets.delete {app, keys}`. They are applied to the running app. - **Operate:** `app.list`, `app.deployments`, `app.rollback {app, deployment_id}`, `app.logs` (server apps), `app.delete`. - **Price:** 5.00 USD per app and per month, debited at first go-live and then every month, plus usage (requests, compute time) beyond the included allowance, billed from the wallet. An app whose wallet is empty is suspended and comes back after a top-up. Deleting an app stops billing; the current month is not refunded. Reference, generated from the live contract: - `app.upload`: `{}` - `app.deploy` · paid: `{app?: string, upload_id?: string, files?: [{path: string, content_base64: string}], memory_mb?: 256|512|1024|2048, confirmed: true}` - `app.list`: `{}` - `app.get`: `{app: string}` - `app.deployments`: `{app: string}` - `app.rollback`: `{app: string, deployment_id: string, confirmed: true}` - `app.secrets.set`: `{app: string, secrets: object, confirmed: true}` - `app.secrets.delete`: `{app: string, keys: [string], confirmed: true}` - `app.domain.attach`: `{app: string, domain: string, confirmed: true}` - `app.domain.detach`: `{app: string, domain: string, confirmed: true}` - `app.logs`: `{app: string, limit?: integer}` - `app.delete`: `{app: string, confirmed: true}` ## Video and audio processing Process media files on demand, billed per second of compute and capped per job. 1. **Send the file**, or skip this step and pass a public link (`source_url`, a direct file link or a YouTube link): `video.upload` returns `{upload_id, url}`, then `curl -T video.mp4 ""` (5 GB max, link valid 1 hour). 2. **Start a job** with `source_url` or `upload_id`, an `Idempotency-Key`, and optionally `max_cost_usd`. Every job answers `202` with a call id. 3. **Follow it** with `GET /calls/` (MCP: `call_status`) until `success` or `failure`. The result holds `data`, `files` (each with a download `url` valid 24 hours, regenerated on every read) and `usage.compute_seconds`. What each operation does: - `video.probe`: duration, container, codecs, resolution, frame rate and audio tracks. - `video.ffmpeg`: any ffmpeg processing written as output options in `args` (cut with `-ss`/`-t`, scale, crop, overlay with `-filter_complex`, convert, extract the audio or one frame) into one file of the chosen `format`. Inputs are the source then `inputs` (up to 4), in order. Options that read or write files or reach the network are refused with `invalid_parameters`. - `audio.transcribe`: text, segments and word-level timings, language detected or given; `transcript.json`, `.srt` and `.vtt`. - `video.reframe`: the whole video moved to another canvas (`aspect` 9:16 by default), following the speaker or stacking two speakers (`mode`), with a blurred fit when nobody is found. - `video.captions`: transcribes and burns animated word-by-word subtitles in (`subtitles.style`: highlight, karaoke, pop-scale, fade, typewriter, glow, bounce-in, outline, shake, color-wave, bold), plus `captions.srt`. - `video.clips`: a long video (podcast, interview, live, YouTube link) into short vertical clips of about `target_seconds`. Without `count` the model keeps every moment worth a clip (up to 20); `count` caps the number: moments chosen by a text model (`model`, any alias from `text.models`; its tokens are billed as a normal model call), speaker framing, animated subtitles, optional `cut_silences`, and a `brief` to steer the choice. Each clip comes with its video, thumbnail and SRT, a title, a hook line and why it was picked. Styling `video.captions` and `video.clips`: - `subtitles`: `style` (one of the 11 animations), `font` (Anton or Inter), `font_size` (20 to 120), `position` (top, center, bottom), `active_color` (the word being spoken), `inactive_color`, `highlight_color`, `max_words_per_group`, or `enabled: false`. Colors are `#RRGGBB`. - `watermark`: a logo, `{url | upload_id, position (top-left, top, top-right, center, bottom-left, bottom, bottom-right), size (% of the width, 5 to 60), opacity (20 to 100), timing}`, or a band along the bottom, `{kind: "band", text, color, text_color, opacity, timing}`. `timing` is permanent, intro, outro or intro_outro (the first and/or last 5 s). Send a logo file with `video.upload` first, or give a public link; a PNG with transparency works best. - `outro`: `{url | upload_id}`, a closing video of 15 s at most, appended to every clip. - `video.clips` only: omit `count` and the model keeps every moment worth a clip (about one per two minutes of source at most, never two picks that repeat each other); give `count` for a fixed maximum. Transcription, reframing, captions and clips run on a GPU; probe and ffmpeg on a CPU. Long sources take minutes: poll every 15 to 30 seconds. Price: one rate per second of compute, shown by `catalog_get`. A job never costs more than its `max_cost_usd` (default per operation in the catalog): it stops at the cap with `cost_cap_reached` and keeps what it produced. Failures are billed for the compute they used. Errors specific to processing: `source_unreachable` (link private, gone or blocked), `source_too_long`, `unsupported_media`, `invalid_parameters`, `cost_cap_reached`, `processing_failed`, `video_budget_too_small` (raise `max_cost_usd`). Reference, generated from the live contract: - `video.upload`: `{}` - `video.probe` · paid: `{source_url?: string, upload_id?: string, max_cost_usd?: number}` - `video.ffmpeg` · paid: `{source_url?: string, upload_id?: string, max_cost_usd?: number, inputs?: [{source_url?: string, upload_id?: string}], args: [string], format: "mp4"|"mov"|"webm"|"mkv"|"gif"|"mp3"|"m4a"|"wav"|"ogg"|"flac"|"png"|"jpg"|"webp"}` - `audio.transcribe` · paid: `{source_url?: string, upload_id?: string, max_cost_usd?: number, language?: string}` - `video.reframe` · paid: `{source_url?: string, upload_id?: string, max_cost_usd?: number, mode?: "auto"|"face_tracking"|"split"|"center"|"blurred", aspect?: "9:16"|"1:1"|"16:9"}` - `video.captions` · paid: `{source_url?: string, upload_id?: string, max_cost_usd?: number, language?: string, aspect?: "9:16"|"1:1"|"16:9", subtitles?: {enabled?: boolean, style?: "highlight"|"karaoke"|"pop-scale"|"fade"|"typewriter"|"glow"|"bounce-in"|"outline"|"shake"|"color-wave"|"bold", font?: "Anton"|"Inter", font_size?: integer, position?: "top"|"center"|"bottom", active_color?: string, inactive_color?: string, highlight_color?: string, max_words_per_group?: integer}, watermark?: {kind?: "image", url?: string, upload_id?: string, position?: "top-left"|"top"|"top-right"|"center"|"bottom-left"|"bottom"|"bottom-right", size?: number, opacity?: number, timing?: "permanent"|"intro"|"outro"|"intro_outro"}|{kind: "band", text?: string, color?: string, text_color?: string, opacity?: number, timing?: "permanent"|"intro"|"outro"|"intro_outro"}, outro?: {url?: string, upload_id?: string}}` - `video.clips` · paid: `{source_url?: string, upload_id?: string, max_cost_usd?: number, count?: integer, target_seconds?: integer, language?: string, brief?: string, cut_silences?: boolean, mode?: "auto"|"face_tracking"|"split"|"center"|"blurred", aspect?: "9:16"|"1:1"|"16:9", subtitles?: {enabled?: boolean, style?: "highlight"|"karaoke"|"pop-scale"|"fade"|"typewriter"|"glow"|"bounce-in"|"outline"|"shake"|"color-wave"|"bold", font?: "Anton"|"Inter", font_size?: integer, position?: "top"|"center"|"bottom", active_color?: string, inactive_color?: string, highlight_color?: string, max_words_per_group?: integer}, watermark?: {kind?: "image", url?: string, upload_id?: string, position?: "top-left"|"top"|"top-right"|"center"|"bottom-left"|"bottom"|"bottom-right", size?: number, opacity?: number, timing?: "permanent"|"intro"|"outro"|"intro_outro"}|{kind: "band", text?: string, color?: string, text_color?: string, opacity?: number, timing?: "permanent"|"intro"|"outro"|"intro_outro"}, outro?: {url?: string, upload_id?: string}, model?: string}` ## Your storage Everything the platform keeps for you (uploads, processing outputs, app sources) lives in your own folder. It is measured and billed every hour, at a rate per GB shown in the catalog. - `files.list {prefix?, after?}`: your files with their size and a download link valid 1 hour, 1000 per page. - `files.usage`: the size of your folder at the last measurement and what it costs per hour. - `files.delete {paths | prefix, confirmed: true}`: delete files, or a whole folder such as `video/`. Billing stops from the next hour. If your balance stays below zero for 30 days, your folder is emptied. - `files.list`: `{prefix?: string, after?: string}` - `files.usage`: `{}` - `files.delete`: `{paths?: [string], prefix?: string, confirmed: true}` ## Errors Errors are JSON `{"detail": ""}` with a stable code, sometimes followed by `:`. Over MCP, the code is the text of an error result. Common codes: | HTTP | Code | What to do | |---|---|---| | 400 | `idempotency_key_required` | Add an `Idempotency-Key` header (MCP: `idempotency_key`). | | 400 | `invalid_*_parameters:` | Fix the input. Read the schema with `catalog_get`. | | 400 | `unsupported_project` | Send a built site, a bundled edge function or a `Dockerfile`. | | 401 | `invalid_project_key` | Check the `Authorization: Bearer` header. | | 402 | `insufficient_balance` | The wallet is empty: ask the account owner to top up. Do not retry in a loop. | | 403 | `resource_not_allowed`, `connection_not_allowed` | This key has no access to that capability or account. | | 404 | `endpoint_not_found`, `call_not_found`, `app_not_found`, `domain_not_found` | Check the id. Other accounts' resources look missing too. | | 409 | `idempotency_key_reused`, `idempotency_conflict` | Same key with a different body: use a new key for a new intent. | | 409 | `price_above_max` | The price rose above your cap: search again and confirm the new price with the user. | | 409 | `order_in_progress` | An order for this domain is already running: poll it. | | 413 | `request_too_large` | Use `app.upload` or `video.upload` for files, or send less data. | | 429 / 502 / 503 | `*_busy`, `*_unavailable` | Temporary. Retry later with the **same** `Idempotency-Key`. | ## Rules for AI agents 1. Search the catalog before assuming a capability exists, and read its schema before calling it. 2. Tell the user the price before any paid call, and set `confirmed: true` only after they agree. 3. Send one `Idempotency-Key` per intent. Retry with the same key, never with a new one. 4. Never resubmit a `pending`, `unknown` or `202` call: poll it. 5. Stop on `402 insufficient_balance` and tell the user to top up. 6. Use model aliases and capability ids exactly as the catalog returns them. 7. For a nice app address, buy a domain (`domain.search`, then `domain.register`) and attach it with `app.domain.attach`.