ReqManagedAgents
One Session loop, any loop host — server-side (Claude Managed Agents, AgentCore) or in-process (Local). Your custom tools execute on your node regardless of which host runs the loop, so your code and data never leave it — the loop host only ever sees each tool's name, description, input schema, and the text result you return.
One loop, three backends behind a single Provider behaviour:
| Provider | Module | Transport |
|---|---|---|
| Anthropic Claude Managed Agents (public beta) | ReqManagedAgents.Providers.ClaudeManagedAgents | :streaming — long-lived SSE; beta header managed-agents-2026-04-01 |
| AWS Bedrock AgentCore Harness | ReqManagedAgents.Providers.BedrockAgentCore | :request_response — synchronous SigV4-signed invoke |
| Local (in-process) | ReqManagedAgents.Providers.Local | :request_response — in-process loop over a pluggable chat_fun (default: ReqLLM via the optional req_llm dep); one model call per turn; loop guards for weak-instruction-following local models |
Local + routing
Point Local's chat_fun at an OpenAI-compatible gateway lane (base_url + per-run api_key
via model_config) and you get hard data-plane budget enforcement with no coupling to the
gateway's internals. Direct-to-provider chat_funs remain available for dev and tests.
{:ok, result} =
ReqManagedAgents.Session.run(ReqManagedAgents.Providers.Local,
handler: MyTools,
spec: %{system_prompt: "...", tools: tools, terminal_tool: "submit", model_config: nil},
model_config: %{model: "openai:gpt-oss", base_url: lane_url, api_key: granted_key},
prompt: "Go."
)
Install
def deps do
[{:req_managed_agents, "~> 0.1"}]
end
Using the Bedrock AgentCore provider? Add the optional AWS deps (Anthropic-only users can skip these):
def deps do
[
{:req_managed_agents, "~> 0.1"},
{:ex_aws_auth, "~> 1.4"},
{:aws_event_stream, "~> 0.1"}
]
end
Using Providers.Local with the default chat_fun (ReqLLM)? Add:
def deps do
[
{:req_managed_agents, "~> 0.1"},
{:req_llm, "~> 1.10"}
]
end
Injected chat_funs (any OpenAI-compatible endpoint via plain Req) need nothing extra.
Configuration
Every config/credential value the library reads funnels through
ReqManagedAgents.Config, in one fixed priority order: an explicit opts
keyword wins, then Application.get_env(:req_managed_agents, key), then a
System.get_env environment variable, then a default. This is the complete
list of keys read anywhere in the library:
| Key | opt | :req_managed_agents app env | ENV var | Default |
|---|---|---|---|---|
| Anthropic API key | :api_key | :api_key | ANTHROPIC_API_KEY | (required) |
| Base URL | :base_url | :base_url | — | "https://api.anthropic.com" |
| Beta header | :beta | :beta | — | "managed-agents-2026-04-01" |
| Files beta header | :files_beta | :files_beta | — | "files-api-2025-04-14" |
| Anthropic API version | :anthropic_version | :anthropic_version | — | "2023-06-01" |
| Receive timeout (ms) | :receive_timeout | :receive_timeout | — | 60_000 |
| Provider profile | :profile | :profile | — | :anthropic |
| AWS access key ID | :aws_access_key_id | :aws_access_key_id | AWS_ACCESS_KEY_ID | (required) |
| AWS secret access key | :aws_secret_access_key | :aws_secret_access_key | AWS_SECRET_ACCESS_KEY | (required) |
| AWS region | :aws_region | :aws_region | AWS_REGION, then AWS_DEFAULT_REGION | "us-east-1" |
| AWS session token | :aws_session_token | :aws_session_token | AWS_SESSION_TOKEN | nil |
Client.new/1 resolves the Anthropic keys; AgentCore.SigV4.from_env/1
resolves the AWS keys (it still works called with no args — opts just gives
you an override point without touching the environment).
The core: one loop, the loop host is a parameter
ReqManagedAgents.Session is the unified loop — invoke a turn → run your return-of-control tools
locally → resume → repeat — parameterized by a provider module. The loop host runs the agent loop —
a managed provider server-side, or Providers.Local in-process. It returns the same result
shape for every provider:
alias ReqManagedAgents.Session
alias ReqManagedAgents.Providers.{ClaudeManagedAgents, BedrockAgentCore}
# Claude Managed Agents (streaming)
{:ok, %ReqManagedAgents.SessionResult{} = result} =
Session.run(ClaudeManagedAgents,
client: ReqManagedAgents.new(), agent_id: agent_id, environment_id: env_id,
prompt: "…", handler: MyHandler)
result.terminal # :end_turn | :requires_action | :terminated — uniform across providers
result.text # the assistant's accumulated text
result.usage # %ReqManagedAgents.Usage{input_tokens:, output_tokens:, …}
# AWS Bedrock AgentCore (request/response) — same handler, same result struct
{:ok, %ReqManagedAgents.SessionResult{}} =
Session.run(BedrockAgentCore,
harness_arn: arn, runtime_session_id: sid,
prompt: "…", handler: MyHandler)
terminal is the uniform signal to branch on. stop_reason is each provider's raw native value (a map for Claude, e.g.
%{"type" => "end_turn"}; a string for Bedrock, e.g. "end_turn") — preserved verbatim, never
flattened. The raw events are always in events.
- Sync:
Session.run(provider, opts)blocks until a terminal and returns{:ok, …}/{:error, reason}. - Live / supervised:
Session.start_link(provider, opts)(reconnecting, multi-turn) +Session.message(pid, text); passnotify: pidto be told when a turn terminates.
Convenience facade (Claude)
For the Claude path, thin sugar over the above:
ReqManagedAgents.run_to_completion/1≡Session.run(ClaudeManagedAgents, opts)ReqManagedAgents.start_session/1≡Session.start_link(ClaudeManagedAgents, opts)ReqManagedAgents.new/1— a control-plane client.
For the Bedrock path, ReqManagedAgents.AgentCore.invoke_to_completion/1 ≡
Session.run(BedrockAgentCore, opts).
Writing a handler
Implement ReqManagedAgents.Handler — handle_tool_call/3 runs your tool locally and returns the
text result; the optional handle_event/2 observes raw events as they stream.
defmodule MyHandler do
@behaviour ReqManagedAgents.Handler
@impl true
def handle_tool_call("lookup_customer", %{"email" => email}, _ctx),
do: {:ok, "Customer #{email}: Pro plan, active."} # your private code + data
@impl true
def handle_event(_ev, _ctx), do: :ok
end
Three runnable, heavily-commented examples ship with the package:
examples/claude_managed_agents.exs— the full Claude lifecycle: agent + environment setup, a local tool handler, and the%SessionResult{}(text, terminal, token usage).examples/bedrock_agent_core.exs— AgentCore Harness:provision/3(idempotent, READY-polled),Session.run/2,teardown/2, and the AWS gotchas (session-id contract, cross-region model profiles, async deletion).examples/provider_agnostic.exs— the core claim: one handler, one loop, two providers, same result shape.
The Claude pattern (setup)
- Create a versioned agent once (model, system prompt, custom-tool definitions); store its id.
- Create an environment once with
Client.create_environment/2and reuse its id (a session needs anenvironment_id). - Start a session; the provider drives the loop and emits
agent.custom_tool_use. The library runs your tool via theHandlercallback and posts the result back. Onend_turn, you're done.
The Bedrock AgentCore pattern (setup)
- Provision a Harness once — CreateHarness + READY-poll, idempotent and cached — via
ReqManagedAgents.provision/3(Provisioner.ensure/3under the hood, built onReqManagedAgents.AgentCore.Client). Store the returned handle; tear down withReqManagedAgents.teardown/2. Session.run(BedrockAgentCore, harness_arn: …, runtime_session_id: …, …). Each turn is one synchronous signed invoke; resume re-sends the assistanttoolUse+ yourtoolResultdelta. (runtimeSessionIdmust be ≥33 chars.) Long runs: passidle_timeout:(inter-chunk liveness guard, default 300s — the turn itself has no client wall clock) and the server budgetstimeout_seconds:,max_iterations:,max_tokens:(per-invocation overrides of the harness defaults). Note:Session.run/2's:timeoutmust be ≥ the server budget — a client timeout returns{:error, :timeout}but does NOT cancel the in-flight invoke; the harness keeps executing (and billing) server-side up to its owntimeoutSeconds. Events stream to yourHandler.handle_event/2live as the turn runs.
Layers
ReqManagedAgents.Provider— the behaviour every backend implements (invocation +normalize/1).ReqManagedAgents.Session— the unified, supervised, reconnecting loop driven by yourHandler.ReqManagedAgents.Client— Claude control-plane HTTP (agents, sessions, events, files).ReqManagedAgents.SSE/.Stream— the Claude event stream.ReqManagedAgents.AgentCore.Client/.Converse/ReqManagedAgents.Provisioner— Bedrock AgentCore wire client, Converse decoding, and Harness provisioning.ReqManagedAgents.Event/.Consolidate— pure builders, classification, reconnect helpers.ReqManagedAgents.ToolSchema— custom-tool schema construction.ReqManagedAgents.Artifacts/.Artifact/.SessionInfo— name-keyed session-artifact verbs over provider-native stores + the runtime identity handed to handlers.ReqManagedAgents.SessionResult/.TurnResult/.Usage/.ToolUse/.ToolResult— the canonical result vocabulary shared by every provider.
Telemetry
req_managed_agents emits :telemetry events you can attach to:
| Event | Measurements | Metadata |
|---|---|---|
[:req_managed_agents, :request, :start | :stop | :exception] | duration | method, path, status |
[:req_managed_agents, :agent_core, :request, :start | :stop | :exception] | duration | operation, service, method, path, status |
[:req_managed_agents, :stream, :connected | :event | :done | :error] | — | session_id, type, usage, reason |
[:req_managed_agents, :tool, :start | :stop | :exception] | duration | tool, session_id, is_error |
[:req_managed_agents, :session, :tool_uses] | tool_use_count | turn, tool_use_ids |
[:req_managed_agents, :session, :terminal] | — | terminal |
All providers run through Session, so the :session events fire regardless of loop host.
:stream:event also fires for both providers as events arrive mid-turn — on Claude,
type is the SSE event type and session_id/usage are set; on Bedrock AgentCore, type is
the Converse envelope key (e.g. "contentBlockDelta") and there is no session_id. The other
:stream events (:connected/:done/:error) are Claude-only. Pass
telemetry_metadata: %{…} to merge custom tags (e.g. tenant) into every event; library-set keys
take precedence. ReqManagedAgents.OpenTelemetry bridges these to OTel GenAI spans.
Files (Claude)
{:ok, %{"id" => file_id}} = ReqManagedAgents.Client.upload_file(client, %{purpose: "agent", file: "report.csv"})
{:ok, _} = ReqManagedAgents.Client.attach_file_to_session(client, session_id, %{file_id: file_id, mount_path: "/data/report.csv"})
{:ok, bytes} = ReqManagedAgents.Client.download_file(client, file_id)
The Files API uses its own beta header (files-api-2025-04-14); download_file/2 returns raw bytes.
Artifacts — retrieve what your agent built
An agent writes deliverables into its session sandbox; the file's name is the only
identity the model ever sees. ReqManagedAgents.Artifacts gives one vocabulary over
provider-native session storage — list, fetch, put, delete, name-keyed and
session-scoped:
alias ReqManagedAgents.Artifacts
alias ReqManagedAgents.Artifacts.{ClaudeFiles, AgentCoreSessionStorage}
# Claude Managed Agents — the Files API, scoped to one session
store = {ClaudeFiles, ClaudeFiles.store(client, session_id)}
{:ok, artifacts} = Artifacts.list(store) # [%ReqManagedAgents.Artifact{name: "report.md", …}]
{:ok, bytes} = Artifacts.fetch(store, "report.md")
# Bedrock AgentCore — a sessionStorage mount (no VPC), command-backed
store =
{AgentCoreSessionStorage,
AgentCoreSessionStorage.store(ac_client, harness_arn, runtime_session_id, "/mnt/data")}
{:ok, bytes} = Artifacts.fetch(store, "report.md")
Handlers receive a %ReqManagedAgents.SessionInfo{} (optional 4th argument to
handle_tool_call/4) carrying the session_id, so a tool can build the store for its
OWN session and fetch what the agent just wrote.
The parity story, honestly: Anthropic offers a provider-hosted blob store (zero infra;
bytes on Anthropic); AWS mounts your storage into the microVM (sessionStorage
needs nothing; EFS/S3 mounts need VPC mode) plus direct shell access
(AgentCore.Client.invoke_agent_runtime_command/2 — no model loop, no token cost).
The sessionStorage store handles report-scale artifacts (bytes transit the command
stream as Base64); an S3-mount store (host side = plain S3) is designed for 0.4.
Declare mounts via the opaque environment field on the provision spec.
The outputs-dir convention (Claude Managed Agents, established live 2026-07-03): only files the agent writes under
/mnt/session/outputs/become session artifacts — scoped to the session, downloadable, retrievable viaClaudeFiles. Files written elsewhere (e.g./workspace) leave non-downloadable, unscoped residue. The path is exposed asClaudeFiles.outputs_dir/0(+output_path/1for a named file) — interpolate it into your agent's system prompt instead of copying the string.
Environments are images
The Docker mental model maps directly onto the CMA environment lifecycle — with the same rules: a changed spec is a new image, not an in-place update; tags are movable pointers; sessions are the containers that churn; prune is explicit GC.
| Docker | RMA |
|---|---|
| Dockerfile | env spec (canonical map) |
| image digest | spec hash — content-addressed identity |
| repository | base name ("data_analysis") |
repo@digest | provider-side name <base>_<digest8> |
docker build (cached) | ensure_environment/3 — build-if-absent, never rebuilds on a hit |
repo:tag (movable) | Store-backed tag → digest pointer |
docker run | create_session — ephemeral, references an image |
docker image prune | prune_environments/3 — explicit GC, never automatic |
Worked example
alias ReqManagedAgents.Provisioner
alias ReqManagedAgents.Provisioner.Store
store = {Store.File, path: Path.expand("~/.cache/myapp/provisions.json")}
env_spec = %{type: "cloud", packages: %{pip: ["pandas"]}, networking: %{type: "unrestricted"}}
# Build once — next run hits the store and returns the same handle instantly:
{:ok, handle} =
ReqManagedAgents.ensure_environment(client, env_spec, name: "data_analysis", store: store)
# handle == %{environment_id: "env_id_…", name: "data_analysis_3f9a1b2c", digest: "3f9a1b2c"}
# Pin the current image as "prod" (movable pointer; retag freely):
:ok = Provisioner.tag("data_analysis", "prod", handle, store: store)
# Resolve the pinned image later — never falls back; {:error, :unknown_tag} on miss:
{:ok, %{environment_id: _env_id}} = Provisioner.resolve("data_analysis:prod", store: store)
# GC old versions — keep the newest 3 (plus any tagged digest; keep: has no default):
{:ok, %{archived: _old, kept: _live}} =
Provisioner.prune_environments(client, "data_analysis", keep: 3, store: store)
Store.File persists handles and tags across OS processes (CLI tools, cron, mix tasks),
with atomic writes and a single-writer assumption. The default is Store.ETS — in-process
only. Values must be JSON-encodable (provision handles always are).
Declared runtimes
Add a runtimes: key to the env spec to have the library produce a bootstrap script
and system-prompt instruction the agent runs on first need:
env_spec = %{
type: "cloud",
packages: %{},
networking: %{type: "unrestricted"},
runtimes: [%{lang: :elixir, version: "1.20.2", via: :mise}]
}
{:ok, handle} = ReqManagedAgents.ensure_environment(client, env_spec, name: "myapp")
# handle.bootstrap == %{script: "…mise install script…", instructions: "…"}
Pass handle.bootstrap.instructions into your agent's system prompt. The agent runs
the bootstrap script once via bash before the first command that needs the runtime.
Proven end-to-end: ~11s on ubuntu-24.04 (precompiled OTP from mise; no compile step).
Only via: :mise is supported. The runtimes list is digest-covered — adding or changing
a runtime version produces a new image automatically, no extra machinery.
Using with Jido
The core is Jido-free. To use Jido Actions as tools, implement handle_tool_call/3 by delegating
to Jido.Action.Tool.execute_action/3, and derive the tool definitions with
Jido.Action.Tool.to_tool/1 (or ReqManagedAgents.ToolSchema.to_custom_tool/3). A dedicated
adapter package is planned.
Internal docs
Internal planning docs under docs/superpowers/ and docs/qa/ are this repo's working log and may reference internal tracker ids; no other surface may (source, tests, CI config, commit messages, PR titles — tracker linkage belongs only in a PR body's trailing Closes … line).
License
Apache-2.0.