Strands internals: a framework as a design review

This book built an agent loop by hand in Part 1 and never picked up a framework, on the theory that you understand a tool best after building the thing it replaces. Part 10 collects that debt. It reads the open protocols and frameworks (Strands, MCP, A2A) as a design review of everything the book built from scratch: for each, what it does for you, what it hides, and where the hand-built version was already doing the real work. This first chapter takes Strands, AWS's open-source agent SDK, and finds that its headline features are thin, well-made conveniences over exactly the loop you already own.

What Strands is

Strands Agents is an open-source (Apache 2.0) agent SDK in Python and TypeScript, and, unlike a prescriptive platform, it is model-driven: you give an agent a model, a system prompt, and a set of tools, and it runs the loop, letting the model choose each step. That is the Part 1 definition of an agent, word for word, which is the first and most reassuring finding of the review: the framework agrees with the book about what an agent is. Two facts make Strands worth reading over a random library. It is what AWS uses internally for production agents (Amazon Q Developer and several service teams), so it is battle-tested rather than aspirational; and it is model-agnostic, speaking to Bedrock, Anthropic's API directly, and others, so adopting it does not marry you to one model plane.

Lab 10.1: the framework, in 60 lines

The fastest way to demystify a framework is to build its core, so the lab implements the three primitives Strands actually ships, over the Part 1 model interface:

python3 mini_framework.py
--- registered tool, derived from the function ---
  name='lookup' desc='Look up a fact about a topic.'
  schema params=['topic']

--- a coordinator delegating to a researcher sub-agent ---
[coordinator] task: How fast does a microVM boot? Ask a specialist.
[coordinator] calls ask_researcher({'question': 'how fast does a microVM boot?'})
    [researcher] task: how fast does a microVM boot?
    [researcher] calls lookup({'topic': 'firecracker'})
    [researcher] -> Firecracker boots a microVM in under 125ms.
[coordinator] -> Per the researcher: a microVM boots in under 125ms.

Three primitives, three demystifications:

  • Tool registration by decorator. The @tool decorator turns a plain function into a tool by introspecting it: the name from the function name, the description from the docstring, the schema from the signature. Strands' real decorator does exactly this with more type fidelity, but the mechanism is not magic, it is inspect over a function, and it is the same tool definition the book has used all along, generated instead of hand-written.
  • The Agent object. An Agent is the Part 1 loop wrapped in a class that holds a model and a toolbox and exposes run(). Strands' Agent adds session persistence, streaming, and hooks, but the beating heart is the five-beat cycle from Chapter 7, unchanged.
  • Agents-as-tools. The lab's punchline: a coordinator delegates to a researcher, and the trace shows the researcher running its own loop (calling its own lookup tool) nested inside the coordinator's. The mechanism is one function: agent_as_tool wraps a sub-agent's run as an ordinary tool. Delegation is not a special execution mode; it is a loop calling a loop, which is the entire content of a framework's multi-agent story.

The multi-agent primitives, reviewed

Strands ships four multi-agent primitives, and the topology chapter already named the shapes they implement, which lets us review them as a set:

Strands primitiveThe topology it isBuilt by hand in
Agents-as-toolsHierarchical delegationThe lab above, in one function
SwarmHandoff-style delegationChapter 22's hierarchical shape
GraphPipeline generalized to a DAGChapter 22's pipeline
WorkflowStructured multi-step flowChapter 45's state machine, in miniature

The review's verdict: these are real conveniences that save real code, and none of them is a capability the book lacked. The graph primitive is a nicer way to express a pipeline than wiring it yourself; the swarm primitive packages handoffs you could write by hand. Adopt them to write less glue, not because they unlock anything the loop could not do, and read their source when you want to know exactly what your glue became.

What a framework does and does not give you

The honest accounting, because "use a framework" and "build from scratch" is a false binary. Strands gives you: the loop written and tested, tool registration ergonomics, session persistence, streaming, provider abstraction, and the multi-agent primitives, real time saved on the parts of the book that were mechanical. Strands does not give you, and this is the load-bearing point, the control plane Hive owns: the budget ledger, the token governor, the scheduling layer, the verification mesh, the memory pipeline, the trust boundaries. A framework runs one agent's loop well; a platform runs a governed, budgeted, verified, multi-tenant fleet of them, and the second is most of this book.

This is the same buy-versus-build shape as AgentCore, one altitude down: the runtime hosted the loop, the framework structures the loop's inside, and both leave the planes around the loop to you. The right posture is to adopt a framework for the loop's ergonomics and keep the platform's control concerns as your architecture, which is exactly how Hive would use Strands: as the worker's inner loop, inside the scheduler, ledger, and governor the book built.

Don't be confused: framework vs platform. A framework (Strands, LangGraph) is a library that structures how one agent runs: its loop, its tools, its sub-agents. A platform (what this book builds) is a system that runs many agents safely at scale: budgets, isolation, scheduling, verification, tenancy. You import a framework; you operate a platform. Confusing them is how a team adopts Strands, gets a great single-agent loop, and is then surprised that it did not come with a budget ledger or a multi-tenant quota, because those were never a framework's job.

Full source

"""Lab 10.1: a mini agent framework, to read a real one as a design review.

Agent frameworks (Strands, LangGraph, ...) look like magic until you
build the core in 60 lines over the Part 1 loop. This lab implements the
three primitives a framework like Strands actually ships:

  1. @tool: register a function as a tool, deriving name/description/
     schema from the function itself,
  2. Agent: the Part 1 loop, packaged as a reusable object,
  3. agents-as-tools: expose one agent to another as a tool, so a
     coordinator can delegate to a specialist. A sub-agent turns out to
     be just a tool whose implementation runs another loop.

The demo runs a coordinator that delegates to a researcher sub-agent,
which runs its own loop with its own tool. The nested trajectory prints
so you can see delegation is not special: it is a loop calling a loop.

Reuses the Part 1 model interface. Standard library only. Deterministic.
"""

from __future__ import annotations

import inspect

from loop_agent import ModelTurn, ScriptedModel, Tool, ToolCall


def tool(fn) -> Tool:
    """Decorator: build a Tool from a function's name, docstring, and args."""
    params = {
        "type": "object",
        "properties": {name: {"type": "string"}
                       for name in inspect.signature(fn).parameters},
        "required": list(inspect.signature(fn).parameters),
    }
    return Tool(name=fn.__name__,
                description=(fn.__doc__ or "").strip(),
                params=params, fn=fn)


class Agent:
    """The Part 1 loop, packaged: a model, a toolbox, and run()."""

    def __init__(self, name: str, model, tools: list[Tool], depth: int = 0):
        self.name = name
        self.model = model
        self.toolbox = {t.name: t for t in tools}
        self.depth = depth

    def _log(self, msg: str) -> None:
        print(f"{'    ' * self.depth}[{self.name}] {msg}")

    def run(self, task: str, max_turns: int = 8) -> str:
        messages = [{"role": "user", "content": task}]
        self._log(f"task: {task}")
        for _ in range(max_turns):
            reply = self.model.respond(messages)
            messages.append({"role": "assistant", "content": reply.text})
            if reply.stop_reason == "end_turn":
                self._log(f"-> {reply.text}")
                return reply.text
            results = []
            for call in reply.tool_calls:
                self._log(f"calls {call.name}({call.args})")
                out = self.toolbox[call.name].fn(**call.args)
                results.append({"tool_call_id": call.id, "content": out})
            messages.append({"role": "tool", "results": results})
        raise RuntimeError("max turns")


def agent_as_tool(agent: Agent, name: str, description: str) -> Tool:
    """Expose an Agent as a Tool: calling it runs the sub-agent's loop."""
    return Tool(name=name, description=description,
                params={"type": "object",
                        "properties": {"question": {"type": "string"}},
                        "required": ["question"]},
                fn=lambda question: agent.run(question))


# --- the specialist: a researcher with one tool ---------------------------

@tool
def lookup(topic: str) -> str:
    """Look up a fact about a topic."""
    facts = {"firecracker": "Firecracker boots a microVM in under 125ms.",
             "quota": "The account TPM quota caps fleet throughput."}
    return facts.get(topic.lower(), "no fact found")


researcher = Agent(
    name="researcher",
    model=ScriptedModel([
        ModelTurn("tool_use", "Looking it up.",
                  [ToolCall("r1", "lookup", {"topic": "firecracker"})]),
        ModelTurn("end_turn",
                  text="Firecracker boots a microVM in under 125ms."),
    ]),
    tools=[lookup],
    depth=1,
)

# --- the coordinator: delegates to the researcher as a tool ---------------

coordinator = Agent(
    name="coordinator",
    model=ScriptedModel([
        ModelTurn("tool_use", "I'll ask the researcher.",
                  [ToolCall("c1", "ask_researcher",
                            {"question": "how fast does a microVM boot?"})]),
        ModelTurn("end_turn",
                  text="Per the researcher: a microVM boots in under 125ms."),
    ]),
    tools=[agent_as_tool(researcher, "ask_researcher",
                         "Ask the research specialist a question.")],
)


if __name__ == "__main__":
    print("--- registered tool, derived from the function ---")
    print(f"  name={lookup.name!r} desc={lookup.description!r}")
    print(f"  schema params={list(lookup.params['properties'])}\n")

    print("--- a coordinator delegating to a researcher sub-agent ---")
    answer = coordinator.run("How fast does a microVM boot? Ask a specialist.")

    print(f"\nfinal: {answer}")
    print("\ndelegation was not special: the coordinator called a tool whose "
          "implementation ran another Part 1 loop. That is all a framework's "
          "'agents-as-tools' primitive is.")

👉 Next: MCP end to end, the tool protocol you already built from scratch in Part 3, now examined as an ecosystem: hosting servers, the economics of tool discovery, and when the protocol is overhead a plain function call would avoid.