One command and get the AI stories worth reading today, ranked by an LLM loaded from config. Two hundred lines of Python, no framework, no venv juggling thanks to PEP 723 (inline script metadata). Below are four patterns I explored.

We need curation

It's hard to keep up with AI: a lot is happening fast, and most of it is noise. I wanted a script that fans out to my sources concurrently, dedupes, and asks a model to surface what's worth my limited time.

Plenty of tools do this already, but it was a good excuse to play with asyncio and Protocols, keeping the LLM provider swappable.

Pattern 1: fan out with asyncio.gather, fail soft

IO-bound work is where asyncio works well. Here I parallelize the data gathering work with asyncio.gather. But a single raised exception would cancel the whole batch. That's why I ended up wrapping each coroutine in a try/except that prints the error and returns an empty list:

async def safe_fetch(coro: Awaitable[list[Post]], label: str) -> list[Post]:
    try:
        return await coro
    except Exception as e:
        print(f"# fetch failed ({label}): {e}")
        return []

async def main() -> None:
    async with httpx.AsyncClient() as http:
        tasks = [safe_fetch(fetch_hn(http, q), f"hn:{q}") for q in HN_QUERIES]
        tasks += [safe_fetch(fetch_reddit(http, s), f"r/{s}") for s in SUBREDDITS]
        results = await asyncio.gather(*tasks)

The batch survives a single failure, and the error stays close to its source.

If you are on Python 3.11+ and want stricter structured concurrency, asyncio.TaskGroup is the modern default. Here I stuck with gather because the safe_fetch wrapper makes the fail-soft behavior explicit at the call site.

A small dedupe_and_filter step (sort by points, drop seen URLs) runs after gather returns. Standard Python, no library needed, but it stops the LLM from scoring the same HN-vs-Reddit story twice.

Pattern 2: swap the LLM with a Protocol

I initially hardcoded the AI vendor, but it's nicer to have people use their vendor of choice. Using a Protocol is a nice way to define an interface without needing full inheritance. Both AnthropicRanker and OpenAIRanker have the same shape, so they both satisfy the Ranker contract without needing to inherit from it:

class Ranker(Protocol):
    model: str
    async def rank(self, digest: str, system: str) -> str: ...

class AnthropicRanker:
    def __init__(self, api_key: str, model: str) -> None:
        self.client = AsyncAnthropic(api_key=api_key)
        self.model = model

    async def rank(self, digest: str, system: str) -> str:
        msg = await self.client.messages.create(
            model=self.model, max_tokens=2048, system=system,
            messages=[{"role": "user", "content": digest}],
        )
        block = msg.content[0]
        if not isinstance(block, TextBlock):
            raise RuntimeError(f"expected text block, got {block.type}")
        return block.text

OpenAIRanker has the same shape but has a different rank implementation. Neither inherits from Ranker.

Structural typing means the type checker confirms the contract without inheritance ceremony. I covered the broader pattern in How an AI expense agent is actually structured, where the same idea drives a four-layer architecture (used in our Agentic AI cohort) that keeps the core logic of the agent separate from the LLM provider and the data source.

Pattern 3: dispatch with StrEnum + match + assert_never

The provider comes from an environment variable. A plain string would let typos through to runtime. StrEnum makes it a typed value, and match with assert_never makes adding a third provider a type error until I handle it:

class Provider(StrEnum):
    ANTHROPIC = "anthropic"
    OPENAI = "openai"

def make_ranker(provider: Provider) -> Ranker:
    match provider:
        case Provider.ANTHROPIC:
            return AnthropicRanker(
                config("ANTHROPIC_API_KEY"),
                config("CLAUDE_MODEL", default=DEFAULT_ANTHROPIC_MODEL),
            )
        case Provider.OPENAI:
            return OpenAIRanker(
                config("OPENAI_API_KEY"),
                config("OPENAI_MODEL", default=DEFAULT_OPENAI_MODEL),
            )
        case _:
            assert_never(provider)

Add Provider.GROQ = "groq" tomorrow and ty flags it before the script runs:

error[type-assertion-failure]: Argument does not have asserted type `Never`
  --> trend_digest.py:NN:13
   |
NN |             assert_never(provider)
   |             ^^^^^^^^^^^^^--------^
   |                          |
   |                          Inferred type of argument is `Literal[Provider.GROQ]`
   |
info: `Never` and `Literal[Provider.GROQ]` are not equivalent types

The checker is saying: I thought this case was unreachable (Never), but I found a way to reach it when provider is Provider.GROQ. The closest thing Python has to Rust's exhaustive match, which is what I really like about Rust.

Two layers of safety in one line: assert_never gives a static error at check time, and at runtime it raises AssertionError if anything ever bypasses the enum.

Pattern 4: a new way to share scripts: PEP 723 inline script metadata

The whole script (gist here) ships with a PEP 723 inline script header, and the rank prompt lives as a string constant in the same file. So uv run trend_digest.py resolves dependencies in its own environment, and I can keep the whole thing in one file. I can even run the gist URL directly:

$ uv run https://gist.github.com/bbelderbos/af7097f98d5d0e4baee003367b472b56

However now I get decouple.UndefinedValueError: REDDIT_USER_AGENT not found, because the script does require some environment variables to be set.

The config(...) calls come from python-decouple. It reads environment variables and falls back to a .env file (searched upward from the script's directory), raising UndefinedValueError for anything missing without a default=. There is a small catch when running the gist URL directly: uv downloads the script to a temp location, so a .env in your current directory is not picked up. Two ways around it:

# Option A: export them in your shell first
export ANTHROPIC_API_KEY=sk-ant-...
export REDDIT_USER_AGENT="trend-digest/1.0 by yourname"
export PROVIDER=anthropic
uv run https://gist.github.com/bbelderbos/af7097f98d5d0e4baee003367b472b56

# Option B: let uv load the file into the subprocess env
uv run --env-file .env https://gist.github.com/bbelderbos/af7097f98d5d0e4baee003367b472b56

The rank prompt is a string constant at the top of the file (not an external file, to keep it all in one script), so tweak it to your taste: bias toward certain topics, change the output format, or ask for a one-line "why this matters" per story.

Set those, and one command from anywhere fetches and ranks today's AI news. Four patterns, one file, no framework.