stenographer

Stenographer's First Cut: a Link Goes In, a Transcript Comes Out

The spec sat in content-farm's context-v since May. It's a plugin now — paste a YouTube link, get a frontmattered note with a timestamped transcript, no server to stand up first.

Why Care?

A huge share of the thinking worth capturing is spoken now — the podcast, the conference talk, the two-hour founder interview. To a vault built on text, all of it is a dead URL. You can't search it, can't link to a claim inside it, can't quote it without scrubbing a timeline by hand.

Stenographer makes spoken sources behave like every other note. Paste a link, get a file: provider metadata in the frontmatter, the actual words in the body with timestamps attached. From that moment it's ordinary vault material — backlinks, tags, search, dataview, whatever you already run.

The part we're most pleased with is that the transcript is written as a Lossless Flavored Markdown :::transcript block. The same file reads as clean plain markdown in any editor, and hydrates into an interactive timestamp-linked player when it's published through an Astro Knots site. One artifact, two lives, no export step.

What's New?

Everything — this is v0.1.0, the first working cut of a spec that had been sitting in content-farm/context-v/specs/ since May.

  • Two commands. Transcribe a link for a pasted URL; Transcribe the link under the cursor pulls the URL out of your selection, or just the line you're on.

  • Two engines, routed by link shape. Platform URLs (YouTube, TikTok, Instagram, X, Facebook) go to Supadata. Direct audio file URLs go to AssemblyAI when a key is present, which buys speaker diarization.

  • The modal shows you the routing decision before you spend a credit, including the case where your chosen engine physically can't handle the link you pasted.

  • Configurable frontmatter field names, defaulting to the spec's schema.

  • Backlinks carrying both halves of the provenance — the wikilink to the new transcript and the original source URL — written into the note you triggered from.

  • Async jobs handled transparently. Anything over ~20 minutes comes back as a job; Stenographer polls, reports elapsed seconds in the status notice, and times out on a setting rather than hanging.

  • Platform-specific URL keys. A YouTube link writes youtube_url, Apple Podcasts writes apple_podcasts_url, SoundCloud writes soundcloud_url, down a list of a dozen hosts — because that's how a vault actually refers to its sources. Unrecognized hosts fall back to a configurable generic key.

  • Your tag namespace stays yours. Provider tags are off by default; so is the provider description, which on YouTube is usually a wall of sponsor links.

How It Works

Add a Supadata key, run the command, paste a link, pick a mode (native reuses existing captions — fastest and cheapest; generate always transcribes fresh; auto tries native first). A note appears in your transcript folder and opens.

An AssemblyAI key is optional and only matters for direct audio URLs, where it adds speaker labels.

Under the Hood

The interesting decision was what not to build. The spec's architecture routed everything through a "Lossless API Helper" on Fly.io — a Docker container running yt-dlp and youtube-transcript-api, translating for the plugin. That's genuinely the right long-term shape, and it's still the right shape for Metafetch and everything downstream. But it doesn't exist yet, and building it first means the plugin ships after the infrastructure does.

So v1 asked a narrower question: what can a plugin do with no server at all? The answer turns on one constraint that isn't obvious until you hit it — AssemblyAI and Deepgram will not ingest a YouTube page. They want a direct media URL, and producing one from YouTube is exactly the yt-dlp step we were trying to avoid. Supadata ingests the platform URL itself. That single fact determined the whole engine split: Supadata is the only path to YouTube without a server, and AssemblyAI earns the other half of the split because it's the only engine here that knows who is talking.

The routing rule lives in exactly one function and the modal renders its verdict live, because "your engine can't take this link" is invisible until it fails at the worst moment.

Two smaller things worth recording:

Caption tracks arrive as confetti. YouTube hands back 2–5 word fragments. Rendering one timestamped line per fragment produces something nobody will ever read. Fragments now merge up to a character budget — but a speaker change always breaks the line no matter how short it is, because collapsing a diarization boundary to save whitespace destroys real information.

Failures are ranked by what they cost you. Metadata is fetched first since it names the file, but a metadata failure returns null and the run keeps going: losing a title is annoying; discarding a transcript you already paid API credits for is not acceptable. An empty transcript stops the run loudly instead of leaving a hollow note behind. And nothing ever overwrites — collisions append 2, 3, Obsidian-style.

The first real run against a live vault found the bug that testing wouldn't have. The vault aliases several top-level folders into the monorepo with symlinks, and Obsidian's index does not reliably contain symlinked directories. The standard "index says missing, therefore create it" idiom would hit a directory that genuinely exists on disk and throw — meaning a perfectly ordinary transcript folder like Sources/Transcripts would burn API credits and then lose the result to an EEXIST on a folder visible in the file explorer. Existence is now checked against the filesystem adapter as well as the index. Written up for the other plugins with the same exposure at content-farm/context-v/issues/Symlinked-Vault-Folders-Are-Invisible-To-The-Obsidian-Index.md.

On the docs being wrong. AssemblyAI's API-reference page documents the auth header as Authorization: Bearer <key>. Its speech-to-text guide says, explicitly, raw key with no Bearer prefix. The guide is right. Verifying both providers' contracts against live docs rather than memory caught it before it became a mystery 401.

What's Next

  • Zoom recordings, deliberately deferred. Local Zoom files are a file-picker path, not a URL path; cloud recordings need OAuth. Both are v2.

  • Streaming the transcript into the note token by token as it arrives.

  • Playlists and channels — select some or all, process in parallel.

  • The intelligence: frontmatter block — hooks, sentiment, topics, summary.

  • The Lossless API Helper, still the right long-term answer for caption-less video, local Whisper, and everything Metafetch will eventually want.

References

  • Spec: content-farm/context-v/specs/Stenographer-an-Obsidian-Plugin-that-transcribes-Audio-Content.md

  • Exploration: content-farm/context-v/explorations/Enabling-Obsidian-Plugins-to-access-Homegrown-API-Helpers.md