The new generation of AI video tools — Veo, Runway, Sora — can produce a 10-second clip in 90 seconds. The model side of the problem is solved. The workflow side is not. You still have to pull the file off the generator, sit on a Slack thread waiting for someone to nod at it, push it to whatever review tool the client uses, chase the approval click, and only then deliver. Most of that work has nothing to do with the creative judgement and everything to do with shuffling files between systems.

This tutorial shows how to collapse the shuffling step into a single Claude Code command. By the end you will have a working pipeline where the agent generates a clip, uploads it to Lumiqa, opens a review, waits for human sign-off, and either ships or regenerates with the reviewer’s feedback. All of it driven by natural language. Roughly 30 minutes from zero to first run.

I’m treating Claude Code as the agentic OS for developers — it has shell access, a skill system, and it speaks MCP natively. Lumiqa is the review surface — it speaks MCP back and exposes every action as a REST endpoint. The two were built for each other; this post stitches them.

Prerequisites

Before you start, get these in place:

  • Claude Code installed and authenticated. On macOS: brew install anthropic/claude-code/claude-code then claude login. On Linux/WSL, use the install script from Anthropic’s docs.
  • Lumiqa account with at least one workspace. Sign up free — the Free Developer tier gives you 100 uploads/month and full API + MCP access.
  • An API key from your dashboard → Settings → API. The key starts with lk_live_; copy it somewhere safe, you only see it once.
  • Your workspace slug — it’s the part of the dashboard URL after /w/, e.g. lumiqa-creative-studio-lrbb.
  • Python 3.11+ for the helper script. (Node 20+ works too; the patterns translate one-to-one. Python in the main samples.)

Total setup cost: free. The whole tutorial fits inside the free tier.

Step 1 — Connect Claude Code to Lumiqa via MCP

Claude Code talks to MCP servers via the claude mcp subcommand. Lumiqa hosts its MCP server at https://lumiqa.io/mcp/<workspace-slug> and authenticates with a Bearer token. One line to wire it up:

# Replace the slug with yours from the dashboard URL
claude mcp add lumiqa https://lumiqa.io/mcp/lumiqa-creative-studio-lrbb \
  --transport http \
  --header "Authorization: Bearer lk_live_xxxxxxxxxxxx"

That writes an entry to ~/.claude.json under the project scope. If you peek at the file, you’ll see something like:

{
  "mcpServers": {
    "lumiqa": {
      "type": "http",
      "url": "https://lumiqa.io/mcp/lumiqa-creative-studio-lrbb",
      "headers": {
        "Authorization": "Bearer lk_live_xxxxxxxxxxxx"
      }
    }
  }
}

Verify the connection:

claude mcp list

You should see lumiqa with the green check and five tools attached: list_workspaces, upload_video, start_review, get_review_status, add_comment. (Lumiqa also exposes approve_review and deliver_asset on the server side; depending on your account scope you may see those too.)

Smoke-test from a fresh shell:

claude -p "List my Lumiqa workspaces and their IDs."

Claude will fire a single MCP tools/call with name: list_workspaces and stream the result back. If the token is wrong you’ll see a clean 401 Unauthorized — fix and retry. If it works, you’re plumbed in. Total elapsed: under two minutes.

Step 2 — Define the pipeline as a Claude Code skill

A one-shot prompt is fine for a smoke test. For a real pipeline you want a skill — a reusable instruction file that Claude Code activates whenever the user’s request matches the skill’s description. Skills live in ~/.claude/skills/<name>/SKILL.md.

Create the skill folder and write the file:

mkdir -p ~/.claude/skills/video-pipeline/scripts
$EDITOR ~/.claude/skills/video-pipeline/SKILL.md

Paste this into SKILL.md:

---
name: video-pipeline
description: Generate an AI video from a prompt, upload it to Lumiqa, request team review, wait for the verdict, and either ship on approval or regenerate with reviewer feedback on rejection. Use when the user asks to "generate a video and request review" or similar.
---

# Video pipeline

You orchestrate a five-stage video pipeline. Follow the stages in order and never skip a verification step — every stage produces an artifact that the next stage depends on.

## Inputs

- A generation prompt (required, freeform text from the user).
- A target workspace slug (optional, default to the first workspace from `list_workspaces`).
- A list of reviewer emails (optional, default to the workspace's existing team).

## Stages

### 1. Generate

Run `scripts/generate.py` with the user's prompt. The script returns a JSON object with `video_url`, `duration_seconds`, `resolution`. Capture all three.

### 2. Upload

Call the `upload_video` MCP tool on the `lumiqa` server with:
- `workspace`: the slug
- `source_url`: the `video_url` from stage 1
- `title`: a short slug derived from the prompt (max 60 chars)
- `metadata`: `{ "generator": "veo-or-mock", "prompt": "" }`

Save the returned `asset_id`.

### 3. Request review

Call `start_review` with the `asset_id` and the reviewer list. Save the `review_id`.

Tell the user: "Review opened: https://lumiqa.io/review/<review_id> — pinging reviewers."

### 4. Wait for verdict

Poll `get_review_status` every 30 seconds, up to 30 minutes. Stop polling once `status` is `approved` or `changes_requested`. If timeout: tell the user and exit cleanly.

### 5. Branch on verdict

- `approved` → call `deliver_asset` with the `asset_id`. Confirm delivery URL to the user.
- `changes_requested` → fetch the latest comments via `get_review_status` (they're in the `comments` array), concatenate them as feedback, and re-run stage 1 with the original prompt + appended feedback. Loop back to stage 2. Cap at 3 regeneration cycles.

## Failure handling

Any MCP error: surface it verbatim to the user and stop. Do not retry blindly — bad credentials and rate limits look the same on a quick read, and silent retries burn the user's quota.

Two things worth noticing. First, the description field is what Claude Code matches against user prompts to decide whether to activate the skill — write it like a one-sentence pitch, not a manual. Second, the stages are explicit and numbered; agentic models follow numbered checklists far more reliably than open-ended instructions.

You can verify the skill is registered:

claude /skills

video-pipeline should appear in the list with its description.

Step 3 — Add a Python helper for video generation

Skills don’t do generation themselves. They orchestrate. Generation is a shell command Claude runs via the Bash tool. Drop this into ~/.claude/skills/video-pipeline/scripts/generate.py:

#!/usr/bin/env python3
"""generate.py — placeholder AI video generator.

In production swap the body of `generate` for an actual call to Veo,
Runway or Sora. The contract — prompt in, JSON out — stays identical.
"""
import argparse, hashlib, json, sys, time


def generate(prompt: str) -> dict:
    # Stand-in: deterministically pick a stock clip URL.
    # Replace with: requests.post("https://api.runwayml.com/v1/...", ...)
    digest = hashlib.sha256(prompt.encode()).hexdigest()[:8]
    fixtures = [
        "https://storage.googleapis.com/lumiqa-samples/demo-beats-01.mp4",
        "https://storage.googleapis.com/lumiqa-samples/demo-beats-02.mp4",
        "https://storage.googleapis.com/lumiqa-samples/demo-beats-03.mp4",
    ]
    pick = fixtures[int(digest, 16) % len(fixtures)]
    # Simulate the 30-90s latency of a real model so the agent's UX matches prod.
    time.sleep(2)
    return {
        "video_url": pick,
        "duration_seconds": 10,
        "resolution": "1920x1080",
        "seed": digest,
    }


def main() -> 0:
    parser = argparse.ArgumentParser()
    parser.add_argument("--prompt", required=True)
    args = parser.parse_args()
    result = generate(args.prompt)
    json.dump(result, sys.stdout)
    sys.stdout.write("\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Make it executable:

chmod +x ~/.claude/skills/video-pipeline/scripts/generate.py

The script is a stub on purpose. The point of the tutorial is the pipeline, not the model call. When you wire up Veo/Runway/Sora, the only change is the body of generate():

import os, requests

def generate(prompt: str) -> dict:
    res = requests.post(
        "https://api.runwayml.com/v1/image_to_video",
        headers={"Authorization": f"Bearer {os.environ['RUNWAY_KEY']}"},
        json={"prompt": prompt, "duration": 10, "ratio": "16:9"},
        timeout=180,
    )
    res.raise_for_status()
    job = res.json()
    # poll job until status == "succeeded", then return its output URL...
    return {"video_url": job["output"][0], "duration_seconds": 10, "resolution": "1280x768"}

The skill prompt tells Claude Code to invoke the script via the Bash tool:

python3 ~/.claude/skills/video-pipeline/scripts/generate.py --prompt "$PROMPT"

The agent reads the JSON off stdout, parses it, and passes video_url into the upload_video tool call. This decoupling — orchestration in the skill, side-effects in scripts — is the pattern that scales. Add a generate_voiceover.py tomorrow, swap engines next week, the skill body barely changes.

Step 4 — Run the pipeline

You’re wired. Time to fire it.

From any terminal:

claude "Generate a 10-second product demo for our new beat-detection feature, upload to Lumiqa, request team review."

Claude Code matches the request against the skill description, activates video-pipeline, and walks the stages. Here’s what the agent transcript looks like (compressed):

→ Activating skill: video-pipeline
→ Stage 1: generate
$ python3 ~/.claude/skills/video-pipeline/scripts/generate.py \
    --prompt "10s product demo, beat-detection feature, fast cuts on the drum hits"
{"video_url": "https://storage.googleapis.com/lumiqa-samples/demo-beats-02.mp4",
 "duration_seconds": 10, "resolution": "1920x1080", "seed": "a3f9c1d2"}

→ Stage 2: upload to Lumiqa
[mcp:lumiqa] tools/call upload_video {
  "workspace": "lumiqa-creative-studio-lrbb",
  "source_url": "https://storage.googleapis.com/lumiqa-samples/demo-beats-02.mp4",
  "title": "beat-detection-demo",
  "metadata": {"generator": "mock", "prompt": "10s product demo..."}
}
← {"asset_id": "as_01HXY7Z9P2...", "status": "ready"}

→ Stage 3: open review
[mcp:lumiqa] tools/call start_review {
  "asset_id": "as_01HXY7Z9P2...",
  "reviewers": ["[email protected]"]
}
← {"review_id": "rv_01HXY7ZBQ4...", "share_url": "https://lumiqa.io/review/rv_01HXY7ZBQ4..."}

Review opened: https://lumiqa.io/review/rv_01HXY7ZBQ4... — pinging reviewers.

→ Stage 4: poll for verdict (every 30s, max 30min)
... (8 minutes pass) ...

← {"status": "approved", "approved_by": "[email protected]",
   "approved_at": "2026-05-21T16:42:11Z", "comments": []}

→ Stage 5: deliver
[mcp:lumiqa] tools/call deliver_asset {"asset_id": "as_01HXY7Z9P2..."}
← {"delivery_url": "https://cdn.lumiqa.io/delivery/as_01HXY7Z9P2..."}

Delivered. CDN URL: https://cdn.lumiqa.io/delivery/as_01HXY7Z9P2...

On the Lumiqa side, your reviewer opens the share URL, sees the clip with a timecoded comment box, hits Approve, and goes back to whatever they were doing. The whole human-loop step is one click. The agent does the rest.

If the reviewer hits "Request changes" instead and leaves a comment like “Slower on the first beat, the cut feels rushed”, the skill loops: re-runs generate.py with the original prompt plus the appended note, uploads the new clip as a new version of the same asset, re-opens the review. Up to three cycles, then it stops and asks for human input. That cap is the one safety rail you must not remove — agents in unbounded loops are the most efficient way ever invented to burn through a generation budget.

Step 5 — Handle approval/rejection via webhook (optional)

Polling works for short jobs. For longer review windows — overnight, multi-day, distributed teams — webhooks are cleaner. Lumiqa fires review.approved and review.changes_requested events to any URL you register.

The simplest receiver is a Cloudflare Worker. It costs zero, scales to nothing, and the deploy is two commands.

npm create cloudflare@latest lumiqa-webhook -- --type=hello-world --ts=false
cd lumiqa-webhook

Replace src/index.js with:

export default {
  async fetch(request, env) {
    if (request.method !== "POST") return new Response("ok", { status: 200 });

    const event = await request.json();
    const { type, data } = event;

    // Persist to KV so a local poller can pick it up.
    const key = `event:${data.review_id}:${Date.now()}`;
    await env.EVENTS.put(key, JSON.stringify(event), { expirationTtl: 86400 });

    // Optional: ping a local trigger endpoint (e.g. ngrok tunnel to your laptop).
    if (env.LOCAL_TRIGGER_URL) {
      await fetch(env.LOCAL_TRIGGER_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(event),
      });
    }

    return new Response(JSON.stringify({ received: true }), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

A real payload looks like this — store the shape, not a screenshot:

{
  "type": "review.approved",
  "created_at": "2026-05-21T16:42:11Z",
  "data": {
    "review_id": "rv_01HXY7ZBQ4...",
    "asset_id": "as_01HXY7Z9P2...",
    "workspace": "lumiqa-creative-studio-lrbb",
    "approved_by": "[email protected]",
    "comments": []
  }
}

Deploy and register the URL with Lumiqa:

npx wrangler deploy
# → https://lumiqa-webhook.<your-account>.workers.dev

curl -X POST https://lumiqa.io/v1/webhooks \
  -H "Authorization: Bearer lk_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://lumiqa-webhook.acme.workers.dev","events":["review.approved","review.changes_requested"]}'

From here, two patterns work. Pattern A: a tiny local poller running on your machine reads new events out of the Worker’s KV every minute and shells out to claude -p "continue pipeline for review <id>". Simple, reliable, no inbound exposure. Pattern B: the Worker forwards events to an ngrok tunnel pointed at a local HTTP server you wrote, which then triggers Claude Code. Faster but more moving parts. For solo developers, Pattern A is almost always the right call.

On changes_requested, your handler concatenates the comments into a feedback string and feeds it back through the same skill: claude "regenerate <original prompt> with feedback: <comment text>, re-upload to review <id>". Claude treats it as a fresh skill activation with richer context. The pipeline closes its own loop.

What you just built, and where to take it

Step back and look at what’s on your laptop now. A natural-language command kicks off generation, upload, review, polling or webhook, branching, regeneration, and delivery — across an AI model, a SaaS platform, and a Cloudflare edge worker. No glue code in the user-facing layer. No browser tabs. No drag-and-drop. The pipeline that used to take a producer half a day takes a sentence.

The interesting bit is how cheaply it extends. Each piece is a clean seam:

  • Swap the generator. Replace the stub generate.py with Veo, Runway, Sora, Kling, Hailuo — anything with an HTTP API. The skill never knows.
  • Add Slack notifications. Drop a Slack MCP server next to Lumiqa; tell the skill to post the review URL into #review-queue at stage 3. Five lines.
  • Bill on delivery. Add a Stripe MCP server, call create_invoice at stage 5 with the asset’s metadata as line items. Your pipeline now ships and invoices itself.
  • Multi-stage approval. Producer approval before client approval — call start_review twice in sequence with different reviewer lists. Lumiqa already supports it; the skill just chains them.

The full source for this tutorial — skill file, helper script, Worker, sample webhook payloads — lives in the lumiqa-examples repo. Clone it, point it at your workspace, change one line, and run.

Why this matters past the demo

Every video-AI pipeline I see in production has the same hand-rolled middle layer: cron jobs, Zapier, a producer in Slack chasing approvals. That layer is now a 40-line Markdown file. The savings compound the moment you ship more than one project a week.

Spin up Lumiqa in 60 seconds

Free Developer tier: full MCP + REST access, 100 uploads/month, 1 GB storage. No credit card. Built for exactly this use case.

Create your free workspace →

Already have an account? Grab an lk_live_* key from your dashboard.

Questions, weird MCP errors, or a use case I haven’t covered? Email [email protected] or open an issue on the examples repo. I read every one.