· ai claude agent sdk · 13 min read

Build a daily news summarizer with the Claude Agent SDK

A step-by-step guide to building a Python agent that reads RSS feeds, filters by your criteria, and writes a news brief - from the first script to custom tools, system prompts, and topic subagents.

A step-by-step guide to building a Python agent that reads RSS feeds, filters by your criteria, and writes a news brief - from the first script to custom tools, system prompts, and topic subagents.

The problem

Every morning I open five or six tabs: BBC, Ars Technica, Hacker News, a couple of newsletters. Reading all of it takes 30-40 minutes, and most of it has nothing to do with my work. Skipping it means missing the one story that mattered.

This is exactly the kind of job to hand to a machine: read a lot, filter against criteria, write it back short. Doing that used to mean writing your own crawler, your own parser, your own model calls, and your own tool-calling loop. With the Claude Agent SDK, most of that is already built.

This post goes from a ten-line script to a complete agent: it reads the sources you name, filters them against your criteria, and writes the brief to a Markdown file. All the code is Python.


What is the Claude Agent SDK?

claude-agent-sdk is the engine behind Claude Code, packaged as a Python library. You hand it a prompt and it runs the agent loop for you: decide which tool to call, execute it, read the result, call again if something is missing, then return a final answer.

For a news brief this is exactly what you want. The agent has to act on its own: call a tool to fetch stories, reach for another source when the first isn’t enough, filter against criteria, then write the file. You describe the goal and grant permissions; the SDK handles the step-by-step orchestration.

These built-in tools are available to the agent immediately, with nothing to install:

ToolWhat it does
ReadRead a file in the working directory
WriteCreate a new file
EditMake a precise edit to an existing file
BashRun terminal commands
GlobFind files by pattern
GrepSearch file contents with regex
WebSearchSearch the web
WebFetchFetch and read a web page

Step 1: Install and authenticate

This post uses uv to manage the project and its dependencies. If you don’t have it, one command installs it:

curl -LsSf https://astral.sh/uv/install.sh | sh

On Windows PowerShell:

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Create the project and add dependencies:

uv init news-agent
cd news-agent
uv add claude-agent-sdk httpx

The SDK requires Python 3.10 or later, but uv takes that off your hands: it reads requires-python from pyproject.toml, downloads the right Python if your machine doesn’t have it, and creates the virtualenv in .venv - no manual python -m venv and activate. To pin a specific version, run uv python pin 3.12.

httpx is for the custom tool in Step 3. One more convenience: the SDK bundles a native Claude Code binary for your platform, so you don’t need to install Claude Code separately.

That leaves authentication. There are two routes: open an API key and pay per token, or use the Claude subscription you already have.

Option 1: API key

Get a key from the Anthropic Console and export it:

export ANTHROPIC_API_KEY=sk-ant-xxxxx

On Windows PowerShell:

$env:ANTHROPIC_API_KEY = "sk-ant-xxxxx"

Option 2: use your existing Claude subscription

If you already pay for Claude Pro, Max, Team, or Enterprise, you don’t need a separate API key. The agent can authenticate with that subscription and draw on your existing quota instead of generating a separate API bill.

You do this by minting a long-lived OAuth token with a Claude Code command:

claude setup-token

The command opens a browser for you to approve, then prints the token to your terminal. It does not save the token anywhere - copy it yourself and set it as an environment variable:

export CLAUDE_CODE_OAUTH_TOKEN=your-token

The token lasts one year and can only make model requests. If you don’t have the claude command yet, install the Claude Code CLI to run this - once, just to mint the token.

Three things to know before taking this route:

ANTHROPIC_API_KEY outranks CLAUDE_CODE_OAUTH_TOKEN. In the credential precedence order, the API key sits above the OAuth token. If you have a stale ANTHROPIC_API_KEY in .bashrc or a .env file, the agent quietly runs on that key and bills it, never touching your subscription. Run unset ANTHROPIC_API_KEY first.

Subscription quota is not API quota. You share the limit with your day-to-day Claude Code usage, so a heavy news job can eat into what you meant to spend on coding. Worth weighing if you plan to run the agent often.

For your own agent only. The Agent SDK docs are explicit: unless previously approved, Anthropic does not allow third-party developers to offer claude.ai login or claude.ai rate limits for their products, including products built on the Agent SDK. So using your subscription for a personal brief like this one is fine; if you intend to ship it as a product other people use, go back to an API key. The full picture is in the Claude Code authentication docs.

Beyond these two, the SDK also supports Amazon Bedrock, Google Cloud, and Microsoft Foundry through their respective environment variables.


Step 2: Your first agent

Create agent.py:

import asyncio

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query

async def main():
    async for message in query(
        prompt="Find the 5 biggest tech stories of the last 24h, 2 sentences each.",
        options=ClaudeAgentOptions(allowed_tools=["WebSearch", "WebFetch"]),
    ):
        if isinstance(message, ResultMessage) and message.subtype == "success":
            print(message.result)

asyncio.run(main())

Run it:

uv run agent.py

That’s the whole thing. The agent searches, opens the pages it needs, and returns a summary.

Two details in that code are worth calling out:

query() is an async generator. It emits several message types as it runs: SystemMessage (init), AssistantMessage (each model turn, including tool calls), and finally ResultMessage. Here we only care about the last one, so we filter with isinstance.

allowed_tools is a pre-approved list. Tools on the list run straight through without prompting. Tools not on the list are still available, but calls go through the permission flow, so list every tool you want the agent to use on its own.

To see what the agent is doing, add a branch for AssistantMessage. Here is the full agent.py with that added:

import asyncio

from claude_agent_sdk import (
    AssistantMessage,
    ClaudeAgentOptions,
    ResultMessage,
    ToolUseBlock,
    query,
)

async def main():
    async for message in query(
        prompt="Find the 5 biggest tech stories of the last 24h, 2 sentences each.",
        options=ClaudeAgentOptions(allowed_tools=["WebSearch", "WebFetch"]),
    ):
        if isinstance(message, AssistantMessage):
            for block in message.content:
                if isinstance(block, ToolUseBlock):
                    print(f"[tool call] {block.name} {block.input}")
        elif isinstance(message, ResultMessage) and message.subtype == "success":
            print(message.result)

asyncio.run(main())

Step 3: Control your sources with a custom tool

WebSearch is convenient, but it has one big drawback here: you don’t control the sources. Today it pulls from BBC, tomorrow from some content farm. For a brief you read every morning, you want a fixed set of sources you chose in advance.

The fix is a custom tool that reads RSS from a list you specify. The Agent SDK lets you define tools in-process through an in-process MCP server - no separate server to run.

A tool has four parts: a name, a description, an input schema, and a handler. Create news_tool.py:

import json
import re
import xml.etree.ElementTree as ET
from typing import Any

import httpx
from claude_agent_sdk import ToolAnnotations, create_sdk_mcp_server, tool

# Sources you control completely
FEEDS = {
    "bbc": "https://feeds.bbci.co.uk/news/rss.xml",
    "arstechnica": "https://feeds.arstechnica.com/arstechnica/index",
    "hackernews": "https://hnrss.org/frontpage",
}
HEADERS = {"user-agent": "Mozilla/5.0 (news-agent)"}
TAG_RE = re.compile(r"<[^>]+>")

def clean(text: str | None) -> str:
    """Strip HTML out of RSS content and collapse whitespace."""
    if not text:
        return ""
    return re.sub(r"\s+", " ", TAG_RE.sub(" ", text)).strip()

def result(text: str, is_error: bool = False) -> dict[str, Any]:
    """Wrap a string in the tool-result shape the SDK expects."""
    out: dict[str, Any] = {"content": [{"type": "text", "text": text}]}
    if is_error:
        out["is_error"] = True
    return out

@tool(
    "get_headlines",
    "Fetch the latest stories from a preconfigured RSS source. limit defaults to 15.",
    {
        "type": "object",
        "properties": {
            "source": {"type": "string", "enum": list(FEEDS)},
            "limit": {"type": "integer", "minimum": 1, "maximum": 30},
        },
        "required": ["source"],
    },
    annotations=ToolAnnotations(readOnlyHint=True),
)
async def get_headlines(args: dict[str, Any]) -> dict[str, Any]:
    source, limit = args["source"], args.get("limit", 15)
    try:
        async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
            response = await client.get(FEEDS[source], headers=HEADERS)
        if response.status_code != 200:
            return result(f"HTTP {response.status_code} from {source}", is_error=True)

        root = ET.fromstring(response.content)
        items = [
            {
                "title": clean(item.findtext("title")),
                "link": clean(item.findtext("link")),
                "published_at": clean(item.findtext("pubDate")),
                "summary": clean(item.findtext("description"))[:400],
            }
            for item in list(root.iter("item"))[:limit]
        ]
        return result(json.dumps(items, indent=2))
    except Exception as e:
        # Compose the error message yourself so Claude knows how to react
        return result(f"Could not fetch from {source}: {e}", is_error=True)

news_server = create_sdk_mcp_server(name="news", version="1.0.0", tools=[get_headlines])

Four things worth noting:

Tool names are namespaced. The server is registered under the key news, so get_headlines gets the full name mcp__news__get_headlines (the pattern is mcp__{server}__{tool}). That is the name you put in allowed_tools; with several tools, use mcp__news__*.

The full JSON Schema is here because we need enum and an optional parameter. The shorthand form {"source": str, "limit": int} is more convenient, but it treats every key as required and cannot express enum. We need both, so the schema declares required explicitly and the handler reads the optional value with args.get("limit", 15). The enum list comes straight from list(FEEDS), so adding a source means editing one place.

Return is_error: True rather than letting an exception escape. An exception doesn’t stop the agent - the SDK catches it and turns it into an error result - but Claude only sees the raw exception message. Catching it yourself lets you say which source failed, so Claude can skip it and carry on with the others. Every return goes through the result() helper so the content shape isn’t repeated in each branch.

readOnlyHint=True enables parallel calls. Claude knows the tool changes nothing, so it fetches all three sources at once instead of one after another. Note that ToolAnnotations uses camelCase, unlike the rest of the Python API.

On the parser: xml.etree.ElementTree from the standard library handles CDATA transparently, so wrapped description fields come through fine and all that’s left is stripping the HTML inside. This code targets RSS 2.0; if you also need Atom (entry instead of item), use feedparser. That distinction is real - The Verge’s feed, for example, is Atom and this parser would return nothing for it.

Now wire the tool into the agent:

import asyncio

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
from news_tool import news_server

async def main():
    options = ClaudeAgentOptions(
        mcp_servers={"news": news_server},
        allowed_tools=["mcp__news__get_headlines"],
    )
    async for message in query(
        prompt="Fetch from all three sources and summarize the 5 most notable stories.",
        options=options,
    ):
        if isinstance(message, ResultMessage) and message.subtype == "success":
            print(message.result)

asyncio.run(main())

Step 4: Shape the output and write it to a file

The agent now reads the right sources, but the output looks different every day. Two things fix that: a system prompt spelling out the criteria and the layout, and the Write tool to save the result.

The system prompt gets reused in Step 5, so put it in its own file, prompts.py:

SYSTEM_PROMPT = """You are a news briefing assistant for a working software engineer.

Selection criteria:
- Prioritize: technology, macroeconomics, and policy that affects the software industry.
- Skip: celebrity news, sports, outrage bait, and thinly disguised advertising.
- If several sources cover the same event, merge them into a single entry.

Output format (Markdown):
- Level 1 heading: "News brief for <date>"
- Each story is a level 2 section: headline, 2-3 sentence summary, and a source link.
- End with a "Most notable" section explaining in one sentence why that story matters.

Use only information present in the tool results. Do not speculate and do not invent
details that are absent from the sources. If a source fails, say so at the end."""

Then agent.py:

import asyncio
from datetime import date

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
from news_tool import news_server
from prompts import SYSTEM_PROMPT

async def main():
    today = date.today().isoformat()
    options = ClaudeAgentOptions(
        system_prompt=SYSTEM_PROMPT,
        mcp_servers={"news": news_server},
        allowed_tools=["mcp__news__get_headlines", "Write"],
        max_turns=20,
        max_budget_usd=0.5,
    )
    async for message in query(
        prompt=(
            "Fetch the latest from all three sources, pick 5-7 stories by the "
            f"criteria, then write the brief to ./briefs/{today}.md"
        ),
        options=options,
    ):
        if isinstance(message, ResultMessage) and message.subtype == "success":
            print(message.result)

asyncio.run(main())

Two new options are worth a note:

max_turns caps how many turns the agent gets. If it somehow falls into a loop of tool calls, it stops after 20 instead of running forever.

max_budget_usd caps the cost of a single run - a safety net for your wallet when something goes unexpectedly wrong.

That last line in the system prompt, the one restricting the agent to information present in the tool results, is not filler. The model has plenty of background knowledge about news topics, and without the constraint it may add plausible-sounding details that appear nowhere in your sources. For a news brief, that is a serious failure.


Step 5: Split topics across subagents

As the source list grows, cramming everything into one agent bloats the context and degrades the summaries. The fix is to give each topic area its own subagent with its own context, then have the main agent synthesize.

import asyncio
from datetime import date

from claude_agent_sdk import AgentDefinition, ClaudeAgentOptions, ResultMessage, query
from news_tool import news_server
from prompts import SYSTEM_PROMPT

COLLECTORS = {
    "world-news": AgentDefinition(
        description="Covers general and world news.",
        prompt=(
            "Fetch from the bbc source. Drop celebrity news and sports. "
            "Return at most 4 stories, each with a headline, 2 sentences, and a link."
        ),
        tools=["mcp__news__get_headlines"],
    ),
    "tech-news": AgentDefinition(
        description="Covers technology and engineering news.",
        prompt=(
            "Fetch from the arstechnica and hackernews sources. Prefer AI, programming "
            "languages, and infrastructure. Return at most 4 stories with links."
        ),
        tools=["mcp__news__get_headlines"],
    ),
}

async def main():
    today = date.today().isoformat()
    options = ClaudeAgentOptions(
        system_prompt=SYSTEM_PROMPT,
        mcp_servers={"news": news_server},
        allowed_tools=["mcp__news__get_headlines", "Write", "Agent"],
        agents=COLLECTORS,
        max_turns=30,
        max_budget_usd=1.0,
    )
    try:
        async for message in query(
            prompt=(
                "Use the world-news and tech-news subagents to collect, then "
                f"synthesize a single brief and write it to ./briefs/{today}.md"
            ),
            options=options,
        ):
            if isinstance(message, ResultMessage) and message.subtype == "success":
                print(message.result)
    except Exception as error:
        # query() raises after emitting an error result; the loop only prints successes
        print(f"Run failed: {error}")

asyncio.run(main())

The easiest thing to get wrong here: subagents are invoked through the Agent tool, so "Agent" has to be in allowed_tools. Leave it out and the main agent stalls at the permission prompt.

The main agent keeps SYSTEM_PROMPT from Step 4 - it is the one synthesizing and writing the file, so it still needs the selection criteria and the formatting rules. The two subagents have their own narrower prompt covering collection only. That is the division of labor: subagents gather raw material, the main agent decides what the brief looks like.

Each subagent has its own tools list. Above, both are granted RSS reading only - neither can write files. Only the main agent has Write. That is good separation: subagents collect, the main agent owns the final output.

One detail that trips people up: AgentDefinition names its optional fields in camelCase (disallowedTools, maxTurns, permissionMode, mcpServers), while ClaudeAgentOptions uses snake_case. Writing max_turns=5 inside AgentDefinition fails; it has to be maxTurns=5.

Messages emitted from inside a subagent also carry a parent_tool_use_id field, so you can tell which subagent run a message belongs to when you need detailed logs.


Wrapping up

The most valuable thing about the Claude Agent SDK here isn’t the summarizing itself. It is that you never write the tool-orchestration loop, never handle the case where the agent needs more data mid-run, and get file read/write tools out of the box to connect the result to the rest of your system.

The path we took:

  1. A minimal agent with WebSearch - works immediately, but no control over sources.
  2. A custom RSS tool over an in-process MCP server - you decide the sources.
  3. A system prompt plus the Write tool - stable output, written straight to Markdown.
  4. Topic subagents - separate contexts, clear permission boundaries.

From this skeleton you can go in several directions: swap Write for a custom tool that posts the brief to Slack or Telegram, add newsletter sources over IMAP, or use a PostToolUse hook to log every tool call for auditing.

The official docs are worth keeping open: the Agent SDK overview, the Python reference, and the custom tools guide.

Back to Blog