Your First AI Agent Should Only Be Able to Read
Building your first AI agent in 2026 doesn’t have to mean writing a loop. You import a library that already has one, point it at a folder, and it starts reading files and running commands. That speed is the appeal, and it’s also the trap, because the quickest first run tends to be the one with the broadest permissions. Even the official quickstart for the Claude Agent SDK builds a bug fixer that auto-approves file edits.
I’d start smaller: an agent that can read a codebase and explain it, and can’t do anything else. That’s genuinely useful for onboarding, for “where does this config value come from” questions, and for getting oriented before a review. Building it also forces you to learn how the permission system behaves, and a few of the defaults are not what you’d guess.
What the Agent SDK is
The Agent SDK is Claude Code packaged as a library for Python and TypeScript, with the same tools, agent loop and context management, driven from your own code. The Python package, claude-agent-sdk, needs Python 3.10 or newer and bundles a native Claude Code binary, so on most platforms pip install is the whole setup. The newest Python release right now is v0.2.152, published September 2, which bundles Claude Code 2.1.259. If the name is new to you, it used to be the Claude Code SDK; the rename came with version 0.1.0 in September 2025.
You call query() with a prompt and a ClaudeAgentOptions object, then iterate over messages as Claude works. The last one is a ResultMessage that tells you how the run ended, with the final text on success, the number of turns, a cost estimate and a session ID. Authentication is an ANTHROPIC_API_KEY in the environment, or environment variables that route requests through providers such as Amazon Bedrock, Google Cloud or Microsoft Foundry. If you’re thinking about shipping a product on it, note that Anthropic doesn’t allow third party developers to offer claude.ai login or rate limits in their agents unless previously approved, so plan on API keys.
allowed_tools doesn’t mean what it sounds like
This is the one that catches almost everyone. allowed_tools reads like an allowlist, and it isn’t. The permissions guide describes it as the set of tools that get auto-approved. Any tool you don’t list is still available to Claude, and calls to it fall through to the permission mode and your can_use_tool callback. The guide is blunt about the worst case: allowed_tools=["Read"] alongside permission_mode="bypassPermissions" still approves every tool, Bash, Write and Edit included.
A bare name is also broader than it looks. Listing "Read" approves every call to the Read tool (apart from a few built-in exceptions), not just reads inside your project.
It helps to see that three options do three separate jobs. tools decides which built-in tools exist in the session at all. disallowed_tools with a bare name such as "Bash" removes that tool from Claude’s context, while a scoped rule such as "Bash(rm *)" leaves the tool in place and denies matching calls in every mode. allowed_tools only decides what skips the approval step. When Claude requests a tool, hooks run first, then deny rules, then ask rules, then the permission mode, then allow rules, and whatever is still unresolved lands in can_use_tool.
For a read-only agent, tools is the option doing the real work. There’s one wrinkle: on macOS, Linux and WSL, Claude Code leaves the Glob and Grep tools out of the default set and has Claude search with find and grep through Bash instead. The tools reference says naming them in the tools option brings the dedicated versions back, so tools=["Read", "Glob", "Grep"] gets you file search without handing over a shell.
Some reads never ask
The next surprise is that plenty of calls never needed approval in the first place. File reads inside the working directory run without a prompt, and so does a built-in set of read-only shell commands, including cat, ls, grep and find. dontAsk mode turns every call that would have prompted into a denial, but calls that never prompt still run. So dontAsk plus a short allow list doesn’t, on its own, stop an agent that has Bash from running cat .env.
That’s why I’d remove Bash entirely and add deny rules for the files you care about. A rule like Read(./.env) keeps Claude Code’s file tools off that path and drops matching files from search results. It is not an operating system control, though. The settings reference says these rules don’t apply to arbitrary subprocesses that open files on their own, and points to the sandbox for real enforcement. The honest version of “keep secrets away from the agent” is to not have secrets in the directory it runs in.
It’s worth knowing where the output goes, too. Sessions are written to disk under ~/.claude/projects/ as JSONL transcripts containing every tool call and tool result, so a file the agent read is now also sitting in a transcript. The sessions guide says Python users can set CLAUDE_CODE_SKIP_PROMPT_HISTORY in the env option to suppress those writes.
Your agent inherits your config, and the repo’s
If you leave setting_sources unset, the SDK reads the same filesystem settings as the CLI: user settings under ~/.claude, the project’s .claude/settings.json, local settings, CLAUDE.md files, and project skills, agents and commands. On your laptop, the agent you’re testing quietly picks up whatever you’ve configured for your own Claude Code use, and then behaves differently on a server that doesn’t have any of it.
The more serious case is a repository you didn’t write. An SDK session never shows the workspace trust dialog. The docs have a table of what runs before you trust a folder, and for an SDK session in a folder you never trusted, hooks defined in the repo’s settings files are used, as is its env block, and servers in its .mcp.json connect without asking whenever project settings are loaded. The repo’s allow rules are held back, which is good, but a hook is a shell command. Point a default-configured agent at a fresh clone and that clone’s hooks get to run on your machine.
Passing setting_sources=[] turns those files off. It doesn’t cover quite everything: managed policy settings and ~/.claude.json are read regardless, and auto memory still loads unless you set CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 in the env option.
One more default worth changing: when you don’t set system_prompt, the SDK uses a minimal prompt that covers tool calling and leaves out the Claude Code preset’s security and safety instructions. For a narrow agent, a short prompt of your own is fine. Just don’t assume the CLI’s behavior came along.
A read-only starter
Here’s roughly what I’d start with. It isn’t production code, but every option is real in v0.2.152:
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
options = ClaudeAgentOptions(
cwd="/path/to/your/repo",
system_prompt="You explain codebases. Cite file paths. Never guess.",
tools=["Read", "Glob", "Grep"], # no Bash, no Edit, no Write
disallowed_tools=["Read(./.env)", "Read(./.env.*)"],
permission_mode="dontAsk", # a would-be prompt becomes a denial
setting_sources=[], # ignore ~/.claude and the repo's .claude/
env={"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"},
max_turns=25,
max_budget_usd=1.00,
)
async def main():
async for msg in query(prompt="How does login work here?", options=options):
if isinstance(msg, ResultMessage):
print(msg.subtype, msg.num_turns, msg.total_cost_usd)
if msg.subtype == "success":
print(msg.result)
asyncio.run(main())
Notice there’s no allowed_tools at all. Reads inside the working directory don’t need approval, so they just run. A read outside it would normally need approval, and dontAsk refuses it. With no Bash in the session, the read-only shell command loophole is gone too. The deny rules mirror the example in the settings reference and only cover the repo root, so add patterns for secrets/ folders or nested env files if your project has them. Also keep in mind that Glob doesn’t respect .gitignore by default, while Grep skips gitignored files, so the agent can find ignored files by name.
Put a ceiling on turns and spend
Both limits default to none. According to the agent loop docs, max_turns counts tool-use round trips, and hitting either limit ends the run with an error_max_turns or error_max_budget_usd result. A single-shot query() yields that result message and then raises, so wrap the loop in a try if your code needs to keep going.
Two caveats on the budget. The SDK’s own example notes that the check happens after each API call completes, so a run can overshoot your number by up to one API call’s worth. And total_cost_usd is a client-side estimate computed from a price table bundled with the SDK, so the cost tracking docs tell you to use the Claude Console or the Usage and Cost API for real billing numbers. Treat max_budget_usd as a circuit breaker, not an invoice.
Earning write access
Once the explanations are consistently good, add write access on purpose rather than by default. That might be scoped Edit rules for one directory, or acceptEdits in a throwaway environment. Anthropic’s secure deployment guide covers the heavier options, from a container with all capabilities dropped, a read-only root filesystem and no network, to a proxy that injects credentials so the agent never sees the key at all.
The SDK makes a capable agent almost free to build, which is exactly why the first one should be boring. Restrict what exists with tools, remember that allowed_tools only skips a prompt, cut off the settings you didn’t mean to inherit, and put a number on turns and spend. If an agent that can only read still surprises you, you’ve learned something cheaply. If it doesn’t, you’re in a good position to hand it a pen.