| name | code-strands |
| 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
For prompt design for Strands system prompts and tool descriptions, apply the meta-prompt-engineering skill.
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:
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):
@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.
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,
tools=[get_transcript_field_suggestions, get_datadog_service_catalog, update_jira_field],
callback_handler=None,
)
return str(agent(context_json))
orchestrator = Agent(
system_prompt=ORCHESTRATOR_PROMPT,
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
For typing inter-agent contracts with Pydantic models and parse-don't-validate, apply the type-driven-design skill.
Structured Outputs Between Agents
Use Pydantic models for typed inter-agent contracts instead of string blobs:
from pydantic import BaseModel
class WorkflowResult(BaseModel):
should_act: bool
comment_section: str | None
auto_updates: list[FieldUpdate]
flag_for_human: str | None
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:
@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()
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:
result = orchestrator(message, invocation_state={
"issue_key": "BTS-12345",
"dry_run": True,
"jira_base_url": "https://betfanatics.atlassian.net/browse",
})
@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:
@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)
return str(sub_agent(query, invocation_state=tool_context.invocation_state))
Agent.__call__ Signature
result: AgentResult = agent(
prompt,
invocation_state=None,
structured_output_model=None,
)
result.stop_reason
result.message
result.metrics
result.structured_output
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:
@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
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.
For optimizing context windows, compaction, and sub-agent isolation, apply the meta-context-engineering skill.
Conversation Management (Context Window Control)
Three built-in strategies โ pick based on session length and memory needs:
from strands.agent.conversation_manager import (
SlidingWindowConversationManager,
SummarizingConversationManager,
)
agent = Agent(conversation_manager=SlidingWindowConversationManager(
window_size=20,
should_truncate_results=True,
per_turn=True,
))
agent = Agent(conversation_manager=SummarizingConversationManager(
summary_ratio=0.3,
preserve_recent_messages=10,
summarization_agent=Agent(model=haiku_model),
))
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
_specialist = Agent(system_prompt="...", tools=[...])
@tool
def my_specialist(query: str) -> str:
return str(_specialist(query))
@tool
def my_specialist(query: str) -> str:
agent = Agent(system_prompt="...", tools=[...], callback_handler=None)
return str(agent(query))
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
For selecting which Claude model to use for orchestrators vs. specialists, apply the meta-model-selection skill.
When to Split a Monolithic Agent
Split when any of these are true:
- System prompt exceeds ~2,000 tokens with clearly distinct domain sections
- Toolbelt has 15+ tools and wrong-tool selection is a recurring problem
- Context window overflows regularly on complex runs
- Some sub-tasks can run concurrently (use async)
- Different domains warrant different model capabilities or costs
- 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:
orchestrator = Agent(
model=BedrockModel(model_id="amazon.nova-lite-v1:0"),
tools=[field_classification_specialist, mitigated_closure_specialist],
)
Splitting a Monolithic Prompt
- Identify independent "workflows" or "concerns" in the prompt
- Each concern becomes a specialist with its own system prompt + minimal tool set
- Cross-cutting rules (comment formatting, unassigned handling, section ordering) stay in the orchestrator
- Define a
WorkflowResult Pydantic model as the contract; serialize with .model_dump_json() across the agent-as-tool boundary
- Add
context=True to specialist @tool functions so they can forward invocation_state
- 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 |
Related Skills
| Skill | When to apply |
|---|
meta-prompt-engineering | Crafting focused system prompts and tool descriptions for Strands agents |
meta-context-engineering | Optimizing context windows, compaction, and sub-agent isolation |
meta-model-selection | Choosing the right Claude model for orchestrators vs. specialists |
type-driven-design | Typing inter-agent Pydantic contracts; parse-don't-validate at boundaries |
python-development | Python standards (uv, Pydantic, async) for Strands tool implementations |
security-review | Auditing tool permissions, invocation_state exposure, and agent trust boundaries |
Reference