LLM Integration Guide

1. Purpose of this page

This page tells a large language model (LLM) or AI coding agent how to write code that uses QuickTranscript, either through the REST API or through the hosted MCP (Model Context Protocol) server. It explains how to transcribe and summarize audio or video files, and what the service does not do. The machine-readable schema at /openapi.json describes the same REST contract. It does not describe the MCP tools: MCP clients discover tool schemas with tools/list (see section 9). This page adds workflow rules, gotchas, and complete examples.

REST base URL: https://quicktranscript.app. MCP endpoint: https://quicktranscript.app/mcp/.

2. Rules for the LLM

  1. Authenticate every REST and MCP request with the X-API-Key header. Keys start with vdz_. Users create keys at /api-keys. Never invent a key.
  2. Read the key from an environment variable (for example QUICKTRANSCRIPT_API_KEY). Never hard-code it, log it, or include it in commits.
  3. Transcription runs as an asynchronous job. With REST, submit with POST /api/transcriptions, then poll GET /api/transcriptions/{task_id} until status is complete or failed. With MCP, call transcribe_url, then get_transcription_status (see section 9).
  4. For REST submission, send the request body as form fields: multipart/form-data (required for file uploads) or application/x-www-form-urlencoded. Do not send a JSON body. A JSON {"file_url": ...} body is ignored and returns 400 No file or file_url supplied. This rule does not apply to MCP: the MCP SDK sends JSON-RPC for you, and tools/list returns the tool schemas.
  5. A file upload must declare an audio/* or video/* content type. Otherwise the API returns 400 Invalid file type. Set it explicitly: tools such as curl send application/octet-stream for extensions they don't recognize, such as .m4a.
  6. Poll every 5–10 seconds and stop after a timeout you choose. Processing time grows with media length.
  7. Use only the REST endpoints in the table below or the MCP endpoint in section 9. Do not call browser routes such as /upload, /process-url, /task-status/…, or /history. Those return HTML, not JSON.
  8. summary_html is HTML. Render it as HTML or convert it to text. Do not show it to users as raw markup.

3. Endpoint reference

Method & pathPurposeInputSuccess
GET /api/auth/test Verify an API key. Header only. 200 {"authenticated": true, "user_email": "…" | null}
GET /api/credits Read the credit balance. Doesn't spend credits. Header only. 200 {"credits": 12}
POST /api/transcriptions Submit a job. Multipart form with exactly one of:
  • file: the media file (content type audio/* or video/*)
  • file_url (or the equivalent file_url_form): a publicly reachable URL
Query parameter summarize (bool, default true). Use false for a transcript without a summary.
202 {"task_id": "<uuid>", "status": "pending"}
GET /api/transcriptions/{task_id} Poll one job. Path task_id. 200 status object
GET /api/transcriptions/completed Fetch completed jobs that haven't been returned before (built for Zapier-style polling triggers). Query limit 1–50, default 10. 200 array of result objects
Reading /api/transcriptions/completed is destructive. Each result is returned only once: after a call returns a job, later calls skip it. Use this endpoint in a single polling consumer. To track a specific job, poll /api/transcriptions/{task_id}, which can be read any number of times.

4. Job lifecycle

status moves through these values:

pending → processing → transcribing → summarizing (only if summarize=true) → complete
                                                                           ↘ failed (from any step)

5. Response schemas

Status object: GET /api/transcriptions/{task_id}

{
  "task_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  "status": "transcribing",
  "status_message": "Transcribing audio...",
  "error_message": null,
  "error_type": null,
  "result": null
}

A failed job:

{
  "task_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  "status": "failed",
  "status_message": "Processing failed.",
  "error_message": "The uploaded file appears to be corrupted or in an unsupported format.",
  "error_type": "processing_error",
  "result": null
}

When status is complete, result holds a result object.

Result object

{
  "id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  "task_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  "original_filename": "meeting.mp3",
  "completed_at": "2026-09-24T05:56:00.357729+00:00",
  "duration_seconds": 3038.7,
  "transcription": [
    {"timestamp": "[00:00]", "text": "Okay, let's get started."},
    {"timestamp": "[20:00]", "text": "Moving on to the quarterly results."},
    {"timestamp": "[01:00:00]", "text": "Thanks everyone."}
  ],
  "summary_html": "<p>The meeting covered the <strong>quarterly results</strong>.</p>",
  "final_credit_cost": 2,
  "total_tokens": null
}
FieldNotes
transcriptionOrdered list of segments. timestamp is a string in the format [MM:SS] or [HH:MM:SS]. How finely the transcript is split depends on the speech-to-text provider: one segment can cover a sentence or a 20-minute block. To get the full text, join all text values in order.
summary_htmlSanitized HTML (p, strong, em, lists, headings, tables, links). null when summarize=false.
completed_atISO 8601 timestamp with UTC offset, the same format on both endpoints.
duration_secondsLength of the processed audio or video in seconds. Jobs completed before 2026-09-24 may contain processing time instead.
total_tokensOften null. Do not depend on it.
final_credit_costCredits charged for the job.

6. Limits and credits

7. Errors

REST errors use the FastAPI shape {"detail": "<message>"}. Validation errors (422) return detail as an array. MCP errors are described in section 9.3.

StatusMeaningWhat the client should do
400Both file and file_url sent, neither sent, the file is not audio/video, or the URL could not be downloaded.Fix the request. Show detail to the user. Don't retry unchanged.
401API key missing, malformed, revoked, or unknown.Ask the user for a valid key. Don't retry.
402Insufficient credits: "Insufficient credits. Need N, have M."Tell the user to buy credits at quicktranscript.app.
403The task belongs to another account.Check the task_id and the key.
404Unknown task_id.Check the task_id.
413File exceeds 500 MB.Compress (for example to 64 kbps mono MP3) or split the file, then resubmit.
422Invalid parameter, for example limit=100.Fix the parameter.
5xxServer or network failure.Retry with exponential backoff, at most a few times.

8. Examples

8.1 curl: verify the key and check credits

export QUICKTRANSCRIPT_API_KEY="vdz_your_api_key"

curl -s https://quicktranscript.app/api/auth/test -H "X-API-Key: $QUICKTRANSCRIPT_API_KEY"
# {"authenticated":true,"user_email":null}

curl -s https://quicktranscript.app/api/credits -H "X-API-Key: $QUICKTRANSCRIPT_API_KEY"
# {"credits":12}

8.2 curl: upload a file with an explicit content type

curl -s -X POST https://quicktranscript.app/api/transcriptions \
  -H "X-API-Key: $QUICKTRANSCRIPT_API_KEY" \
  -F "file=@interview.m4a;type=audio/mp4"
# {"task_id":"a1b2c3d4-e5f6-7890-1234-567890abcdef","status":"pending"}

8.3 curl: transcribe from a URL without a summary

curl -s -X POST "https://quicktranscript.app/api/transcriptions?summarize=false" \
  -H "X-API-Key: $QUICKTRANSCRIPT_API_KEY" \
  -F "file_url=https://www.dropbox.com/s/abc123/podcast.mp3?dl=0"

8.4 bash: poll until the job finishes (requires jq)

TASK_ID="a1b2c3d4-e5f6-7890-1234-567890abcdef"
while true; do
  RESPONSE=$(curl -s "https://quicktranscript.app/api/transcriptions/$TASK_ID" \
    -H "X-API-Key: $QUICKTRANSCRIPT_API_KEY")
  STATUS=$(echo "$RESPONSE" | jq -r .status)
  echo "status: $STATUS"
  case "$STATUS" in
    complete) echo "$RESPONSE" | jq -r '.result.transcription[] | "\(.timestamp) \(.text)"'; break ;;
    failed)   echo "$RESPONSE" | jq -r .error_message; exit 1 ;;
  esac
  sleep 5
done

8.5 Python (httpx): complete client

import mimetypes
import os
import time
from pathlib import Path

import httpx

BASE_URL = "https://quicktranscript.app"
TERMINAL_STATUSES = {"complete", "failed"}


class QuickTranscriptError(RuntimeError):
    pass


def _client() -> httpx.Client:
    api_key = os.environ["QUICKTRANSCRIPT_API_KEY"]
    return httpx.Client(
        base_url=BASE_URL, headers={"X-API-Key": api_key}, timeout=120
    )


def _check(response: httpx.Response) -> dict:
    if response.is_error:
        detail = response.json().get("detail", response.text)
        raise QuickTranscriptError(f"{response.status_code}: {detail}")
    return response.json()


def submit_file(client: httpx.Client, path: Path, summarize: bool = True) -> str:
    content_type = mimetypes.guess_type(path.name)[0] or "audio/mpeg"
    with path.open("rb") as media:
        response = client.post(
            "/api/transcriptions",
            params={"summarize": str(summarize).lower()},
            files={"file": (path.name, media, content_type)},
        )
    return _check(response)["task_id"]


def submit_url(client: httpx.Client, url: str, summarize: bool = True) -> str:
    response = client.post(
        "/api/transcriptions",
        params={"summarize": str(summarize).lower()},
        data={"file_url": url},  # form field, NOT json=
    )
    return _check(response)["task_id"]


def wait_for_result(
    client: httpx.Client, task_id: str, interval: float = 5, timeout: float = 3600
) -> dict:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        job = _check(client.get(f"/api/transcriptions/{task_id}"))
        if job["status"] == "complete":
            return job["result"]
        if job["status"] == "failed":
            raise QuickTranscriptError(f"Job {task_id} failed: {job['error_message']}")
        time.sleep(interval)
    raise TimeoutError(f"Job {task_id} did not finish within {timeout}s")


if __name__ == "__main__":
    with _client() as client:
        print("credits:", _check(client.get("/api/credits"))["credits"])
        task_id = submit_file(client, Path("meeting.mp3"))
        result = wait_for_result(client, task_id)
        full_text = "\n".join(
            f"{segment['timestamp']} {segment['text']}"
            for segment in result["transcription"] or []
        )
        print(full_text)
        print(result["summary_html"])

8.6 JavaScript (Node.js 20+, built-in fetch)

import { openAsBlob } from "node:fs";
import { basename } from "node:path";

const BASE_URL = "https://quicktranscript.app";
const headers = { "X-API-Key": process.env.QUICKTRANSCRIPT_API_KEY };

async function check(response) {
  const body = await response.json();
  if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body.detail)}`);
  return body;
}

export async function submitFile(path, { summarize = true, type = "audio/mpeg" } = {}) {
  const form = new FormData();
  form.append("file", await openAsBlob(path, { type }), basename(path));
  const response = await fetch(`${BASE_URL}/api/transcriptions?summarize=${summarize}`, {
    method: "POST",
    headers, // do NOT set Content-Type; fetch adds the multipart boundary
    body: form,
  });
  return (await check(response)).task_id;
}

export async function submitUrl(url, { summarize = true } = {}) {
  const form = new FormData();
  form.append("file_url", url);
  const response = await fetch(`${BASE_URL}/api/transcriptions?summarize=${summarize}`, {
    method: "POST",
    headers,
    body: form,
  });
  return (await check(response)).task_id;
}

export async function waitForResult(taskId, { intervalMs = 5000, timeoutMs = 3_600_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const job = await check(await fetch(`${BASE_URL}/api/transcriptions/${taskId}`, { headers }));
    if (job.status === "complete") return job.result;
    if (job.status === "failed") throw new Error(`Job ${taskId} failed: ${job.error_message}`);
    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }
  throw new Error(`Job ${taskId} timed out`);
}

const taskId = await submitFile("meeting.mp3");
const result = await waitForResult(taskId);
console.log(result.transcription.map((s) => `${s.timestamp} ${s.text}`).join("\n"));

8.7 Python: polling trigger for new completed jobs (Zapier-style)

import os
import time

import httpx

with httpx.Client(
    base_url="https://quicktranscript.app",
    headers={"X-API-Key": os.environ["QUICKTRANSCRIPT_API_KEY"]},
    timeout=30,
) as client:
    while True:
        response = client.get("/api/transcriptions/completed", params={"limit": 50})
        response.raise_for_status()
        for job in response.json():  # each job is returned exactly once
            print(job["task_id"], job["original_filename"])
            # Persist the job before doing anything else: it will not be returned again.
        time.sleep(60)

9. Remote MCP server

QuickTranscript also runs a hosted MCP (Model Context Protocol) server. MCP clients and agents can call it as tools instead of writing REST code. It uses the same API keys, credits, URL downloader, and job records as the REST API.

SettingValue
URLhttps://quicktranscript.app/mcp/. The trailing slash is required.
TransportStreamable HTTP (stateless, JSON responses)
AuthenticationYour API key on every HTTP request, including initialize and tools/list: header X-API-Key: vdz_…, or Authorization: Bearer vdz_… for clients that can only send bearer tokens. If both are sent, X-API-Key wins. OAuth connectors send an OAuth access token (Authorization: Bearer qto_at_…) instead; see OAuth connections.
Tool schemasCall tools/list. /docs and /openapi.json describe the REST API only.
The MCP endpoint accepts a QuickTranscript API key (vdz_…) in the X-API-Key header or as Authorization: Bearer, or an OAuth access token (qto_at_…) as Authorization: Bearer. It does not accept Firebase tokens, cookies, or keys in the query string. Use an MCP SDK or client for the JSON-RPC protocol; do not hand-build MCP requests.

Client compatibility

ClientWorks?How to connect
Claude CodeYesclaude mcp add --transport http quicktranscript https://quicktranscript.app/mcp/ --header "X-API-Key: $QUICKTRANSCRIPT_API_KEY"
Claude API (MCP connector)YesSet url to the MCP endpoint and authorization_token to your API key. It is sent as a bearer token.
OpenAI Responses API / Agents SDKYesMCP tool with server_url set to the endpoint and headers: {"X-API-Key": "…"}.
Cursor, VS Code, and other clients with remote-server header settingsYesAdd the URL and an X-API-Key header in the client's MCP server settings.
MCP Python/TypeScript SDKsYesSee 9.4.
claude.ai custom connectorsYes, with OAuthGo to Customize > Connectors, click +, then Add custom connector. Enter https://quicktranscript.app/mcp/ and click Add. Leave the OAuth client ID and secret empty; Claude registers itself. Connect, then approve on the QuickTranscript page. On Team and Enterprise plans an Owner adds the connector under Organization settings > Connectors, then members click Connect.
ChatGPT connectors (developer mode)Yes, with OAuthTurn on Developer mode under Settings > Security and login. On the ChatGPT Plugins page, click the plus button and create an app with the URL https://quicktranscript.app/mcp/ and OAuth authentication. ChatGPT registers itself; approve on the QuickTranscript page.

Client capabilities and menu names change; check your client's documentation for how it sends custom headers or bearer tokens, or how it adds remote MCP servers (Claude, ChatGPT). Custom connector availability depends on your plan, workspace, and admin policies. QuickTranscript is not a vendor-verified or directory-listed connector.

OAuth connections (claude.ai, ChatGPT)

Clients that only support OAuth use the same URL, https://quicktranscript.app/mcp/. The client discovers the server's OAuth settings, registers itself, and opens a QuickTranscript approval page in your browser:

  1. The page shows the client's name, which the client declares itself and is not verified, and the address it returns to. Check both.
  2. Paste an existing QuickTranscript API key (create one at /api-keys) and click Approve, or click Deny. You don't sign in with your QuickTranscript account on this page.
  3. The client then calls the MCP tools with an OAuth access token. The key itself is never sent to the client.

Approving lets the client, on behalf of the key's account:

Access tokens last 1 hour and the client refreshes them automatically. Refresh tokens rotate on every use; reusing an old refresh token revokes the whole connection. A connection expires 30 days after approval; then reconnect and approve again.

To disconnect, revoke or delete the API key at /api-keys. Every OAuth connection approved with that key stops working immediately. Create a dedicated API key for each connector so you can disconnect one without affecting your other integrations. Clients can also revoke their own connection at /revoke.

The vendor connects from its own cloud, not from your device, so the MCP server must be reachable over public HTTPS. The hosted server at https://quicktranscript.app/mcp/ meets this requirement.

ItemValue
Protected resource metadataGET https://quicktranscript.app/.well-known/oauth-protected-resource (also served with the /mcp and /mcp/ suffixes). Resource https://quicktranscript.app/mcp/, authorization server https://quicktranscript.app, scope mcp.
Authorization server metadataGET https://quicktranscript.app/.well-known/oauth-authorization-server
Endpoints/authorize, /token, /register (Dynamic Client Registration, RFC 7591), /revoke, all on https://quicktranscript.app
FlowResponse type code; grants authorization_code and refresh_token; PKCE S256 only; scope mcp. If sent, resource must be https://quicktranscript.app/mcp/.
Client authenticationnone (public client with PKCE), client_secret_post, client_secret_basic. No OpenID Connect, Client ID Metadata Documents, or client_credentials grant. ChatGPT uses Dynamic Client Registration.
401 challengeUnauthenticated MCP requests return WWW-Authenticate: Bearer resource_metadata="https://quicktranscript.app/.well-known/oauth-protected-resource/mcp/", scope="mcp"
Rate limits (per hour)Client registration: 10 per client IP. Approval page submissions: 20 per browser. /token and /revoke: 120 successful requests per client, 120 failed requests per client IP. Over the limit: 429 with Retry-After.
ErrorsStandard OAuth {"error": "…"} bodies. Storage outage: 503 {"error": "temporarily_unavailable"}; retry later.

9.1 Tools

ToolInputSuccess resultNotes
transcribe_url file_url (string, required): a publicly reachable audio or video file URL. Blank or whitespace-only values are rejected.
summarize (bool, default true).
{"task_id": "<uuid>", "status": "pending"} Submits a job and returns immediately. Consumes credits exactly like POST /api/transcriptions (see section 6). Generates a summary by default; with summarize=false the completed result has summary_html: null. Not idempotent: every successful call creates a separate billable job, so do not retry a submission automatically.
get_transcription_status task_id (string, required): the ID returned by transcribe_url or by REST POST /api/transcriptions. The same status object as GET /api/transcriptions/{task_id}: task_id, status, status_message, error_message, error_type, result. Read-only. Poll every 5–10 seconds until status is complete or failed. At complete, result holds a result object (id, task_id, original_filename, completed_at, duration_seconds, transcription, summary_html, final_credit_cost, total_tokens). A failed job is a successful tool result with status: "failed" and user-facing error_message and error_type.
get_credit_balance None. {"credits": 12} Read-only. Doesn't spend credits. Accounts without a balance return 0.

Successful tool results carry the JSON object in structuredContent. The size limit is the same as the REST API: 500 MB. Larger downloads fail with a 413 tool error.

9.2 Limitations

9.3 Errors

MCP failures happen at two levels. HTTP-level failures reject the request before any MCP message is processed:

HTTP statusMeaningWhat the client should do
401{"detail": "Invalid or missing API key."}: the key or OAuth access token is missing, malformed, expired, revoked, or unknown (in X-API-Key or Authorization: Bearer). OAuth tokens also fail once their source API key is revoked. Returned even for initialize and tools/list, with the WWW-Authenticate challenge shown in OAuth connections.Key clients: ask the user for a valid key; don't retry. OAuth clients: refresh the token, or reconnect.
503{"detail": "Authentication service unavailable."}Retry later with backoff.
421The Host header is not allowed by the server.Use the exact URL https://quicktranscript.app/mcp/.
403The request has an Origin header that the server does not allow.Call the server from a backend or MCP client, not from a web page on another origin.

Tool failures are normal MCP tool results with isError: true. Their text content is prefixed by the MCP SDK, for example:

Error executing tool transcribe_url: 402: Insufficient credits. Need 1, have 0.

The stable part is <status_code>: <detail> at the end of the text. The codes match the REST error table:

CodeMeaningWhat the client should do
400Invalid input, or the URL could not be downloaded or is not allowed.Fix the input. Show the detail to the user. Don't retry unchanged.
402Insufficient credits. Need N, have M.Tell the user to buy credits at quicktranscript.app.
403Access denied. The task belongs to another account.Check the task_id and the key.
404Task not found.Check the task_id.
413File exceeds 500 MB.Compress or split the file, then resubmit.
500Internal server error.Retry read-only tools with backoff. Don't resubmit transcribe_url automatically; ask the user first, because a new call creates a new billable job.

A job that fails during processing is not a tool error: get_transcription_status returns it with status: "failed".

9.4 Python (official MCP SDK): complete client

Install the client dependencies in your uv project with uv add 'mcp>=1.28.1,<2' httpx. Set QUICKTRANSCRIPT_API_KEY to your API key and QUICKTRANSCRIPT_MEDIA_URL to a public media file URL, then run the script with uv run python quicktranscript_mcp.py. The API key header is set on the httpx.AsyncClient passed to streamable_http_client.

import asyncio
import os
import time

import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

MCP_URL = "https://quicktranscript.app/mcp/"


def tool_data(result):
    if result.isError:
        message = result.content[0].text if result.content else "unknown error"
        raise RuntimeError(message)
    return result.structuredContent


async def main():
    headers = {"X-API-Key": os.environ["QUICKTRANSCRIPT_API_KEY"]}
    async with httpx.AsyncClient(headers=headers, timeout=120) as client:
        async with streamable_http_client(MCP_URL, http_client=client) as (read, write, _):
            async with ClientSession(read, write) as session:
                await session.initialize()
                tools = await session.list_tools()
                print("Tools:", [tool.name for tool in tools.tools])

                credits = tool_data(await session.call_tool("get_credit_balance", {}))
                print("Credits:", credits["credits"])

                job = tool_data(await session.call_tool(
                    "transcribe_url",
                    {"file_url": os.environ["QUICKTRANSCRIPT_MEDIA_URL"], "summarize": True},
                ))
                task_id = job["task_id"]
                print("Task:", task_id)

                deadline = time.monotonic() + 600
                while True:
                    status = tool_data(await session.call_tool(
                        "get_transcription_status", {"task_id": task_id}
                    ))
                    if status["status"] in ("complete", "failed"):
                        break
                    if time.monotonic() > deadline:
                        raise TimeoutError(f"Task {task_id} still {status['status']}")
                    await asyncio.sleep(5)

                if status["status"] == "failed":
                    print("Failed:", status["error_type"], status["error_message"])
                else:
                    result = status["result"]
                    text = " ".join(seg["text"] for seg in result["transcription"] or [])
                    print("Transcript:", text[:500])
                    print("Summary HTML present:", result["summary_html"] is not None)


asyncio.run(main())

Other MCP clients can connect if they support Streamable HTTP and custom request headers; check your client's documentation for how to set X-API-Key.

10. Short prompt for another agent

Give this summary to another agent that needs to integrate:

QuickTranscript (https://quicktranscript.app). Auth for REST and MCP: header X-API-Key: vdz_… (from env, never hard-code).
REST:
1. POST /api/transcriptions with form fields (multipart for files) and exactly one of: file (audio/* or video/* content type)
   or file_url (public URL form field; JSON bodies are ignored). Optional query summarize=false. Returns 202 {task_id}.
2. Poll GET /api/transcriptions/{task_id} every 5-10 s until status is "complete" or "failed".
3. On complete, read result.transcription (list of {timestamp "[MM:SS]"/"[HH:MM:SS]", text}) and result.summary_html (HTML or null).
Errors: {"detail": ...}; 401 bad key, 402 no credits, 413 file > 500 MB, 400 bad input, 404/403 unknown or foreign task.
Balance: GET /api/credits. Key check: GET /api/auth/test. Schema: /openapi.json (REST only).
MCP: https://quicktranscript.app/mcp/ (trailing slash), Streamable HTTP, API key on every request as X-API-Key or Authorization: Bearer.
OAuth-only clients (claude.ai custom connectors, ChatGPT connectors) use the same URL: OAuth 2.1 + PKCE S256 + Dynamic Client Registration,
scope mcp, discovery at /.well-known/oauth-protected-resource; the user approves by pasting an API key. Access token 1 h, rotating refresh,
reconnect after 30 days; revoking the source API key disconnects. Also works with Claude Code, Claude API, OpenAI Responses API, MCP SDKs.
Tools (schemas via tools/list): transcribe_url(file_url, summarize=true) -> {task_id, status}; public media URLs only, spends credits,
don't auto-retry. get_transcription_status(task_id) -> same status object as REST; poll every 5-10 s. get_credit_balance() -> {credits}.
Tool errors: isError=true, text ends with "<status_code>: <detail>". HTTP 401 = bad key or token. Local files: REST upload, then MCP poll.