yt

YouTube transcripts for agents and humans. One static Go binary, no API key,
no AI dependencies, no runtime requirements.
- Fetches a video's own caption tracks (manual or auto-generated) via YouTube's
internal Innertube API, using
ANDROID/IOS client contexts — the
WEB-client caption URLs have been pot-token gated since mid-2025 and return
empty bodies.
- Cascades
innertube-android → innertube-ios → watch-page so a single
throttled endpoint doesn't take the fetch down; every response reports which
method won (source).
- Channel monitoring:
yt channel @handle (or list_channel_videos) resolves a
handle and lists the latest uploads with exact publish timestamps from the
channel RSS feed, so an agent can diff against a watermark and see what is new.
- Same binary is the MCP server:
yt mcp, or bare yt when stdin is piped
(the shape MCP clients spawn). Bare yt at a terminal prints help.
- MCP responses are designed for agent context budgets: bounded payloads,
structural truncation with exact resume pointers, time/cue-addressed
ranges, server-side search, typed errors, and session-level caching so one
get_video_info + get_transcript + search_transcript share a single
player fetch.
Dependencies: cobra (CLI), modelcontextprotocol/go-sdk (official MCP SDK),
and golang.org/x/term (TTY detection). The YouTube fetch layer is stdlib only
— net/http, encoding/json, encoding/xml.
Layout
The repository root is the Go module (github.com/ArasanDev/yt) and the
product root.
cmd/yt/ binary entry point
internal/cli/ cobra commands and flags
internal/mcp/ MCP tool schemas, handlers, safety warnings
internal/youtube/ Innertube client, captions, channels, cache, typed failures
internal/search/ pure transcript search
internal/render/ block/cue rendering, SRT, truncation metadata
internal/transcript/ shared Segment/Transcript model and timestamp helpers
internal/apperr/ typed error contract (code, retryable, suggestion)
internal/version/ build version, commit, and date
CI lives in .github/workflows/ci.yml and runs gofmt, go vet,
go test -race, a native build, and a cross-build matrix for the release
targets.
Dependencies point inward: cli and mcp depend on youtube, search,
render, and transcript; youtube depends on apperr and transcript;
search and render depend only on transcript. The acquisition layer does
not import the adapters.
Install
Homebrew (macOS/Linux)
brew install --cask ArasanDev/tap/yt
Install script (macOS/Linux)
curl -fsSL https://raw.githubusercontent.com/ArasanDev/yt/main/install.sh | sh
The script downloads the latest release for your OS/architecture, verifies it
against checksums.txt, and installs to /usr/local/bin when writable,
otherwise ~/.local/bin. Override the target with YT_INSTALL_DIR and pin a
version with YT_VERSION:
YT_VERSION=v0.1.0 YT_INSTALL_DIR="$HOME/bin" \
curl -fsSL https://raw.githubusercontent.com/ArasanDev/yt/main/install.sh | sh
Go developers
go install github.com/ArasanDev/yt/cmd/yt@latest
go install records the module version, so yt --version reports the release
even though ldflags cannot be injected. Use make install for a local build
that also stamps commit and date.
Docker
docker run --rm -i ghcr.io/arasandev/yt:latest mcp
# CLI use:
docker run --rm ghcr.io/arasandev/yt:latest transcript dQw4w9WgXcQ
The image is a distroless static build. With no TTY, bare yt already serves
MCP, so the default container command is mcp.
Prebuilt binaries
Every release publishes archives and a checksums.txt:
https://github.com/ArasanDev/yt/releases
Targets: darwin/linux/windows on amd64 and arm64.
From source
git clone https://github.com/ArasanDev/yt.git
cd yt
make build # ./bin/yt
make install # into GOBIN with version/commit/date stamped
Release builds stamp a version, commit, and date (the default is dev):
go build -trimpath -ldflags "\
-X github.com/ArasanDev/yt/internal/version.Version=v0.1.0 \
-X github.com/ArasanDev/yt/internal/version.Commit=$(git rev-parse --short HEAD) \
-X github.com/ArasanDev/yt/internal/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o bin/yt ./cmd/yt
yt --version
CLI
yt transcript <url-or-id> # transcript to stdout
yt transcript <url> -l en,es -t # language fallback + [MM:SS] stamps
yt transcript <url> --start 1274 --end 1290 # time range
yt transcript <url> -f srt -o subs.srt # SRT file
yt transcript <url> -f json --max-chars 2000 # cue JSON, bounded
yt transcript <url> --bare # body only, no metadata header
yt transcript <url> --start-cue 120 --max-cues 40 # resumable cue window
yt search <url> "harness engineering" # timestamped hits, no full read
yt search <url> "M C P" --match-mode any # spelled acronyms collapse to mcp
yt search <url> "state of the art" --match-mode phrase
yt search <url> "harness" --context-cues 2 # human output shows context
yt tracks <url> # list caption languages
yt info <url> # title, channel, duration, views, chapters
yt channel @aiDotEngineer --limit 10 # latest uploads (exact timestamps)
yt channel @aiDotEngineer --since 2026-10-01 --json
yt doctor # probe which acquisition clients work
yt --version # build version (or module version under go install)
Accepts watch?v=, youtu.be/, /shorts/, /live/, /embed/ URLs or a bare
11-char video ID. By default, -f text includes a metadata header and, when
needed, a truncation trailer. Use --bare for body-only stdout; truncation is
then reported on stderr. Fetch/renderer notes always go to stderr.
CLI usage errors and upstream failures are typed. cmd/yt prints code,
retryable, retry_after_seconds, and suggestion to stderr while keeping
stdout clean. Invalid formats, negative limits, and inverted time ranges fail
before any network request.
MCP
yt mcp serves stdio JSON-RPC; so does bare yt when stdin is not a terminal
(how MCP clients spawn a server), while bare yt at a prompt prints help. Call
get_video_info first: it reports caption availability, live status, and an
estimated transcript size, so you can decide between reading the whole
transcript, searching it, or reading a range.
| Tool |
Purpose |
list_channel_videos |
Latest uploads for a channel (@handle/URL/UC…), from RSS: exact publish times, titles, links, description excerpts. |
get_video_info |
Metadata + caption summary + transcript size estimate + chapters. No transcript download. |
get_transcript |
Transcript text or cue JSON, bounded and addressable. |
search_transcript |
Find a topic with timestamps and context; match_mode all/any/phrase; zero results explain themselves. |
list_tracks |
Caption tracks. Deprecated — use get_video_info.captions. |
For monitoring, call list_channel_videos and pass the previous response's
newest_published as since. Filtering is strictly published > since, so no
video is reported twice. The feed exposes at most 15 entries and has no duration
or view counts; those need get_video_info on a specific video.
Key get_transcript parameters: languages (CSV, default en; original for
the uploader's own language), allow_translation, format (text|json),
granularity (block default |cue), timestamp_mode (block default
|cue|none), start_seconds/end_seconds, start_cue/max_cues,
max_chars (default 50000; bounds the transcript body only).
Truncation is never silent: the header carries TRUNCATED next_start_seconds=… next_cue_index=…, structured metadata carries the same fields, and next_*
values are exact (not rounded), so resuming at them never drops the boundary
cue. max_cues clipping is reported even when end_seconds is also set.
Errors come back as isError results with a machine-readable error.code
(bot_check, rate_limited, no_captions, bad_request, …) plus
retryable and a suggestion.
The transcript body is returned in both content and structuredContent for
client compatibility, so a response is roughly twice the body size;
returned_chars measures one copy.
Auto-dubbed videos list one auto-generated track per language. A track whose
language differs from the uploader's known original is reported as
translated=true, whether it is manual or ASR, so machine-translated speech is
never presented as the speaker's words. get_video_info.captions.original_language
names that track. If the player response carries no audio-track metadata and
multiple caption languages exist, the original is unknown: languages=original
returns an actionable no_matching_language error instead of guessing, and
original_language may be empty. Request a specific language in that case.
Wire into any agent:
devin mcp add yt -- yt mcp # Devin CLI
claude mcp add yt -- yt mcp # Claude Code
or raw mcpServers JSON (Devin, Claude Desktop, Cursor, Windsurf, …):
{"mcpServers": {"yt": {"command": "yt", "args": ["mcp"]}}}
Releasing and distribution
A v* tag triggers .github/workflows/release.yml, which:
- runs the test suite and GoReleaser (archives, checksums, GitHub Release);
- builds and pushes a multi-arch
ghcr.io/arasandev/yt image;
- stamps the version into
server.json and publishes to the MCP registry.
git tag v0.1.0
git push origin v0.1.0
Homebrew cask updates are skipped unless the repository has a
HOMEBREW_TAP_GITHUB_TOKEN secret: a fine-grained PAT with contents: write
on ArasanDev/homebrew-tap. Without it, the release still ships binaries,
Docker, and MCP metadata; add the cask manually or add the secret and re-run.
The MCP registry job is best-effort (continue-on-error). GHCR packages are
private by default: set the yt package to public once in the package settings,
then re-run the job. The registry checks the image annotation
io.modelcontextprotocol.server.name against server.json's name, so those
two strings must stay in sync.
Useful local checks before tagging:
goreleaser check
goreleaser release --snapshot --clean # artifacts in dist/, never published
docker build --build-arg VERSION=dev -t yt:dev .
Development
make build # ./bin/yt
make test # go test -race ./...
make check # gofmt + go vet + go test -race
make snapshot # local GoReleaser snapshot
make clean
See AGENTS.md for the architecture, contracts, and conventions agents must
preserve. In short: stdout is data, stderr is diagnostics; errors are typed;
truncation is never silent and resume pointers are exact; provenance and
content_trust labels are not optional; tests never hit the network.
Caveats
- IP blocking: datacenter IPs (AWS/GCP/VPS) get blocked or rate-limited by
YouTube, surfacing as
bot_check or rate_limited. From a residential IP —
your Mac — it just works. For a server, cross-compile
(GOOS=linux GOARCH=arm64 go build) and expect to need a residential proxy
eventually.
- Auto-generated (
asr) captions lack punctuation, so granularity=sentence
degrades to block with a note. Manual tracks are preferred; YouTube
auto-translation is used when the requested language has no track, and the
result is marked translated=true.
- Multi-dub videos expose one ASR track per language.
languages=original
resolves the actual original via YouTube's audio-track→caption mapping; if
that metadata is absent, the original is unknown and the tool fails with an
actionable error rather than guessing.
- Age-restricted / private videos need auth — not supported.
search_transcript matches multi-word queries within one cue or across up to
3 adjacent cues. match_mode=any relaxes the AND; match_mode=phrase requires
adjacency. Contiguous runs of three or more single-letter tokens (M C P)
collapse to mcp, and terms of 3 or fewer characters match whole words only.
A zero result includes per-term hit counts and a note naming missing terms or
a start-cue boundary, so it is not a silent dead end. Searches cover the
whole transcript; there is no time-range filter yet.
chapters comes from the description's Chapters: block and is parsed from
the full description, before the excerpt is truncated. Emoji/markdown
headings and an inline first entry are accepted; invalid, duplicate,
out-of-order, or past-duration entries are dropped. At least two valid,
strictly increasing, in-duration timestamps are required.
timestamp_window_seconds follows the timestamp mode: 30 when timestamps
are block-level, 0 for cue-level or disabled timestamps. Cite a block
timestamp as the start of that window, not as a single line.
warnings is advisory. It flags instruction-shaped text in transcripts,
search matches/context, video metadata, and channel listings so a caller can
quote rather than obey. User-generated content remains
content_trust: "untrusted" regardless.