Skip to main content

Building Strands Agents

Strands Agents is AWS's open-source Python agent SDK. AgentVisor™ supports Strands natively — your agents run inside a hardened sandbox with policy enforcement, credential brokering, and durable checkpointing backed by Temporal.

This guide covers building Strands agents for AgentVisor.

Prerequisites

  • Python 3.10+
  • Strands Agents installed (pip install strands-agents, or strands-agents[litellm] — see Model Providers)
  • AgentVisor CLI installed
  • An API key for whichever model provider your agent uses (e.g. an Anthropic API key for the example below)

Basic Structure

Every Strands project for AgentVisor needs:

  1. mav-agent-config.yaml: Required — selects the strands framework provider (see below)
  2. agent.py: Python file defining the root Strands agent
  3. requirements.txt: Python dependencies

Why mav-agent-config.yaml Is Required

Unlike LangGraph (langgraph.json), CrewAI (crewai.yaml), and Google ADK (agent.yaml), Strands has no declarative discovery file — it is code-first. Its own experimental agent_config.py (name/model/prompt/tools only) is too thin to use as a discovery mechanism: it can't select a non-default model provider and can't express multi-agent Graphs or Swarms.

Rather than inventing a bespoke strands.yaml, Strands relies entirely on the framework.provider override that mav-agent-config.yaml already provides for every other framework. Strands is simply the first provider where this field is required rather than an optional override over auto-detection:

# mav-agent-config.yaml
framework:
provider: "strands"

Without this file, AgentVisor has no way to select the Strands provider — there is no discovery file for it to auto-detect.

Minimal Agent Example

# agent.py
from strands import Agent
from agentvisor.strands import register_agent

root_agent = Agent(
name="assistant",
system_prompt="You are a helpful assistant. Answer questions clearly and concisely.",
)

register_agent(root_agent)

The register_agent() call enables schema extraction — AgentVisor uses this to expose agent metadata through its API. It also makes the agent resolvable by name via AGENTVISOR_AGENT_NAME.

agent.py at the project root is a hard convention, not just a naming suggestion. Both schema discovery (agentvisor.strands.discover, invoked by schema_cli during dynamic extraction) and the runner's final resolution tier (agent.py:root_agent, see Agent Resolution below) import exactly that file by that name from the project's AGENTVISOR_SOURCE_DIR. If your register_agent() call lives in a different module, import it from agent.py (or call register_agent() there directly) so both paths can find it. This also matters for the build-time schema cache (.agentvisor/schema.json, baked into custom guest images) — it's invalidated by changes to agent.py's own modification time, so restructuring your agent construction across other modules without ever touching agent.py won't trigger a cache rebuild.

Agent Resolution

Because there's no discovery file, the runner (python3 -m agentvisor.strands.runner) resolves the target agent entirely in Python, following the same convention Google ADK's runner uses:

  1. An explicit name via AGENTVISOR_AGENT_NAME, matched against agents registered with register_agent()
  2. The sole register_agent()-registered agent, if exactly one is registered
  3. The conventional agent.py:root_agent module attribute

Any AgentBase or MultiAgentBase (a GraphBuilder DAG or a Swarm) registered this way is treated uniformly as one invocable unit through the common invoke_async/stream_async contract — AgentVisor does not special-case Graph vs. Swarm vs. a single agent for invocation. Checkpointing is narrower — see below.

Checkpointing

AgentVisor persists Strands agent state itself, through the SDK's simple checkpoint API — not through a Strands SessionManager. Durability here means landing in the underlying Temporal history, and the checkpoint API already gets state there with policy enforcement and credential brokering built in, with no separate storage backend to configure. A SessionManager's own backends aren't as clean a fit: FileSessionManager writes to the guest's local disk, which is ephemeral and doesn't survive past a single run — a real disqualifier on its own. A network-backed manager like S3SessionManager doesn't have that problem, and nothing stops an agent from wiring one up directly; AgentVisor's own integration simply doesn't route through it, since the checkpoint API already covers the same need.

Which Strands primitive the runner drives depends on the agent's shape:

Agent shapePrimitiveWhat it carries
plain Agenttake_snapshot(preset="session") / load_snapshot()messages, state, conversation_manager_state, interrupt_state, model_state
Graph / Swarm (MultiAgentBase)serialize_state() / deserialize_state()in-flight task and interrupt state only — narrower than the plain-Agent case, see Multi-Agent Checkpointing below

Both round-trip through agentvisor.checkpoint.save()/load() as one JSON blob per thread. The runner resolves which primitive applies once per run, and logs a warning naming the agent type when neither is present.

For a plain Agent:

  • After each run, the runner calls agent.take_snapshot(preset="session") — the "session" preset is Strands' built-in field set covering messages, state, conversation_manager_state, interrupt_state, and model_state. take_snapshot() raises SnapshotException if called with no preset or explicit include list, since it has no default field set.
  • The resulting Snapshot.to_dict() is passed to agentvisor.checkpoint.save(), keyed by thread.
  • On the next run in the same thread, the runner calls checkpoint.load("") (empty string = latest checkpoint for this thread — the runner is a fresh process per invocation, so it never has a saved checkpoint ID from the prior run to reuse) and restores it via Snapshot.from_dict()/agent.load_snapshot().

This all happens automatically inside agentvisor.strands.runner — your agent code never touches checkpointing directly.

Don't call agentvisor.checkpoint.save()/.load() yourself from a Strands agent. The runner owns the thread's "latest checkpoint" slot for its own Snapshot blob — a checkpoint.load("") call from your own agent code would read back the runner's opaque Snapshot instead of anything you saved, and a checkpoint.save() call from your code would overwrite the slot the runner reads on the next turn, silently dropping the conversation history. This is unlike the ADK and CrewAI runners, which never touch the simple checkpoint API on your behalf.

This is not cross-compatible with the LangGraph AgentVisorCheckpointer — it's a different, framework-specific checkpoint shape. See Checkpointing for the full picture of how AgentVisor's simple checkpoint API is shared (and not shared) across frameworks.

A failed snapshot save or restore is not silent: the runner surfaces it as a checkpoint_error key in the run's output (in addition to a guest-log warning), so a caller can detect that a turn started from — or ended in — dropped conversation history.

Multi-Agent Checkpointing (Graph/Swarm)

A Graph or Swarm (MultiAgentBase) is checkpointed too, but through Strands' own serialize_state()/deserialize_state() pair rather than the snapshot API above — both are fully implemented by Graph and Swarm as a JSON-shaped dict. Both also already carry the same private _interrupt_state a plain Agent uses, so the interrupt/resume mechanics described in Human-in-the-Loop / Interrupts below apply to a Graph/Swarm unchanged.

What this provides — and what it doesn't. This is in-flight task and interrupt/HITL resume durability, not cross-turn conversation memory. A Graph/Swarm that completed its prior run resets every node executor's messages/state the next time its checkpoint is restored — this is Strands' own behavior, not an AgentVisor limitation — so, unlike the single-Agent case above, a Graph/Swarm does not carry conversation history between separate completed runs on the same thread. What is durable is a run that hasn't finished yet: a crash or restart mid-execution, or a pending interrupt, picks back up correctly on the next invocation.

The runner validates a restored checkpoint before applying it: it cross-checks the checkpoint's own type field ("graph"/"swarm") against the live orchestrator's actual class, and confirms every node id the checkpoint references still exists in the live topology. A checkpoint that fails either check — or whose deserialize_state() call raises partway through — is discarded and the orchestrator is reset to a clean state rather than left half-restored, with a checkpoint_error noted in that run's output.

Execution limits are cumulative across every run on a persisted thread, not per-invocation. Swarm's max_handoffs/max_iterations/execution_timeout and Graph's max_node_executions/execution_timeout are evaluated against state that serialize_state()/deserialize_state() persists and restores, so a long-lived thread accumulates against these limits across every turn, not just the current one. If a Graph/Swarm is meant to run on a long-lived AgentVisor thread across many turns, configure generous (or no) limits rather than the defaults you'd pick for a single one-shot invocation. Exhausting them is a real, observable failure mode: Swarm reports FAILED with an empty resume frontier, so the next run's restore silently resets all node history with no signal to the caller; Graph gets stuck with a non-empty frontier that never clears, permanently discarding any new input sent to that thread.

A user-supplied session_manager disables this integration — but only for a Graph/Swarm. If your GraphBuilder or Swarm already has a Strands SessionManager wired onto it, the runner detects it via the object's public session_manager attribute, skips its own checkpoint path for that orchestrator entirely (logging once), and treats it as an explicit opt-out — Strands has no hook that would let the two mechanisms cooperate.

This guard does not extend to a plain Agent. A plain Agent stores its session manager under a private _session_manager attribute, which the runner's guard does not check — so attaching a SessionManager to a single Agent does not opt it out of AgentVisor's snapshot checkpointing above. Both mechanisms would run against the same agent, with no supervision keeping them consistent. Don't attach a Strands SessionManager to a plain Agent under AgentVisor; the snapshot checkpointing above (or the Thread State API for other data) already covers this case.

Known limitations specific to Graph/Swarm checkpointing:

  • No nested-MultiAgentBase interrupt resume or streaming. A Graph/Swarm node that is itself another Graph/Swarm cannot be resumed correctly if it interrupts — only a plain Agent node's inner interrupt/message state is captured. AGENTVISOR_STREAM=true token output is also limited to one level of nesting (a top-level Graph/Swarm's own Agent nodes), so a nested orchestrator's tokens won't appear incrementally either. Unsupported today.
  • Checkpoint and output size scale with graph width/depth. node_results carries only each node's last message, not its conversation — but while an interrupt is pending, the checkpoint also carries the interrupting node's full messages history, which is the case most likely to be large. The run's output embeds every node's full result. Both can be substantially larger than a single-Agent checkpoint or output for a wide or deep graph, against the same size ceiling and Continue-As-New budget described in Checkpoint Sizing and Limits.

If you need cross-turn conversation memory

The table below picks the right orchestrator shape up front:

If your agent is…Cross-turn conversation memoryMechanism
a plain AgentFulltake_snapshot(preset="session") — messages, state, conversation-manager state, interrupt state, model state
a plain Agent with sub-agents wrapped as toolsFullSame — the top-level Agent owns the conversation
a GraphIn-flight / interrupt resume onlyserialize_state(); node executors reset on a completed-run restore
a SwarmIn-flight / interrupt resume onlySame — and Strands resets each node executor before every node turn, even within one run

This is upstream-owned, not an AgentVisor choice. Strands has deliberately decided against per-node conversation persistence for a Graph/Swarm: a node-level SessionManager is hard-rejected (ValueError("Session persistence is not supported for Graph agents yet.")strands/multiagent/graph.py, strands/multiagent/swarm.py), and even Strands' own multi-agent SessionManager — the shipped answer to a feature request for exactly this — persists the same serialize_state()/deserialize_state() shape AgentVisor already uses, so wiring one up would add no conversation memory. Watch strands/types/session.py's SessionType enum for this to change: it currently has a single member, AGENT.

What to do instead, for a Graph/Swarm that needs to carry facts across turns:

  • Read/write the Thread State API (agentvisor.stateThreadStateStore.get_json/put_json) from your own @tool functions. It's thread-scoped and durable across runs, and untouched by any orchestrator reset — see Google ADK's use of the same API for the pattern. Use the Store API (agentvisor.store) instead for facts that must outlive the thread itself.
  • Within a single Swarm run, Strands' own shared_context (the context kwarg of the handoff_to_agent tool) is the designed cross-node channel and is already rendered into each node's prompt — reach for it before reaching for AgentVisor state.
  • If the conversation itself is what must persist, model the system as one top-level Agent with sub-agents wrapped as tools rather than a Graph/Swarm — it takes the snapshot path above and gets full cross-turn memory.

Human-in-the-Loop / Interrupts

Strands agents can pause mid-run and wait for human input using Strands' own native interrupt mechanism (documented in strands/types/interrupt.py): a HookProvider calls event.interrupt(name, reason=...) from a hook callback — most commonly BeforeToolCallEvent — which raises InterruptException and stops the agent's event loop. AgentResult.stop_reason becomes "interrupt" and AgentResult.interrupts carries the pending interrupt(s).

AgentVisor's host-side interrupt contract is already framework-agnostic — the Strands runner sets the same output["__interrupt__"] marker that LangGraph's interrupt() sets — so a paused Strands run gets the identical run/thread status handling (status: "interrupted", the A2A input_required mapping) with no framework-specific host code.

Requesting approval

from typing import Any

from strands import Agent, tool
from strands.hooks import BeforeToolCallEvent, HookProvider, HookRegistry
from agentvisor.strands import register_agent


@tool
def delete_record(key: str) -> bool:
"""Delete a record by key."""
return True


class ApprovalHook(HookProvider):
def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
registry.add_callback(BeforeToolCallEvent, self.approve)

def approve(self, event: BeforeToolCallEvent) -> None:
if event.tool_use["name"] != "delete_record":
return

approval = event.interrupt("for_delete_record", reason="This action requires approval")
if approval != "approved":
event.cancel_tool = "approval was not granted"


root_agent = Agent(
hooks=[ApprovalHook()],
tools=[delete_record],
system_prompt="You delete records given their keys.",
)

register_agent(root_agent)

reason (above) and the resume response (below) must be JSON-serializable. Both are persisted verbatim inside the checkpoint saved while interrupted (see Checkpointing / Multi-Agent Checkpointing above) — Interrupt.to_dict() is a bare dataclasses.asdict(), so neither field is sanitized before it reaches json.dumps(). A non-JSON value (a datetime, a Pydantic model, any other custom object) fails that checkpoint save; for an interrupted run this fails the run outright rather than logging and continuing, since reporting the run as interrupted after its checkpoint failed to save would strand the thread with nothing to resume from. Stick to strings, numbers, booleans, None, and JSON-safe dicts/lists for both.

Output shape when interrupted

{
"status": "interrupted",
"output": {
"__interrupt__": true,
"interrupted": true,
"interrupts": [
{
"interruptId": "v1:before_tool_call:tooluse_1a2b3c:f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "for_delete_record",
"reason": "This action requires approval"
}
]
}
}

interruptId is generated by Strands itself — a deterministic value derived from the interrupt name and, for tool-call interrupts, the tool use id. Treat it as opaque and copy it verbatim from the prior run's output; AgentVisor never inspects or rewrites it.

Resuming

Resume by starting a new run on the same thread with a resume key holding a list of Strands' own InterruptResponseContent dicts, verbatim:

curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=30s" \
-H "Content-Type: application/json" \
-d '{"input": {"resume": [{"interruptResponse": {"interruptId": "v1:before_tool_call:tooluse_1a2b3c:f47ac10b-58cc-4372-a567-0e02b2c3d479", "response": "approved"}}]}}'

resume is a Strands-specific input key, distinct from the message/input/query keys used for a normal turn — the runner does zero translation of it, so a caller round-trips Strands' own wire shape unchanged. A run sent while an interrupt is pending that omits a valid resume payload fails cleanly with an actionable error rather than crashing inside the Strands SDK.

Once resumed, the thread returns to normal operation — the next run can use a plain {"message": ...} input as usual.

MCP Tools

agentvisor.strands.mcp exposes the AgentVisor MCP gateway as native Strands AgentTool instances, with no dependency on langchain_core — mirroring the same native-adapter pattern used by agentvisor.crewai.mcp:

from strands import Agent
from agentvisor.strands import register_agent
from agentvisor.strands.mcp import get_mcp_tools

root_agent = Agent(
name="researcher",
system_prompt="You are a research assistant with web browsing capabilities.",
tools=get_mcp_tools("fetch"),
)

register_agent(root_agent)

get_mcp_tools(server_name) returns tools from one configured MCP server, or all configured servers if server_name is omitted. Each call to a returned tool is a self-contained, policy-enforced, credential-brokered gRPC round trip to the host — unlike Strands' own MCPClient (which implements the ToolProvider protocol because it owns a live stdio/SSE connection), there's no connection lifecycle for AgentVisor's adapter to manage.

Model Providers

Strands ships model provider integrations for Bedrock, Anthropic, OpenAI, Gemini, LiteLLM, Ollama, and others. Every provider accepts a client_args escape hatch that is unpacked directly into the vendor SDK's own client constructor (e.g. AsyncAnthropic(**client_args)) — this is the integration point AgentVisor's proxy and credential substitution rely on.

httpx-based providers (Anthropic, OpenAI-compatible, Ollama)

These work directly. The standard HTTPS_PROXY/SSL_CERT_FILE environment variables AgentVisor injects into the guest are already respected by httpx-based clients, so requests are transparently routed through the AgentVisor proxy (and hence through policy enforcement and credential substitution) with no extra wiring:

from strands.models.anthropic import AnthropicModel

model = AnthropicModel(client_args={}, model_id="claude-haiku-4-5-20251001")

Bedrock: use LiteLLM, not BedrockModel directly

Bedrock is Strands' default model provider, but direct BedrockModel access (raw boto3) does not work behind AgentVisor's TLS-terminating MITM proxy. SigV4 signing is computed against the original endpoint, and botocore's eventstream checksum validation can reject a response-modifying proxy's stream. This is tracked upstream as strands-agents/harness-sdk#672 and is a documented known limitation, not something AgentVisor can work around at the proxy layer.

The recommended path to Bedrock is LiteLLM. AgentVisor already runs this route — AgentVisor → LiteLLM → Bedrock — previously exercised with LangGraph as the driving framework. LiteLLM's transport/signing path is framework-agnostic, so the same route carries over directly to Strands. Strands ships a litellm extra:

pip install "strands-agents[litellm]"
from strands.models.litellm import LiteLLMModel

# client_args flows into litellm.acompletion()'s own kwargs. No proxy override
# is needed here — litellm's providers already respect HTTPS_PROXY/SSL_CERT_FILE.
model = LiteLLMModel(client_args={}, model_id="anthropic/claude-haiku-4-5-20251001")

# Point model_id at a "bedrock/..." model to use Bedrock instead — the
# client_args wiring is unchanged.

See the Strands Research Agent example for a complete, runnable configuration of this pattern.

AWS_CA_BUNDLE for raw boto3 tools

Not needed for the LiteLLM path above. If a tool in your agent talks to AWS services via raw boto3 directly, note that botocore honors AWS_CA_BUNDLE instead of SSL_CERT_FILE. AgentVisor already exposes the CA bundle path via SSL_CERT_FILE before your agent process starts, so set AWS_CA_BUNDLE yourself from it at runtime:

import os

os.environ.setdefault("AWS_CA_BUNDLE", os.environ.get("SSL_CERT_FILE", ""))

The CA path is randomized per guest-runtime start, so it can't be hardcoded into static config — this one-line callout is the supported way to pick it up.

Observability

No special wiring is needed. Strands uses the standard OpenTelemetry SDK and skips creating its own tracer provider if a global one already exists — the runner sets one up the same way the tracing-agent example does for LangGraph. See Agent Tracing for configuring OTLP exporters.

Configuration

requirements.txt

strands-agents[litellm]>=1.54.0,<2

Don't add a bare agentvisor here — it's pre-installed in the guest image; see requirements.txt and the SDK.

agentvisor.yaml — Credential Substitution

proxy:
credentials:
- name: anthropic-key
guest_env_var: ANTHROPIC_API_KEY
destinations:
- "api\\.anthropic\\.com"
resolver:
type: bearer_token
source: env
env_var: ANTHROPIC_API_KEY

How it works:

  1. AgentVisor generates a symbolic token (e.g., mav-tok-a1b2c3...)
  2. The token is injected as ANTHROPIC_API_KEY inside the sandbox
  3. LiteLLM (or your model provider of choice) sends requests using that token
  4. The proxy substitutes the token with the real ANTHROPIC_API_KEY from the host

The real API key is never exposed inside the sandbox.

Testing Locally

# 1. Start Temporal
temporal server start-dev

# 2. Set up Python environment
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

# 3. Export credentials
export ANTHROPIC_API_KEY="sk-ant-..."
export AGENTVISOR_AUTHZ_TYPE=allowall

# 4. Serve the agent (from the project directory, mav-agent-config.yaml required)
agentvisor serve . --sandbox=none

# 5. Create a thread and run
THREAD=$(curl -sX POST http://localhost:8090/threads | jq -r '.thread_id')

curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=120s" \
-H "Content-Type: application/json" \
-d '{"input": {"message": "Research quantum computing"}}'

Multi-turn conversation

Because the agent's snapshot is persisted per thread, follow-up questions within the same thread pick up where the conversation left off:

curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=120s" \
-H "Content-Type: application/json" \
-d '{"input": {"message": "What are the main challenges?"}}'

Known Limitations

  • Direct Bedrock access (BedrockModel, raw boto3) is not supported behind AgentVisor's TLS-terminating proxy — use LiteLLM instead (see Model Providers above).
  • Strands SessionManager persistence is not used. AgentVisor drives Strands' own snapshot and serialize_state() primitives directly instead, since the checkpoint API already provides Temporal-backed durability with no separate storage backend to configure. See Checkpointing.
  • Strands' own Sandbox abstraction (execute_streaming, read_file, write_file, Docker/SSH backends) is not integrated — it overlaps with AgentVisor's own sandbox boundary. Agents should not rely on it inside the AgentVisor sandbox.
  • Multi-agent Graph/Swarm are supported only as "one invocable unit" — AgentVisor does not expose per-node observability or control beyond what the top-level invoke_async/stream_async result already carries.
  • Multi-agent Graph/Swarm checkpointing covers in-flight/interrupt resume, not conversation memory. See Multi-Agent Checkpointing (Graph/Swarm) for the full scope, and If you need cross-turn conversation memory for the supported alternatives.

Example

See the Strands Research Agent example for a complete working example with:

  • MCP tool integration for web research
  • LiteLLM-backed model configuration (the proven Bedrock-access route, using Anthropic directly for a simple runnable example)
  • Snapshot-based checkpointing across multi-turn conversation
  • Proxy credential substitution

See the Strands Graph Approval Agent example for a runnable demonstration of Multi-Agent Checkpointing: a two-node Graph with a tool-call interrupt gated on human approval, persisted and resumed via serialize_state()/deserialize_state().

Next Steps