Why I Built a YouTube Blocker That Thinks

I'm a stickler for watching loads of YouTube videos, either for research, learning, or just to play in the background. But there's the issue of going down a rabbit hole when trying to use it for legitimate things, or just limiting my time on it. It would be nice without having to specify the specific keywords or channels I'd like to block. So that's what came about with this project.

The interesting thing about what I built isn't really the YouTube blocker. It's the pattern underneath it: take a task that used to require exhaustive enumeration, "list every URL you want to block", and replace it with a description. The LLM handles the fuzzy matching; you just say what you mean. That shift applies to a lot of tools. Filters, classifiers, routing rules, most of them are really just trying to encode intent. And intent is something you can describe in a sentence.


The Problem with Lists

Most content blockers work with lists: URLs, keywords, channel names. You add what you want to block, and the extension checks each page against the list. Simple. But lists have a fundamental problem, the internet is infinite and your attention is finite. You can't enumerate every TV show recap channel, every rabbit hole, every "just one more" video before you've already watched it.

So I built MoreTime differently. Instead of a list, you write a sentence: "block TV show watch-alongs and movie reaction videos" or "no reality TV recaps while I'm working". Then you set a schedule for when the rule should apply, either for a fixed duration like "the next 2 hours", or on a recurring daily schedule like "weekdays between 9am and 6pm". The extension figures out what to block and when.


The Pipeline

Running a language model on every YouTube page load would be slow and expensive. So the blocking pipeline uses a two-stage approach.

First, it converts both your rule description and the video's metadata (title, channel, description) into embedding vectors and computes cosine similarity. If similarity is above 0.80, it's a confident block, no LLM needed. If the score is clearly low, it still calls the LLM, because embeddings alone are not reliable enough to auto-reject. Only the ambiguous middle range triggers a full Claude call through a ReAct subgraph that can reason step-by-step before deciding.

Similarity ≥ 0.80  →  Block immediately (high confidence)
Similarity < 0.80  →  Ask the LLM to decide
LLM decides        →  Block or pass

The result is a system that feels like it understands intent, without paying LLM costs on every page visit.


Why LangGraph? Taming the Complexity

The pipeline could have been a single function with a few if statements. But as I added more stages, metadata resolution, quality checks, embedding, LLM fallback, the logic started to sprawl. LangGraph let me turn each step into a named node and make the routing between them explicit and inspectable.

The orchestrator graph routes by task type into two subgraphs: one for generating rules, one for checking metadata. Each subgraph is compiled once at startup and reused across requests. The check-metadata graph in particular has conditional edges that encode the hybrid decision logic cleanly:

metadata_quality_guard
    ↓ (metadata ok)
embedding_similarity
    ↓ similarity ≥ 0.80          ↓ otherwise
high_similarity_decision    llm_decision (ReAct)
    ↓                            ↓
            finalize

Two Things That Surprised Me

YouTube Doesn't Actually Reload Pages

YouTube doesn't work like a normal website. When you click from one video to another, the page doesn't fully reload, it just swaps out the content. That creates two awkward timing problems for an extension trying to read video information.

The first: the extension code normally runs once when a page loads. If you navigate to a second video without a real reload, that code never re-runs, and it's still holding the title and channel name from the video you just left. You could end up making a blocking decision based on the wrong video entirely.

The second: even on a fresh page load, there's a brief window where the video information hasn't rendered yet. During that moment, the page title is literally just "YouTube." If the extension reads too early, it sees nothing useful and has to guess.

Both of these are now handled. The extension listens for YouTube's own internal navigation events and waits one second after each one before re-checking, long enough for the new video's information to appear. And on the backend, before any AI evaluation happens, there's a simple check: if the title is a generic placeholder and there's no channel name, skip the whole pipeline and don't block. A false negative (missing a video that should be blocked) is much less annoying than a false positive (blocking the wrong video).

Content Scripts Can't Talk to the Backend Directly

One constraint that shapes everything: content scripts, the code that runs inside the YouTube page, can't make direct HTTP requests to the backend. CORS restrictions block it.

So all network calls go through the background service worker, which acts as a proxy. The content script sends a message, the service worker makes the fetch, and the result comes back as another message. Every blocking decision flows through this pattern:

Content Script  →  [chrome.runtime.sendMessage]  →  Service Worker
                                                          ↓
                                                    POST /check-metadata
                                                          ↓
Service Worker  →  [sendResponse]  →  Content Script

It adds a layer of indirection, but it also means the API key and backend URL never touch the page context.


What I'd Do Differently

A few things I'd revisit:

  • Caching embedding results. Right now, the same rule description gets embedded on every check. Caching the rule embedding (keyed by rule ID + description hash) would eliminate most redundant OpenAI calls.
  • Streaming the generate step. Rule generation currently blocks until Claude returns the full response. Streaming the summary back to the popup would make the UX feel faster.
  • Server-sent metadata. The content script polls for active rules every time a video loads. A push mechanism (or at least a longer-lived connection) would be cleaner.