# 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_fun`s remain available for dev and tests.
```elixir
{: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
```elixir
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):
```elixir
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:
```elixir
def deps do
[
{:req_managed_agents, "~> 0.1"},
{:req_llm, "~> 1.10"}
]
end
```
Injected `chat_fun`s (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:
```elixir
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)`; pass `notify: pid` to 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.
```elixir
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`](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`](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`](examples/provider_agnostic.exs) — the core claim: one
handler, one loop, two providers, same result shape.
## 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
- `ReqManagedAgents.Provider` — the behaviour every backend implements (invocation + `normalize/1`).
- `ReqManagedAgents.Session` — the unified, supervised, reconnecting loop driven by your `Handler`.
- `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)
```elixir
{: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:
```elixir
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.
| 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
```elixir
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:
```elixir
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.