Frameworks: Strands Agents¶
Strands Agents is AWS's own open-source agent framework — the exact
thing running underneath AgentCore's Harness when you configure a
harness with no code at all. Set Harness aside, though, and using
Strands directly is the same shape as Pydantic AI:
pip install, write Python, own the process it runs in. The interesting
comparison isn't "which one is more AWS" — it's how much of the surface
each one hands you as a built-in object versus something you wire up
yourself.
The core shape: Agent, tools, direct calls¶
An Agent takes tools and a system prompt in its constructor, and the
agent object itself is callable — there's no .run()/.run_sync() split
the way Pydantic AI has:
from strands import Agent, tool
@tool
def weather(city: str) -> dict:
"""Get current weather for a city."""
return fetch_weather(city)
agent = Agent(system_prompt="You are a helpful assistant.", tools=[weather])
result = agent("What's the weather in Seattle?")
The @tool decorator does the same job as Pydantic AI's @agent.tool —
docstring becomes the description, function signature becomes the input
schema — but it isn't bound to one agent at decoration time. A @tool
function is a portable callable, handed to whichever Agent's tools=
list wants it, which is why it slots into the agents-as-tools pattern
below with no extra wiring.
Structured output is a method call rather than a constructor-time
output_type, or a constructor-time default if every call should return
the same shape:
from pydantic import BaseModel
from strands import Agent
class PersonInfo(BaseModel):
name: str
age: int
agent = Agent()
result = agent.structured_output(PersonInfo, "John Smith is 30 years old")
Statefulness is a constructor argument, not something you build¶
This is the sharpest contrast with the Pydantic AI chapter's "no session
object" section.
Strands ships an abstract SessionManager base class plus ready-made
backends — FileSessionManager for local disk, S3SessionManager for
S3, and, the one that matters most for this book,
AgentCoreMemorySessionManager wired straight into AgentCore Memory:
from strands import Agent
from strands.session.s3_session_manager import S3SessionManager
session_manager = S3SessionManager(session_id="user-456", bucket="my-agent-sessions")
agent = Agent(session_manager=session_manager)
agent("Tell me about AWS S3")
agent("What did I just ask about?") # no message_history argument anywhere
There's no message_history parameter in that second call. The session
manager loads prior turns before the model runs and persists new ones
after, on every call, automatically — the same shape as AgentCore
Harness's auto-persistence, just pluggable to whichever backend you
choose instead of AWS running it unconditionally.
Multi-agent orchestration is a first-class object¶
Pydantic AI treats "another agent" as a plain await call from inside a
tool — no dedicated abstraction. Strands ships two built-in orchestrator
classes for the same job: Swarm, where agents hand off to each other
with routing driven by structured output, and Graph, an explicit DAG of
agent or swarm nodes wired together with edges:
from strands import Agent
from strands.multiagent import GraphBuilder, Swarm
research_agents = [
Agent(name="medical_researcher", system_prompt="..."),
Agent(name="technology_researcher", system_prompt="..."),
]
research_swarm = Swarm(research_agents)
analyst = Agent(system_prompt="Analyze the provided research.")
builder = GraphBuilder()
builder.add_node(research_swarm, "research_team")
builder.add_node(analyst, "analysis")
builder.add_edge("research_team", "analysis")
graph = builder.build()
result = graph("Research the impact of AI on healthcare")
The plain-function version is still there too — a @tool that builds and
calls another Agent inline is exactly the Pydantic AI pattern:
@tool
def research(query: str) -> str:
"""Research a topic thoroughly."""
return str(Agent(tools=[search_web])(query))
writer = Agent(tools=[research])
writer("Write a post about AI agents")
Both exist side by side. The built-in classes earn their keep when the
handoff/routing/dependency structure is something worth the framework
tracking explicitly — a maxSteps cap, an actual DAG with edges — rather
than logic buried inside a tool function's body.
Pausing for approval: hooks and interrupts¶
Pydantic AI's answer to "a tool needs a human" is raising
ApprovalRequired and getting a DeferredToolRequests sentinel back as
the run's output. Strands reaches the same outcome through its hook
system: a HookProvider registers a callback on BeforeToolCallEvent,
and event.interrupt(...), called from inside that callback, is what
actually pauses the run:
from strands import Agent, tool
from strands.hooks import BeforeToolCallEvent, HookProvider, HookRegistry
@tool
def delete_files(paths: list[str]) -> bool: ...
class ApprovalHook(HookProvider):
def register_hooks(self, registry: HookRegistry, **kwargs) -> None:
registry.add_callback(BeforeToolCallEvent, self.approve)
def approve(self, event: BeforeToolCallEvent) -> None:
if event.tool_use["name"] != "delete_files":
return
approval = event.interrupt("delete-approval", reason=event.tool_use["input"])
if approval.lower() != "y":
event.cancel_tool = "User denied permission to delete files"
agent = Agent(hooks=[ApprovalHook()], tools=[delete_files])
result = agent("Delete a/b/c.txt")
while result.stop_reason == "interrupt":
responses = [
{"interruptResponse": {"interruptId": i.id, "response": input(f"Approve {i.reason}? (y/N): ")}}
for i in result.interrupts
]
result = agent(responses)
Same shape underneath as Pydantic AI's deferred tools — the run stops
instead of erroring, and resuming means sending something back keyed by
an ID — but the decision point sits in a different place. Pydantic AI's
ApprovalRequired is raised from inside the tool itself; Strands'
BeforeToolCallEvent fires before delete_files ever runs, so
delete_files carries no approval logic at all, the hook owns it
entirely. And the resume payload is a plain list of interruptResponse
dicts fed straight back into the same callable agent(...), not a
separate deferred_tool_results parameter alongside a hand-carried
message_history — a session manager, if one's attached, is already
carrying the history, so the interrupt response is the only thing left
for the caller to supply.
Deploying to AgentCore Runtime¶
Same BedrockAgentCoreApp wrapper documented in the AWS
AgentCore chapter's Runtime section — Strands
plugs in exactly the way any framework does, because Runtime's
integration surface is "a Python function that returns a string or a
stream," not something Strands-specific:
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from strands import Agent
app = BedrockAgentCoreApp()
agent = Agent(system_prompt="Be concise.")
@app.entrypoint
def invoke(payload):
return str(agent(payload["prompt"]))
if __name__ == "__main__":
app.run()
None of the Strands-specific machinery above — session manager, Swarm,
interrupts — is required to reach this point. Runtime doesn't know or
care that this happens to be the same framework Harness runs internally;
from Runtime's side, this container looks exactly like the Pydantic AI
Runtime example.
Where the AgentCore-specific wiring actually is¶
Set Runtime aside and the two frameworks converge on the story from the
Pydantic AI chapter: Gateway, Identity,
Browser, and Code Interpreter are plain bedrock_agentcore/boto3 calls,
equally manual no matter which framework issued them. What Strands gets
that Pydantic AI doesn't is narrower than a blanket "better AgentCore
integration": AgentCoreMemorySessionManager turns on automatic
persistence to AgentCore Memory with a constructor argument instead of
hand-written create_event/retrieve_memories calls, and Harness
itself — Strands running headless inside a config-driven wrapper AWS
built around this one framework specifically. Outside those two, picking
Strands over Pydantic AI buys built-in session management, Swarm/Graph
orchestration objects, and a hook-based interrupt system — real
differences, but general framework ergonomics rather than anything
AgentCore-flavored.