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."
  )

(Providers.Local reads the bare-map spec keys directly and never coerces to %Agent.Spec{}, so it takes no :name; the managed providers do — see "Provision once, run anywhere".)

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}

# `handle` is what `provision/3` returns — see "Provision once, run anywhere" below.

# Claude Managed Agents (streaming) — `agent:`/`environment:` take the handle
# (each lifts the id it needs); no hand-threaded raw ids.
{:ok, %ReqManagedAgents.SessionResult{} = result} =
  Session.run(ClaudeManagedAgents,
    client: ReqManagedAgents.new(), agent: handle, environment: handle,
    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;
# its handle carries a `harness_arn`.
{:ok, %ReqManagedAgents.SessionResult{}} =
  Session.run(BedrockAgentCore,
    harness_arn: handle.harness_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); pass notify: pid to be told when a turn terminates.

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:

  • examples/claude_managed_agents.exs — the full Claude lifecycle: provision/3 (agent + environment in one call), a local tool handler, and the %SessionResult{} (text, terminal, token usage).
  • examples/bedrock_agent_core.exs — AgentCore Harness: the same provision/3Session.run/2teardown/2 shape, plus 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.

Provision once, run anywhere

Both managed providers speak one vocabulary: build an %ReqManagedAgents.Agent.Spec{} (a :name is required — Agent.Spec.new/1 rejects a nameless spec), provision it — passing any environment as the :environment option (an Environment.Spec, or a flat map that coerces to one; its config is handed verbatim to the provider's wire environment field, no per-key indexing) — and thread the returned handle into Session.run/2. The provider module is the only thing you change.

alias ReqManagedAgents.Agent.Spec

spec = %Spec{
  name: "billing-support",
  system_prompt: "You are a concise billing-support agent. Use tools for customer data.",
  model_config: model_config,   # provider-specific wire shape — see the table
  tools: tools                  # SCHEMAS only; the implementations stay in your Handler
}

# create-or-reuse, cached in-process per {provider, spec}; `teardown/2` releases it
{:ok, handle} = ReqManagedAgents.provision(provider, spec, environment: env_spec)

# then thread `handle` into Session.run — the connection opts are the one
# per-provider difference (see the table below).

What actually differs between the two providers is only this:

Claude Managed AgentsBedrock AgentCore
mode:streaming — long-lived SSE, events pushed:request_response — one synchronous signed invoke per turn
credentialsANTHROPIC_API_KEY + beta headerAWS SigV4 (AWS_*) + an execution-role ARN
model_config wireplain model-id string ("claude-haiku-4-5")%{"bedrockModelConfig" => %{"modelId" => "us.…"}} (cross-region inference profile)
provision createsa versioned agent and an environment (two resources)one harness folding in model + tools + environment
provision handle%{agent_id:, environment_id:}%{harness_arn:, harness_id:}
Session.run connectionagent: handle, environment: handleharness_arn: handle.harness_arn, runtime_session_id: sid (id ≥33 chars)
capabilitiesoutcomes, server-tool observation, cross-batch tool recovery, resume/reconnectnone of these — a dropped turn just re-invokes

:agent/:environment accept a handle (a struct, or a bare map with the same agent_id:/environment_id: keys) and unpack to the raw ids before the provider opens the session; an explicit :agent_id/:environment_id still works and wins if both are given.

Each AgentCore turn is one signed invoke; resume re-sends the assistant toolUse + your toolResult delta. Long runs stream incrementally with no client wall clock — only silence fails a turn (idle_timeout:, inter-chunk guard, default 300s); cost is bounded server-side via timeout_seconds:/max_iterations:/max_tokens: (per-invocation overrides of the harness defaults). Session.run/2's own :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) up to its timeoutSeconds. Events reach Handler.handle_event/2 live either way.

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 :environment provisioning option (an Environment.Spec; its config is passed verbatim to the provider's wire environment field).

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 is a %ReqManagedAgents.Provisioner.Environment.Handle{} struct
# (dot-access + Jason-encodes to %{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.

Agents as managed entities

ensure_agent/3 is the content-addressed cousin of provision/3 ("Provision once, run anywhere"): same %Agent.Spec{} vocabulary, but Store-backed, digest-named, tag- and prune-aware, returning a typed handle you splat straight into Session.run/2.

The same content-addressed lifecycle Provisioner.Environments gives environments applies to agents: ReqManagedAgents.Agent.Spec hashes an agent's identity (system_prompt, tools, terminal_tool, model_configname is the base, not identity content); ensure_agent/3 is build-if-absent, never re-creating on a hit; tags are movable pointers; prune is explicit GC.

agent_spec = %{
  name: "support_bot",
  system_prompt: "You triage support tickets.",
  tools: [],
  model_config: %{model: "claude-opus-4-6"}
}

# Build once — a second call with the same spec returns the same handle, no re-create:
{:ok, agent} = ReqManagedAgents.ensure_agent(client, agent_spec, name: "support_bot", store: store)
# agent is a %ReqManagedAgents.Agent.Handle{} struct
# (dot-access + Jason-encodes to %{agent_id: "agent_id_…", name: "support_bot_3f9a1b2c", digest: "3f9a1b2c"})

# Pin the current version as "prod" (movable; retag freely):
:ok = ReqManagedAgents.tag_agent("support_bot", "prod", agent, store: store)

# Resolve the pinned version later — {:error, :unknown_tag} on miss, never a silent fallback:
{:ok, agent} = ReqManagedAgents.resolve_agent("support_bot:prod", store: store)

# GC old versions — keep the newest 3 (plus any tagged digest; keep: has no default):
{:ok, %{archived: _old, kept: _live}} =
  ReqManagedAgents.prune_agents(client, "support_bot", keep: 3, store: store)

A 409 on create (a name collision on the provider side) recovers by name instead of failing — the provider-side name is <base>_<digest8>, so a live agent with that exact name IS this exact spec.

Pass the ensure_agent/3 handle straight into Session.run/2 alongside an environment handle — :agent/:environment are unpacked to :agent_id/:environment_id before the provider opens the session, so callers stop hand-threading raw ids:

{:ok, env} = ReqManagedAgents.ensure_environment(client, env_spec, name: "data_analysis", store: store)

{:ok, result} =
  ReqManagedAgents.Session.run(ReqManagedAgents.Providers.ClaudeManagedAgents,
    agent: agent,
    environment: env,
    handler: MyTools,
    prompt: "Summarize this quarter's tickets"
  )

An explicit :agent_id/:environment_id still works and wins if both a handle and an id are given.

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.