talkthrough-mcp
This MCP server locally ingests videos/audio (files or public URLs) into searchable evidence—transcript, keyframes, OCR, wall-clock timestamps, and optional speaker diarization—then serves retrieval tools for triage, bug reports, meetings, and log correlation.
Ingest local media: process
.mov,.mp4,.webm,.mkv,.ogv,.m4a,.mp3,.wav,.ogg,.flacfiles withprocess_media, idempotent by content hash.Ingest public URLs: download one public video/audio URL (YouTube, TikTok, Instagram reels, direct media links, etc.) once with
process_url, then analyze locally with no later network access.Transcribe speech: local Whisper transcription with language auto-detection, vocabulary biasing, and configurable models.
Extract visual evidence: scene-change keyframes, OCR-indexed on-screen text, and exact-timestamp native-resolution frame extraction with optional cropping.
Search the index: case-insensitive substring search across both transcript and OCR text, with speaker filtering.
Retrieve moments: bundle transcript slices, up to 3 frames, OCR text, and wall-clock ranges for triage.
Anchor wall-clock time: resolve recording start time from metadata or
recorded_atto correlate remarks with server logs.Identify speakers: opt-in local diarization with
S1/S2labels, optionalnum_speakers, andlabel_speakersto persist verified names.Work with prior jobs:
list_jobsshows processed recordings; job IDs remain stable across renames/moves.Support agent workflows: built-in MCP prompts for bug reports, triage, specs, backlogs, meeting actions, and log correlation.
Keep everything local: no cloud STT/LLM calls or telemetry; only one-time model/tool downloads and explicit URL fetching.
Integrates with GitHub Copilot CLI to allow AI agents to ingest and analyze narrated screen recordings, extracting transcript segments, keyframes, and OCR text for automated workflows.
Integrates with OpenAI Codex CLI to enable AI agents to process and query narrated screen recordings, turning them into structured data such as transcripts, keyframes, and searchable text.
talkthrough-mcp
Quickstart · Tools · Benchmarks · FAQ · Troubleshooting · Changelog · Contributing
Don't write a bug report. Record it.
Give Claude Code or Codex a narrated .mov/.mp4 — or a public video link —
and talkthrough turns it into searchable transcript, exact frames, OCR and
wall-clock timestamps, locally — so your agent writes an evidence-backed
issue draft or investigates the fix.
Also works for meetings, workshops, product demos, and production incidents.

Illustration — an animation of the /talkthrough:bug storyline, not a screen
capture: the recording is indexed locally (transcript · keyframes · OCR ·
wall-clock), the evidence checkpoint is assembled, and the draft is ready to
file with your own tracker tooling (gh, Jira, a GitHub MCP server —
talkthrough itself never leaves your machine and never posts anything).
Real runs, no animation:
▶ Watch the demo with sound (1:18) — an unedited session: a narrated recording goes in, a ready-to-file
bug-report.mdcomes out.Silent recording → issue draft — the whole thing as files you can re-run: a Playwright-recorded, audio-free
.mp4and the unedited agent output. Every number is reproducible — processing that file givesjob_id 8703a66bbe77a7d0(the job id is the sha256 prefix, soshasum -a 256predicts it), 17 keyframes / 4 unique, and 0 transcript segments because there is no audio track.
Quickstart
One command, no system dependencies: ffmpeg falls back to a bundled build,
OCR is pip-only, and whisper models download themselves on first use. The
only prerequisite is uv (brew install uv or
curl -LsSf https://astral.sh/uv/install.sh | sh).
Cold setup has two separate stages: uvx first resolves a compatible Python
and the pinned server environment, then the first process_media downloads
any missing media/model assets. A plugin update can create a new environment
and, without system ffmpeg, fetch the ~80 MB bundled ffmpeg again; shared
Whisper/OCR/diarization caches and warm, network-free jobs remain reusable.
See Troubleshooting.
Claude Code
Two install paths — pick one, not both (the plugin already includes the server; installing both would register it twice):
Server only — the 9 tools + 6 prompts, and nothing else on your system. Choose this for a minimal setup, or when you manage MCP servers yourself across several clients:
claude mcp add -s user talkthrough -- uvx --python ">=3.11,<3.14" "talkthrough-mcp[diarization,url]"Full plugin — the same server, plus native slash commands
(/talkthrough:bug, /talkthrough:triage-recording, …) that handle the
ceremony for you, a ready-made triage subagent, and an agent skill that
teaches Claude the workflow. Choose this for the best out-of-the-box
experience:
/plugin marketplace add korovin-aa97/talkthrough-mcp
/plugin install talkthrough@talkthroughEvery other MCP client
claude_desktop_config.json:
{
"mcpServers": {
"talkthrough": {
"command": "uvx",
"args": [
"--python",
">=3.11,<3.14",
"talkthrough-mcp[diarization,url]"
]
}
}
}More: integrations/claude-desktop/
~/.cursor/mcp.json (or project .cursor/mcp.json):
{
"mcpServers": {
"talkthrough": {
"command": "uvx",
"args": [
"--python",
">=3.11,<3.14",
"talkthrough-mcp[diarization,url]"
]
}
}
}More: integrations/cursor/
~/.codex/config.toml (or project-scoped .codex/config.toml in trusted projects):
[mcp_servers.talkthrough]
command = "uvx"
args = ["--python", ">=3.11,<3.14", "talkthrough-mcp[diarization,url]"]More: integrations/codex/
~/.gemini/settings.json:
{
"mcpServers": {
"talkthrough": {
"command": "uvx",
"args": [
"--python",
">=3.11,<3.14",
"talkthrough-mcp[diarization,url]"
]
}
}
}More: integrations/gemini-cli/
cline_mcp_settings.json (via MCP Servers UI):
{
"mcpServers": {
"talkthrough": {
"command": "uvx",
"args": [
"--python",
">=3.11,<3.14",
"talkthrough-mcp[diarization,url]"
]
}
}
}More: integrations/cline/
~/.openclaw/openclaw.json:
{
"mcp": {
"servers": {
"talkthrough": {
"command": "uvx",
"args": [
"--python",
">=3.11,<3.14",
"talkthrough-mcp[diarization,url]"
]
}
}
}
}More: integrations/openclaw/
opencode.json (project) or ~/.config/opencode/opencode.json:
{
"mcp": {
"talkthrough": {
"type": "local",
"command": [
"uvx",
"--python",
">=3.11,<3.14",
"talkthrough-mcp[diarization,url]"
],
"enabled": true
}
}
}More: integrations/opencode/
~/.config/goose/config.yaml:
extensions:
talkthrough:
enabled: true
type: stdio
cmd: uvx
args: ["--python", ">=3.11,<3.14", "talkthrough-mcp[diarization,url]"]More: integrations/goose/
~/.copilot/mcp-config.json:
{
"mcpServers": {
"talkthrough": {
"command": "uvx",
"args": [
"--python",
">=3.11,<3.14",
"talkthrough-mcp[diarization,url]"
]
}
}
}More: integrations/copilot-cli/
~/.codeium/windsurf/mcp_config.json:
{
"mcpServers": {
"talkthrough": {
"command": "uvx",
"args": [
"--python",
">=3.11,<3.14",
"talkthrough-mcp[diarization,url]"
]
}
}
}More: integrations/windsurf/
settings.json (Zed):
{
"context_servers": {
"talkthrough": {
"source": "custom",
"command": {
"path": "uvx",
"args": [
"--python",
">=3.11,<3.14",
"talkthrough-mcp[diarization,url]"
]
}
}
}
}More: integrations/zed/
Any other MCP stdio client uses the same server command: uvx --python ">=3.11,<3.14" "talkthrough-mcp[diarization,url]".
Per-engine folders with exactly these snippets plus verification steps live
in integrations/; agents can self-install via
llms-install.md.
Who said what (speaker diarization) — included in the configs above
Multi-person recordings (meetings, interviews, panels) can carry S1/S2/…
speaker labels. Every install button, snippet, and the plugin above already
ship the [diarization] engine, so asking your agent "who said what" just
works — diarization itself still runs only when requested per call
(process_media(path=..., diarize=true, num_speakers=<count if known>)),
and its models download once on first use.
Prefer the minimal server without the diarization engine? Use
uvx --python ">=3.11,<3.14" talkthrough-mcp as the command instead (the MCP registry entry also
resolves to this lean form) — an explicit diarize=true will then answer
with the one-line install fix. Details in
Speakers.
Updating to 0.4.4
Update your plugin or generated config, then warm its exact pinned launcher before reconnecting the MCP client:
uvx --python ">=3.11,<3.14" "talkthrough-mcp[diarization,url]==0.4.4" --versionThe output should name 0.4.4 and both extras. This prepares dependencies; the first recording can still need model/tool downloads. A cold environment can exceed the client's startup timeout. See startup recovery. Existing jobs need no migration.
Upgrading from 0.3.x
Regenerated configs and the plugin carry [diarization,url]. A config you
wrote by hand for 0.3.x — uvx --python ">=3.11,<3.14" talkthrough-mcp, or a
pin without the url extra — upgrades the server in place and lists the new
process_url tool,
but only direct https:// media links work until the extra is there:
YouTube and video pages answer with the one-line install fix. Add url to
your command (uvx --python ">=3.11,<3.14" "talkthrough-mcp[diarization,url]"),
restart the client, and check with talkthrough-mcp --version (0.4.1+),
which names the extras the environment has; the server logs the same line
to stderr at every start, so your client's MCP log shows it too.
Local checkout (development)
git clone https://github.com/korovin-aa97/talkthrough-mcp
claude mcp add talkthrough -- uv run --directory /path/to/talkthrough-mcp talkthrough-mcpThen, in your agent:
Process
~/Desktop/recording.movand triage it — or just invoke thetriage-recordingserver prompt.
Related MCP server: pincushion-mcp
Tools
Tool | What it does |
| Ingest a video/audio file: local STT, keyframes, OCR, wall-clock, opt-in speaker labels. Returns a compact summary. Idempotent by content hash — re-calls are instant; |
| The one network tool: download one public video/audio URL once (a direct |
| Paginated transcript as |
| Keyframe images nearest a timestamp or evenly thinned across a range (unique frames by default, max 6/call); each frame names its absolute |
| The "one remark" bundle: transcript slice + up to 3 frames + their OCR text + wall-clock range (+ |
| Substring search over the transcript AND on-screen OCR text. |
| Atomically persist verified names for anonymous speaker labels. Raw |
| Exact-timestamp full-resolution re-extract from the source video (optional crop) when keyframes miss the instant; returns the file's absolute |
| Recent processed recordings with source paths, durations, wall-clock starts, counts, speaker counts when diarized, and the provider/id for URL jobs. |
Every tool description ships 10+ usage examples, so agents pick the right tool without extra prompting.
Server prompts (slash commands in MCP clients)
Prompt | Workflow |
| One recording → evidence-backed GitHub issue draft (silent, narration-free recordings work too) |
| Narrated screencast → precise findings JSON (bug/feature/question routing, frame evidence) |
| Recorded workshop → structured spec with quoted decisions and open questions |
| Product demo → prioritized backlog with timestamped evidence |
| Meeting audio → action items, decisions, open questions |
| Recording remarks ↔ system logs via wall-clock windows |
The same prompts live as plain files in examples/prompts/
if your client doesn't surface MCP prompts. The findings contract used by
triage-recording is examples/output-contract.schema.json.
Works as a skill too (no MCP required)
The same workflow ships as a cross-engine Agent Skill
at .agents/skills/talkthrough/ — Claude Code,
Codex CLI ($talkthrough), Cursor, Copilot, Gemini CLI, Goose and other
SKILL.md-compatible tools read it. Agents without MCP wiring can drive the
CLI directly: talkthrough-mcp process recording.mov --json prints the
same summary the MCP tool returns, and the job store is shared either way.
Wall-clock anchoring
Every timestamped result carries both t_ms (video-relative) and t_wall
(ISO 8601 real time) once the recording start is known. Resolution ladder:
recorded_atparameter (agent/user override) → confidenceexactQuickTime
com.apple.quicktime.creationdatetag, carries the local timezone (QuickTime Player recordings; ⌘⇧5 wrote it before macOS 26) →highContainer
creation_timetag (UTC) →medium— macOS 26+ ⌘⇧5/ReplayKit screen recordings land here (nocreationdatetag anymore); passrecorded_at=when local-tzt_wallmattersFile mtime minus duration (recorders finalize files at recording END) →
lowNothing → tools still work with relative
t_msonly
Why it matters: "the upload spinner froze here" becomes a ±30 s grep window in your server logs.
Speakers (optional diarization)
With the [diarization] extra installed (included in every generated config —
see Quickstart),
process_media(diarize=true) labels who said what — locally, like everything
else here (sherpa-onnx runtime, no
torch, no accounts, no GPU):
Speakers become
S1,S2, … in order of first appearance; new diarized jobs split speaker changes at word boundaries, while old jobs continue to report honest segment-level precision. Every transcript segment gets aspeaker, and the tools surface it everywhere — roster with talk time inget_transcript,speakers_in_rangeinget_moment,speakeronsearchhits,S1:prefixes in the text/SRT formats, a speaker count inlist_jobs.Know the headcount? Pass
num_speakers. Clustering toward an exact k removes the main failure mode of unknown-count mode (similar voices merging or one voice splitting). It is a target, not a guarantee: the clusterer can converge on fewer clusters than k, and a re-run that changed nothing says so in the payload (labels_changed: false). Agents are instructed to pass the headcount via the tool guidance; do the same in your own calls.Already processed a recording? Calling
process_media(diarize=true)on it re-runs only diarization — whisper is not re-run, and labels land in the existing job. Same for changingnum_speakers. The diarization stage itself still re-scans the full audio: minutes on long recordings.Full rebuilds keep named jobs safe. If a job has active or pending names,
force=truealso requiresdiarize=true; otherwise the call refuses before changing stored data. A successful force rebuilds in staging and moves every previous identity to pending review against the fresh roster. Any processing or commit failure leaves the prior manifest and frames intact.Labels start anonymous. After checking self-introductions, vocatives, or video evidence, call
label_speakersto preserve a verified mapping such asS1→ "Alice" across sessions. The roster can expose bounded OCRname_candidates, but those are raw hints and are never saved automatically. The raw label remains present besidespeaker_name. If a later diarization amend changes the labels, verified names stop being active and move to boundedspeaker_names_pending_reviewevidence instead of being silently lost. Re-check the current roster and explicitly confirm or remove each affected label withlabel_speakers.Video jobs produced before 0.3.1 keep their original flat OCR and can return
name_candidates_noteto explain why hints are absent. They remain fully readable without migration;force=true, diarize=trueregenerates line-aware OCR while preserving saved identities for review.
Models download once (~47 MB total) from pinned, checksum-verified URLs into
~/.talkthrough/models/; warm runs are zero-network like the rest of the
pipeline. Speed on an M-series CPU (4 threads): a 26-minute meeting diarizes
in about 2 minutes (RTF ≈ 0.08), on top of the transcription time. Memory:
expect on the order of 1–1.5 GB peak RSS while an hour-plus meeting is being
diarized (measured on a real 73-minute recording); it is released when the
stage completes.
Role | Model | Download | Weights license |
Segmentation | pyannote segmentation-3.0 (ONNX export by k2-fsa) | 7 MB | MIT |
Embedding (default) | NeMo | 40 MB | Apache-2.0 (per its NGC model card, the NeMo Toolkit license) |
Embedding (alt) | WeSpeaker | 27 MB | CC-BY-4.0 |
Embedding (alt) | 3D-Speaker | 30 MB | Apache-2.0 |
The default won a real-meeting accept-eval (RU/EN/ES + a 3-speaker 26-minute
meeting): it was the only candidate to isolate all three real voices at
num_speakers=3, at 2× the speed of the runner-up.
Pick an alternate embedding model (or point at your own .onnx file for
offline machines) via TALKTHROUGH_DIARIZATION_EMB_MODEL; tune the
unknown-count sensitivity via TALKTHROUGH_DIARIZATION_THRESHOLD (see
docs/TROUBLESHOOTING.md). Honest quality notes
live in Limitations.
Privacy
Everything runs locally: your recordings never leave your machine, speech is
transcribed by a local whisper model, OCR and speaker diarization are local
ONNX inference, and there is no telemetry. For local files the only network
access is one-time tool/model downloads (ffmpeg build, whisper model, OCR
models, diarization models — the latter pinned by URL + sha256). The one
deliberate exception is process_url: it downloads the public source you
name from its provider or CDN, once, and nothing else — no media ever goes
up, no cloud STT or LLM is called, and after that download every tool on
the job is network-free again. The raw URL (which may carry signed tokens)
is not stored: the job keeps a hash, the public provider id or host and a
bounded title. Diarization keeps no voiceprint database: voice embeddings
live only in process memory, and only anonymous turn labels (S1/S2) land
on disk. Your agent sees only the payloads the MCP tools return (text and
selected frames) in your existing session; talkthrough itself makes no LLM
calls.
Languages
Narration in any of Whisper's ~99 languages works: the language is
auto-detected per recording, and the summary reports both language and
language_probability so agents can tell a confident detection from a shaky
one (silence or music at the start can fool the detector — pin it with
language="ru" and force=true when that happens). Speaker diarization is
acoustic — it fingerprints voices, not words — so it is language-independent
and works across all of those languages unchanged.
Pick the model for your languages — per call (model= parameter, agents do
this themselves when a transcript comes back garbled) or as the server
default (TALKTHROUGH_WHISPER_MODEL):
Model | Size | Best for |
| 464 MB | English and major-language narration on CPU |
| ~1.5 GB | recommended for non-English — near-large quality at near-small speed |
| ~1.5 GB | conservative alternative to turbo |
| 75–145 MB | quick drafts, CI |
| — | English-only, slightly faster/better for EN |
Tips that work in every language: pass product names via
vocabulary="Term1, Term2" (biases the decoder so jargon survives), and note
that the workflow prompts instruct agents to write digests in the
narrator's language while keeping quotes verbatim — the server never
translates (exact quotes are evidence; translation is the agent's job).
On-screen text (OCR) defaults to RapidOCR's Latin + Chinese models. For other
scripts set TALKTHROUGH_OCR_LANG to your language — ru/uk (→ the
eslav pack), ja, ko, ar, hi, el, th, or any RapidOCR pack name
like cyrillic — and reprocess with force=true; the matching recognition
model downloads once. Spoken-language support is unaffected either way.
Configuration
Env var | Default | Meaning |
|
| default whisper model ( |
|
| set |
| Latin+Chinese | recognition script for on-screen text: a language code ( |
| — | advanced: JSON object of raw RapidOCR params merged over the derived ones, e.g. |
|
| set |
|
| clustering sensitivity when |
|
| segmentation model: allowlist name or a path to a local |
|
| embedding model: allowlist name (see Speakers) or a local |
|
| ONNX threads for both diarization models |
|
| max media duration (also checked against provider metadata before a |
|
| keyframe budget per job, spread across the whole duration (the 1 s selection floor auto-grows to |
|
| hard cap for one |
|
| job store root (URL jobs keep their downloaded source under |
CLI
The pipeline is also a CLI — useful for pre-processing long recordings outside an agent session (the store is content-addressed, so the agent then queries the same job instantly):
talkthrough-mcp process ~/Videos/long-session.mov # prints the summary
talkthrough-mcp process demo.mov --json # machine-readable
talkthrough-mcp process sync.m4a --diarize --num-speakers 3 # who said what
talkthrough-mcp process-url "https://youtu.be/nHfGfEiVdE8" # one public URL, downloaded once
talkthrough-mcp gc --keep-days 30 # clean the job store (sources go with their jobs)
talkthrough-mcp serve # stdio MCP server (default)
talkthrough-mcp --version # package version + which extras this environment has--json keeps stdout machine-readable on failure too (0.4.1), including a
missing argument or unknown option: the process exits with code 2, stderr
carries the human error: … line, and stdout
carries one JSON document, {"error": {"type": "UnsupportedUrlError", "message": "…"}}. --version also says which optional extras the
environment has — the quickest check when a hand-written config launches
the minimal server (a launcher without the url extra) that advertises
process_url but can only read direct media links; the server logs the
same line to stderr at every start.
First run notes: missing system ffmpeg triggers a one-time static-ffmpeg
download; the first transcription downloads the whisper model (~460 MB for
small); both are cached. After that, expect roughly 3× faster than real time
on an Apple-Silicon CPU with the default model, OCR included (a 2-minute clip
processes in ~40 s) — and instant re-runs on the same file. Progress streams
as MCP progress notifications, and the CLI prints stage lines. More:
docs/TROUBLESHOOTING.md.
Windows
CI runs lint, the unit suite, a full CLI smoke, and a diarize smoke on
windows-latest (static-ffmpeg Windows build, whisper tiny transcription,
OCR, the instant idempotent re-run, and a speaker-roster assert through the
native sherpa-onnx stack). Notes: the per-job lock always serializes threads;
POSIX also uses fcntl for cross-process locking. Quote paths with spaces
(uv run talkthrough-mcp process "C:\Videos\Screen Recording.mp4").
If something breaks, please open an issue.
Supported inputs
Video: .mov .mp4 .webm .mkv .ogv — audio-only: .m4a .mp3 .wav .ogg
.flac (transcript tools only; frame tools explain why they're unavailable).
URLs (via process_url, since 0.4.0): a direct https:// link to one of
those media files; one public YouTube video (watch, youtu.be, shorts,
a completed live); or any public video page yt-dlp can read — public
Instagram reels, TikTok, Wikimedia Commons file pages (each verified on
release day), the rest of yt-dlp's ~1800 site extractors and pages with a
plain HTML5/HLS player (the [url] extra, which the generated configs above
already carry, brings yt-dlp). Always anonymous: a site that demands a
sign-in from anonymous clients (Vimeo does, with this yt-dlp) is refused
with the reason. The source is
downloaded once, kept inside the job, and never re-fetched for later
questions. Not supported: playlists, channels, active live streams,
private, members-only, age-restricted or DRM-protected videos, cookies or
logins — a site that hides a video behind a login or a bot wall answers
with a clear refusal, not a workaround (Instagram in particular rate-limits
anonymous access). You are responsible for having the right to download
and process what you point it at; talkthrough does not bypass any
restriction.
Limitations
Honest edges, so you can decide fast:
Speaker labels are word-level and opt-in on new jobs. Each Whisper word is assigned by maximum overlap with the diarization turns, so fast exchanges split without losing the raw label. Old jobs remain readable and honestly report
attribution_precision="segment"; reprocess withforce=trueto add word timings. Sub-second interjections ("yeah", "mhm") can still be absorbed when the diarization engine does not detect a separate turn, and heavy crosstalk degrades clustering (the segmentation model tracks at most 2 simultaneous voices). Quality is pyannote-3.x-generation. The comfort zone without hints is roughly 2–8 speakers; passnum_speakerswhenever the headcount is known — it removes the worst failure mode at any size, and it is the way to go for large meetings (10+).URL ingestion covers one public video at a time, without logins. Direct HTTPS media links, single public YouTube videos and public video pages yt-dlp can read; playlists, channels, live streams, gated or DRM content and anything behind a login or a bot wall are refused with a reason. Sites change; a page that worked yesterday can need a newer yt-dlp tomorrow. A provider's upload date is not a recording time, so URL jobs have
wall_clock: nullunless the container has a usable creation timestamp or you passrecorded_at. The download file's mtime is never used.Memory: budget about 2 GB for a cold run. Whisper, the OCR models and the frame pass live in one process. A 78-second video on
tinywith OCR peaked at 1.6 GB RSS during the 0.4.0 release QA (download included); the defaultsmallmodel needs more, larger models proportionally so. An 8 GB laptop copes; on anything tighter keep the model small or run the CLI ahead of the agent session.Keyframes + transcript, not motion analysis. A glitch between scene changes can be invisible in the frame set;
extract_framere-checks any instant, but frame-by-frame motion reasoning is your multimodal model's job.STT quality tracks the model you pick. The default
smallfavors speed; non-English narration wantsmodel="large-v3-turbo"(see Languages).OCR reads crisp UI text well; tiny or low-contrast print is best-effort.
Wall-clock confidence depends on recorder metadata — worst case pass
recorded_at=(see the ladder above).Windows caveats — POSIX lock degrades to a no-op; see the Windows section above.
How it compares
talkthrough | cloud recorder SaaS | meeting notetakers | typical video-analyzer MCPs | |
Runs fully locally | ✅ | ❌ | ❌ | varies |
Any local video/audio file | ✅ | browser/app captures | meetings only | ✅ |
Public video URL: downloaded once, kept with the job, analyzed locally | ✅ | n/a | n/a | temp download, often cloud analysis |
Wall-clock anchoring (log correlation) | ✅ | ❌ | ❌ | ❌ |
Who-said-what speaker labels | ✅ local, opt-in | some | ✅ cloud | ❌ |
Ships agent workflows (prompts, skill, findings contract) | ✅ | ❌ | ❌ | ❌ |
OCR of on-screen text, searchable | ✅ | some | ❌ | rare |
FAQ
Why not just upload the video to a multimodal model (e.g. Gemini)?
For a short, non-sensitive clip — do that. The trade-offs appear with length
and sensitivity: an hour of screen recording costs on the order of a million
tokens per question, the file leaves your machine, and you still can't map a
remark to 14:32:07 UTC to grep your server logs. talkthrough indexes once,
locally, then answers any number of follow-ups from the index.
Why not screenpipe?
Different job. screenpipe is an always-on recorder of your machine going
forward (commercial license). It can't open the .mov a teammate or customer
just sent you. talkthrough analyzes any file it's handed — the two compose
fine.
There are agent skills that "watch" videos. Why a server with an index? Watch-style skills push a budgeted frame dump into the context window (and go sparse on long videos), often call cloud STT for the audio, and keep nothing. talkthrough builds a persistent local index — transcript + OCR, full-text searchable — retrieves exact frames lazily, anchors everything to wall-clock time, and answers the next question without reprocessing.
I use Jam for bug reports — do I need this? Keep Jam for browser bugs: console+network captured at record time is great evidence. talkthrough covers what a browser extension can't — desktop apps, mobile screencasts, ops incidents, meetings, any file — with no account, and correlates with server-side logs via wall-clock time.
Which agent model do I need to drive this?
For v0.4.0 the six model configs below ran 61 isolated behaviour cells on
URL ingestion (YouTube, a TikTok page, a speechless Instagram reel,
playlist refusal, the missing wall clock) and the 0.3.2 integrity fixes;
every cell passes on every runner after two product fixes the first
attempts exposed (Codex needs a per-tool approval for the open-world
process_url, and the server now says itself when a URL job has no wall
clock). For v0.3.0 we ran 210 isolated agent cells across 6 model configs (Claude
haiku/sonnet/opus, Codex gpt-5.5 at two reasoning efforts, and gpt-5.4-mini)
and 35 logical scenarios on 5 real recordings plus safety and speaker-label
fixtures. All 102 LLM-judged full-grid results and every mechanical zero were
manually audited; the 30 new speaker behavior runs passed, and old-server
control left 0 release-caused regressions. This is a model-drift snapshot,
not a leaderboard: see the current matrix in
docs/MODEL-NOTES.md. The chart and narrative in
benchmarks/ remain the historical v0.2.0 snapshot.
Can't I just script ffmpeg + whisper myself?
Yes — that's exactly this pipeline. What you'd be rebuilding: scene-change
detection with perceptual dedup, OCR, transcript+OCR search, the wall-clock
ladder, MCP tools with embedded usage examples, six workflow prompts, and a
findings contract. One uvx command instead of an afternoon of glue.
Is it really local? What leaves my machine?
Nothing goes up, ever. For local files the network is used only for one-time
downloads (ffmpeg build, whisper/OCR/diarization models). process_url is
the single tool that talks to the network at runtime, and only down: it
fetches the public source you named, once. No telemetry. See
Privacy — and SECURITY.md treats a violation of
this promise as a vulnerability.
For agents & tooling
Machine-readable entry points, so AI agents can install and use this server without a human reading docs:
llms-install.md— step-by-step install instructions for agentsllms.txt— index of the documentation.agents/skills/talkthrough/SKILL.md— an Agent Skill teaching the tool workflow; discovered automatically inside a checkout by Codex CLI ($talkthrough) and readable by Claude Code, Cursor, Copilot, Gemini CLI and other SKILL.md-compatible toolsAGENTS.md— instructions for coding agents contributing to this reposerver.json— MCP registry manifestintegrations/— per-engine adapters, all generated from one source of truth and drift-tested (incl. the Claude Code plugin underintegrations/claude-code/)docs/URL_ACCEPTANCE_CORPUS.md— the live URL corpus behind the release QA ofprocess_url(manual, needs the network; CI stays offline)
Roadmap
cloud STT · embeddings/semantic search · hosted/remote mode · .mcpb
bundle · whisper.cpp backend
License
MIT
Available Tools
9 toolsextract_frameAIdempotent
Re-extract ONE frame at an exact timestamp from the ORIGINAL source video at native resolution, with an optional crop {x, y, w, h} in source pixels. Use when the stored keyframes miss the instant (they capture scene changes + a 1 fps floor) or when you need full-resolution detail. Slower than get_frames — it decodes the source file, which must still exist at its recorded path (URL jobs keep their downloaded source inside the job, no network). When NOT to use: normal browsing — get_frames serves stored keyframes instantly without touching the source. Examples:
keyframes sit at 12:31 and 12:38 but the flash happened at 12:34.5 → extract_frame(job_id, at_ms=754500)
extract_frame(job_id="...", at_ms=754500, crop={"x":800,"y":40,"w":400,"h":120}) — zoom into the toast text
tiny UI text unreadable in the 1568px keyframe → extract_frame at the same ms for native resolution
verify a one-frame glitch: extract_frame at 12300, 12400, 12500 and compare
OCR missed small text → extract_frame with a tight crop, then read the returned image
crop coordinates are SOURCE pixels (a Retina screen recording may be 2940x1912) — not keyframe scale
response JSON carries "path" (absolute) — "save this screenshot next to my docs" = copy from path yourself
source file moved or deleted → clear error; stored keyframes via get_frames still work
audio-only job → always errors: there is no video stream to decode
anti-example: "show me around 5:00" → get_frames(job_id, at_ms=300000); extract_frame is for exact instants
anti-example: scanning a range frame by frame → get_frames(start_ms, end_ms) first, refine once after
Inputs: at_ms (required), job_id (required), crop.
get_framesARead-onlyIdempotent
Fetch stored keyframe images (JPEG, <=1568px wide) as MCP image content: the frames nearest to at_ms, OR unique frames across [start_ms, end_ms] evenly thinned to max_frames. Serves unique frames by default (near-duplicates from static scenes are filtered); hard cap 6 images per call. When NOT to use: exact instants between keyframes or native-resolution detail (use extract_frame), or finding on-screen text (use search — OCR text is indexed). Examples:
get_frames(job_id="...", at_ms=83500) — what was on screen when the remark at 1:23.5 was spoken
get_frames(job_id="...", at_ms=83500, max_frames=2) — tighter context, fewer tokens
get_frames(job_id="...", start_ms=0, end_ms=600000, max_frames=6) — overview strip of the first 10 min
get_frames(job_id="...", start_ms=290000, end_ms=310000, include_duplicates=true) — every capture near 5:00
transcript hit at t_ms=421500 → get_frames(job_id, at_ms=421500) for the visual evidence
walking a demo scene by scene → one ranged call per scene beats one giant range
frame files are named by video-ms (t00083500.jpg ↔ t_ms 83500) — stable refs for findings
valid_from_ms/valid_to_ms on each frame = when the screen looked like this — check the span covers your moment
keep max_frames at 2-4 unless you are truly comparing scenes; images are token-expensive
every frame entry carries "path" (absolute) — save/copy the image elsewhere with your own file tools
audio-only job → this tool errors by design; use get_transcript / get_moment instead
anti-example: need EXACTLY 12:34.500 between two keyframes → extract_frame(job_id, at_ms=754500)
anti-example: "find the screen with the red error banner" → search(job_id, "error") first, then jump
Inputs: job_id (required), at_ms, end_ms, start_ms, +2 more.
get_momentARead-onlyIdempotent
The "one remark" evidence bundle: transcript slice + up to 3 unique frames + their OCR text + the wall-clock range for [start_ms, end_ms], in a single call. This is the workhorse for triage: one call per finding gives you the quote, the screenshot, and the on-screen text. When NOT to use: broad exploration (get_transcript / get_frames) or keyword lookup (search). Examples:
get_moment(job_id="...", start_ms=83000, end_ms=97000) — full evidence for the remark at 1:23-1:37
segment seq 12 spans t0_ms=83210, t1_ms=96800 → get_moment(job_id, 83210, 96800)
pad ±2000 ms around the spoken range — narrators react to things already on screen
triage loop: for each candidate finding, exactly one get_moment call → quote + frame + OCR
user: "what was I showing when I said 'this button is broken'?" → search first, then get_moment at the hit
opening context of a meeting: get_moment(job_id, 0, 15000)
response includes the t_wall range when known → quote it in bug reports for log correlation
diarized job → speakers_in_range + speaker on each segment: who is talking in this window, at a glance
frame entries carry "path" (absolute) — copy the screenshot elsewhere with your own file tools
"was X on screen at t?" → yes iff some frame's valid_from_ms <= t < valid_to_ms — no extra calls needed
audio-only job → returns the transcript slice plus a no-frames note (that is expected)
anti-example: whole-video summary → get_transcript(format="text"), not a chain of get_moments
anti-example: need more than 3 frames of a range → get_frames(start_ms=..., end_ms=..., max_frames=6)
keep ranges under ~30 s; a 5-min "moment" dilutes the bundle and wastes tokens
Inputs: end_ms (required), job_id (required), start_ms (required).
get_transcriptARead-onlyIdempotent
Retrieve the transcript of a processed job, lazily and paginated. Formats: "segments" (default — seq, t_ms, t_wall when known, speaker when diarized, text), "text" (plain prose; "S1:" prefixes at speaker changes), "srt" (subtitles, speaker-prefixed cues). Diarized jobs also return the roster, attribution_precision, saved speaker_name values, raw OCR name_candidates, and bounded pending-review names plus old-roster context after a relabel. Pre-0.3.1 video jobs may return name_candidates_note because their flat OCR is readable but less useful for hints. Pending names are evidence to re-check, never active identities. A stale pending label can only be removed with label_speakers(..., labels={"Sx":null}). Raw S labels remain canonical. Responses are capped (~8k tokens): when truncated=true, continue from the returned next_start_ms. When NOT to use: to find one keyword (use search) or to inspect one moment with visuals (use get_moment). Examples:
get_transcript(job_id="a1b2c3d4e5f60718") — whole transcript of a short recording
get_transcript(job_id="a1b2c3d4e5f60718", start_ms=0, end_ms=120000) — just the first two minutes
get_transcript(job_id="...", format="text") — prose block for summarization
get_transcript(job_id="...", format="srt") — subtitle export the user asked for
diarized job: segments carry "speaker" + a roster header (top-12 by talk time; speakers_truncated counts the rest)
"what did S2 say?" → format="segments", collect entries with speaker=="S2" (labels are in order of first voice)
got truncated=true with next_start_ms=421500 → get_transcript(job_id="...", start_ms=421500)
user: "what was said between 5:00 and 6:30?" → start_ms=300000, end_ms=390000
legacy video name_candidates_note → explain the limitation; safe regeneration uses force=true+diarize=true
correlate speech with logs: each segment's t_wall lines up with your log timestamps
no speaker fields on a meeting job → re-run process_media with diarize=true (adds them without re-transcribing)
attribution_precision="segment" → force=true+diarize=true is required for exact word boundaries
pending context points to old evidence; stale labels accept null removal, never a new name
anti-example: "where did they mention checkout?" → search(job_id, "checkout"), not full paging
anti-example: screenshots around a remark → get_moment(job_id, start_ms, end_ms)
Inputs: job_id (required), end_ms, format, start_ms. Returns: object
label_speakersAIdempotent
Persist VERIFIED human-readable names for anonymous S1/S2/… labels on one diarized job. labels is a patch: a 1-100 character name saves it; null or blank removes it. Optional evidence (max 500 characters per label) records why the mapping is trusted. Raw labels remain canonical in JSON; names appear separately and in text/SRT display. The write is atomic, locked, local, and idempotent. OCR name_candidates are raw hints only and are never saved automatically. A relabelled job can carry pending-review names with source-roster anchors. For a label still in the roster, an explicit patch confirms, replaces, or removes only that entry. For a stale pending label, only an explicit null removes its name, evidence, and context.
When NOT to use: before diarization, or when a name is only a guess without human/screen evidence.
Examples:
label_speakers(job_id="...", labels={"S1":"Vera"}) — save one verified mapping
label_speakers(job_id="...", labels={"S1":"Vera","S2":"Tom"}) — patch several labels
save evidence: label_speakers(job_id="...", labels={"S1":"Vera"}, evidence={"S1":"intro at 1200ms"})
screen name plate confirms S2 → save the name and cite its frame timestamp in evidence
label_speakers(job_id="...", labels={"S2":null}) — remove S2's name and evidence together
label_speakers(job_id="...", labels={"S2":" "}) — blank also removes the mapping
named S1, new proof → label_speakers(job_id="...", labels={}, evidence={"S1":"title card at 0ms"})
two Alexanders may map to S2 and S5 — duplicate names are allowed and search covers both
response roster carries speaker_name beside label; raw S labels are never replaced
name_candidates may be UI text or another person's name → inspect frames before deciding
unknown label or a name over 100 characters → error lists the valid roster labels
fresh session: get_transcript returns saved names; do not infer the mapping again
stale pending S3 → labels={"S3":null}; never assign a name while S3 is outside the roster
anti-example: uncertain identity → keep S anonymous until evidence verifies the name
Inputs: job_id (required), labels (required), evidence. Returns: object
list_jobsARead-onlyIdempotent
List processed recordings, newest first: job_id, source filename, duration, created, wall-clock start, segment/frame counts. The store is content-addressed — the same file maps to the same job even after renames or moves, and jobs persist across sessions and machines restarts. Jobs with unreadable manifests are omitted; an empty list does not prove there are no stored sources. process_media or process_url may rebuild such a job and report manifest_recovery_note. When NOT to use: as a health check or before every call — job_ids are stable, remember them. Examples:
user: "triage the recording I processed this morning" → list_jobs() → pick by filename + created
user names neither job_id nor path → list_jobs() first; only ask if still ambiguous
resume yesterday's analysis in a fresh conversation → list_jobs() → reuse its job_id directly
file was renamed after processing → match by duration/created; the content hash ignores names
wall_clock.start answers "WHEN was this session?" — pick the job from "yesterday around 15:00"
after CLI batch pre-processing (
talkthrough-mcp process big.mov) the job shows up here — query ittwo jobs with the same filename → the newer created one is usually the re-recording
diarized jobs show "speakers": N — "the 4-person meeting from Tuesday" is findable at a glance
URL jobs carry "origin" (provider, provider_id, title) — "the YouTube video from yesterday" is findable
empty list → no readable jobs; a damaged manifest may still have a source recoverable by process_media/process_url
job disappeared → likely
talkthrough-mcp gccleaned it; re-run process_media on the file (same id)anti-example: checking whether a NEW file is processed → just call process_media, it is idempotent+instant
Inputs: no named parameters. Returns: object
process_mediaAIdempotent
Ingest a LOCAL video or audio file and make it queryable: validates the file, transcribes speech locally (whisper), extracts scene-change keyframes, OCRs on-screen text, resolves the wall-clock start time, and (opt-in) labels who said what via local speaker diarization. Returns a compact summary (job_id, media info, wall_clock, transcript preview, speaker roster when diarized) — full data stays on disk and is served lazily by the other tools. Idempotent by content hash: re-calling on an already-processed file returns instantly. For MULTI-PERSON recordings (meetings, interviews, calls) diarize=true is part of a proper analysis — pass it even when the user only asks for a summary. num_speakers is a target the clusterer may not reach, not a constraint — the payload says when a re-run changed nothing (labels_changed). If an amend changes the labels, verified names become pending-review evidence rather than active identities, with old-roster anchors for re-checking. Current pending labels can be confirmed/replaced/removed; stale labels can only be removed with an explicit null patch. Full force reprocessing of a job with saved or pending identities requires diarize=true and preserves every old identity as pending review against the rebuilt roster; without diarization it refuses before changing the stored job. When NOT to use: to re-fetch data you already processed (use the retrieval tools), or for URLs — local file paths only; a public video/audio URL goes to process_url. Examples:
process_media(path="/Users/sam/Desktop/bug-repro.mov") — narrated screencast, defaults are right
meetings: model="large-v3-turbo" + vocabulary=<attendees, terms> + num_speakers=N — turbo's extra cost is trivial
process_media(path="/tmp/standup.m4a") — audio-only: transcript tools work, frame tools will error
process_media(path="/rec/panel.mov", diarize=true, num_speakers=4) — headcount known? ALWAYS pass it: best accuracy
relabel amend → names become pending with old anchors; stale labels are removable only with null
error mentions [diarization] → run uvx --python ">=3.11,<3.14" "talkthrough-mcp[diarization]"
know the attendees? process_media(path=..., vocabulary="Anastasia, Evgenii, OKR") — names+jargon survive STT
user: "analyze/summarize this meeting" → include diarize=true — speaker structure is not optional extra credit
noisy threshold roster (clusters ≫ people)? ASK your user for the real headcount, then re-run with num_speakers=N
cap_hit or sampling_interval_s in summary → for slide hunts raise TALKTHROUGH_MAX_FRAMES or use extract_frame
summary shows wall_clock=null → ask when recording started, re-call with recorded_at=... and force=true
transcript garbled or language_probability low → re-call with model="large-v3-turbo" (or language="ru") + force=true
after success, do NOT dump everything — continue with get_transcript / get_moment / search on the job_id
anti-example: frames from an already-processed job → get_frames(job_id=...), never process_media again
named job + force=true → include diarize=true; old identities return as pending review, never silently vanish
Inputs: path (required), force, model, diarize, +4 more. Returns: object
process_urlA
Download ONE public video/audio URL once (this is the only tool that uses the network), then run the same LOCAL pipeline as process_media: transcript, keyframes, OCR, wall-clock, optional diarization. reused describes the pipeline cache, NOT network activity: a new URL is downloaded to compare bytes even when reused=true; a local rebuild can have reused=false with no download. Read origin.network and origin.reused_url_mapping for the download outcome. An indexed job with an unreadable manifest can rebuild from its verified local source; manifest_recovery_note explains recovery. list_jobs omits unreadable manifests. Lost provider metadata stays unknown unless refresh=true. Supported: direct https:// links to a media file (mp4/mov/webm/mkv/ogv/m4a/mp3/wav/ogg/flac), one public YouTube video (watch, youtu.be, shorts, a completed live), and any public video PAGE yt-dlp can read — Instagram (public reels/posts), TikTok, Wikimedia Commons, pages with an HTML5/HLS player, other sites as far as their yt-dlp extractor works anonymously (Vimeo does not). Not supported: playlists, channels, active live streams, private/members-only/age-restricted/DRM videos, cookies/logins; sites that hide a video behind a login or a bot wall fail with a clear reason. The downloaded source is kept inside the job, so extract_frame works later without network; the raw URL is never stored (only a hash, the provider id/host and a bounded title). A repeat call on the same URL serves the stored job without touching the network unless refresh=true. Job ids stay content hashes: the same video from two URLs is one job. YouTube and other pages need the optional [url] extra. The provider's upload date is NOT the recording start: wall_clock uses usable container creation metadata or recorded_at; download mtime is never used. When NOT to use: for local files (process_media), or to re-fetch data you already processed (use the retrieval tools). Examples:
process_url(url="https://youtu.be/nHfGfEiVdE8") — one public YouTube video, defaults are right
process_url(url="https://www.youtube.com/watch?v=ID&list=PL...") — the playlist part is ignored: ONE video
process_url(url="https://cdn.example.com/recordings/standup.mp4") — direct https link to a media file
process_url(url="https://www.tiktok.com/@nasa/video/7…") — a public video page; origin.provider names the site
meeting from a link: process_url(url=..., diarize=true, num_speakers=3, vocabulary="Vera, Tom, OKR")
non-English narration: process_url(url=..., model="large-v3-turbo", language="ru")
known recording start: process_url(url=..., recorded_at="2026-09-05T14:00:00+02:00") — enables t_wall
the video changed on the provider → process_url(url=..., refresh=true): new download, maybe a new job_id
re-anchor or change the model on a stored URL job → process_url(url=..., recorded_at=..., force=true), no download
error mentions [url] → run uvx --python ">=3.11,<3.14" "talkthrough-mcp[diarization,url]" and restart
playlist / channel / live / private URL → clear error; pass a single public video URL instead
"bot check"/"sign-in" refusal on Instagram/TikTok → the site blocked anonymous access; report it, no workaround
origin.published_at is the provider's upload time, not when the recording was made — never use it as t_wall
after success continue with get_transcript / search / get_moment on the job_id — never re-download
anti-example: a file on disk → process_media(path=...); process_url is only for https URLs
Inputs: url (required), force, model, diarize, +5 more. Returns: object
searchARead-onlyIdempotent
Case-insensitive word search across BOTH transcript segments and frame OCR text. The default match_mode="all_words" requires EVERY query word as a substring; match_mode="any_word" requires at least one (ё and е are interchangeable). Hits carry source (transcript|ocr), t_ms, t_wall when known, the matched text, and the nearest frame position — everything needed to jump straight to evidence. Optional speaker accepts a raw label ("S2") or saved name and narrows to that voice's transcript hits. Duplicate saved names search all matching labels honestly. No embeddings. When NOT to use: fuzzy/semantic questions ("anything about performance?") — page get_transcript and read; regex is not supported. Examples:
search(job_id="...", query="login") — every spoken or on-screen mention of login
user: "what did I say about the login button?" → search(job_id, "login button") → get_moment at hits
search(job_id, "TypeError") — on-screen stack traces and error text are OCR-indexed; great for bug repros
search(job_id, "€49") — prices, IDs, and literals on screen are findable via OCR
take hit.t_wall and grep your server logs ±30 s around it to pair remark ↔ log line
broad lexical recall: search(job_id, "timeout latency", match_mode="any_word")
multi-word = ALL words as substrings, any order: "first phase" hits "the first real phase"
stems beat inflected phrases: "кнопк отправк" finds «Кнопка отправки» and «кнопку отправки»
every hit has nearest_frame_ms → get_frames(job_id, at_ms=) shows the moment
diarized job: transcript hits carry "speaker" — "who mentioned the deadline?" is answered by the hit itself
search(job_id, "deadline", speaker="S2") — only S2's mentions; OCR hits are excluded (screens have no voice)
search(job_id, "deadline", speaker="Vera") — saved names are matched case-insensitively
audio-only job → transcript hits only (there is no OCR index)
anti-example: "summarize the pricing discussion" → get_transcript(format="text") and read it
anti-example: "everything S2 said" → get_transcript and collect speaker=="S2" — search always needs a query
Inputs: query (required), job_id (required), speaker, match_mode. Returns: object
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 9 tools
Each tool targets a distinct stage of the pipeline: ingestion (process_url vs process_media), browsing (list_jobs), retrieval (get_transcript, get_frames, extract_frame, get_moment), search, and annotation (label_speakers). The descriptions include explicit 'When NOT to use' sections and anti-examples that sharply delineate boundaries, e.g. get_frames (stored keyframes) vs extract_frame (exact native-resolution frame) vs get_moment (bundled evidence).
Names are uniformly snake_case and follow a predictable verb_noun pattern (process_url, process_media, list_jobs, extract_frame, get_frames, get_moment, get_transcript, label_speakers). The single tool 'search' is a minor stylistic deviation but remains natural and readable within the set.
Nine tools is well-scoped for a media ingestion and analysis server. Each tool earns its place by covering a required capability without redundancy or bloat.
The surface covers ingestion (local and URL), listing, transcript retrieval, frame access, evidence bundling, lexical search, and speaker labeling—a complete workflow. The one notable gap is a delete/GC tool for managing stored jobs; the server only mentions a CLI `talkthrough-mcp gc`, leaving no in-band way to remove recordings.
Maintenance
Related MCP Connectors
Visual feedback from your website's visitors as tasks for your coding agent.
Feedback, roadmap, agent task execution with review, changelog, status pages and error logs.
Production bugs with replay, network, console and stack, ranked for your coding agent to fix.
Loom for agents: AI agents record narrated product demos, plus transcripts, summaries, and search.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI coding agents to capture screen and voice recordings, extract timestamped frames, and receive structured Markdown reports with context for bug fixing and UI feedback.9 npm19MIT
- AlicenseAqualityBmaintenanceVisual feedback as agent work packets: stakeholders pin on your live app and your AI coding agent reads each pin (selector, screenshot, DOM, thread, acceptance criteria) via MCP and ships the fix.39899 npm1MIT
- AlicenseNot gradedqualityBmaintenanceAn MCP server that watches for screen recordings, transcribes spoken feedback locally, and attaches each comment to the relevant frame, enabling coding agents to see and fix the issues you mention.6MIT
- AlicenseAqualityCmaintenanceTurn screen recordings into automated Playwright E2E reproduction tests, structured GitHub bug reports with clickable video timestamps, and SOP walkthroughs.621 npmMIT