Chapter 18
Week 5.3: Strands Agents
Welcome to Week 5 - Agent Frameworks
Day 2: AWS Strands
Same week, same five steps, a new framework. The whole idea this week is that once you know one agent framework, you basically know them all, so every day we build the same agent the same five steps and watch the idioms rhyme:
- Create the agent - give it a model and a system prompt.
- Run it - send a message, get a reply.
- Add tools - plain typed functions the agent can call.
- Add MCP - connect a tool server someone else wrote, wired the same way every time.
- Put it in a loop with a goal - hand it an objective and let it work, step after step, until the job is done.
Steps 1 and 2 are still just an LLM call. Tools and MCP give the agent things it can do. Step 5 is where it becomes an agent: the framework runs the loop on its own, choosing a tool, reading the result, choosing again, until the goal is met.
The running project is the same SQLite todo board from Day 1. A worker picks one goal off the board, plans its own steps, does the work with its agent loop, and ticks each step off. The board code (board.py) is the same file, byte for byte; only the framework around it changes.
Today is AWS Strands: a lightweight, open-source SDK built around a model-driven loop. You hand the agent a task and some tools, and the model itself plans, calls a tool, reads the result and keeps going until it is done. Strands is known for two things: that minimal loop, and true portability, where you write the tools once and swap the backing model in a single line. We will see that swap at the end.
Setup
Two things this day needs, both already in place from earlier weeks:
- Node, for
npx(the filesystem MCP server runs over it). Check withnode --version. OPENAI_API_KEYin the repo-root.env. Today's frameworks use OpenAI'sgpt-5.4-mini, and that key has been in your.envsince Week 1.
Strands is part of the repo's environment, so a normal uv sync from the repo root installs everything. Open this notebook in Cursor and pick the repo's default Python 3.12.12 kernel, the same one you use every week, then run the cells top to bottom.
Warm the filesystem MCP server once now so the first run is fast, then stop it with Ctrl-C as soon as it prints that it is running:
npx -y @modelcontextprotocol/server-filesystem .import os
import subprocess
from pathlib import Path
from dotenv import load_dotenv
from strands import Agent, tool
from strands.models.openai import OpenAIModel
from strands.tools.mcp import MCPClient
from mcp import stdio_client, StdioServerParameters
load_dotenv(override=True)Step 1: Create the agent
In Strands an agent is an Agent: a model plus a system_prompt. We build the model once as an OpenAIModel pointed at gpt-5.4-mini and reuse it everywhere. One thing to know up front: a bare Agent() with no model quietly defaults to AWS Bedrock and expects AWS credentials, so we always pass model= explicitly.
MODEL = "gpt-5.4-mini"
model = OpenAIModel(client_args={"api_key": os.environ["OPENAI_API_KEY"]}, model_id=MODEL)
agent = Agent(
model=model,
system_prompt="You are a concise, friendly assistant. Reply in a single short sentence.",
)Step 2: Run it
Send a message and await the reply. Strands streams the text as it is generated, so you watch it appear live; with no tools yet there is nothing to loop over, so the agent just answers. This is still only an LLM call. A plain agent("...") works too, but inside a notebook we await invoke_async so it cooperates with Jupyter's event loop.
result = await agent.invoke_async("Say hello in Spanish.")Our project this week: a SQLite todo board
The worker coordinates through the same tiny SQLite board as Day 1, the same board.py file: one file, one table, no server to run. A worker is handed one goal; to reach it, it writes its own step todos under that goal, ticks each one off as it goes, and closes the goal at the end. Under the hood the board is just a list of dicts (a goal has parent_id of None, a step points at its goal):
import board
board.reset_board()
board.add_goal("Read notes.txt, translate its contents into natural Spanish, and write the Spanish to spanish.txt.")
board.list_todos()show_board() prints that same data nicely, in the rich style from Week 1: each goal with its steps indented beneath, done tasks struck through in green and the one in progress in yellow. There are no steps yet; the worker writes its own when it plans.
board.show_board()Step 3: Add tools
A tool in Strands is a typed Python function wrapped in the @tool decorator. The type hints become the argument schema, the docstring becomes the description the model reads (its Args: section documents each parameter), and you hand the functions to the agent in its tools=[...] list.
We write three small board tools: show_todos to read the board, plan_steps to break a goal into steps, and complete_task to mark a todo done. Here we give a quick agent just two of them and ask what is on the board; watch it decide, on its own, to call show_todos before it answers. That decide, call, read, answer is the agent loop starting to turn. All three tools come together in step 5.
@tool
def show_todos() -> list[dict]:
"""List every todo on the board. A goal has parent_id None; a step has parent_id set to its goal's id."""
return board.list_todos()
@tool
def plan_steps(goal_id: int, steps: list[str]) -> dict:
"""Break a goal into an ordered checklist of steps on the board.
Args:
goal_id: The id of the goal to break down.
steps: Short descriptions of the steps to take, in order.
"""
return {"goal_id": goal_id, "step_ids": [board.add_step(goal_id, step) for step in steps]}
@tool
def complete_task(task_id: int, result: str) -> dict:
"""Mark a todo (a step or the goal) done and record a short result summary.
Args:
task_id: The id of the todo to mark done.
result: A short summary of what was accomplished.
"""
board.complete_todo(task_id, result)
return {"task_id": task_id, "status": "done"}board_agent = Agent(
model=model,
system_prompt="You help manage a shared todo board.",
tools=[show_todos, complete_task],
)result = await board_agent.invoke_async("What is on the board right now, and what is its status?")Step 4: Add MCP
MCP is just more tools: ones you did not write, connected over a small protocol. We give the agent the filesystem reference server, the same Node server every framework uses this week, scoped to a single workspace folder so it can only touch files in there. In Strands you wrap the server in an MCPClient and add it to the same tools=[...] list, and Strands manages the connection lifecycle for you.
We pass errlog=subprocess.DEVNULL to the server. That quiets its startup logging, and it is also what lets the server run from a Jupyter kernel on Windows, where the kernel's stderr has no real file descriptor. On Mac and Linux it changes nothing.
workspace = Path("workspace").resolve() # the only folder the agent may touch
filesystem = MCPClient(
lambda: stdio_client(
StdioServerParameters(
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", str(workspace)],
cwd=str(workspace), # start the server in the workspace so relative file names resolve there
),
errlog=subprocess.DEVNULL,
),
startup_timeout=60
)file_agent = Agent(
model=model,
system_prompt="You can read and write files in your workspace. Use your tools to do what is asked.",
tools=[filesystem],
)result = await file_agent.invoke_async("Read notes.txt and summarize it in one short sentence.")Step 5: Put it in a loop with a goal
Now the payoff. Give one agent all three board tools and the filesystem server, hand it the goal, and let it run. It plans its own steps on the board, works them with its file tools, ticks each one off, and closes the goal when the work is done. That is the agent loop running on its own: read, plan, act, check off, repeat. Watch the board fill with steps and then strike them through.
INSTRUCTIONS = """
You are a careful worker with a shared todo board and a set of file tools.
Take the pending goal and see it through. Begin by laying out a short plan: the handful of concrete steps the work itself breaks down into, added to the board under the goal. Then carry them out with your file tools, marking each step done as you finish it. Once the steps are all done, close the goal. Your files live in the single folder your tools are allowed to use.
"""
worker = Agent(
model=model,
system_prompt=INSTRUCTIONS,
tools=[show_todos, plan_steps, complete_task, filesystem],
)
board.reset_board()
goal_id = board.add_goal("Read notes.txt, translate its contents into natural Spanish, and write the Spanish to spanish.txt.")
board.claim_todo(goal_id)
await worker.invoke_async("Please work the pending goal on the board.")
board.show_board()Run the same worker from the terminal
Everything you just watched in step 5 is also packaged as a small script, strands_worker.py, sitting next to this notebook. It seeds the same goal, builds the same agent with the same three board tools and the filesystem MCP server, and runs the same loop, only from the command line instead of the kernel. Open a terminal in this folder and run it:
uv run strands_worker.pyYou will see the agent plan its steps, work the goal and tick each one off, then the finished board and the Spanish it wrote. This is the shape every worker takes on Day 5, where a Google ADK orchestrator launches one of these per framework as a parallel subprocess against a single shared board.
The one cool thing: swap the model in one line
Strands' headline feature is portability. You wrote the tools and the loop once. To run the exact same agent on a different provider, you change one line, the OpenAIModel. Point base_url at any OpenAI-compatible endpoint and nothing else moves:
# The same agent on OpenRouter, Ollama, LM Studio, a local vLLM server, anything OpenAI-compatible
model = OpenAIModel(
client_args={
"api_key": OPENROUTER_KEY, # any non-empty string for a local server
"base_url": "https://openrouter.ai/api/v1",
},
model_id="gpt-5.4-mini", # or whatever that endpoint serves
)Strands also speaks to Anthropic, Gemini and Bedrock through their own model classes, but the OpenAI-compatible base_url covers most of the ecosystem. The tools, the filesystem MCP server and the board never change. That is the whole thesis of the week inside a single framework: the agent is the agent, and the model behind it is a swappable detail.
