AgentEx Product Requirements Document
Copy Markdown1. Product overview
Product name
AgentEx
Product type
Open-source Elixir framework for building, running, supervising, coordinating, and persisting AI agents.
Summary
AgentEx provides a native Elixir and OTP runtime for building AI agents without forcing developers to compose many unrelated abstractions.
The framework should make simple agents simple while still supporting:
- tool calling;
- persistent conversations;
- many concurrent agent threads;
- human approvals;
- model-provider adapters;
- MCP tools;
- shared-chat multi-agent teams;
- stateful workflows;
- parallel workflow branches;
- checkpointing;
- streaming;
- telemetry;
- bulk execution through fleets.
AgentEx should feel like an Elixir-native agent runtime rather than a wrapper around a Python framework.
Its core design should map directly to OTP concepts:
Agent definition → Elixir module
Agent thread → supervised process
Thread lookup → Registry
Thread lifecycle → DynamicSupervisor
Parallel execution → supervised Tasks
Persistence → configurable memory backend2. Problem statement
Existing agent frameworks often expose too many overlapping concepts:
agent
runner
executor
session
chain
graph
runtime
workflow
memory
context
state
orchestratorThis creates several problems:
- Developers must understand the framework’s internal architecture before running a basic agent.
- Stateful and stateless execution often use completely different APIs.
- Multi-agent systems frequently rely on ad hoc routing and isolated histories.
- Graph and workflow systems expose low-level graph terminology even when users are describing business workflows.
- Elixir developers cannot easily take advantage of OTP supervision, lightweight processes, fault isolation, and registries.
- Model providers, tools, persistence, tracing, and approvals are often tightly coupled.
- Scaling from one agent to thousands of concurrent agent sessions requires custom infrastructure.
AgentEx should solve this by offering a small set of consistent concepts.
3. Product vision
AgentEx should become the standard Elixir runtime for building applications such as:
- AI assistants;
- coding agents similar to Claude Code;
- personal-agent platforms similar to OpenClaw;
- customer-support agents;
- research agents;
- background monitoring agents;
- document-processing systems;
- multi-agent collaboration systems;
- approval-driven automation;
- stateful workflow applications;
- large multi-tenant agent platforms.
The framework should make this progression natural:
One agent
→ persistent agent threads
→ multiple collaborating agents
→ explicit workflows
→ thousands of concurrent jobs4. Product principles
4.1 Simplicity first
A basic agent should require only:
defmodule ResearchAgent do
use AgentEx
model "openai:gpt-5"
system "You are a research assistant."
tools [
SearchWeb
]
endAnd should run through:
ResearchAgent.run("Research Phoenix.")4.2 OTP-native
AgentEx must reuse OTP rather than recreate it.
It should use:
Supervisor;DynamicSupervisor;Registry;GenServer;Task.Supervisor;- process monitoring;
- standard process messaging;
:telemetry;- ETS where appropriate.
AgentEx must not introduce custom replacements for existing OTP primitives.
4.3 One consistent API
The same high-level API should apply across agents, teams, and workflows:
run(...)
prompt(...)
save(...)
stop(...)
active_threads(...)
alive_threads(...)
approve(...)
reject(...)
edit(...)
respond(...)4.4 Explicit state ownership
Each runtime abstraction must have one clear source of truth.
- Agent thread: thread conversation state.
- Team thread: shared team-chat history.
- Workflow thread: workflow state.
- Fleet: job and scheduling state.
4.5 Extensible, not coupled
AgentEx core must not be tightly coupled to:
- OpenAI;
- Anthropic;
- Neo4j;
- Postgres;
- a specific vector store;
- a specific telemetry UI;
- a specific MCP implementation.
All external integrations should use behaviours and adapters.
4.6 Safe defaults
Potentially dangerous actions should support approval requirements.
Concurrency, loops, retries, costs, and tool calls should have configurable limits.
4.7 Stateless by default, stateful by choice
run/2 should be ephemeral by default.
prompt/3 should be used when the caller explicitly wants a persistent thread.
5. Core terminology
5.1 Agent
An agent is a reusable behavior definition.
It contains:
- a model;
- a system prompt;
- tools;
- callbacks;
- limits;
- memory configuration;
- persistence configuration.
An agent is not a process and is not a single conversation.
The Elixir module is the agent definition.
ResearchAgent
SecurityAgent
WriterAgent5.2 Thread
A thread is one stateful instance of an agent, team, or workflow.
It contains:
- an ID;
- state;
- lifecycle metadata;
- pending approvals;
- checkpoints;
- runtime status.
A thread is represented by a supervised process while alive.
5.3 Team
A team is a reusable collection of agent participants operating in one shared chat.
The shared team-chat history is the source of truth.
Participants do not maintain separate hidden conversation histories by default.
5.4 Workflow
A workflow is an explicit stateful execution structure containing:
- workflow state;
- steps;
- transitions;
- named parallel splits;
- joins;
- conditional routing;
- cycles;
- interrupts;
- checkpoints.
The public abstraction is called Workflow, although it may internally compile into a graph.
5.5 Fleet
A Fleet coordinates large numbers of agent, team, or workflow jobs.
It provides:
- bounded concurrency;
- queueing;
- retries;
- status;
- pause and resume;
- cancellation;
- result collection;
- result streaming.
5.6 Tool
A tool is a capability exposed to a model.
Tools may be:
- Elixir modules;
- Elixir functions;
- anonymous functions;
- MCP tools.
5.7 Provider
A provider connects AgentEx to a model backend.
Examples:
- OpenAI;
- Anthropic;
- Google;
- OpenRouter;
- local OpenAI-compatible servers;
- custom providers.
6. Target users
Primary users
- Elixir and Phoenix developers;
- teams building AI-powered SaaS products;
- developers building multi-agent systems;
- developers building persistent assistants;
- infrastructure engineers building high-concurrency agent systems;
- companies that want AI agents inside existing Elixir applications.
Secondary users
- researchers experimenting with agent architectures;
- developers building coding agents;
- developers building self-hosted personal agents;
- teams migrating agent systems away from Python.
7. Main use cases
7.1 Basic agent
Run one model with tools.
ResearchAgent.run("Research Phoenix.")7.2 Stateful assistant
Maintain a persistent conversation.
ResearchAgent.prompt(
{:new, "phoenix"},
"Research Phoenix."
)
ResearchAgent.prompt(
"phoenix",
"Who maintains it?"
)7.3 Approval-driven automation
Allow the model to prepare an action but require human approval before execution.
tool SendEmail,
approval: :required7.4 Agentic RAG
Expose document retrieval as a normal tool.
tools [
SearchDocuments
]7.5 Shared-chat multi-agent collaboration
Allow researcher, security, and writer agents to collaborate in one canonical conversation.
7.6 Explicit workflow
Run agents, teams, tools, functions, and sub-workflows according to explicit transitions.
7.7 Large-scale execution
Run thousands of independent agent threads with bounded provider concurrency.
7.8 MCP integration
Import tools exposed by external MCP servers and use them as normal AgentEx tools.
7.9 Coding-agent platform
Build a Claude Code-style product using:
- filesystem tools;
- shell tools;
- code search;
- patching;
- testing;
- approvals;
- persistent threads;
- terminal UI.
7.10 Personal-agent platform
Build an OpenClaw-style product using:
- persistent agents;
- scheduled jobs;
- WhatsApp, Telegram, or Slack integrations;
- AgentEx tools;
- MCP;
- memory;
- a Phoenix gateway;
- a dashboard.
8. Agent definition API
8.1 Basic definition
defmodule ResearchAgent do
use AgentEx,
memory: :ets,
persistence:
{:checkpoint,
every: :timer.minutes(5),
changes: 10,
idle: :timer.minutes(1),
on: [:approval, :stop]}
model "openai:gpt-5"
system """
You are an open-source research assistant.
"""
tools [
SearchGitHub,
RepoStats
]
end8.2 Agent configuration
Agent definitions should support:
model "provider:model-name"
system "..."
tools [...]
memory :memory
memory :ets
memory MyCustomMemory
persistence :none
persistence :always
persistence {:checkpoint, ...}
max_steps 20
max_tool_calls 10
timeout :timer.minutes(2)
max_cost_usd 0.50
before_run &callback/1
after_run &callback/1
before_model &callback/1
after_model &callback/1
before_tool &callback/1
after_tool &callback/19. Model providers
9.1 Provider namespace
Provider-related modules must use the AgentEx.Provider prefix.
AgentEx.Provider
AgentEx.Provider.Registry
AgentEx.Provider.Response
AgentEx.Provider.Stream
AgentEx.Provider.LocalOptional provider packages expose:
AgentEx.Provider.OpenAI
AgentEx.Provider.Anthropic
AgentEx.Provider.Google
AgentEx.Provider.OpenRouter9.2 Provider resolution
model "openai:gpt-5"is parsed into:
%AgentEx.Model{
provider: AgentEx.Provider.OpenAI,
name: "gpt-5",
options: []
}Provider resolution order:
Explicit provider module
→ application provider registry
→ built-in provider registry
→ unknown-provider error9.3 Separate provider packages
AgentEx core should remain small.
Suggested packages:
agent_ex
agent_ex_openai
agent_ex_anthropic
agent_ex_google
agent_ex_openrouter9.4 Built-in local provider
Core should include a local OpenAI-compatible provider.
model "local:qwen3",
base_url: "http://localhost:11434/v1"It should support services such as:
- Ollama;
- llama.cpp servers;
- vLLM;
- LocalAI;
- other OpenAI-compatible endpoints.
9.5 Custom providers
defmodule MyApp.CustomProvider do
@behaviour AgentEx.Provider
@impl true
def call(model, messages, tools, opts) do
...
end
endDirect usage:
model MyApp.CustomProvider,
name: "reasoner-v2"Registered usage:
config :agent_ex, :providers,
acme: MyApp.CustomProvidermodel "acme:reasoner-v2"9.6 Provider responsibilities
A provider must:
- Convert AgentEx messages to provider format.
- Convert AgentEx tool specifications to provider format.
- Call the provider API.
- Normalize text responses.
- Normalize tool calls.
- Normalize token and cost usage.
- Support streaming where available.
- Return typed errors.
10. Tool API
10.1 Module tools
defmodule SearchGitHub do
use AgentEx.Tool
name :search_github
description "Search GitHub repositories."
input do
field :query, :string, required: true
field :limit, :integer, default: 10
end
def call(%{query: query, limit: limit}, context) do
GitHub.search(query,
limit: limit,
user_id: context.user_id
)
end
endThe input declaration is optional. When it is omitted, AgentEx should use an
empty object schema and call the tool with %{}.
defmodule CurrentTime do
use AgentEx.Tool
description "Get the current UTC date and time."
def call(%{}, _context) do
{:ok, DateTime.utc_now()}
end
endTool names may be atoms or strings.
They must be normalized internally to strings.
If a module does not explicitly declare a name, AgentEx should derive one from the module name.
RepoStats → repo_stats10.2 Function tools
tool :search_docs,
description: "Search internal documents.",
input: [
query: [type: :string, required: true]
],
call: &search_docs/2Anonymous function:
tool :current_time,
description: "Return the current application time.",
input: [],
call: fn _args, _context ->
DateTime.utc_now()
end10.3 Multiple tool declarations
tools [
SearchGitHub,
{RepoStats,
name: :repository_stats},
{SendMessage,
approval: :required},
{:get_maintainers,
description: "Get repository maintainers.",
input: [
repository: [type: :string, required: true]
],
call: &get_maintainers/2}
]Supported forms:
ToolModule
{ToolModule, opts}
{:function_tool_name, opts}
{:mcp, server_name, opts}Duplicate normalized names must cause a compile-time error where possible.
10.4 Tool callbacks
Tool module callbacks:
before_call &validate/1
after_call &audit/1Registration callbacks:
tool SendMessage,
before_call: &check_permissions/1,
after_call: &record_delivery/1Agent-level callbacks:
before_tool &log_tool/1
after_tool &record_tool_result/1Callbacks should return either:
contextor:
{:halt, reason}10.5 Tool execution options
tool SearchGitHub,
timeout: 15_000,
retries: 2,
backoff: :exponential10.6 Tool normalization
All tools should become:
%AgentEx.Tool.Spec{
name: "search_github",
description: "...",
input_schema: %{...},
executor: ...
}The main agent loop must not need to know whether the executor is:
- a module;
- a function;
- an MCP call.
11. MCP support
11.1 Purpose
AgentEx should act as an MCP client so external MCP tools can be used like normal AgentEx tools.
MCP should support:
- tools;
- resources;
- prompts;
- stdio transport;
- HTTP transport.
11.2 Inline MCP server
mcp :github,
command: "github-mcp-server",
args: ["stdio"]11.3 Remote MCP server
mcp :internal,
url: "https://mcp.example.com",
headers: fn context ->
[
{"authorization", "Bearer #{context.access_token}"}
]
end11.4 MCP tool declaration
tool :search_repositories,
mcp: :githubRename locally:
tool :github_search,
mcp: :github,
remote: :search_repositoriesNormal tool options remain available:
tool :create_issue,
mcp: :github,
approval: :required,
timeout: 30_000,
retries: 211.5 Import MCP tools
Import all:
tools [
{:mcp, :github}
]Import selected:
tools [
{:mcp, :github,
only: [
:search_repositories,
:get_issue,
:create_issue
]}
]Exclude tools:
tools [
{:mcp, :github,
except: [:delete_repository]}
]11.6 Reusable MCP module
defmodule MyApp.GitHubMCP do
use AgentEx.MCP
transport :stdio,
command: "github-mcp-server",
args: ["stdio"]
endUsage:
tools [
{MyApp.GitHubMCP,
only: [:search_repositories, :get_issue]}
]11.7 MCP tool execution
MCP tools should normalize into ordinary tool specs:
%AgentEx.Tool.Spec{
name: "search_repositories",
description: "...",
input_schema: %{...},
executor:
{AgentEx.MCP.Tool,
server: :github,
remote_name: "search_repositories"}
}11.8 Future MCP server support
A later version may allow AgentEx to expose:
- tools;
- agents;
- teams;
- workflows;
as an MCP server.
This is not required for the first release.
12. Messages
12.1 Message structure
%AgentEx.Message{
role: :user,
content: "Hello",
metadata: %{}
}Supported roles:
:system
:user
:assistant
:tool12.2 Constructors
AgentEx.Message.system(content)
AgentEx.Message.user(content)
AgentEx.Message.assistant(content)
AgentEx.Message.tool(content, opts)12.3 Multiple-message input
ResearchAgent.run([
system: "Be concise.",
user: "Tell me about Phoenix.",
assistant: "Phoenix is an Elixir framework.",
user: "Who created it?"
])The shorthand list must normalize into %AgentEx.Message{} values.
12.4 Metadata
Messages may support metadata such as:
- sender;
- participant;
- tool call ID;
- provider ID;
- timestamps;
- attachments;
- tracing attributes.
Metadata should be optional and must not complicate simple usage.
13. Agent execution model
13.1 One-off execution
ResearchAgent.run("What is Phoenix?")Default behavior:
Create temporary execution state
→ run model/tool loop
→ return response
→ terminate executionProperties:
- no persistent thread process;
- no generated thread ID;
thread_idisnil;- no state is persisted by default;
- does not accept a PID;
savedefaults tofalse.
Response:
%AgentEx.Response{
content: "Phoenix is...",
message: %AgentEx.Message{},
thread_id: nil,
tool_calls: [],
usage: %{}
}13.2 Run against stored state
ResearchAgent.run(
"Summarize the thread.",
thread: {:existing, "phoenix"},
save: false
)Persist the resulting state:
ResearchAgent.run(
"Check for updates.",
thread: {:existing, "phoenix"},
save: true
)This should load state, execute ephemerally, optionally save, and exit without leaving a live thread worker.
13.3 Stateful prompt
Generated thread ID:
ResearchAgent.prompt(
:new,
"Research Phoenix."
)Supplied ID:
ResearchAgent.prompt(
{:new, "phoenix"},
"Research Phoenix."
)Continue:
ResearchAgent.prompt(
"phoenix",
"Who maintains it?"
)By PID:
ResearchAgent.prompt(
pid,
"Continue."
)13.4 Thread collision behavior
Creating a supplied thread ID that already exists must return:
{:error, :thread_already_exists}Continuing an unknown thread must return:
{:error, :thread_not_found}13.5 Thread ID generator
Agents should support a custom thread ID generator.
use AgentEx,
thread_id_generator: &MyApp.ThreadIDs.generate/1The generator should receive enough context to generate stable IDs, including:
- agent module;
- creation options;
- optional caller context.
14. Agent loop
The core agent loop should behave as follows:
Input messages
→ call provider
→ receive assistant response or tool calls
→ execute tools
→ append tool results
→ call provider again
→ repeat
→ final assistant responseThe loop must support:
- multiple tool calls;
- sequential tool rounds;
- streaming;
- approvals;
- limits;
- callbacks;
- usage collection;
- tracing;
- errors;
- cancellation.
15. Thread architecture
15.1 Per-agent supervision tree
Adding an agent to an application supervisor:
children = [
ResearchAgent
]should start the agent’s internal runtime infrastructure.
Conceptually:
ResearchAgent.Supervisor
├── Registry
├── DynamicSupervisor
└── CheckpointManagerResearchAgent.start_link/1 starts the infrastructure, not a conversation thread.
Threads are started through prompt/3.
15.2 Thread registration
Each live thread should be registered using:
{ResearchAgent, thread_id}15.3 Thread lookup behavior
Thread is alive
→ route request to existing process
Thread exists but is not alive
→ load state
→ start process
→ execute request
Thread does not exist
→ return error15.4 Prompt behavior while running
Default behavior:
idle thread → start a new run
running thread → queue prompt
interrupted thread→ await approval or resume inputA later version may allow configurable policies:
:queue
:reject
:interrupt16. Thread lifecycle
16.1 Active
active means:
This thread is intended to remain available and be restored after an application restart.
It is a persisted desired state.
16.2 Alive
alive means:
This thread currently has a running process.
It is derived from the runtime Registry and is never persisted.
16.3 Example restart lifecycle
Before restart:
active: true
alive: trueImmediately after restart:
active: true
alive: falseAfter restoration:
active: true
alive: true16.4 In-memory backend restart behavior
With:
memory: :memoryall thread state and lifecycle metadata are lost after a full application restart.
No thread can be restored.
16.5 Stop behavior
ResearchAgent.stop("phoenix")must:
- Persist
active: false. - Save or checkpoint state.
- Terminate the worker.
Persisting inactive status must occur before process termination.
16.6 Listing
ResearchAgent.active_threads()
ResearchAgent.alive_threads()
ResearchAgent.thread("phoenix")17. Memory
17.1 Process memory
memory: :memoryCharacteristics:
- state lives in the thread process;
- fastest simple option;
- lost when process/application state is lost;
- no restoration after a full restart.
17.2 ETS memory
memory: :etsCharacteristics:
- shared in-memory storage;
- fast concurrent access;
- survives individual thread process restarts;
- lost after a complete BEAM restart unless paired with an external persistence backend.
17.3 Custom backend
memory: MyApp.AgentMemorySuggested behaviour:
@callback load(agent, thread_id, opts) ::
{:ok, state}
| :not_found
| {:error, term()}
@callback save(agent, thread_id, state, opts) ::
:ok
| {:error, term()}
@callback set_active(agent, thread_id, boolean, opts) ::
:ok
| {:error, term()}
@callback active_threads(agent, opts) ::
{:ok, [thread_id]}
| {:error, term()}17.4 Memory modes
The system should clearly separate:
Working memory
= current state used by the live process
Persistence backend
= durable external state
Checkpoint policy
= when working memory is flushed18. Persistence
18.1 None
persistence: :noneNo automatic persistence of conversation state.
Manual save remains possible where a backend supports it.
18.2 Always
persistence: :alwaysPersist every state change.
18.3 Checkpoint
persistence:
{:checkpoint,
every: :timer.minutes(5),
changes: 10,
idle: :timer.minutes(1),
on: [:approval, :stop]}A checkpoint occurs when any configured trigger matches.
Supported triggers:
- time interval;
- state-change count;
- idle duration;
- specific events.
Potential events:
:approval
:interrupt
:tool_call
:stop
:end18.4 Lifecycle metadata
Lifecycle metadata must persist immediately regardless of conversation-state policy.
Examples:
active
inactive
created_at
updated_atConversation state follows the configured persistence policy.
Examples:
messages
tool history
agent state
workflow state
team history18.5 Manual saving
ResearchAgent.save()
ResearchAgent.save("phoenix")
ResearchAgent.save(pid)save/0 saves all dirty threads for the agent.
19. Approvals
19.1 Tool declaration
tool SendMessage,
approval: :required19.2 Approval result
When the model requests the tool:
{:approval_required, approval}19.3 Approval structure
%AgentEx.Approval{
id: "approval-123",
agent: ResearchAgent,
thread_id: "phoenix",
tool: "send_message",
arguments: %{}
}19.4 Supported decisions
Approve:
ResearchAgent.approve(approval.id)Reject:
ResearchAgent.reject(
approval.id,
reason: "Do not send it."
)Edit and execute:
ResearchAgent.edit(
approval.id,
arguments: updated_arguments
)Respond to model:
ResearchAgent.respond(
approval.id,
"Rewrite the message."
)19.5 Allowed decisions
Tools may restrict available approval decisions.
tool SendMessage,
approval: [
allowed_decisions: [
:approve,
:edit,
:reject,
:respond
]
]19.6 Persistence
An approval interruption must checkpoint enough state to resume the exact run after:
- process restart;
- application restart;
- delayed user decision.
20. Streaming and events
20.1 Agent streaming
ResearchAgent.prompt(
"phoenix",
"Continue.",
stream: fn
{:token, token} ->
IO.write(token)
{:tool_started, call} ->
IO.inspect(call)
{:tool_finished, result} ->
IO.inspect(result)
{:approval_required, approval} ->
IO.inspect(approval)
{:completed, response} ->
IO.inspect(response)
end
)20.2 Event model
AgentEx should expose normalized events such as:
run_started
provider_started
token
tool_started
tool_finished
approval_required
thread_started
checkpoint_saved
team_message
workflow_step_started
workflow_split_started
workflow_join_completed
run_completed
run_failed20.3 Event consumers
Events should be usable by:
- LiveView;
- terminal interfaces;
- logs;
- telemetry;
- OpenTelemetry;
- dashboards;
- audit systems.
21. Teams
21.1 Team mental model
A team is one shared chat with many participants.
Team thread
├── user
├── researcher
├── security
└── writerThe canonical shared history is the source of truth.
Participants do not keep separate hidden chat histories by default.
21.2 Participant ownership
Each participant owns only its configuration:
- model;
- system prompt;
- tools;
- permissions;
- participant-specific glue.
21.3 Team message
%AgentEx.Message{
role: :assistant,
sender: :security,
content: "I found one dependency concern."
}21.4 Team modes
Teams support:
:coordinated
:broadcast22. Coordinated teams
22.1 Definition
defmodule ReviewTeam do
use AgentEx.Team
participants [
researcher: ResearchAgent,
security: SecurityAgent,
writer: WriterAgent
]
coordinator :researcher
end22.2 Flow
User message
→ coordinator
→ coordinator delegates work
→ participants post updates
→ coordinator decides when finished
→ final response22.3 Delegation results
Coordinator actions may include:
{:delegate,
[
{:researcher, "Check repository activity."},
{:security, "Check known advisories."}
]}Parallel delegation:
{:parallel,
[
{:researcher, "Inspect maintenance activity."},
{:security, "Inspect vulnerabilities."}
]}Finish:
{:done, "Final answer"}23. Broadcast teams
23.1 Definition
defmodule ReviewTeam do
use AgentEx.Team,
memory: :ets
participants [
researcher: ResearchAgent,
security: SecurityAgent,
writer: WriterAgent
]
mode :broadcast
glue """
A new message was added to the shared chat.
Sender: {{sender}}
Message: {{message}}
Read the full shared history.
Interpret this event from your role.
Respond when useful.
Otherwise return :pass.
"""
max_rounds 5
end23.2 Glue prompt
The glue prompt is the adapter between a committed shared-chat message and a participant invocation.
It should provide:
- sender;
- new message;
- team identity;
- participant identity;
- full shared history;
- instructions for interpreting the update.
Effective participant invocation:
Participant system prompt
+ team glue prompt
+ participant glue override
+ canonical shared history
+ new-message event23.3 Broadcast flow
New message committed
→ render glue prompt
→ invoke eligible participants
→ participant responds or passes
→ responses are committed
→ new responses become events
→ repeat23.4 Participant results
{:message, "I found two risks."}
:pass
:doneTargeted result:
{:message,
"Verify this dependency.",
audience: [:security]}23.5 Loop prevention
Broadcast teams must stop when:
- every eligible participant returns
:pass; - a participant returns
:done; max_roundsis reached;- an approval is required;
- an interrupt occurs;
- the user stops the thread.
23.6 Participant options
participant :security, SecurityAgent,
receive_from: [:user, :researcher],
glue: """
Interpret updates for security implications.
""",
max_responses: 3Array form:
participants [
{:researcher, ResearchAgent},
{:security, SecurityAgent,
receive_from: [:user, :researcher]},
{:writer, WriterAgent,
receive_from: :all}
]23.7 Team API
ReviewTeam.prompt(thread, input, opts \\ [])
ReviewTeam.save()
ReviewTeam.save(thread)
ReviewTeam.stop(thread)
ReviewTeam.thread(thread_id)
ReviewTeam.active_threads()
ReviewTeam.alive_threads()
ReviewTeam.approve(approval_id, opts \\ [])
ReviewTeam.reject(approval_id, opts \\ [])
ReviewTeam.edit(approval_id, opts)
ReviewTeam.respond(approval_id, message)24. Workflows
24.1 Purpose
Workflows provide explicit, stateful execution similar in capability to LangGraph while using AgentEx terminology.
The public vocabulary is:
workflow
state
step
transition
split
join24.2 Workflow state
The workflow owns its state.
state do
field :repository, :string, required: true
field :research, :string
field :security, :string
field :critical?, :boolean, default: false
field :findings, {:array, :map},
default: [],
reducer: &Kernel.++/2
endSteps receive state and return partial state updates.
24.3 Full example
defmodule ReviewWorkflow do
use AgentEx.Workflow,
memory: :ets,
persistence:
{:checkpoint,
on: [:interrupt, :approval, :stop, :end]}
state do
field :repository, :string, required: true
field :research, :string
field :security, :string
field :quality, :string
field :critical?, :boolean, default: false
field :report, :string
field :findings, {:array, :map},
default: [],
reducer: &Kernel.++/2
end
step :research, &research/1
step :security,
agent: SecurityAgent,
input: &security_prompt/1,
output: &store_security/2
step :quality,
agent: QualityAgent,
input: &quality_prompt/1,
output: :quality
step :escalate,
tool: NotifySecurityTeam,
input: &escalation_input/1
step :report,
team: ReportTeam,
input: &report_prompt/1,
output: :report
start :research
split :review_checks,
from: :research,
to: [:security, :quality]
join :review_checks,
to: :route_review,
wait: :all
step :route_review, &route_review/1
transition :route_review,
when: &critical?/1,
to: :escalate
transition :route_review,
otherwise: :report
transition :escalate, to: :report
transition :report, to: :end
end25. Workflow step types
25.1 Function step
step :prepare, &prepare/1def prepare(state) do
%{repository: String.trim(state.repository)}
end25.2 Inline step
step :prepare, fn state ->
%{repository: String.trim(state.repository)}
end25.3 Agent step
step :research,
agent: ResearchAgent,
input: &research_prompt/1,
output: :researchAgent steps should be stateless by default.
Internally they call:
ResearchAgent.run(prompt)The workflow remains the state source of truth.
25.4 Stateful agent step
step :research,
agent: ResearchAgent,
thread: fn state ->
"research-#{state.repository}"
end,
input: &research_prompt/1,
output: :researchThis explicitly introduces a second state layer and should be opt-in.
25.5 Team step
step :review,
team: ReviewTeam,
input: &review_prompt/1,
output: :review25.6 Tool step
step :notify,
tool: SendMessage,
input: ¬ification_input/125.7 Sub-workflow step
step :audit,
workflow: AuditWorkflow,
input: &audit_input/1,
output: :audit26. Workflow input and output mapping
26.1 Input mapping
step :security,
agent: SecurityAgent,
input: fn state ->
"""
Repository: #{state.repository}
Research:
#{state.research}
"""
endWithout an input mapper, the full workflow state may be passed.
26.2 Output key
output: :securityThis stores the normalized response under state.security.
26.3 Output function
output: fn response, state ->
%{
security: response.content,
critical?: response.metadata[:critical?] || false
}
end26.4 Ignore output
output: :ignore27. Workflow transitions
27.1 Start
start :research27.2 Normal transition
transition :research, to: :security
transition :report, to: :end27.3 Conditional transition
transition :security,
when: &critical?/1,
to: :escalate
transition :security,
otherwise: :reportConditions are evaluated in declaration order.
27.4 Dynamic transition
transition :classify,
to: &route/1def route(state) do
cond do
state.critical? -> :escalate
state.needs_review? -> :review
true -> :report
end
end27.5 Cycles
transition :write, to: :review
transition :review,
when: &needs_revision?/1,
to: :write
transition :review,
otherwise: :endCycle protection:
max_steps 50
max_visits :write, 528. Named parallel splits and joins
28.1 Named split
split :review_checks,
from: :research,
to: [:security, :quality]The split name prevents the caller from having to repeat branch lists manually.
28.2 Join
join :review_checks,
to: :combine,
wait: :allSupported join policies:
wait: :all
wait: :any
wait: {:count, 2}Default:
wait: :all28.3 Conditional split
split :required_checks,
from: :classify,
to: &required_checks/1The runtime must store the branches selected for that specific run.
The matching join waits only for selected branches.
28.4 Parallel state snapshots
Every branch receives the same state snapshot taken at split time.
28.5 State merging
Different keys merge normally.
%{security: result}
%{quality: result}Concurrent writes to the same key require a reducer.
field :findings, {:array, :map},
default: [],
reducer: &Kernel.++/2Without a reducer:
{:error, {:state_conflict, :findings}}28.6 Runtime split state
Conceptually:
%AgentEx.Workflow.Split{
name: :review_checks,
selected_branches: [:security, :quality],
completed: MapSet.new(),
results: %{}
}29. Workflow interrupts
29.1 Create interrupt
def review(state) do
AgentEx.Workflow.interrupt(
:human_review,
%{report: state.report}
)
endResult:
{:interrupted, interrupt}29.2 Resume
ReviewWorkflow.resume(
interrupt.id,
%{approved: true}
)29.3 Checkpoint requirements
A workflow checkpoint must include:
- workflow state;
- current step;
- completed steps;
- active splits;
- selected branches;
- completed branches;
- pending joins;
- approvals;
- interrupts;
- execution limits;
- relevant usage metadata.
30. Workflow errors
30.1 Per-step retry
step :security,
agent: SecurityAgent,
retries: 3,
backoff: :exponential,
timeout: 30_00030.2 Error transition
transition :security,
on_error: :security_failed30.3 Global error handler
on_error &handle_error/2Possible return values:
{:retry, opts}
{:continue, next_step, state_update}
{:halt, reason}31. Workflow API
Workflow.run(input, opts \\ [])
Workflow.prompt(
thread,
input,
opts \\ []
)
Workflow.resume(
interrupt_id,
input,
opts \\ []
)
Workflow.save()
Workflow.save(thread)
Workflow.stop(thread)
Workflow.thread(thread_id)
Workflow.active_threads()
Workflow.alive_threads()
Workflow.approve(approval_id, opts \\ [])
Workflow.reject(approval_id, opts \\ [])
Workflow.edit(approval_id, opts)
Workflow.respond(approval_id, message)32. Fleet
32.1 Purpose
Fleet coordinates many independent agent, team, or workflow executions.
It is not another type of agent.
It is an operational scheduler.
32.2 Definition
defmodule ResearchFleet do
use AgentEx.Fleet,
agent: ResearchAgent,
max_running: 100,
max_queue: 10_000
endPotential targets:
agent: ResearchAgent
team: ReviewTeam
workflow: ReviewWorkflow32.3 Jobs
jobs =
for id <- 1..4_000 do
%{
thread: {:new, "thread-#{id}"},
input: "Research project #{id}"
}
end32.4 Start
{:ok, run} =
ResearchFleet.start(jobs)32.5 Run state
%AgentEx.Fleet.Run{
id: "fleet-run-123",
total: 4_000,
queued: 3_900,
running: 100,
completed: 0,
failed: 0
}32.6 Control API
ResearchFleet.status(run.id)
ResearchFleet.results(run.id)
ResearchFleet.stream(run.id)
ResearchFleet.pause(run.id)
ResearchFleet.resume(run.id)
ResearchFleet.cancel(run.id)
ResearchFleet.cancel(run.id, force: true)
ResearchFleet.retry_failed(
run.id,
max_attempts: 3
)
ResearchFleet.save()
ResearchFleet.save(run.id)32.7 Concurrency
The framework must distinguish:
4,000 alive threads
≠
4,000 simultaneous model callsFleet should enforce bounded provider and tool concurrency.
32.8 Implementation recommendation
Initial implementation:
GenServerqueue;Task.Supervisor;- running counters;
- retry tracking;
- process monitoring.
GenStage is not required for core workflows.
It may be considered later for Fleet if continuous producer-consumer backpressure becomes necessary.
33. RAG
33.1 Initial support
AgentEx supports agentic RAG through tools.
defmodule SearchDocuments do
use AgentEx.Tool
name :search_documents
description "Retrieve relevant documents."
input do
field :query, :string, required: true
end
def call(%{query: query}, _context) do
MyVectorStore.search(query, top_k: 5)
end
end33.2 Flow
Model decides retrieval is needed
→ calls retrieval tool
→ tool returns chunks
→ chunks enter model context
→ model answers33.3 Out of initial core scope
The first release does not need a large dedicated RAG subsystem for:
- ingestion;
- chunking;
- embedding;
- vector database management;
- document lifecycle management.
These may be added later as optional packages or helper behaviours.
34. Context and callbacks
34.1 Execution context
AgentEx should support a context map or struct containing application-owned data.
Examples:
user_id
tenant_id
permissions
access_token
request_id
locale
metadataThe context should be available to:
- tools;
- MCP header callbacks;
- callbacks;
- providers where needed;
- approval policies.
34.2 Callback pipeline
Supported callbacks:
before_run
after_run
before_model
after_model
before_tool
after_tool
before_step
after_stepCallbacks should be composable and should support halting execution.
35. Limits and safety controls
Agent and workflow execution should support:
max_steps 20
max_tool_calls 10
timeout :timer.minutes(2)
max_cost_usd 0.50Workflow-specific:
max_visits :write, 5Team-specific:
max_rounds 5Fleet-specific:
max_running 100
max_queue 10_000Additional future controls may include:
- maximum parallel branches;
- maximum tokens;
- maximum retries;
- provider-specific rate limits;
- tool allowlists;
- tool denylists;
- per-user budgets.
36. Telemetry and tracing
36.1 Telemetry events
AgentEx should emit :telemetry events by default. Telemetry can be disabled
application-wide:
config :agent_ex, telemetry: falseAn agent can override the application setting:
defmodule QuietAgent do
use AgentEx, telemetry: false
endSetting use AgentEx, telemetry: true on an agent enables its telemetry even
when the application-wide setting is disabled.
AgentEx should emit events such as:
[:agent_ex, :run, :start]
[:agent_ex, :run, :stop]
[:agent_ex, :run, :exception]
[:agent_ex, :provider, :start]
[:agent_ex, :provider, :stop]
[:agent_ex, :provider, :exception]
[:agent_ex, :tool, :start]
[:agent_ex, :tool, :stop]
[:agent_ex, :tool, :exception]
[:agent_ex, :thread, :start]
[:agent_ex, :thread, :stop]
[:agent_ex, :checkpoint, :start]
[:agent_ex, :checkpoint, :stop]
[:agent_ex, :team, :message]
[:agent_ex, :workflow, :step, :start]
[:agent_ex, :workflow, :step, :stop]
[:agent_ex, :workflow, :split, :start]
[:agent_ex, :workflow, :join, :stop]36.2 Metadata
Telemetry metadata should include where relevant:
- agent module;
- team module;
- workflow module;
- thread ID;
- provider;
- model;
- tool name;
- step name;
- split name;
- duration;
- token usage;
- cost;
- error category.
36.3 OpenTelemetry
OpenTelemetry support should be optional and built on top of :telemetry.
A tracing UI is not required for the first release.
37. Supervision
37.1 Application supervision
def start(_type, _args) do
children = [
ResearchAgent,
SecurityAgent,
ReviewTeam,
ReviewWorkflow,
ResearchFleet
]
Supervisor.start_link(
children,
strategy: :one_for_one,
name: MyApp.Supervisor
)
endEach top-level module exposes:
child_spec/1
start_link/137.2 Internal responsibilities
Supervisors are responsible for process lifecycle.
Memory backends are responsible for state persistence.
Workers are responsible for loading their state during initialization.
37.3 Restoration
AgentEx should restore threads where:
active = trueRestoration may initially be eager for active threads.
A later version may support:
restore: :lazy
restore: :active
restore: :allThe definition of active must remain consistent.
38. Proposed module structure
Core runtime
AgentEx
AgentEx.Agent
AgentEx.Runtime
AgentEx.Runner
AgentEx.Loop
AgentEx.Context
AgentEx.Response
AgentEx.ErrorProviders
AgentEx.Provider
AgentEx.Provider.Registry
AgentEx.Provider.Response
AgentEx.Provider.Stream
AgentEx.Provider.Local
AgentEx.ModelMessages
AgentEx.Message
AgentEx.Message.ToolCall
AgentEx.Message.ToolResultTools
AgentEx.Tool
AgentEx.Tool.Spec
AgentEx.Tool.Registry
AgentEx.Tool.Executor
AgentEx.Tool.Context
AgentEx.Tool.Result
AgentEx.Tool.ErrorMCP
AgentEx.MCP
AgentEx.MCP.Client
AgentEx.MCP.Connection
AgentEx.MCP.Registry
AgentEx.MCP.Tool
AgentEx.MCP.Resource
AgentEx.MCP.Prompt
AgentEx.MCP.Transport
AgentEx.MCP.Transport.Stdio
AgentEx.MCP.Transport.HTTPThreads
AgentEx.Thread
AgentEx.Thread.Server
AgentEx.Thread.Supervisor
AgentEx.Thread.Registry
AgentEx.Thread.Manager
AgentEx.Thread.State
AgentEx.Thread.ID
AgentEx.Thread.MetadataMemory and persistence
AgentEx.Memory
AgentEx.Memory.Process
AgentEx.Memory.ETS
AgentEx.Persistence
AgentEx.Persistence.Policy
AgentEx.Persistence.None
AgentEx.Persistence.Always
AgentEx.Persistence.Checkpoint
AgentEx.Checkpoint
AgentEx.Checkpoint.SchedulerApprovals and interrupts
AgentEx.Approval
AgentEx.Approval.Store
AgentEx.Approval.Decision
AgentEx.Interrupt
AgentEx.Interrupt.StoreTeams
AgentEx.Team
AgentEx.Team.Server
AgentEx.Team.Supervisor
AgentEx.Team.Registry
AgentEx.Team.Thread
AgentEx.Team.State
AgentEx.Team.Participant
AgentEx.Team.Glue
AgentEx.Team.Router
AgentEx.Team.Coordinator
AgentEx.Team.BroadcastWorkflows
AgentEx.Workflow
AgentEx.Workflow.Server
AgentEx.Workflow.Supervisor
AgentEx.Workflow.Registry
AgentEx.Workflow.State
AgentEx.Workflow.Step
AgentEx.Workflow.Transition
AgentEx.Workflow.Split
AgentEx.Workflow.Join
AgentEx.Workflow.Router
AgentEx.Workflow.Executor
AgentEx.Workflow.Result
AgentEx.Workflow.CheckpointStep adapters:
AgentEx.Workflow.Step.Function
AgentEx.Workflow.Step.Agent
AgentEx.Workflow.Step.Team
AgentEx.Workflow.Step.Tool
AgentEx.Workflow.Step.WorkflowFleet
AgentEx.Fleet
AgentEx.Fleet.Server
AgentEx.Fleet.Supervisor
AgentEx.Fleet.Queue
AgentEx.Fleet.Job
AgentEx.Fleet.Run
AgentEx.Fleet.Status
AgentEx.Fleet.Result
AgentEx.Fleet.Scheduler
AgentEx.Fleet.WorkerEvents and observability
AgentEx.Event
AgentEx.Event.Stream
AgentEx.Event.Dispatcher
AgentEx.TelemetrySchemas
AgentEx.Schema
AgentEx.Schema.Field
AgentEx.Schema.Validator
AgentEx.Schema.Error39. Public APIs
39.1 Agent
Agent.run(input, opts \\ [])
Agent.prompt(
thread_id_or_pid,
input,
opts \\ []
)
Agent.save()
Agent.save(thread_id_or_pid)
Agent.stop(thread_id_or_pid)
Agent.thread(thread_id)
Agent.active_threads()
Agent.alive_threads()
Agent.approve(approval_id, opts \\ [])
Agent.reject(approval_id, opts \\ [])
Agent.edit(approval_id, opts)
Agent.respond(approval_id, message)39.2 Team
Team.prompt(thread, input, opts \\ [])
Team.save()
Team.save(thread)
Team.stop(thread)
Team.thread(thread_id)
Team.active_threads()
Team.alive_threads()
Team.approve(approval_id, opts \\ [])
Team.reject(approval_id, opts \\ [])
Team.edit(approval_id, opts)
Team.respond(approval_id, message)39.3 Workflow
Workflow.run(input, opts \\ [])
Workflow.prompt(
thread,
input,
opts \\ []
)
Workflow.resume(
interrupt_id,
input,
opts \\ []
)
Workflow.save()
Workflow.save(thread)
Workflow.stop(thread)
Workflow.thread(thread_id)
Workflow.active_threads()
Workflow.alive_threads()
Workflow.approve(approval_id, opts \\ [])
Workflow.reject(approval_id, opts \\ [])
Workflow.edit(approval_id, opts)
Workflow.respond(approval_id, message)39.4 Fleet
Fleet.start(jobs, opts \\ [])
Fleet.status(run_id)
Fleet.results(run_id)
Fleet.stream(run_id)
Fleet.pause(run_id)
Fleet.resume(run_id)
Fleet.cancel(run_id, opts \\ [])
Fleet.retry_failed(run_id, opts \\ [])
Fleet.save()
Fleet.save(run_id)40. Non-functional requirements
40.1 Performance
AgentEx should support thousands of idle or lightly active thread processes on a typical BEAM node.
The framework must avoid:
- one large global GenServer;
- serializing unrelated thread work;
- unnecessary process creation for one-off runs;
- copying full histories between multiple participant processes;
- unbounded model-call concurrency.
40.2 Fault isolation
A failure in one thread must not crash unrelated threads.
A failure in one workflow branch must be isolated and routed through workflow error handling.
A failure in one Fleet job must not terminate the entire Fleet run.
40.3 Determinism
Where concurrent results are merged, AgentEx must use deterministic rules.
For parallel workflow branches:
- updates to separate keys merge;
- shared-key updates require reducers;
- unresolved conflicts produce explicit errors.
40.4 Extensibility
Core behaviours should allow third parties to implement:
- providers;
- memory backends;
- persistence backends;
- MCP transports;
- tool schemas;
- event handlers;
- telemetry exporters.
40.5 Developer experience
Compile-time validation should catch:
- duplicate tool names;
- duplicate step names;
- unknown transition destinations;
- missing start steps;
- missing split joins where required;
- invalid join references;
- invalid provider names where statically known;
- malformed schemas.
Runtime errors should be typed and descriptive.
40.6 Documentation
Documentation must include:
- five-minute basic-agent guide;
- provider guide;
- tool guide;
- persistent-thread guide;
- approvals guide;
- team guide;
- workflow guide;
- Fleet guide;
- MCP guide;
- memory and persistence guide;
- Phoenix LiveView streaming example;
- production deployment guide.
41. First-release scope
Required for v0.1
use AgentEx;- model-provider behaviour;
- local provider;
- provider registry;
- messages;
- module tools;
- function tools;
- arrays of tool declarations;
- agent loop;
- one-off
run; - persistent
prompt; - thread supervision;
- Registry lookup;
- process and ETS memory;
- custom memory behaviour;
:none,:always, and checkpoint persistence;- manual saving;
- active and alive thread states;
- approvals;
- streaming;
- telemetry;
- basic MCP client support;
- coordinated or broadcast Team;
- Workflow with:
- state;
- function steps;
- agent steps;
- team steps;
- tool steps;
- transitions;
- conditional transitions;
- cycles;
- named splits;
- joins;
- state reducers;
- interrupts;
- checkpoint resume.
Candidate for v0.2
- Fleet;
- HTTP MCP improvements;
- MCP resources and prompts;
- cost budgets;
- richer structured outputs;
- provider-specific packages;
- Phoenix LiveView helpers;
- OpenTelemetry package;
- workflow visualization;
- improved thread restoration policies.
Future scope
- AgentEx as MCP server;
- graph memory;
- vector-memory helpers;
- distributed Fleet;
- distributed workflow execution;
- durable external queue integrations;
- scheduling and cron;
- skills/plugin ecosystem;
- tracing UI;
- agent marketplace;
- browser automation package;
- coding-agent toolkit;
- channel adapters.
42. Explicitly out of scope for initial release
AgentEx v0.1 should not attempt to provide:
- a complete vector database;
- a graph database;
- a full RAG ingestion platform;
- a managed tracing SaaS;
- a hosted model gateway;
- a WhatsApp implementation;
- a complete OpenClaw product;
- a complete Claude Code product;
- a custom replacement for OTP supervisors;
- a custom distributed queue;
- a Cypher-compatible graph engine.
AgentEx should provide the runtime needed to build those applications.
43. Success metrics
Developer adoption
- A developer can define and run a simple agent in under ten minutes.
- A developer can add a persistent thread without learning internal supervisors.
- A developer can convert a local tool into an MCP-backed tool without changing the agent loop.
- A developer can build a basic coordinated team in fewer than 30 lines of configuration.
- A developer can define a branching workflow without manually implementing process coordination.
Reliability
- A crashing thread restarts without taking down unrelated threads.
- Persisted active threads restore correctly.
- Approval interruptions resume from the correct point.
- Workflow checkpoints restore active split and join state.
- Fleet concurrency remains within configured bounds.
API simplicity
The number of primary concepts should remain limited to:
Agent
Thread
Tool
Provider
Team
Workflow
FleetThe public API should not require developers to manually construct:
- runners;
- executors;
- sessions;
- graph builders;
- orchestration contexts;
for ordinary use cases.
44. Acceptance criteria
Basic agent
Given a valid provider and agent definition:
ResearchAgent.run("Hello")returns:
{:ok, %AgentEx.Response{}}with thread_id: nil.
Persistent thread
Creating:
ResearchAgent.prompt(
{:new, "thread-1"},
"Hello"
)starts and registers a thread process.
Calling:
ResearchAgent.prompt(
"thread-1",
"Continue"
)uses the same persisted conversation state.
Collision handling
Creating the same supplied thread ID twice returns:
{:error, :thread_already_exists}Stop behavior
Calling:
ResearchAgent.stop("thread-1")persists inactive state before terminating the process.
Approval
A required-approval tool does not execute until approved.
Editing an approval executes the edited arguments, not the original arguments.
MCP
An imported MCP tool appears to the model as an ordinary AgentEx tool.
Approvals and callbacks work for MCP tools.
Team
Every participant sees the same canonical team history.
Participant messages are committed with the participant identity.
A broadcast team stops when all participants pass or when max_rounds is reached.
Workflow
A workflow step receives state and returns a partial update.
Conditional transitions choose the correct destination.
A named split starts selected branches in parallel.
A join waits according to its configured policy.
Concurrent writes without a reducer produce a state-conflict error.
An interrupted workflow can resume from its checkpoint.
Fleet
Fleet never runs more than max_running jobs concurrently.
Failed jobs can be retried without rerunning successful jobs.
45. Final product model
AgentEx
│
├── Provider
│ └── Connects models
│
├── Tool
│ └── Local functions, modules, or MCP
│
├── Agent
│ └── Reusable autonomous behavior
│
├── Thread
│ └── One stateful runtime instance
│
├── Team
│ └── One shared chat with many agents
│
├── Workflow
│ └── Explicit stateful execution
│
├── Fleet
│ └── Bulk scheduling and concurrency
│
├── Memory
│ └── State storage
│
├── Persistence
│ └── Direct or checkpointed durability
│
├── Approval
│ └── Human-controlled actions
│
├── MCP
│ └── External capabilities
│
└── Telemetry
└── Events, tracing, and monitoringAgentEx should remain easy to understand:
Agent
= what can act
Thread
= one ongoing instance
Team
= who collaborates
Workflow
= what happens next
Fleet
= how many run at once
Tool
= what they can do
Provider
= which model they use