Best practices for the AWS Strands Agents SDK — structuring prompts,
Scanned 9/20/2026
Install to Claude Code
npx -y skills add tstapler/dotfiles --skill strands-best-practices --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Strands Best Practices?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/tstapler-strands-best-practices)More formats (shields.io, HTML) on the badges page.
---
name: strands-best-practices
description: Best practices for the AWS Strands Agents SDK — structuring prompts,
multi-agent patterns, structured I/O, and splitting monolithic agents into specialists.
Use when designing or refactoring Strands-based agent systems.
---
# Strands Agents SDK Best Practices
## Core Philosophy
Strands is **model-driven**: agents decide what to do, tools define what's possible. Keep system prompts focused on a single domain of expertise. Fat prompts become brittle; specialists compose cleanly.
## `@tool` Decorator — How It Works
Strands builds the LLM tool spec from your function signature automatically:
```python
from strands import tool
@tool
def analyze_incident(incident_key: str, severity: str, days_back: int = 30) -> str:
"""Analyze a BTS incident and return classification recommendations.
Args:
incident_key: Jira ticket ID (e.g. BTS-12345)
severity: P1, P2, P3, or P4
days_back: Days back for comparison window
"""
...
```
- **First docstring paragraph** → tool description shown to the LLM (make it precise — this is the routing signal)
- **`Args:` section** → per-parameter descriptions in the tool spec
- **Type annotations** → JSON Schema types
- **Default values** → optional parameters
Override name/description or provide a full custom schema (e.g. for enums):
```python
@tool(name="get_weather", description="Retrieves weather forecast")
def weather_forecast(...): ...
@tool(inputSchema={"json": {"type": "object", "properties": {"shape": {"type": "string", "enum": ["circle", "rectangle"]}}, "required": ["shape"]}})
def calculate_area(shape: str): ...
```
## Agent-as-Tool Pattern (Primary Decomposition Strategy)
Wrap specialist agents in `@tool` functions. The orchestrator routes to them; each specialist has a short, focused system prompt.
```python
from strands import Agent, tool
@tool
def field_classification_specialist(context_json: str) -> str:
"""Assess missing classification fields and return a comment section if action needed."""
agent = Agent(
system_prompt=FIELD_CLASSIFICATION_PROMPT, # ~60 lines, single concern
tools=[get_transcript_field_suggestions, get_datadog_service_catalog, update_jira_field],
callback_handler=None, # suppress intermediate noise from appearing in orchestrator output
)
return str(agent(context_json))
# Orchestrator uses specialists as tools
orchestrator = Agent(
system_prompt=ORCHESTRATOR_PROMPT, # routing + combining logic only
tools=[field_classification_specialist, mitigated_closure_specialist, coe_specialist],
)
```
**Key rules:**
- `callback_handler=None` on sub-agents — prevents duplicate/noisy output in the orchestrator's stream
- Each specialist gets only the tools it needs; don't share tool lists
- The `@tool` docstring IS the routing description — make it unambiguous
## Structured Outputs Between Agents
Use Pydantic models for typed inter-agent contracts instead of string blobs:
```python
from pydantic import BaseModel
class WorkflowResult(BaseModel):
should_act: bool
comment_section: str | None # Markdown section for the combined comment
auto_updates: list[FieldUpdate] # Fields to apply before commenting
flag_for_human: str | None # Reason string if human review needed
# Agent produces structured output
result = agent("...", structured_output_model=WorkflowResult)
workflow_result: WorkflowResult = result.structured_output
```
**⚠ Known bug**: `structured_output_model` + `tools=` has known issues (GitHub #872, #891, #1032) where tool calls may not fire when structured output is active. A revamp is in progress. **Workaround**: use `structured_output_model` only on agents that don't need to call tools, or serialize the result as JSON string and deserialize on the receiving side.
**⚠ `str(AgentResult)` loses structured output**: when returning from an agent-as-tool, use `.model_dump_json()` to serialize the Pydantic model and parse it back on the orchestrator side:
```python
@tool
def my_specialist(query: str) -> str:
agent = Agent(system_prompt=PROMPT, tools=[...], callback_handler=None)
result = agent(query, structured_output_model=WorkflowResult)
return result.structured_output.model_dump_json() # serialize explicitly
# In orchestrator tool handler or post-processing:
workflow_result = WorkflowResult.model_validate_json(specialist_return_value)
```
## Passing Metadata Without Polluting LLM Context
Use `invocation_state` for configuration and metadata that tools need but the LLM shouldn't see in its token budget:
```python
result = orchestrator(message, invocation_state={
"issue_key": "BTS-12345",
"dry_run": True,
"jira_base_url": "https://betfanatics.atlassian.net/browse",
})
# Tools access it via ToolContext — never visible in the LLM prompt
@tool(context=True)
def add_jira_comment(body: str, tool_context: ToolContext) -> str:
issue_key = tool_context.invocation_state["issue_key"]
dry_run = tool_context.invocation_state.get("dry_run", False)
...
```
**⚠ `invocation_state` does NOT auto-propagate to sub-agents**: when a `@tool` spawns a sub-agent, the parent's `invocation_state` is not forwarded automatically. You must thread it explicitly:
```python
@tool(context=True)
def my_specialist(query: str, tool_context: ToolContext) -> str:
"""Run specialist agent."""
sub_agent = Agent(system_prompt=PROMPT, tools=[...], callback_handler=None)
# Manually forward needed state into the prompt or invocation_state
return str(sub_agent(query, invocation_state=tool_context.invocation_state))
```
## `Agent.__call__` Signature
```python
result: AgentResult = agent(
prompt, # str | list[ContentBlock] | list[Message] | None
invocation_state=None, # dict — context for tools, invisible to LLM
structured_output_model=None, # per-call override of agent-level default
)
# AgentResult fields:
result.stop_reason # why the agent stopped
result.message # final message
result.metrics # token counts, cycle durations, tool stats
result.structured_output # populated if structured_output_model was set
```
**Note**: `prompt` must be `str`, `ContentBlock` list, `Message` list, or `None` — not a raw dict or dataclass. Structured context must be serialized into the string or passed via `invocation_state`.
## Multi-Agent Patterns (When to Use Each)
| Pattern | Use When | How Context Flows |
|---------|----------|-------------------|
| **Agent-as-Tool** | Orchestrator delegates to specialists; results combine | Orchestrator collects returns, aggregates |
| **Graph** | Conditional routing with LLM-decided paths, cycles OK | Full conversation transcript shared across nodes |
| **Swarm** | Agents hand off to peers; exploration/multidisciplinary | Shared context with prior agent knowledge |
| **Workflow (DAG)** | Repeatable pipeline, parallel steps, deterministic | Task-specific context from dependencies only |
For structured processes with one combined output (e.g. incident management): **Agent-as-Tool** is correct — specialists are called by an orchestrator that owns the final assembly.
## Monitoring Sub-Agent Tool Use (Async Streaming)
Bubble sub-agent events up through the tool layer using `stream_async`:
```python
@tool
async def my_specialist(query: str) -> AsyncIterator:
"""Run specialist agent and stream its progress."""
agent = Agent(system_prompt=PROMPT, tools=[...], callback_handler=None)
result = None
async for event in agent.stream_async(query):
yield event # bubbles up tool_stream_event to parent callback
if "result" in event:
result = event["result"]
yield str(result)
```
Graph/Swarm emit additional events: `multiagent_node_start`, `multiagent_node_stop`, `multiagent_handoff`, `multiagent_result`.
## Conversation Management (Context Window Control)
Three built-in strategies — pick based on session length and memory needs:
```python
from strands.agent.conversation_manager import (
SlidingWindowConversationManager,
SummarizingConversationManager,
)
# Default: drop oldest messages, truncate large tool results
agent = Agent(conversation_manager=SlidingWindowConversationManager(
window_size=20,
should_truncate_results=True,
per_turn=True, # proactive management before each model call
))
# Long-running agents that need early context: summarize instead of drop
agent = Agent(conversation_manager=SummarizingConversationManager(
summary_ratio=0.3,
preserve_recent_messages=10,
summarization_agent=Agent(model=haiku_model), # use a cheap model for summaries
))
# Manual control (no automatic truncation)
from strands.agent.conversation_manager import NullConversationManager
agent = Agent(conversation_manager=NullConversationManager())
```
For short-lived per-incident agents (our use case): `SlidingWindowConversationManager` with `per_turn=True` is appropriate — each agent run is bounded and fresh.
**Note**: Native token counting is not yet exposed (GitHub #1197); access `agent.messages` for manual inspection.
## Singleton vs Fresh Instance for Agent-as-Tool
```python
# Singleton: shared conversation history across all calls to this tool
_specialist = Agent(system_prompt="...", tools=[...])
@tool
def my_specialist(query: str) -> str:
return str(_specialist(query)) # history accumulates — useful for stateful sessions
# Fresh instance: clean slate each call (our pattern for incident agents)
@tool
def my_specialist(query: str) -> str:
agent = Agent(system_prompt="...", tools=[...], callback_handler=None)
return str(agent(query)) # no cross-contamination between incidents
```
**For stateless, parallelized incident processing: always use fresh instances.**
## Prompt Sizing Guidelines
No SDK-imposed limit — the constraint is the model's context window. Practical guidance:
- **Orchestrator**: routing logic + cross-cutting rules only (~50-80 lines / ~200-400 tokens)
- **Specialist**: one workflow domain only (~40-80 lines / ~100-500 tokens)
- **Rule of thumb**: if a prompt has two `---` section separators for unrelated concerns, it should be two agents
## When to Split a Monolithic Agent
Split when **any** of these are true:
1. System prompt exceeds ~2,000 tokens with clearly distinct domain sections
2. Toolbelt has 15+ tools and wrong-tool selection is a recurring problem
3. Context window overflows regularly on complex runs
4. Some sub-tasks can run concurrently (use async)
5. Different domains warrant different model capabilities or costs
6. Multiple teams need to independently maintain different capabilities
**Model optimization** — the orchestrator only needs to route; use a cheap/fast model there. Specialist sub-agents can use more capable models where their domain requires it:
```python
orchestrator = Agent(
model=BedrockModel(model_id="amazon.nova-lite-v1:0"), # cheap router
tools=[field_classification_specialist, mitigated_closure_specialist],
)
# Each specialist uses its own model (defaulting to Sonnet)
```
## Splitting a Monolithic Prompt
1. Identify independent "workflows" or "concerns" in the prompt
2. Each concern becomes a specialist with its own system prompt + minimal tool set
3. Cross-cutting rules (comment formatting, unassigned handling, section ordering) stay in the orchestrator
4. Define a `WorkflowResult` Pydantic model as the contract; serialize with `.model_dump_json()` across the agent-as-tool boundary
5. Add `context=True` to specialist `@tool` functions so they can forward `invocation_state`
6. Orchestrator collects results, applies auto-updates, assembles and posts one combined output
## Known Limitations (as of 2026-02)
| Issue | Impact | Workaround |
|-------|--------|------------|
| `structured_output_model` + `tools` conflicts (GH #872, #891, #1032) | Tool calls may not fire when structured output active | Separate output-producing agents from tool-calling agents; serialize via JSON string |
| `invocation_state` not auto-propagated to sub-agents | Sub-agent tools can't see parent state | Pass `tool_context.invocation_state` explicitly to sub-agent `invocation_state=` |
| `str(AgentResult)` drops `structured_output` | Pydantic models lost across agent-as-tool boundary | Use `.model_dump_json()` / `model_validate_json()` explicitly |
| Structured output is Python-only | No TypeScript structured output | N/A |
## Reference
- [Agents as Tools](https://strandsagents.com/latest/documentation/docs/user-guide/concepts/multi-agent/agents-as-tools/)
- [Multi-Agent Patterns](https://strandsagents.com/latest/documentation/docs/user-guide/concepts/multi-agent/multi-agent-patterns/)
- [Structured Output](https://strandsagents.com/latest/documentation/docs/user-guide/concepts/agents/structured-output/)
- [Custom Tools](https://strandsagents.com/latest/documentation/docs/user-guide/concepts/tools/custom-tools/)
- [Callback Handlers / Streaming](https://strandsagents.com/latest/documentation/docs/user-guide/concepts/streaming/callback-handlers/)
- [Agent API Reference](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/)
- [strands-agents/samples](https://github.com/strands-agents/samples)
- [Deep Agents pattern (community)](https://www.pierreange.ai/blog/deep-agents-using-strands)Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!