The Agent SDK version

In the previous post, I built a news reader that uses Claude Code as its execution environment. It worked well, but the project was spread across half a dozen config files, each in a different directory, connected by naming conventions. Changing anything meant knowing where each piece lived and how they were wired together.
That post ended with a teaser about the Claude Agent SDK and a five-line script that wraps the existing project. That was the lazy migration. This post goes the other direction: throw away the scaffolding and rebuild from scratch as a single Python file.
The goal is to have everything in one Python file you can import, test, and extend like any other code.
Configuration as parameters
The same configuration from the Claude Code version, now as SDK arguments:
async for message in query(
prompt="Crawl Hacker News and Lobsters...",
options=ClaudeAgentOptions(
model="haiku",
permission_mode="dontAsk",
mcp_servers={"news": news_server},
allowed_tools=[
"Agent",
"WebFetch(domain:news.ycombinator.com)",
"WebFetch(domain:lobste.rs)",
"mcp__news__save_news_item",
],
agents={
"news-fetcher": AgentDefinition(
description="Fetch and filter news from a specific site",
prompt="...",
tools=["WebFetch", "mcp__news__save_news_item"],
model="haiku",
)
},
),
)
permission_mode="dontAsk" auto-denies any tool not in allowed_tools, so the script runs non-interactively. Domain-scoped rules like WebFetch(domain:...) use the same syntax as Claude Code agent definitions.
The snippet has two different tools-like fields: allowed_tools on the options and tools on the agent definition. They control different things:
allowed_tools | tools (on AgentDefinition) | |
|---|---|---|
| Scope | Session-wide. Main agent and all sub-agents. | Per sub-agent |
| What it does | Permission boundary: only these tools can execute | Visibility: what the sub-agent sees in its tool list |
| Denied calls | Silently blocked by dontAsk | Not offered to the agent at all |
| Supports domain scoping | Yes. WebFetch(domain:...) | No. Tool names only. |
allowed_tools is the security boundary: nothing outside the list can run, regardless of which agent requests it. tools on the agent definition is a narrower lens: it controls what each sub-agent is offered, so a news-fetcher sees WebFetch and save_news_item but not Agent.
There’s a gap here: allowed_tools is session-wide, so there’s no way to say “WebFetch is allowed for sub-agents but not the main agent.” In practice the prompt is enough. Across many runs the orchestrator always delegated, but it’s not a hard boundary.
In-process tools
In the previous post, the MCP server was a separate Python file that ran as a subprocess. Claude Code spawned it on demand, and .mcp.json wired them together. The SDK lets you define tools in the same file with a @tool decorator:
from claude_agent_sdk import tool, create_sdk_mcp_server
@tool(
"save_news_item",
"Save a news item as a JSON file in the data directory.",
NewsItem.model_json_schema(),
)
async def save_news_item(args: dict[str, Any]) -> dict[str, Any]:
item = NewsItem.model_validate(args)
slug = "".join(c if c.isalnum() else "-" for c in item.title.lower())[:60]
path = DATA_DIR / f"{int(time.time())}_{slug}.json"
path.write_text(json.dumps(item.model_dump(), indent=2))
return {"content": [{"type": "text", "text": f"Saved: {item.title}"}]}
news_server = create_sdk_mcp_server(
name="news", version="1.0.0", tools=[save_news_item],
)
The architecture is the same as before: sub-agents call save_news_item to store each item, with Pydantic validating the input. The difference is the tool handler is just a function in the same file.
Sub-agents as parameters
In Claude Code, sub-agents are markdown files in .claude/agents/. In the SDK, they’re AgentDefinition objects:
"news-fetcher": AgentDefinition(
description="Fetch and filter news from a specific site",
prompt="Fetch the front page. Identify items relevant to: Python, "
"developer tools, AI/ML, software architecture...",
tools=["WebFetch", "mcp__news__save_news_item"],
model="haiku",
)
The full script
The complete script ties everything together. The pieces you’ve seen above (the data model, the @tool function, the AgentDefinition) are defined at the top, and main() wires them into a single query() call:
async def main():
async for message in query(
prompt=PROMPT,
options=ClaudeAgentOptions(
model="haiku",
permission_mode="dontAsk",
mcp_servers={"news": news_server},
allowed_tools=[
"Agent",
"WebFetch(domain:news.ycombinator.com)",
"WebFetch(domain:lobste.rs)",
"mcp__news__save_news_item",
],
agents={"news-fetcher": NEWS_FETCHER},
),
):
if isinstance(message, ResultMessage):
if message.result:
print(message.result)
if message.total_cost_usd:
print(f"\nCost: ${message.total_cost_usd:.4f}")
asyncio.run(main())
Each run costs a couple of cents (the SDK returns total_cost_usd on every message), and you can cap spending with max_budget_usd or limit iterations with max_turns. It reads like a regular Python script because it is one.
The full project is on GitHub. The Agent SDK docs cover everything I didn’t get to here.