diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md
new file mode 100644
index 0000000..ec73d60
--- /dev/null
+++ b/.ai/HANDOFF.md
@@ -0,0 +1,53 @@
+# HANDOFF
+
+Append-only role handoff log. Each role adds one entry when its step is complete.
+
+### T-001,T-002 — plan — 2026-09-05T00:00:00Z
+
+| Field | Value |
+|-------|-------|
+| Agent | claude |
+| Summary | Wrote implementation plan for containerised YouTube downloader (FastAPI + yt-dlp + ffmpeg); created TASKS.md with T-001 (app code) and T-002 (Dockerfile + README); both tasks moved to ready_for_implement |
+| Files Changed | ROADMAP.md, .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md |
+| Next Role | implement |
+
+---
+
+### T-001 — implement — 2026-09-07T08:34:50Z
+
+| Field | Value |
+|-------|-------|
+| Agent | codex |
+| Summary | Implemented the FastAPI downloader backend and inline browser interface. |
+| Files Changed | app.py, requirements.txt, .ai/TASKS.md, .ai/HANDOFF.md |
+| Validation | `/tmp/youtube-downloader-venv/bin/python -m py_compile app.py`; mocked FastAPI route checks passed; `git diff --check` passed |
+| Commit | Pending reviewer approval |
+| Next Role | review |
+
+---
+
+### T-001 — review — 2026-09-07T00:00:00Z
+
+| Field | Value |
+|-------|-------|
+| Agent | claude |
+| Summary | Reviewed FastAPI downloader backend against `.ai/PLAN.md`; re-ran mocked route checks (home page, valid download, invalid/empty/missing-field errors, yt-dlp failure, unicode filename) — all passed. |
+| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
+| Verdict | PASS_WITH_NOTES |
+| Blocking Findings | none |
+| Next Role | implement |
+
+---
+
+### T-001 — implement — 2026-09-07T08:48:25Z
+
+| Field | Value |
+|-------|-------|
+| Agent | codex |
+| Summary | Squashed the reviewed downloader implementation into the task commit. |
+| Files Changed | app.py, requirements.txt, ROADMAP.md, .ai/config.json, .ai/PLAN.md, .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
+| Validation | Reviewer verification passed; `git diff --check` passed |
+| Commit | Created by this handoff's task commit |
+| Next Role | none |
+
+---
diff --git a/.ai/PLAN.md b/.ai/PLAN.md
new file mode 100644
index 0000000..440ce78
--- /dev/null
+++ b/.ai/PLAN.md
@@ -0,0 +1,120 @@
+# Plan
+
+Status: **ready_for_implement**
+
+Goal: implement a containerised YouTube downloader web app per `ROADMAP.md`.
+
+## Scope
+
+- Single Docker container running a FastAPI server (Python 3.12-slim + ffmpeg + yt-dlp).
+- Minimal browser UI: one URL text field + **Download MP3** and **Download Video** buttons.
+- MP3 download: yt-dlp extracts best audio, ffmpeg re-encodes to MP3 at highest quality (`-q:a 0`), file streamed back to browser.
+- Video download: yt-dlp fetches `bestvideo+bestaudio`, merges to MP4 via ffmpeg, file streamed back to browser.
+- Invalid/empty URL returns a JSON error; the UI displays it without crashing.
+- No persistent storage — temp dir per request, cleaned up after response.
+
+## Acceptance Criteria
+
+- `docker build -t yt-dl .` exits 0.
+- `docker run --rm -p 8080:8080 yt-dl` starts the server and `curl http://localhost:8080/` returns 200.
+- A valid YouTube URL + MP3 button delivers a `.mp3` file to the browser.
+- A valid YouTube URL + Video button delivers a `.mp4` file to the browser.
+- An empty URL or garbage URL returns an error visible in the UI.
+
+## Implementation Phases
+
+### Phase 1 — T-001: FastAPI backend + HTML/JS frontend
+
+**Files to create:**
+- `app.py` — entire server + inline HTML
+- `requirements.txt`
+
+**`app.py` structure:**
+
+1. **HTML constant** (`HTML`) — inline single-page UI:
+ - Text input for URL, placeholder "Paste YouTube URL…"
+ - Two buttons: `Download MP3` / `Download Video`
+ - Status `
` for errors/progress text
+ - JS `downloadFile(mode)`:
+ - Reads URL from input, validates non-empty client-side
+ - `fetch('/download', { method:'POST', body: FormData{url, mode} })`
+ - On success (content-type not application/json): creates object URL → `
` click → revoke URL
+ - On error or JSON response: parse JSON and show `.message` in status div
+
+2. **FastAPI app** (`app`):
+ - `GET /` — returns `HTMLResponse(HTML)`
+ - `POST /download` — `url: str = Form(...)`, `mode: Literal["mp3","video"] = Form(...)`
+ - Validate URL non-empty and starts with `http`; if not, raise `HTTPException(400)`
+ - Create `tempfile.TemporaryDirectory()`
+ - Build `ydl_opts`:
+ - MP3: `format='bestaudio/best'`, postprocessor `FFmpegExtractAudio` with `preferredcodec='mp3'`, `preferredquality='0'`
+ - Video: `format='bestvideo+bestaudio/best'`, `merge_output_format='mp4'`
+ - `outtmpl=tmpdir/%(title)s.%(ext)s`
+ - Run `yt_dlp.YoutubeDL(ydl_opts).download([url])` inside `asyncio.get_event_loop().run_in_executor(None, ...)` to avoid blocking the event loop
+ - Find the downloaded file in tmpdir (glob the single file present)
+ - Return `FileResponse(path, filename=..., background=BackgroundTask(cleanup_tmpdir))` — cleanup runs after response is sent
+ - Wrap yt-dlp call in try/except; on any exception raise `HTTPException(500, detail=str(e))`
+
+3. **`requirements.txt`**:
+ ```
+ fastapi>=0.111.0
+ uvicorn[standard]>=0.29.0
+ yt-dlp>=2024.4.9
+ python-multipart>=0.0.9
+ ```
+
+**Key decisions:**
+- Inline HTML keeps the project a two-file app (plus Dockerfile) — no template engine, no static asset serving needed.
+- `run_in_executor` keeps uvicorn's event loop unblocked while yt-dlp runs (which can take 30–120 s).
+- `FileResponse` with `BackgroundTask` cleanup is the standard FastAPI pattern for temp file responses.
+- yt-dlp is called via its Python API (not subprocess) to avoid shell escaping issues with URLs.
+
+---
+
+### Phase 2 — T-002: Dockerfile + README
+
+**Files to create/update:**
+- `Dockerfile`
+- `README.md` (update with build/run instructions)
+
+**`Dockerfile`**:
+```dockerfile
+FROM python:3.12-slim
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app.py .
+
+EXPOSE 8080
+
+CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
+```
+
+**`README.md`** must document:
+- Prerequisites: Docker
+- Build: `docker build -t yt-dl .`
+- Run: `docker run --rm -p 8080:8080 yt-dl`
+- Usage: open `http://localhost:8080` in a browser
+- Note on video size / download time expectations
+
+## Validation
+
+```bash
+# Build
+docker build -t yt-dl .
+
+# Smoke test — server starts and home page returns 200
+docker run --rm -d -p 8080:8080 --name yt-dl-test yt-dl
+sleep 3
+curl -sf http://localhost:8080/ | grep -q "Download MP3"
+docker stop yt-dl-test
+```
+
+No unit-test framework is introduced; the app is small enough that the docker smoke test is the validation gate. The reviewer will perform a live download test.
diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md
new file mode 100644
index 0000000..a950a69
--- /dev/null
+++ b/.ai/REVIEW.md
@@ -0,0 +1,39 @@
+# REVIEW
+
+Append-only review log. Each task section is appended or updated in place; prior task history is preserved.
+
+---
+
+## T-001 — FastAPI backend (`app.py`) + inline HTML/JS frontend (`requirements.txt`)
+
+**Verdict:** PASS_WITH_NOTES
+
+### Findings
+
+1. **[minor]** `app.py:147` — On any failure inside `download()` (including yt-dlp errors), `str(exc)` is returned verbatim to the browser as the `message` field. yt-dlp exceptions can include local filesystem paths (the temp dir) or verbose internal detail. Not a required fix for this local/self-hosted tool, but consider mapping to a generic message (e.g. "The video could not be downloaded.") and logging the raw exception server-side instead, before this is exposed beyond local/dev use.
+ - Required fix: No.
+
+No blocker or major findings.
+
+### Verification
+
+**Steps performed:**
+- Re-read `.ai/PLAN.md` (Phase 1 spec for T-001) and diffed it against `app.py` / `requirements.txt`.
+- `python -m py_compile app.py` — succeeded.
+- Installed `fastapi`, `uvicorn`, `httpx`, `python-multipart`, `yt-dlp` into a scratch venv and exercised the app with `starlette.testclient.TestClient`, mocking `yt_dlp.YoutubeDL` to avoid live network calls:
+ - `GET /` → 200, body contains "Download MP3" and "Download Video".
+ - `POST /download` with a garbage (non-`http`) URL → 400 JSON `{"message": ...}`.
+ - `POST /download` with an empty URL → 400 JSON.
+ - `POST /download` missing the `mode` field (triggers `RequestValidationError` handler) → 400 JSON with `.message`.
+ - `POST /download` with a valid URL, mode `mp3`, mocked yt-dlp writing a fake file → 200, correct bytes streamed, `Content-Disposition: attachment; filename="..."`, temp dir cleaned up via `BackgroundTask`.
+ - `POST /download` where mocked yt-dlp raises `RuntimeError` → 500 JSON `{"message": "..."}` (no server crash), temp dir cleaned up on the error path.
+ - Filename containing non-ASCII characters (e.g. "café mix 🎵.mp3") → correctly RFC 5987-encoded as `filename*=utf-8''...` by Starlette; no crash or mojibake.
+
+**Findings from verification:** All acceptance criteria for T-001 hold:
+- `GET /` returns 200 with HTML containing "Download MP3".
+- `POST /download` with a valid URL + mode streams a file.
+- Invalid URL returns 400 JSON with `.message`.
+
+**Risks:**
+- No live network test against real YouTube was performed (would require external network access and a real video URL); mocked yt-dlp calls confirm the FastAPI plumbing (routing, form validation, error handling, file streaming, temp-dir cleanup) is correct, but do not confirm yt-dlp/ffmpeg behavior against a live video. This risk carries into the Docker-level validation planned for T-002 (`docker build` + smoke test), where a live download should be attempted at least once.
+- Large/long-running downloads have no timeout or size cap — acceptable for v1 per `ROADMAP.md` (out of scope), but worth revisiting if this moves beyond local/dev use.
diff --git a/.ai/TASKS.md b/.ai/TASKS.md
new file mode 100644
index 0000000..038656d
--- /dev/null
+++ b/.ai/TASKS.md
@@ -0,0 +1,24 @@
+# TASKS
+
+Use this board to coordinate handoff between planner, implementer, and reviewer.
+
+Status values:
+- `in_planning`
+- `ready_for_implement`
+- `in_implementation`
+- `ready_for_review`
+- `in_review`
+- `ready_to_commit`
+- `changes_requested`
+- `done`
+
+Command expectations:
+- planner moves tasks into `in_planning` and `ready_for_implement`
+- implementer moves tasks into `in_implementation`, `ready_for_review`, and `done`, and resumes work from `changes_requested` and `ready_to_commit`
+- reviewer moves tasks into `in_review`, `ready_to_commit`, or `changes_requested`
+- `status_cycle` should report deterministic task status, current owner role, and next recommended action based on this board
+
+| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role |
+| --- | --- | --- | --- | --- | --- |
+| T-001 | FastAPI backend (`app.py`) + inline HTML/JS frontend (`requirements.txt`) | done | `GET /` returns 200 with HTML containing "Download MP3"; `POST /download` with valid URL + mode streams a file; invalid URL returns 400 JSON with `.message` | Reviewer re-ran mocked FastAPI route checks (GET /, valid download, invalid/empty/missing-field errors, yt-dlp failure, unicode filename) — all passed; see `.ai/REVIEW.md` | none |
+| T-002 | Dockerfile + README.md (build/run docs) | ready_for_implement | `docker build -t yt-dl .` exits 0; `docker run --rm -p 8080:8080 yt-dl` starts server; `curl http://localhost:8080/` returns HTML with download buttons | n/a | implement |
diff --git a/.ai/config.json b/.ai/config.json
index fbffcfe..02ca32e 100644
--- a/.ai/config.json
+++ b/.ai/config.json
@@ -7,7 +7,7 @@
},
"implement": {
"agent": "codex",
- "model": "gpt-5.4"
+ "model": "gpt-5.6-terra"
},
"review": {
"agent": "claude",
diff --git a/ROADMAP.md b/ROADMAP.md
index 9ba667b..b8a1802 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,29 +1,27 @@
# ROADMAP
-Goal: define and deliver the scope for this cycle.
-
-Delete any unused example sections below. Only the Goal and one concrete priority are required.
+Goal: deliver a containerised YouTube downloader web app — one URL input, two download buttons (MP3 / Video), always best available quality.
## Priority 1
-Objective: replace with objective.
-
-- Replace with planned outcome.
-
-## Examples
-
-These example sections are optional illustrations, not required structure.
-
-
-## Priority 2
+Objective: Containerised YouTube downloader with a minimal browser UI.
-Objective: optional second objective.
+- User pastes a YouTube URL into a single input field.
+- Clicking **Download MP3** triggers a server-side yt-dlp run that extracts audio at the highest available bitrate (converted to MP3 via ffmpeg) and streams the file back to the browser for download.
+- Clicking **Download Video** triggers a server-side yt-dlp run that fetches the best video+audio quality and streams the file back to the browser for download.
+- The entire app runs in a single Docker container (`docker build` / `docker run -p 8080:8080 yt-dl`) with no external dependencies beyond Docker.
+- No login, no database, no persistent storage required — stateless request/response.
-- Replace with optional planned outcome.
+## Acceptance Criteria
-
-## Priority 3
+- `docker build` completes without errors.
+- `docker run -p 8080:8080 yt-dl` starts the server.
+- Pasting a valid YouTube URL and clicking **Download MP3** delivers an `.mp3` file to the browser.
+- Pasting a valid YouTube URL and clicking **Download Video** delivers a best-quality video file (`.mp4` / `.mkv`) to the browser.
+- Invalid or empty URLs show a clear error message in the UI (no server crash).
-Objective: optional third objective.
+## Out of Scope
-- Replace with optional planned outcome.
+- Playlist batch downloads.
+- User accounts / history.
+- Progress bars / live download status (nice-to-have but not required for v1).
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..ca5c9bd
--- /dev/null
+++ b/app.py
@@ -0,0 +1,153 @@
+"""FastAPI application serving a small YouTube download interface."""
+
+import asyncio
+import shutil
+import tempfile
+from pathlib import Path
+from typing import Literal
+
+import yt_dlp
+from fastapi import FastAPI, Form, HTTPException, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
+from starlette.background import BackgroundTask
+
+
+HTML = """
+
+
+
+
+ YouTube Downloader
+
+
+
+
+ YouTube Downloader
+
+
+
+
+
+
+
+
+
+
+"""
+
+app = FastAPI()
+
+
+def cleanup_tmpdir(path: str) -> None:
+ """Remove a request's temporary download directory after streaming."""
+ shutil.rmtree(path, ignore_errors=True)
+
+
+@app.exception_handler(HTTPException)
+async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse:
+ """Expose errors in the format expected by the browser client."""
+ message = exc.detail if isinstance(exc.detail, str) else "The request could not be processed."
+ return JSONResponse(status_code=exc.status_code, content={"message": message})
+
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(_: Request, __: RequestValidationError) -> JSONResponse:
+ """Return malformed form submissions as a client-readable error."""
+ return JSONResponse(status_code=400, content={"message": "A URL and download mode are required."})
+
+
+@app.get("/", response_class=HTMLResponse)
+async def home() -> str:
+ """Serve the single-page downloader interface."""
+ return HTML
+
+
+@app.post("/download")
+async def download(
+ url: str = Form(...), mode: Literal["mp3", "video"] = Form(...)
+) -> FileResponse:
+ """Download and stream a requested audio or video file."""
+ if not url.strip() or not url.lower().startswith("http"):
+ raise HTTPException(status_code=400, detail="Please provide a valid URL starting with http.")
+
+ temp_dir = tempfile.mkdtemp(prefix="youtube-download-")
+ output_template = str(Path(temp_dir) / "%(title)s.%(ext)s")
+ ydl_opts: dict[str, object] = {"outtmpl": output_template}
+ if mode == "mp3":
+ ydl_opts.update(
+ {
+ "format": "bestaudio/best",
+ "postprocessors": [
+ {
+ "key": "FFmpegExtractAudio",
+ "preferredcodec": "mp3",
+ "preferredquality": "0",
+ }
+ ],
+ }
+ )
+ else:
+ ydl_opts.update({"format": "bestvideo+bestaudio/best", "merge_output_format": "mp4"})
+
+ def fetch_media() -> None:
+ with yt_dlp.YoutubeDL(ydl_opts) as downloader:
+ downloader.download([url])
+
+ try:
+ await asyncio.get_running_loop().run_in_executor(None, fetch_media)
+ downloads = [path for path in Path(temp_dir).iterdir() if path.is_file()]
+ if len(downloads) != 1:
+ raise RuntimeError("The downloaded file could not be identified.")
+ media_path = downloads[0]
+ except Exception as exc:
+ cleanup_tmpdir(temp_dir)
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ return FileResponse(
+ media_path,
+ filename=media_path.name,
+ background=BackgroundTask(cleanup_tmpdir, temp_dir),
+ )
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..de7889e
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+fastapi>=0.111.0
+uvicorn[standard]>=0.29.0
+yt-dlp>=2024.4.9
+python-multipart>=0.0.9