DEV Community

Morgan Willis
Morgan Willis

Posted on

AI Agents Don’t Need Complex Workflows. Build One in Python in 10 Minutes

Building an AI agent in Python can be as easy as giving a model some tools and letting it figure out the rest.

Most agent setups start the same way: you wire up tool calls, manage retries, track state, and write the routing logic that decides what happens when. It works, but it's brittle. Every time the workflow changes, you're back in the code rewiring the sequence.

Strands is an open-source Python SDK built around a different idea.

Instead of you hardcoding the orchestration, you let the model handle it. You give it tools and a goal, and the SDK takes care of the agent loop, tool execution, and conversation state. You can go from zero to a working agent in about 10 minutes, and the same primitives that make a simple agent easy to build can be combined to give you more complex setups when you need them.

A Model Driven Approach to AI Agents

The Strands team calls this a model-driven approach. The LLM is the orchestrator and you define the capabilities it can use.

In practice, your agent code is mostly plugging in the different desired components. Here's what a basic agent looks like:

from strands import Agent

agent = Agent(system_prompt="You are a helpful assistant.")
response = agent("What's the capital of France?")
print(response)
Enter fullscreen mode Exit fullscreen mode

That's a working agent. It uses Amazon Bedrock as the model provider by default, but you can swap in any supported provider. We'll use OpenAI for the rest of this post.

Setting Up a Python AI Agent with OpenAI

Install the SDK with the OpenAI extension:

pip install 'strands-agents[openai]' strands-agents-tools
Enter fullscreen mode Exit fullscreen mode

Set your API key:

export OPENAI_API_KEY=your_api_key_here
Enter fullscreen mode Exit fullscreen mode

Now create an agent that uses OpenAI:

import os
from strands import Agent
from strands.models.openai import OpenAIModel

model = OpenAIModel(
    client_args={"api_key": os.environ["OPENAI_API_KEY"]},
    model_id="gpt-4o",
)

agent = Agent(
    model=model,
    system_prompt="You are a helpful assistant.",
)

response = agent("What's the capital of France?")
print(response)
Enter fullscreen mode Exit fullscreen mode

Run it with python agent.py and you should get a response. The agent handles the API call to the OpenAI model and response parsing for you.

So far, this doesn't have any tools it can use to interact with the real world. It does however have a main agent loop handled, and is the starting point from which you will build a more capable agent.

The Building Blocks of an AI Agent

Strands has a couple of main building blocks you should be aware of: agents, tools, models, and hooks. Understanding how they fit together is most of what you need to know.

Models

A model is the LLM provider. Strands supports Bedrock (the default), OpenAI, Anthropic, Google Gemini, Meta Llama, Ollama for local models, and several others. You configure the model once and pass it to your agent.

model = OpenAIModel(
    client_args={"api_key": os.environ["OPENAI_API_KEY"]},
    model_id="gpt-4o",
    params={"temperature": 0.3},
)
Enter fullscreen mode Exit fullscreen mode

You can set inference parameters using params. One good one to note is temperature. Use a lower temperature for factual tasks or a higher temperature for creative ones. Other supported inference parameters depend on the model.

Giving Your Agent Tools

Tools are Python functions that extend what the agent can do beyond generating text.

Here's a custom tool to return weather data:

from strands import tool

@tool
def get_weather(location: str) -> str:
    """Get the current weather for a location.

    Args:
        location: City name, e.g. "Seattle"
    """
    # In a real app, this would call a weather API
    return f"Weather in {location}: Sunny, 72°F"
Enter fullscreen mode Exit fullscreen mode

The @tool decorator is all you need. The docstring matters because the model uses it along with the function's type hints to decide when to call this function and what arguments to pass. Clear docstrings lead to better tool usage.

Strands also ships with a community tools package that includes common utilities:

from strands_tools import calculator, python_repl, http_request
Enter fullscreen mode Exit fullscreen mode

These give your agent the ability to do math, run Python code, and make HTTP requests out of the box.

Wiring It All Together

The agent brings everything together. You give it a model, tools, and a system prompt:

agent = Agent(
    model=model,
    tools=[get_weather, calculator],
    system_prompt="You are a helpful assistant that can check weather and do math.",
)
Enter fullscreen mode Exit fullscreen mode

When you call the agent with a message, it enters an agent loop. The model reads the message, decides if it needs to use any tools, calls them if it does, reads the results, and either calls more tools or generates a final response. This loop continues until the model decides it has enough information to answer.

You don't write any of that loop logic, the SDK handles it for you.

Hooking into the Agent Lifecycle

Hooks let you subscribe to lifecycle events in the agent loop without modifying the agent's core logic. The agent emits events at specific points during execution: before and after model calls, before and after tool calls, when messages are added, and at the start and end of each invocation. You register callbacks for the events you care about.

Here's a hook that logs every tool call:

from strands.hooks import BeforeToolCallEvent, AfterToolCallEvent

def log_tool_call(event: BeforeToolCallEvent):
    print(f"Calling tool: {event.tool_use['name']}")
    print(f"With input: {event.tool_use['input']}")

def log_tool_result(event: AfterToolCallEvent):
    print(f"Tool {event.tool_use['name']} completed")

agent = Agent(
    model=model,
    tools=[get_weather, calculator],
    system_prompt="You are a helpful assistant.",
)

agent.add_hook(log_tool_call, BeforeToolCallEvent)
agent.add_hook(log_tool_result, AfterToolCallEvent)
Enter fullscreen mode Exit fullscreen mode

The available events cover the full lifecycle:

  • BeforeInvocationEvent / AfterInvocationEvent for the overall request
  • BeforeModelCallEvent / AfterModelCallEvent for LLM calls
  • BeforeToolCallEvent / AfterToolCallEvent for tool execution
  • MessageAddedEvent when messages are added to conversation history

Hooks are useful for logging, metrics, basic guardrails, and adding logic to your agents lifecycle. You can also cancel a tool call from a BeforeToolCallEvent hook by setting event.cancel_tool to a message, which stops the tool from executing and sends that message back to the model as an error. For example, you can check the tool name and arguments, and block it if something looks wrong.

Once you start building more complex agents, you'll find yourself wanting to bundle related hooks and tools into reusable packages. We'll get to that later in this post.

Building a Multi-Tool Agent

Here's a more complete example. This agent has a few tools as examples to do things like look up weather, do calculations, and count letters in words. The actual tools themselves don't matter much for learning how to build an agent, as those will be unique to your specific use case. For now, we are just exploring how to wire all of the pieces together into an agent that does things:

import os
from strands import Agent, tool
from strands.models.openai import OpenAIModel
from strands_tools import calculator

@tool
def get_weather(location: str) -> str:
    """Get the current weather for a location.

    Args:
        location: City name, e.g. "Seattle"
    """
    return f"Weather in {location}: Sunny, 72°F"

@tool
def letter_counter(word: str, letter: str) -> int:
    """Count occurrences of a specific letter in a word.

    Args:
        word: The word to search in
        letter: The single letter to count
    """
    return word.lower().count(letter.lower())

model = OpenAIModel(
    client_args={"api_key": os.environ["OPENAI_API_KEY"]},
    model_id="gpt-4o",
)

agent = Agent(
    model=model,
    tools=[get_weather, calculator, letter_counter],
    system_prompt="You are a helpful assistant.",
)

response = agent("""I have a few questions:
1. What's the weather in Seattle?
2. What is 1547 * 382?
3. How many r's are in "strawberry"?
""")
print(response)
Enter fullscreen mode Exit fullscreen mode

The agent will call each tool as needed, collect the results, and give you a single coherent response. You didn't have to write any routing logic or decide which tool to call for which question. The model handles that.

How AI Agents Remember Conversations

Agents maintain conversation context automatically within a running process. Each call to the agent adds to the conversation history, so the model remembers what was said earlier:

agent("My name is Morgan.")
response = agent("What's my name?")
print(response)  # Will remember "Morgan"
Enter fullscreen mode Exit fullscreen mode

This works across multiple turns without any extra code because the SDK manages the message history internally.

That said, this memory only lives as long as the agent's process lives. If you're running a script locally and it exits, the history is gone. If you're hosting an agent behind an API, the process restarts with every request so message history is not maintained.

This is because LLMs are stateless by default, and the conversation history that makes them feel stateful is just a list of messages that gets sent with every request.

For anything beyond a local script, you need to persist that history somewhere.

Session Managers

Strands provides session managers that save and restore conversation state across invocations. The simplest option is FileSessionManager, which writes session data to the local filesystem:

from strands import Agent
from strands.session.file_session_manager import FileSessionManager

session_manager = FileSessionManager(
    session_id="my-session",
    storage_dir="./sessions",
)

agent = Agent(
    model=model,
    system_prompt="You are a helpful assistant.",
    session_manager=session_manager,
)

# First run
agent("My name is Morgan.")

# Later, even after a restart, the agent remembers
agent = Agent(
    model=model,
    system_prompt="You are a helpful assistant.",
    session_manager=FileSessionManager(
        session_id="my-session",
        storage_dir="./sessions",
    ),
)
response = agent("What's my name?")
print(response)  # Still remembers "Morgan"
Enter fullscreen mode Exit fullscreen mode

FileSessionManager stores each message as a JSON file on disk. It works well for local development. For hosted setups, you'd swap in a session manager backed by a database or a managed memory service like Amazon Bedrock AgentCore Memory. The integration pattern is the same, but you'd need to provision the infrastructure for the data store.

Managing the Context Window

There's another problem that shows up in longer conversations. Every LLM has a context window, which is the maximum amount of tokens it can process in a single request. Your system prompt, the full conversation history, tool definitions, and the model's response all have to fit inside that window.

For short conversations this isn't an issue. But if your agent runs for dozens of turns, or if tools return large results, the conversation history can grow past what the model can handle.

Strands provides a few out of the box conversation managers to deal with this:

The sliding window manager keeps the most recent messages and drops the oldest ones when the history gets too long:

from strands import Agent
from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager

agent = Agent(
    model=model,
    conversation_manager=SlidingWindowConversationManager(
        window_size=40,  # keep the last 40 messages
    ),
)
Enter fullscreen mode Exit fullscreen mode

This is simple and predictable: old messages fall off the end. The downside is that the agent loses context from earlier in the conversation. If a user said something important 50 messages ago, it's gone.

The summarizing manager takes a different approach. Instead of dropping old messages, it summarizes them and then keeps the summary:

from strands import Agent
from strands.agent.conversation_manager.summarizing_conversation_manager import SummarizingConversationManager

agent = Agent(
    model=model,
    conversation_manager=SummarizingConversationManager(
        summary_ratio=0.3,  # summarize the oldest 30% of messages
        preserve_recent_messages=10,  # always keep the last 10
    ),
)
Enter fullscreen mode Exit fullscreen mode

When the context gets too large, the summarizing manager takes the context and generates a summary using the LLM. It then replaces those messages with the summary. The agent keeps the gist of what happened earlier without the full verbatim history. This costs an extra model call when summarization triggers, but it preserves more context than a simple sliding window.

Which one you pick depends on your use case. For short, focused interactions, the sliding window is fine. For longer sessions where earlier context matters, the summarizing manager is worth the extra cost.

There are also more advanced techniques you can use to manage your context window. Figuring out all the ways to manage context is called context engineering, and is an entire discipline in the AI engineering world. For simple agents, sliding window or summarization are good places to start.

How the Agent Loop Ties It All Together

Stack these pieces together and you get a pretty capable agent without writing much code. A model handles reasoning and tool selection.

  • Tools extend what the agent can do.

  • Hooks give you control over the lifecycle.

  • Session managers persist state across restarts.

  • Conversation managers keep the context window under control.

The agent loop ties it all together: the model calls tools, reads results, handles errors by trying a different approach, and returns the final response.

This is the starting point. Once you start building more advanced agents you'll need more capabilities. That's where plugins come in.

Plugins are classes that bundle hooks and tools together into behavioral modifications you can attach to any agent. The SDK ships with a few built-in plugins that show what this looks like in practice, and you can build your own custom plugins as needed.

Extending Your Agent with Plugins

Steering

Steering is a plugin that evaluates the agent's output and sends corrective feedback when the response drifts from your guidelines. You give it a system prompt that defines the rules, and it uses a separate LLM call to judge each response before it reaches the user.

from strands import Agent
from strands.vended_plugins.steering import LLMSteeringHandler

agent = Agent(
    model=model,
    tools=[get_weather, calculator],
    plugins=[
        LLMSteeringHandler(
            system_prompt="Ensure all responses are professional and concise. "
            "Reject any response that includes speculation or unverified claims."
        ),
    ],
)
Enter fullscreen mode Exit fullscreen mode

Under the hood, the steering plugin hooks into the agent's lifecycle using a BeforeToolCallEvent hook. It intercepts tool calls, runs them through the evaluator, and returns one of three actions: proceed (let it through), guide (reject with feedback so the agent retries), or interrupt (escalate to a human). You don't write any of that logic. You just describe the rules in the system prompt for the steering handler.

This is useful for enforcing tone in customer-facing agents, preventing agents from calling tools with dangerous arguments, or evaluating if agents are following directions.

Skills

Skills are modular instructions that agents discover and activate at runtime. They follow the Agent Skills specification, an open standard for packaging agent capabilities as folders containing instructions, scripts, and resources.

A skill might teach an agent how to perform a code review following your team's conventions, how to deploy to a specific environment, or how to write content in a particular style.

The agent only loads a skill's metadata (name and description) initially. When the agent decides a skill is relevant to the current task, it activates it and pulls in the full instructions. This keeps the context window clean since the agent only loads what it needs.

Building Your Own Plugins

You can also build custom plugins. A plugin is a class that extends Plugin and uses @hook and @tool decorators:

from strands import Agent, tool
from strands.plugins import Plugin, hook
from strands.hooks import BeforeToolCallEvent, AfterToolCallEvent

class LoggingPlugin(Plugin):
    """A plugin that logs all tool calls and provides a utility tool."""

    name = "logging-plugin"

    @hook
    def log_before_tool(self, event: BeforeToolCallEvent) -> None:
        """Called before each tool execution."""
        print(f"[LOG] Calling tool: {event.tool_use['name']}")
        print(f"[LOG] Input: {event.tool_use['input']}")

    @hook
    def log_after_tool(self, event: AfterToolCallEvent) -> None:
        """Called after each tool execution."""
        print(f"[LOG] Tool completed: {event.tool_use['name']}")

    @tool
    def debug_print(self, message: str) -> str:
        """Print a debug message.

        Args:
            message: The message to print
        """
        print(f"[DEBUG] {message}")
        return f"Printed: {message}"

# Using the plugin
agent = Agent(plugins=[LoggingPlugin()])
agent("Calculate 2 + 2 and print the result")
Enter fullscreen mode Exit fullscreen mode

When the agent initializes, it scans the plugin for @hook and @tool methods and registers them automatically. You can stack multiple plugins on the same agent, and each one manages its own hooks and state without interfering with the others.

Beyond plugins, Strands supports multi-agent patterns where agents invoke other agents as tools, MCP (Model Context Protocol) servers for connecting to external tool providers, and structured output for getting typed responses. These are all topics for another post. The point is that the same primitives (agents, tools, models, hooks) compose into more complex setups without requiring you to learn a different API.

Install and Build Your First AI Agent

The fastest path from here:

  1. Install: pip install 'strands-agents[openai]' strands-agents-tools
  2. Set your API key: export OPENAI_API_KEY=your_key
  3. Write a simple agent with one custom tool
  4. Run it and see what happens

The Strands documentation has more examples, including multi-agent setups, observability, and production deployment patterns. The GitHub repo has the source and community tools.

The SDK is open source and actively developed. If you've been putting off building an agent because the frameworks felt heavy, give Strands a look. The barrier to entry is low, and it provides enough composability to keep up with you as your use case gets more complex.

Top comments (2)

Collapse
 
isaacclarke profile image
SlavaLobozov

Love the "let the model handle it" philosophy! Strands looks really clean - especially the decorator approach for tools. Great writeup!)

Collapse
 
morganwilliscloud profile image
Morgan Willis

Yeah it’s super easy to use!