2. Integrate with an AI agent (Claude Code, Cursor, ChatGPT…)

Two ways, pick one:

Connect the MCP server (Claude Code, Codex, Cursor, Gemini CLI, VS Code…)

The server speaks MCP over Streamable HTTP at https://metaplatho.com/api/mcp and takes the same key as the REST API, in either header:

Authorization: Bearer ca_live_...      # or
X-Api-Key: ca_live_...

The panel's Developer API page (/api) shows every command below with your key already filled in. The key goes into your local agent config — where that agent keeps its other tokens — never into a chat.

# Claude Code
claude mcp add --transport http metaplatho https://metaplatho.com/api/mcp \
  --header "Authorization: Bearer $API_KEY"
# Codex (the token is read from an env var; Codex has no header flag)
export MP_API_KEY=ca_live_...
codex mcp add metaplatho --url https://metaplatho.com/api/mcp --bearer-token-env-var MP_API_KEY
# Gemini CLI
gemini mcp add --transport http --header "Authorization: Bearer $API_KEY" metaplatho https://metaplatho.com/api/mcp
// Cursor (~/.cursor/mcp.json), Windsurf and most other clients
{ "mcpServers": { "metaplatho": {
    "url": "https://metaplatho.com/api/mcp",
    "headers": { "Authorization": "Bearer ca_live_..." } } } }
// VS Code (.vscode/mcp.json)
{ "servers": { "metaplatho": {
    "type": "http", "url": "https://metaplatho.com/api/mcp",
    "headers": { "Authorization": "Bearer ca_live_..." } } } }
// OpenAI Responses API — your own software, server-side
{ "model": "gpt-5",
  "tools": [{ "type": "mcp", "server_label": "metaplatho",
              "server_url": "https://metaplatho.com/api/mcp",
              "headers": { "Authorization": "Bearer ca_live_..." },
              "require_approval": "never" }],
  "input": "compress https://example.com/demo.mp4 for Telegram" }

Then tell the agent what you need — "compress ./demo.mp4 for Telegram and give me the link" — or "add this integration to my bot".

The tools

Tool What it does
ping() Checks the key and the balance. Spends nothing.
list_operations(kind?) The live catalog: every service_slug / operation_key, what media it accepts, input (file or url), its rate card tariff_class and base credits_per_call, fields (each param's type/min/max) and presets. kind = image | video | audio narrows it.
submit_job(service_slug, operation_key, file_url?, params?, webhook_url?) Starts a job. file_url is the input file. Debits at submit, refunds on failure — the same path as POST /jobs/api.
get_job(job_id) Status, output_url once done, error once failed, estimated_cost / final_cost, queue position.
wait_for_job(job_id, timeout_seconds=60) Polls for you, up to 120 s per call; timed_out: true means call again.
list_jobs(limit=20) Your recent jobs, newest first.
presign_upload(filename, content_type) A slot for a file on the agent's machine: PUT the bytes to put_url (the answer includes the curl -T line), then pass url as file_url. Multi-GB is fine; the bytes never pass through the API.
create_upload_link(service_slug, operation_key, params?, callback_url?, metadata?, …) A one-time upload page for a file your end user holds (Telegram bots): the same body as POST /upload-sessions/api, section 7.
get_upload_link(session_id) The link's state and the jobs it produced.

Every call goes through the same code as the REST route it mirrors: the same plan quota, per-key daily cap, in-flight cap, maintenance window, billing and usage journal. The MCP server adds no prices and no limits of its own. Errors come back as tool errors carrying the REST status and detail (402: not enough credits to submit job, 429: … (retry after 60s)).

The server is stateless: every JSON-RPC call is self-contained, which is what lets it sit behind a load balancer with no sticky sessions. A wrong key is a plain HTTP 401 (a disabled account 403), not a JSON-RPC error — so claude mcp add tells you right away. Discovery needs no key: initialize, tools/list and ping answer without one, so registries and catalogs can read the tool list. Every tool call needs the key — without it, 401.

An admin can switch MCP access off per audience (feature mcp, on by default) without touching the REST API.

The prompt (for agents without MCP)

Copy the block below, paste it into your AI coding agent and say "here is the spec, add this integration". It is a complete, self-contained contract — the agent needs nothing else from this page.

Do not put your API key in the prompt. The block deliberately uses an env var. Pasting a live credential into a third-party chat leaks it into that vendor's logs; the agent only needs to know the key's name.

You are integrating the MetaPlatho API (image & video processing) into this project.

BASE URL: https://metaplatho.com
AUTH: every request sends the header  X-Api-Key: <key>
  The key looks like ca_live_... . Read it from the MP_API_KEY env var.
  Never hardcode it, never log it, never commit it.
  Authorization: Bearer <key> is accepted too (same key, same limits).
  A HUMAN gets the key at https://metaplatho.com/docs/api (sign in -> create key); there is no
  API that mints one. Alternatives to this prompt: https://metaplatho.com/llms-full.txt (the whole
  reference, one fetch) and the MCP server https://metaplatho.com/api/mcp (the operations as
  tools, same key as Bearer). The Postman collection is https://metaplatho.com/api/v1/public/postman.

THE WHOLE API IS TWO REQUESTS.

1) SUBMIT — one multipart POST carrying the file AND what to do with it:

   POST https://metaplatho.com/api/v1/jobs/upload
   header: X-Api-Key: <key>
   multipart/form-data fields:
     file           (required)  the binary file
     files          (instead of file) several files for ONE job, in order — repeat the field;
                    only for merge_pdfs / images_to_pdf (catalog: config.inputs "many")
     service_slug   (required)  "photoconvert" for images, "clipconvert" for video,
                                "docconvert" for PDF
     operation_key  (required)  what to do — see DISCOVERY
     params         (optional)  the operation's settings as a JSON STRING, e.g. {"quality":70}
     webhook_url    (optional)  public http(s) URL; the finished job is POSTed there
     high_priority  (optional)  accepted for compatibility — every job runs at the plan's priority
   -> 201 {"id":"<uuid>","status":"processing","estimated_cost":1,"result":null,"error":null}

   THE #1 INTEGRATION BUG — params is a STRING, not an object. It is a multipart
   form field, so serialise it yourself:
     JS:     form.append("params", JSON.stringify({quality: 70}))   correct
             form.append("params", {quality: 70})                   WRONG, sends [object Object]
     Python: data={"params": json.dumps({"quality": 70})}           correct
             data={"params": {"quality": 70}}                       WRONG, dict repr is not JSON
   The server answers 400 "params must be a JSON object" when you get this wrong.

   service_slug and operation_key are a PAIR, and each operation declares the media
   kinds it takes ("accepts" in the catalog — usually one; transcribe takes video AND
   audio). Check it before submitting. A video operation on an
   image is a 400 whose detail names BOTH kinds, e.g.
     "this operation works on video files — the file you sent looks like image"
   An unknown operation_key for that service is 400 "unknown operation '<key>'".
   Do NOT put file_url in params: the server injects it from the uploaded file and it always wins.
   Do NOT set Content-Type yourself; let the HTTP client set the multipart boundary.

2) POLL — until the status is terminal:

   GET https://metaplatho.com/api/v1/jobs/{id}     (same X-Api-Key header)
   status "queued" | "processing" -> keep polling, every 2-3 seconds
   status "done"      -> result.output_url is a plain public URL; download it directly
                         result.outputs lists EVERY file the job made: [{role, kind, format,
                         url, filename, size_bytes}] — e.g. a captioned video (role "result")
                         plus its captions as an SRT file (role "captions")
   status "failed"    -> read `error`; refunded: final_cost 0, or the base if the worker
                         had the file over 60 s (the per-minute part is never charged)
   status "canceled"  -> refunded the same way

   If you set webhook_url, skip polling entirely: the same job object is POSTed there
   on done/failed/canceled, header X-MetaPlatho-Event: job.done|job.failed|job.canceled.
   It is UNSIGNED (re-read GET /jobs/{id} before acting on money); 5xx/429/timeouts are
   retried twice (5 s, 30 s). A subtitles job's captions are a file too:
   result.outputs[] role "captions" -> GET /api/v1/jobs/{id}/captions.srt (no key needed).

   transcribe = text file only. For a VIDEO with captions use operation_key "subtitles"
   (transcribe sent with burn/style/font/position/animation runs as subtitles).
   Caption aliases accepted: burn:true -> mode:"burn", fontsize px -> font_size %,
   color -> text_color, background "rgba(0,0,0,0.55)" -> background_color+opacity,
   position "center" -> "middle", font "Arial" -> "liberation_sans".

TELEGRAM BOTS AND ANY "MY END USER HAS THE FILE" INTEGRATION:

   A Telegram bot CANNOT relay big media: the Bot API lets a bot download only
   ~20 MB via getFile and send only ~50 MB. That limit cannot be worked around
   from the bot side — do not try to stream the file through your bot. Mint a
   one-time upload link instead and let the user's browser upload straight to
   storage, bypassing both Telegram's servers and ours.

   POST https://metaplatho.com/api/v1/upload-sessions/api      header: X-Api-Key: <key>
   {
     "service_slug": "clipconvert",
     "operation_key": "compress",
     "params": {"level": "medium"},
     "expires_in_seconds": 3600,
     "max_file_size_bytes": 2147483648,
     "allowed_mime_types": ["video/mp4", "video/quicktime"],
     "callback_url": "https://my-bot.example.com/hooks/mp",
     "delete_after_processing": true,
     "metadata": {"telegram_user_id": "123", "chat_id": "456", "request_id": "abc"}
   }
   -> 201 {"id":"<session_id>", "upload_url":"https://metaplatho.com/u/<token>",
           "expires_at":"...", "estimated_cost":3,
           "callback_secret":"<shown once, only when the server generated it>"}

   Send upload_url to the user and stop. The page it opens already does
   presigned multipart upload with per-part retry, a progress bar and cancel —
   you do not build an uploader.

   Fields that matter for a bot:
     metadata               opaque JSON, echoed back VERBATIM in every callback.
                            This is how you map a finished file back to a chat:
                            put telegram_user_id / chat_id / your request_id here.
     expires_in_seconds     60..604800, default 1h, server-capped at 24h.
     max_files              1..20 (default 1); every file runs every operation.
     operations[]           up to 10 {service_slug, operation_key, params} — one
                            job per entry on the same file. Mutually exclusive
                            with the single service_slug/operation_key/params.
     bundle_zip             true -> the owner can pull one .zip of everything.
     max_file_size_bytes    your own cap; your tier's per-file cap (6 GB on paid
                            plans) still applies — a link never takes a bigger file
                            than its owner could upload.
     allowed_mime_types     anything else is rejected with 415.
     delete_after_processing  true -> the uploaded SOURCE is deleted as soon as
                            processing succeeds.

   Unknown operation (404) and insufficient credits (402) are reported at CREATE
   time — before the user ever opens the link. Handle them there.

   ONE-TIME AND EXPIRING BY DESIGN: a second upload to the same link gets 409,
   an expired link gets 410. Both are final — mint a new session. On your side,
   store request_id -> session_id so a retried bot command does not mint a
   second link and charge twice.

RESULTS FROM AN UPLOAD LINK — webhook or poll:

   With callback_url set, these events are POSTed to it:
     upload.completed       the file landed and was verified
     processing.completed   all linked jobs finished, at least one succeeded
     processing.failed      they all failed
   Headers: X-BigLoader-Event: <event>
            X-BigLoader-Signature: hex HMAC-SHA256(callback_secret, RAW body)
   Verify the HMAC over the RAW request body BEFORE parsing JSON. Delivery is
   best-effort, so keep polling as a fallback.
   Body: {"event","session_id","status","file":{...},"job_id","job_ids",
          "metadata":<yours, verbatim>,"jobs":[{"id","operation_key","status",
          "output_url","error"}],"result":{"output_url"}}

   GET    https://metaplatho.com/api/v1/upload-sessions/{session_id}    status + jobs + results
          created -> uploading -> uploaded -> processing -> done
          (plus failed, expired) — coarse upload progress comes from here
   GET    https://metaplatho.com/api/v1/upload-sessions                 your recent sessions
   GET    https://metaplatho.com/api/v1/upload-sessions/{id}/archive    .zip of all uploads
   DELETE https://metaplatho.com/api/v1/upload-sessions/{id}            deletes the files and
          cancels + refunds any in-flight job

   SENDING THE RESULT BACK: result.output_url is a plain public URL. If the
   processed file exceeds Telegram's ~50 MB send limit, send the URL as a
   message instead of uploading the file. A round video note (operation "circle"
   with format "note", <=60s) only renders round when a bot calls sendVideoNote
   with an uploaded file.

FILES OVER ~100 MB THAT YOUR OWN SERVER HOLDS (the CDN caps request bodies) —
three steps instead of one:
   a) POST https://metaplatho.com/api/v1/uploads   (multipart, field `file`)  -> {"url": "..."}
      for multi-GB: POST https://metaplatho.com/api/v1/uploads/presign -> PUT the bytes to put_url
   b) POST https://metaplatho.com/api/v1/jobs/api  (JSON body, not multipart):
      {"service_slug":"...","operation_key":"...","params":{"file_url":"<url from a>"}}
   c) poll exactly as in step 2.

ERRORS — the body is {"detail": ...}: a string, or a list of field errors for 422.
   400 bad request — unknown operation, invalid file URL, wrong media kind, OR a
       param the operation refused. A bad param NAMES the field and refunds any charge:
       "could not start the job — quality: Input should be less than or equal to 100 (charge refunded)"
   401 "missing API key" / "invalid API key"            402 "not enough credits to submit job"
   404 unknown service or job       415 file type not allowed
   413 file too large — a JSON {"detail"} naming the tier's cap comes from the multipart
       create route and from upload links; POST /jobs/upload with a body over ~100 MB is
       cut off by the CDN with an HTML 413 (no JSON) — use the presigned / multipart path.
   422 validation error — pydantic detail array; e.g. webhook_url pointing at a
       private address, or ?limit outside 1..200 on GET /jobs
   429 rate limited — HONOR the Retry-After response header (seconds). Two counters: the
       plan's per-minute/per-day budget, and the per-key daily cap: 429 "daily request limit
       exceeded for this API token" with Retry-After 3600 — wait, do not rotate keys.
   5xx retry with exponential backoff

BILLING: every operation is metered by processing time at one of four RATE CARDS — its
`tariff_class` in /studio/tools: photo (1 base, +1/min, cap 5), light (1, +1, cap 10),
standard (2, +2, cap 40), speech (5, +3, cap 60). The card's base is debited per file at submit,
then the card's rate for each further STARTED minute of processing beyond the first, never
more than the card's cap for one file. A `params.operations` pipeline is priced at the dearest
class of any step in it. POST /api/v1/studio/quote {files:[{duration_sec,width,height,
size_bytes,kind}], operation_ids:[...]} estimates real files on real tools before you submit.
A job that fails or is canceled within its first 60 s of worker time is refunded in full
(final_cost 0); after that the base is kept (final_cost = base). The per-minute part is never
charged on a failed or canceled job. Every input file has two per-tier ceilings, length and size:
anonymous 5 min / 300 MB, free account 5 min / 1 GB, paid 45 min / 6 GB. A file over the size cap
is refused with 413 when the upload is opened, before a byte is stored; an input longer than the
length limit is refused after the worker's probe and refunded in full. Read the live cards from
GET /api/v1/public/config -> billing.tariff_classes. GET https://metaplatho.com/api/v1/me/ping ->
{"ok":true,"plan":"free","credits":98} is a cheap credential + balance check.

DISCOVERY — never hardcode the operation list:

   GET https://metaplatho.com/api/v1/studio/tools     (public, no auth)
   Returns a JSON array; every entry is one operation with: operation_id, service_slug,
   operation_key, label{en,uk,ru}, group, accepts (["image"], ["video"], ["video","audio"]
   for transcribe),
   tariff_class, credits_per_call (the card's base), config.fields[] (each tunable param with key/type/min/max) and
   presets[] (ready-made settings, each {index, label, params}), config.api_params[]
   (the rest of the params, each {key, default, values}) and example — a request that
   works as-is. Fetch this once and derive your operation list + validation from it.

WHAT TO BUILD — a small typed client. These are DIFFERENT paths; do not collapse
them into one submit():
  submit(file, serviceSlug, operationKey, params) -> jobId
      multipart POST /jobs/upload. Files up to ~100 MB that your process holds.
  submitLarge(pathOrStream, serviceSlug, operationKey, params) -> jobId
      presign (or multipart create/sign-part/complete) -> PUT the bytes ->
      POST /jobs/api with params.file_url. Multi-GB files your process holds.
  createUploadLink({operation, params, metadata, ttl, maxFileSizeBytes,
                    allowedMimeTypes, callbackUrl, deleteAfterProcessing})
      -> {uploadUrl, sessionId, expiresAt}
      For files your END USER holds (Telegram bots). You never touch the bytes.
  waitForResult(jobId) -> outputUrl          poll GET /jobs/{id}
  getSession(sessionId) -> {status, jobs, files}   (metadata is echoed only in callbacks — keep your own copy)
  verifyCallback(rawBody, signatureHeader, secret) -> boolean
      HMAC-SHA256 over the RAW body; use a constant-time compare.
  listOperations() from /studio/tools
Also: retry on 429/5xx honoring Retry-After; persist your own request_id ->
session_id / job_id so a retried command never charges twice; keep the
credential in the environment. Ask me before adding a dependency.

Getting the operation list into the prompt

The block above deliberately tells the agent to discover operations at runtime instead of pasting 40 of them. If your agent has no network access, fetch the catalog yourself and hand it over as a file:

curl -s https://metaplatho.com/api/v1/studio/tools > operations.json

Then: "use operations.json as the operation catalog". The panel's Developer API page (/api) also has a Copy AI prompt button that embeds a current snapshot of the catalog for you.

Checking what the agent built

Ask it to prove the integration works against a real job:

Run the integration end to end: submit a small test image with
service_slug=photoconvert, operation_key=compress, params={"quality":70},
poll until done, download result.output_url, and show me the job id,
the final status and the output file size.

If that produces a downloaded file, the integration is correct.

Seeing what you ran

Every job and batch — from the studio, from the API, from an upload link — lands in your journal: https://metaplatho.com/journal (sign in; it is also linked from the studio header and the account page). Each row says which tool ran, on which file, through which API key, from which IP, how long it took and what it cost — the submit price, any processing-time surcharge and any refund as separate lines. Filter by key, tool, status or date, open a run for its files and the upstream's report (format, size, dimensions), or Export CSV of exactly what the filters show. The page reads the same data through the signed-in session (GET /api/v1/me/journal, GET /api/v1/me/usage/breakdown); it is not part of the key-authenticated API.