---
name: xora
description: Process media with FFmpeg in the cloud via the Xora API — transcode, compress, resize, trim, or concat video, extract audio, grab thumbnails, make web-ready MP4s or edit proxies, read ffprobe metadata, or run any raw FFmpeg argument array. Use this whenever the user wants to convert, shrink, cut, merge, or inspect a video/audio file or build a media pipeline, even if they never say "Xora" or "FFmpeg" ("make this video smaller", "grab a frame at 10s", "turn this .mov into an mp4", "rip the audio"). Requires a Xora API key.
---

# Xora — FFmpeg in the cloud

Xora runs real FFmpeg behind one REST endpoint. You submit a job (a named
recipe or a raw argument array), poll its state, and download the output from
a signed URL. Full reference an agent can fetch: <https://xora.sh/llms.txt>.

## Setup

Every call needs a Xora API key (`nte_…`):

- Use `$XORA_API_KEY` if set; otherwise ask the user for a key (they create
  one at <https://xora.sh/app>). Never invent or hardcode keys.
- If this environment has the Xora MCP server connected (tools `create_upload`,
  `create_job`, `get_job`, `list_recipes`, `cancel_job`), prefer those tools —
  they take the same JSON bodies shown below and enforce the same limits.
  Everything else in this skill still applies.

## Choose the job mode

1. **`mode: "recipe"`** — a named operation, right for ~90% of asks. Pick from
   the cheat sheet below.
2. **`mode: "ffmpeg"`** — raw FFmpeg args, for filter graphs, watermarks,
   custom codec flags, multi-input work, or anything no recipe covers. If you
   can write the local FFmpeg command, you can run it here unchanged.
3. **`mode: "probe"`** — ffprobe metadata only (no output file). Run this
   first when you don't know the input's codec, duration, or dimensions and
   the choice of recipe depends on it.

Inputs are either a URL the service can fetch (public HTTPS, or a presigned
S3/R2 URL) or a **direct upload** for a file that only exists locally — see
below. Never guess at a URL for a path the user gave you.

## Local files: upload first

When the input is a path on this machine rather than a URL, upload it and use
the returned id in place of `input.url`:

```bash
# 1. Ask for a presigned upload (sizeBytes is checked against the plan cap)
curl -X POST https://api.xora.sh/v1/uploads \
  -H "Authorization: Bearer $XORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sizeBytes": 734003200, "filename": "interview.mov"}'
# → { "uploadId": "01KSD…", "url": "https://…?X-Amz-Signature=…", "maxBytes": … }

# 2. PUT the raw bytes — no auth header, URL valid 1 hour
curl -X PUT "$UPLOAD_URL" --upload-file interview.mov

# 3. Create the job with uploadId instead of url
#    { "mode": "recipe", "input": { "uploadId": "01KSD…" }, … }
```

With MCP connected, step 1 is the `create_upload` tool; step 2 is still a plain
HTTP `PUT` you make yourself.

Worth knowing:
- `input` takes exactly one of `url` or `uploadId` — never both.
- Uploads work for single-input `recipe`, `probe`, and `ffmpeg` jobs. Multi-input
  work (`input_files`, the `concat` recipe) and saved presets still need URLs.
- 5 GB per file. Uploads are deleted after 3 days but can be reused by any
  number of jobs until then — never re-upload the same file for a second job.
- `upload not found or not yet uploaded` from `create_job` means the `PUT` in
  step 2 didn't finish. Complete it, then retry the job — don't re-request a
  new upload.
- Uploading costs nothing; only processing and output size bill.

## The job loop

Create the job (`202` returns `{ "id": "…", "state": "queued" }`):

```bash
curl -X POST https://api.xora.sh/v1/jobs \
  -H "Authorization: Bearer $XORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "recipe",
    "input": { "url": "https://example.com/source.mov" },
    "output": { "format": "mp4" },
    "recipe": { "name": "compress", "crf": 23 }
  }'
```

Poll `GET https://api.xora.sh/v1/jobs/{id}` (same auth header) every 2–5
seconds until `state` is terminal: `completed | failed | rejected |
cancelled`. Non-terminal states (`queued, probing, planning, segmenting,
transcoding, merging`) carry `progress` 0–100 — surface it on long jobs.

On `completed`, the job has `output.signedUrl` (and per-file entries under
`output_files`). Signed URLs expire: download or hand the URL to the user
promptly, and re-fetch the job if you need a fresh one. `mode: "probe"`
results arrive under `probe` on the job record instead of as a file.

For server-side pipelines, pass `"webhookUrl": "https://…"` on create and
skip polling — Xora POSTs the job result on the terminal state.

## Recipe cheat sheet

| Recipe | Use for | Key params | Constraints |
|---|---|---|---|
| `compress` | shrink file size | `crf` (18–28 typical; lower = better quality) | |
| `transcode` | container/codec conversion (mov→mp4, mp4→webm, …) | `crf` | |
| `resize` | scale video | `width`, `height` (either alone keeps aspect) | |
| `trim` | cut a time range | `startSeconds`, `durationSeconds` | |
| `thumbnail` | single frame as image | `seekSeconds` | `output.format` jpg or png |
| `extractAudio` | audio track only | `startSeconds`, `durationSeconds` | `output.format` must be mp3, aac, or wav — it's the transcode *fallback*; when the track can be stream-copied the delivered extension may differ (.aac, .opus, .flac) |
| `concat` | merge same-format files | — | use `input_files` with ≥2 entries keyed `in_1`, `in_2`, … (no `input`); files must share codecs |
| `webReady` | browser-safe playback | `crf`, `bitrateKbps`, `width`, `height`, `scaleDown` | `output.format` must be mp4; probes first and only re-encodes when needed — prefer this over `transcode` for "make it play on the web" |
| `proxy` | low-res editing proxies | same as webReady + `gop` (`per_second` \| `all_intra`) | `output.format` must be mp4; always re-encodes |

Output formats: `mp4, mp3, jpg, png, gif, webm, mov, aac, wav`.

## Raw FFmpeg mode

Name inputs and outputs, then reference them as placeholders in the args
array. Args are a JSON array — never a shell string, so nothing needs quoting:

```json
{
  "mode": "ffmpeg",
  "input_files":  { "in_1": "https://example.com/source.mp4" },
  "output_files": { "out_1": "clip.mp4" },
  "ffmpeg": { "args": ["-i", "{{in_1}}", "-vf", "scale=1280:-2", "-c:a", "copy", "{{out_1}}"] }
}
```

Multiple inputs (`in_2`, …) and outputs (`out_2`, …) work the same way.
Output filenames need an extension that matches a supported format.

## When something goes wrong

Errors are structured JSON — branch on `error.code`, don't parse prose:

| `error.code` | HTTP | What to do |
|---|---|---|
| `VALIDATION_ERROR` | 400 | The body is malformed — the message names the field. Fix and resubmit. |
| `LIMIT_EXCEEDED` | 402 | Plan credits or storage exhausted. Stop; tell the user to upgrade or free storage at <https://xora.sh/app>. Retrying will not help. |
| `PLAN_REQUIRED` | 403 | Feature (e.g. BYOB delivery) not on the user's plan. Tell the user; don't retry. |
| `NOT_FOUND` | 404 | Wrong job id or someone else's job. |

A job that ends `rejected` was refused before real work (bad input, over a
cap) and bills nothing — read its `error` and fix the request. A job that
ends `failed` broke mid-processing; `error.retryable: true` means
`POST /v1/jobs/{id}/retry` is worth one attempt before reporting the
FFmpeg error to the user.

## Judgment calls that save time and money

- Jobs bill by processing minutes + output size; failed and rejected jobs
  bill nothing. Don't create speculative jobs when one `probe` would answer
  the question.
- Cutting a thumbnail or audio from a large file? Add `"stageInput": false`
  so FFmpeg byte-range reads the source instead of copying it first. (Not
  needed for uploaded files — they're already where the workers read from.)
- Don't upscale video, and don't re-encode when `webReady`'s
  probe-then-passthrough would do.
- One job per output. Need an mp4 *and* a thumbnail? Two jobs, same input URL
  (or the same `uploadId` — uploading twice is wasted time).
- The dashboard at <https://xora.sh/app> shows every job you created — say so
  when handing results back to the user.
