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:

ProviderModuleTransport
Anthropic Claude Managed Agents (public beta)ReqManagedAgents.Providers.ClaudeManagedAgents:streaming — long-lived SSE; beta header managed-agents-2026-04-01
AWS Bedrock AgentCore HarnessReqManagedAgents.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:

Keyopt:req_managed_agents app envENV varDefault
Anthropic API key:api_key:api_keyANTHROPIC_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_timeout60_000
Provider profile:profile:profile:anthropic
AWS access key ID:aws_access_key_id:aws_access_key_idAWS_ACCESS_KEY_ID(required)
AWS secret access key:aws_secret_access_key:aws_secret_access_keyAWS_SECRET_ACCESS_KEY(required)
AWS region:aws_region:aws_regionAWS_REGION, then AWS_DEFAULT_REGION"us-east-1"
AWS session token:aws_session_token:aws_session_tokenAWS_SESSION_TOKENnil

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.

Convenience facade (Claude)

For the Claude path, thin sugar over the above:

For the Bedrock path, ReqManagedAgents.AgentCore.invoke_to_completion/1Session.run(BedrockAgentCore, opts).

Writing a handler

Implement ReqManagedAgents.Handlerhandle_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:

The Claude pattern (setup)

  1. Create a versioned agent once (model, system prompt, custom-tool definitions); store its id.
  2. Create an environment once with Client.create_environment/2 and reuse its id (a session needs an environment_id).
  3. Start a session; the provider drives the loop and emits agent.custom_tool_use. The library runs your tool via the Handler callback and posts the result back. On end_turn, you're done.

The Bedrock AgentCore pattern (setup)

  1. Provision a Harness once — CreateHarness + READY-poll, idempotent and cached — via ReqManagedAgents.provision/3 (Provisioner.ensure/3 under the hood, built on ReqManagedAgents.AgentCore.Client). Store the returned handle; tear down with ReqManagedAgents.teardown/2.
  2. Session.run(BedrockAgentCore, harness_arn: …, runtime_session_id: …, …). Each turn is one synchronous signed invoke; resume re-sends the assistant toolUse + your toolResult delta. (runtimeSessionId must be ≥33 chars.) Long runs: pass idle_timeout: (inter-chunk liveness guard, default 300s — the turn itself has no client wall clock) and the server budgets timeout_seconds:, max_iterations:, max_tokens: (per-invocation overrides of the harness defaults). Note: Session.run/2's :timeout must 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 own timeoutSeconds. Events stream to your Handler.handle_event/2 live as the turn runs.

Layers

Telemetry

req_managed_agents emits :telemetry events you can attach to:

EventMeasurementsMetadata
[:req_managed_agents, :request, :start | :stop | :exception]durationmethod, path, status
[:req_managed_agents, :agent_core, :request, :start | :stop | :exception]durationoperation, service, method, path, status
[:req_managed_agents, :stream, :connected | :event | :done | :error]session_id, type, usage, reason
[:req_managed_agents, :tool, :start | :stop | :exception]durationtool, session_id, is_error
[:req_managed_agents, :session, :tool_uses]tool_use_countturn, 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 via ClaudeFiles. Files written elsewhere (e.g. /workspace) leave non-downloadable, unscoped residue. The path is exposed as ClaudeFiles.outputs_dir/0 (+ output_path/1 for 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.

DockerRMA
Dockerfileenv spec (canonical map)
image digestspec hash — content-addressed identity
repositorybase name ("data_analysis")
repo@digestprovider-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 runcreate_session — ephemeral, references an image
docker image pruneprune_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.