Configure Deep Agent LangGraph agents in YAML and expose them via FastAPI.
composable-agents is a Python framework that lets you declare AI agents as simple YAML files and instantly expose them as a full-featured HTTP API. It is built on deepagents (LangGraph-based Deep Agent) with a strict hexagonal architecture, making every component testable and replaceable.
The server supports multi-agent mode: multiple agents are defined as separate YAML files in an agents/ directory, each thread is bound to a specific agent at creation time, and agents are lazily instantiated on first use. Sub-agents declared in the subagents config are fully traced: every event emitted by a sub-agent carries its name in the source field of the corresponding TraceEvent, so the client can group events per sub-agent (see TraceEvent).
- Python 3.11+
- UV package manager
- PostgreSQL 15+ (required for thread and agent config persistence)
- An API key for at least one LLM provider (Anthropic, OpenAI, or Google)
git clone https://github.com/your-org/composable-agents.git
cd composable-agents
uv sync
cp .env.example .envEdit .env and add your API key and database credentials:
OPENAI_API_KEY=sk-...
# PostgreSQL (required)
DATABASE_URL=postgresql://raganything:raganything@localhost:5433/raganything
⚠️ Breaking change: ThePOSTGRES_HOST,POSTGRES_PORT,POSTGRES_USER,POSTGRES_PASSWORD, andPOSTGRES_DATABASEenvironment variables have been replaced by a singleDATABASE_URLvariable. If you are upgrading from a previous version, construct yourDATABASE_URLaspostgresql://<user>:<password>@<host>:<port>/<database>and remove the oldPOSTGRES_*variables from your.env.
⚠️ Breaking change (trace events): The legacyStreamEventSSE format and themessagestable have been removed. The/streamand WebSocket endpoints now emitTraceEventJSON objects. See Breaking Changes for migration details.
Each agent is a standalone YAML file inside the agents/ directory. A minimal agent only needs a name. Create agents/my-agent.yaml:
name: my-agentOr use one of the provided examples in the agents/ directory (see Examples).
uv run python -m src validate agents/my-agent.yamluv run python -m src.main serveThe API starts on http://localhost:8000. On startup, the server:
- Runs Alembic migrations automatically to bring the database schema up to date.
- Initializes persistence (PostgreSQL engine, MinIO store, agent seeding).
- Reads the
AGENTS_DIRenvironment variable (default:./agents) to discover available agents.
Agents are not loaded into memory until a thread references them for the first time.
# Health check
curl http://localhost:8000/health
# Create a thread bound to an agent (agent_name must match a YAML filename in agents/)
curl -X POST http://localhost:8000/api/v1/threads \
-H "Content-Type: application/json" \
-d '{"agent_name": "my-agent"}'
# Send a message (replace <thread_id> with the id from the previous response)
curl -X POST http://localhost:8000/api/v1/chat/<thread_id> \
-H "Content-Type: application/json" \
-d '{"message": "Hello, what can you do?"}'
# Stream a message (yields TraceEvent JSON objects, one per SSE line, ends with [DONE])
curl -N -X POST http://localhost:8000/api/v1/chat/<thread_id>/stream \
-H "Content-Type: application/json" \
-d '{"message": "Hello, what can you do?"}'
# Get the full thread history (thread + turns grouped by turn_id)
curl http://localhost:8000/api/v1/threads/<thread_id>/history
# Get the flat trace of events for a thread
curl http://localhost:8000/api/v1/threads/<thread_id>/tracecomposable-agents now supports running multiple agents simultaneously. Each agent is defined by a separate YAML file in the agents/ directory. Sub-agents declared via subagents are traced individually: each TraceEvent they emit includes the sub-agent name in its source field, so the timeline can be grouped per sub-agent on the client side.
- Discovery -- On startup, the server scans
AGENTS_DIR(default:./agents) for.yamlfiles. The filename (without extension) becomes the agent name. - Thread creation -- When creating a thread via
POST /api/v1/threads, you specify anagent_name. If no matching YAML file exists, the API returns404. - Lazy loading -- The agent (LangGraph graph + runner) is created only when a thread first sends a message to it. Subsequent requests reuse the cached runner.
- Per-thread binding -- Each thread is permanently bound to its agent. Different threads can use different agents.
| Component | Location | Role |
|---|---|---|
AgentRegistry (port) |
src/domain/ports/agent_registry.py |
Abstract interface for retrieving agent runners by name. |
DeepAgentRegistry (adapter) |
src/infrastructure/deepagent/registry.py |
Scans agents/ directory, creates and caches runners on demand. |
AgentNotFoundError |
src/domain/exceptions.py |
Raised when a requested agent name has no corresponding YAML file. |
# Create a thread using the research assistant agent
curl -X POST http://localhost:8000/api/v1/threads \
-H "Content-Type: application/json" \
-d '{"agent_name": "research-assistant"}'
# Returns: {"id": "thread-1-uuid", "agent_name": "research-assistant", ...}
# Create another thread using the code reviewer agent
curl -X POST http://localhost:8000/api/v1/threads \
-H "Content-Type: application/json" \
-d '{"agent_name": "code-reviewer"}'
# Returns: {"id": "thread-2-uuid", "agent_name": "code-reviewer", ...}
# Each thread talks to its own agent
curl -X POST http://localhost:8000/api/v1/chat/<thread-1-uuid> \
-H "Content-Type: application/json" \
-d '{"message": "Summarize the latest research on transformers."}'
curl -X POST http://localhost:8000/api/v1/chat/<thread-2-uuid> \
-H "Content-Type: application/json" \
-d '{"message": "Review this Python function for security issues."}'Every agent is defined by a single YAML file validated against the AgentConfig Pydantic schema.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string (required) |
-- | Unique agent name (1-100 characters). |
model |
string |
"claude-sonnet-4-5-20250929" |
LLM model identifier. See Supported Models. |
system_prompt |
string |
null |
Inline system prompt. Mutually exclusive with system_prompt_file. |
system_prompt_file |
string |
null |
Path to a text file containing the system prompt (resolved relative to the YAML file). Mutually exclusive with system_prompt. |
tools |
list[string] |
[] |
Python tool references in module.path:attribute format. |
backend |
BackendConfig |
{"type": "state", "store_backend": "memory", "checkpoint_backend": "memory"} |
Persistence backend. See Backends. |
hitl |
HITLConfig |
{"rules": {}} |
Human-in-the-loop interrupt rules. |
memory |
list[string] |
[] |
Paths to memory files (e.g. "./AGENTS.md"). |
skills |
list[string] |
[] |
Paths to skill directories (e.g. "./skills/"). |
subagents |
list[SubAgentConfig] |
[] |
Sub-agent definitions for delegation. |
mcp_servers |
list[McpServerConfig] |
[] |
MCP server connections. See MCP Servers. |
debug |
bool |
false |
Enable debug mode. |
response_format |
dict |
null |
Inline JSON Schema dict for structured output. See Structured Output (response_format). |
| Field | Type | Default | Description |
|---|---|---|---|
name |
string (required) |
-- | Sub-agent name. |
description |
string (required) |
-- | Description of the sub-agent's role. |
instructions |
string |
null |
System prompt / instructions for the sub-agent. |
model |
string |
null |
Override model for this sub-agent. |
tools |
list[string] |
[] |
Tool references specific to this sub-agent. |
skills |
list[string] |
[] |
Skill paths for this sub-agent. |
mcp_servers |
list[McpServerConfig] |
[] |
MCP servers for this sub-agent. |
| Field | Type | Default | Description |
|---|---|---|---|
name |
string (required) |
-- | Server identifier. |
transport |
"stdio" or "http" (required) |
-- | Transport type. |
command |
string |
null |
Command to run (required for stdio transport). |
args |
list[string] |
[] |
Command arguments (for stdio transport). |
url |
string |
null |
Server URL (required for http transport). |
headers |
dict[string, string] |
{} |
HTTP headers (for http transport). |
env |
dict[string, string] |
{} |
Environment variables for the server process. Supports ${VAR_NAME} syntax for resolving env vars. |
HITL rules map tool names to either a boolean or a detailed rule:
hitl:
rules:
write_file: true # Simple: interrupt on any call
execute: # Detailed: restrict allowed decisions
allowed_decisions:
- approve
- rejectAllowed decisions: approve, edit, reject.
The response_format field in agent YAML configures structured output — forcing the LLM to reply with JSON conforming to a JSON Schema. Define it inline as a dict (a valid JSON Schema) in the agent's YAML:
name: invoice-extractor
model: claude-sonnet-4-5-20250929
response_format:
type: object
properties:
invoice_number: { type: string }
total_cents: { type: integer }
currency: { type: string, enum: ["USD", "EUR", "GBP"] }
paid: { anyOf: [{ type: boolean }, { type: "null" }] }
required: [invoice_number, total_cents, currency]
additionalProperties: falseThe dict is passed natively to create_deep_agent(response_format=dict). No custom tool injection or prompt instruction concatenation is performed — the previous "tool leurre" hack (_create_response_tool, _JSON_TYPE_MAP, STRUCTURED_OUTPUT_INSTRUCTION) has been deleted.
langchain uses an AutoStrategy internally to pick the right delivery mechanism based on the model name:
ProviderStrategy— the schema is passed as a native provider parameter (Anthropicresponse_format/tool_use strict mode, OpenAIresponse_formatwithjson_schema, etc.).ToolStrategy— when the provider does not support native structured output, langchain injects a real tool whose schema is the JSON Schema, and the LLM is asked to call it.
You do not need to choose the strategy yourself — AutoStrategy selects based on the configured model.
When the LLM produces a structured response, it is attached to the Message as structured_response (a dict validated against the schema). The AI_MESSAGE trace event carries the structured payload inside content as a JSON-serialized Message — it is not placed in metadata. The metadata of an AI_MESSAGE now only contains {"status": ...}.
Clients consuming AI_MESSAGE events must JSON-parse content and read structured_response from the resulting Message object.
If the LLM fails to produce a structured response despite a response_format being configured:
- A warning is logged (
STRUCTURED_RESPONSE_MISSING). Message.structured_responseis set toNone.- No error is raised — the client decides how to handle the absence.
schema_utils.py converts the JSON Schema dict to a Pydantic model at agent build time. The converter supports:
type(string, integer, number, boolean, object, array, string)type: ["string", "null"]— array form for nullable scalarsanyOf— nullable fields (useanyOf: [{type: <T>}, {type: "null"}])enum— maps toLiteralon the Pydantic sideproperties/required— nested objectsitems— arrays of objects or scalars
Not supported (will raise at build time or be ignored):
$ref,$defsoneOf,allOfif/then/elsedependentSchemaspatternProperties
When AutoStrategy selects ProviderStrategy against a provider that enforces strict mode (e.g. Anthropic's strict tool-use), the schema must satisfy the provider's constraints or the request will be rejected:
- Set
additionalProperties: falseon every object (recommended default). - Use
anyOffor nullable fields — do not usetype: ["string", "null"]for Anthropic strict mode; useanyOf: [{type: "string"}, {type: "null"}]instead. - Do not put
defaultonrequiredfields (Anthropic strict mode forbids it). - All properties listed in
requiredmust appear inproperties.
These constraints only apply when the provider enforces strict mode; ToolStrategy is more permissive. Since AutoStrategy picks automatically, authoring schemas that satisfy the strict constraints up front is the safest approach.
| Provider | Format | Example |
|---|---|---|
| Anthropic | claude-<variant> |
claude-sonnet-4-5-20250929 |
| OpenAI | openai:<model> |
openai:gpt-4o |
google_genai:<model> |
google_genai:gemini-2.0-flash |
The default model is claude-sonnet-4-5-20250929.
For OpenAI-compatible endpoints (OpenRouter, LiteLLM, vLLM, etc.), set the OPENAI_BASE_URL environment variable to point to your endpoint. The OpenAI SDK reads this variable automatically.
Agents can connect to Model Context Protocol (MCP) servers for tool access. MCP servers are defined in the agent's YAML config:
name: mcp-agent
model: claude-sonnet-4-5-20250929
system_prompt: "You are an agent with MCP tool access."
mcp_servers:
- name: filesystem
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]Environment variables in env fields support the ${VAR_NAME} resolution syntax.
When an MCP server requires authentication via a custom HTTP header (e.g. X-API-Key), use the headers field. The ${VAR_NAME} syntax is supported for resolving environment variables at runtime:
mcp_servers:
- name: bricks
transport: http
url: https://raganything.soludev.tech/bricks/mcp
headers:
X-API-Key: "${MCP_RAGANYTHING_API_KEY}"Set the corresponding environment variable in .env:
MCP_RAGANYTHING_API_KEY=your-shared-secret-keyThe value must match the API_KEY configured on the mcp-raganything server.
The Todo List, Filesystem, and Sub-Agent middlewares are always installed by create_deep_agent defaults and cannot be toggled via the YAML configuration. Sub-agent delegation is enabled automatically when subagents is non-empty.
The BackendConfig schema controls where agent state and checkpoints are persisted.
| Field | Type | Default | Description |
|---|---|---|---|
type |
BackendType (state | store) |
state |
Backend kind. state = in-memory LangGraph state, store = LangGraph store-backed. |
store_backend |
Literal["memory", "postgres"] |
memory" |
Where the LangGraph store lives. memory = in-process, postgres = PostgreSQL-backed (singleton reused across all agent builds). |
checkpoint_backend |
Literal["memory", "postgres"] |
memory" |
Where the LangGraph checkpointer lives. memory = in-process, postgres = PostgreSQL-backed (singleton reused across all agent builds). |
| Name | Enum Value | Description |
|---|---|---|
| State | state |
Default in-memory state backend (no extra config). |
| Store | store |
LangGraph store-based backend. The store itself is backed by store_backend. |
When store_backend or checkpoint_backend is set to "postgres", the framework instantiates a single PostgresStore / PostgresSaver (from langgraph-checkpoint-postgres) and reuses the same instance across every agent build. This avoids opening a new connection pool per agent.
Note: The
langgraph-checkpoint-postgrespackage is required and is included in the project dependencies.
Example YAML enabling Postgres for both store and checkpointer:
name: persistent-agent
backend:
type: store
store_backend: postgres
checkpoint_backend: postgresThe StoreBackend is wired with a per-run namespace via StoreBackend(store=store, namespace=lambda r: ("filesystem",)) (the deprecated StoreBackend(runtime) pattern has been removed).
All endpoints are prefixed appropriately. The server runs on http://localhost:8000 by default.
| Method | Path | Description | Success Status |
|---|---|---|---|
GET |
/health |
Health check | 200 |
POST |
/api/v1/threads |
Create a new conversation thread (bound to an agent) | 201 |
GET |
/api/v1/threads |
List all threads | 200 |
GET |
/api/v1/threads/{thread_id} |
Get a specific thread | 200 |
DELETE |
/api/v1/threads/{thread_id} |
Delete a thread | 204 |
GET |
/api/v1/threads/{thread_id}/history |
Get thread history grouped by turn (ThreadHistory) |
200 |
GET |
/api/v1/threads/{thread_id}/trace |
Get the flat list of TraceEvents for a thread |
200 |
GET |
/api/v1/threads/{thread_id}/messages |
List messages in a thread (projection from trace_events: HUMAN_MESSAGE + AI_MESSAGE only, backward-compat) |
200 |
POST |
/api/v1/chat/{thread_id} |
Send a message and get the full response | 200 |
POST |
/api/v1/chat/{thread_id}/stream |
Send a message and stream the response (SSE) | 200 |
POST |
/api/v1/threads/{thread_id}/hitl |
Submit a human-in-the-loop decision | 200 |
GET |
/api/v1/agents |
List all agent configs from agents/ directory |
200 |
GET |
/api/v1/agents/{agent_name} |
Get a specific agent configuration | 200 |
GET |
/api/v1/store/files |
List file paths in the store (optional prefix query param) |
200 |
GET |
/api/v1/store/files/{path} |
Get a single file's content by path | 200 |
PUT |
/api/v1/store/files/{path} |
Create or replace a file in the store | 200 |
DELETE |
/api/v1/store/files/{path} |
Delete a file from the store | 204 |
WS |
/api/v1/ws/{thread_id} |
WebSocket endpoint for streaming chat | -- |
POST |
/prompts/create |
Create a new prompt | 201 |
GET |
/prompts/get/{identifier} |
Get a specific prompt by identifier, version, or tag | 200 |
PUT |
/prompts/update/{identifier} |
Update an existing prompt (creates new version) | 200 |
| Status | Condition |
|---|---|
400 |
General configuration error |
404 |
Thread not found, agent not found, or config file not found |
422 |
Validation error (bad request body, invalid config schema) |
502 |
Agent execution error (LLM failure) |
500 |
Unexpected domain error |
curl http://localhost:8000/healthResponse:
{"status": "ok"}curl http://localhost:8000/api/v1/agentsResponse (200):
[
{
"name": "code-reviewer",
"model": "claude-sonnet-4-5-20250929",
"system_prompt": "You are an expert code reviewer...",
"tools": [],
"backend": {"type": "state", "store_backend": "memory", "checkpoint_backend": "memory"},
"hitl": {"rules": {"write_file": true, "execute": {"allowed_decisions": ["approve", "reject"]}}},
"subagents": [...]
},
{
"name": "example-agent",
"model": "openai:anthropic/claude-haiku-4.5:nitro",
"system_prompt": "You are a helpful assistant.",
"tools": [],
"backend": {"type": "state", "store_backend": "memory", "checkpoint_backend": "memory"},
"hitl": {"rules": {}},
"subagents": []
}
]curl http://localhost:8000/api/v1/agents/example-agentResponse (200):
{
"name": "example-agent",
"model": "openai:anthropic/claude-haiku-4.5:nitro",
"system_prompt": "You are a helpful assistant.",
"system_prompt_file": null,
"tools": [],
"backend": {"type": "state", "store_backend": "memory", "checkpoint_backend": "memory"},
"hitl": {"rules": {}},
"memory": [],
"skills": [],
"subagents": [],
"mcp_servers": [],
"debug": false
}If the agent does not exist:
curl http://localhost:8000/api/v1/agents/nonexistentResponse (404):
{"detail": "Fichier de configuration introuvable: agents/nonexistent.yaml"}The agent_name must match an existing YAML filename (without the .yaml extension) in the agents/ directory.
curl -X POST http://localhost:8000/api/v1/threads \
-H "Content-Type: application/json" \
-d '{"agent_name": "example-agent"}'Response (201):
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"agent_name": "example-agent",
"messages": [],
"created_at": "2025-01-15T10:30:00.000000",
"updated_at": "2025-01-15T10:30:00.000000"
}If the agent name does not match any YAML file:
curl -X POST http://localhost:8000/api/v1/threads \
-H "Content-Type: application/json" \
-d '{"agent_name": "nonexistent-agent"}'Response (404):
{"detail": "Agent introuvable: nonexistent-agent"}curl -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{"message": "Explain the hexagonal architecture pattern in 3 sentences."}'Response (200):
{
"role": "ai",
"content": "Hexagonal architecture separates core business logic from external concerns...",
"timestamp": "2025-01-15T10:30:05.000000",
"tool_calls": null,
"tool_call_id": null
}curl -N -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890/stream \
-H "Content-Type: application/json" \
-d '{"message": "Write a haiku about programming."}'Response (Server-Sent Events, one TraceEvent JSON object per line):
data: {"id":"...","thread_id":"...","turn_id":"...","type":"HUMAN_MESSAGE","source":null,"name":null,"content":"Write a haiku about programming.","metadata":{},"timestamp":"2025-04-24T10:30:00.000000Z","sequence":0}
data: {"id":"...","thread_id":"...","turn_id":"...","type":"THINKING","source":null,"name":null,"content":"Hmm, a haiku needs 5-7-5 syllables...","metadata":{},"timestamp":"...","sequence":1}
data: {"id":"...","thread_id":"...","turn_id":"...","type":"CONTENT","source":null,"name":null,"content":"Lines","metadata":{},"timestamp":"...","sequence":2}
data: {"id":"...","thread_id":"...","turn_id":"...","type":"CONTENT","source":null,"name":null,"content":" of","metadata":{},"timestamp":"...","sequence":3}
data: {"id":"...","thread_id":"...","turn_id":"...","type":"CONTENT","source":null,"name":null,"content":" code","metadata":{},"timestamp":"...","sequence":4}
data: {"id":"...","thread_id":"...","turn_id":"...","type":"AI_MESSAGE","source":null,"name":null,"content":"Lines of code align...","metadata":{},"timestamp":"...","sequence":5}
data: [DONE]
Each data: line (except the final [DONE]) is a JSON-serialized TraceEvent with the following fields:
| Field | Type | Description |
|---|---|---|
id |
string |
Unique event ID. |
thread_id |
string |
Owning thread ID. |
turn_id |
string |
Turn ID grouping all events from one user message to the next AI reply. |
type |
enum |
One of HUMAN_MESSAGE, AI_MESSAGE, THINKING, CONTENT, TOOL_CALL, TOOL_RESULT. |
source |
string | null |
Sub-agent name when the event was emitted by a sub-agent, null for the parent agent. |
name |
string | null |
Tool name (only for TOOL_CALL / TOOL_RESULT). |
content |
string |
Text payload (message text, thinking text, content chunk, tool arguments/result). |
metadata |
object |
Additional structured data (e.g. tool call ID, {"status": ...} for AI_MESSAGE). The structured response for an AI_MESSAGE is not in metadata — it lives in content as part of the JSON-serialized Message. See Structured Output. |
timestamp |
string |
ISO 8601 timestamp. |
sequence |
int |
Monotonic sequence number within the thread (ordering). |
On error the stream emits a single JSON object (NOT a valid TraceEvent) followed by [DONE]:
data: {"type":"error","data":"Agent execution failed: ..."}
data: [DONE]
Clients should check for type === "error" before parsing as TraceEvent.
- Render
THINKINGevents in a collapsible reasoning panel. - Append
CONTENTevents directly to the chat bubble (or the relevant sub-agent panel whensourceis set). - Render
TOOL_CALLas a badge andTOOL_RESULTas a terminal-style block. - Group events by
sourceto display sub-agent panels separately. - Wait for the
AI_MESSAGEevent to finalize the turn.
This design prevents Cloudflare timeout issues (~100s on idle connections) because chunks and SSE pings (every 15s) keep the connection active.
curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/historyResponse (200) — ThreadHistory:
{
"thread": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"agent_name": "example-agent",
"created_at": "2025-01-15T10:30:00.000000",
"updated_at": "2025-01-15T10:30:05.000000"
},
"turns": [
{
"turn_id": "turn-uuid-1",
"human_message": { "id": "...", "type": "HUMAN_MESSAGE", "content": "Hello", ... },
"ai_message": { "id": "...", "type": "AI_MESSAGE", "content": "Hi!", ... },
"events": [
{ "id": "...", "type": "THINKING", "source": null, "content": "...", ... },
{ "id": "...", "type": "TOOL_CALL", "source": "researcher", "name": "search", ... },
{ "id": "...", "type": "TOOL_RESULT", "source": "researcher", "name": "search", ... }
]
}
]
}events contains the intermediate events (THINKING, CONTENT, TOOL_CALL, TOOL_RESULT) for the turn, in sequence order. human_message and ai_message are the terminal HUMAN_MESSAGE / AI_MESSAGE events.
curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/traceResponse (200):
{
"events": [
{ "id": "...", "type": "HUMAN_MESSAGE", "content": "Hello", ... },
{ "id": "...", "type": "THINKING", "content": "...", ... },
{ "id": "...", "type": "AI_MESSAGE", "content": "Hi!", ... }
]
}Returns the full flat list of TraceEvents for the thread, ordered by sequence.
curl http://localhost:8000/api/v1/threadsResponse (200):
[
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"agent_name": "example-agent",
"messages": [],
"created_at": "2025-01-15T10:30:00.000000",
"updated_at": "2025-01-15T10:30:00.000000"
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"agent_name": "research-assistant",
"messages": [],
"created_at": "2025-01-15T10:31:00.000000",
"updated_at": "2025-01-15T10:31:00.000000"
}
]curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/messagesResponse (200):
[
{
"role": "human",
"content": "Explain the hexagonal architecture pattern in 3 sentences.",
"timestamp": "2025-01-15T10:30:00.000000",
"tool_calls": null,
"tool_call_id": null
},
{
"role": "ai",
"content": "Hexagonal architecture separates core business logic from external concerns...",
"timestamp": "2025-01-15T10:30:05.000000",
"tool_calls": null,
"tool_call_id": null
}
]When the agent is configured with HITL rules and a tool call is interrupted, submit a decision:
curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/hitl \
-H "Content-Type: application/json" \
-d '{
"tool_call_id": "call_abc123",
"action": "approve"
}'Response (200):
{
"role": "ai",
"content": "Action approved. Proceeding with file write.",
"timestamp": "2025-01-15T10:31:00.000000",
"tool_calls": null,
"tool_call_id": null
}curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/hitl \
-H "Content-Type: application/json" \
-d '{
"tool_call_id": "call_abc123",
"action": "reject",
"reason": "This operation is too risky for production."
}'curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/hitl \
-H "Content-Type: application/json" \
-d '{
"tool_call_id": "call_abc123",
"action": "edit",
"edits": {"filename": "safe_output.txt", "content": "sanitized content"}
}'curl -X DELETE http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890Response: 204 No Content
Prompts are managed via a dedicated registry backed by Phoenix. Enable prompt management by setting TRACING_PROVIDER=phoenix and PHOENIX_PROMPT_ENABLED=true in your .env.
curl -X POST http://localhost:8000/prompts/create \
-H "Content-Type: application/json" \
-d '{
"identifier": "customer-support",
"content": [
{
"role": "system",
"content": "You are a helpful customer support agent. Be polite and professional."
}
],
"model_name": "claude-sonnet-4-5-20250929",
"description": "Prompt for general customer support queries",
"tags": ["production"],
"metadata": {"project_name": "composable-agents", "agent_type": "deep_agent"}
}'Response (200):
{
"status": "success",
"prompt": {
"identifier": "customer-support",
"description": "Prompt for general customer support queries",
"current_version": {
"version_id": "v1",
"content": [...],
"model_name": "claude-sonnet-4-5-20250929",
"created_at": "2025-01-15T10:30:00.000000",
"tags": ["support", "production"]
},
"created_at": "2025-01-15T10:30:00.000000",
"updated_at": "2025-01-15T10:30:00.000000"
}
}curl http://localhost:8000/prompts/customer-supportOptional query parameters:
version_id: Get a specific versiontag: Get the prompt with a specific tag
Response (200):
{
"status": "success",
"prompt": {
"identifier": "customer-support",
"description": "",
"current_version": {
"version_id": "UHJvbXB0VmVyc2lvbjo4Mw==",
"content": [
{
"role": "system",
"content": "You are a helpful customer support agent. Be polite and professional."
}
],
"model_name": "claude-sonnet-4-5-20250929",
"tags": []
},
"created_at": null,
"updated_at": null
}
}Create a new version of an existing prompt:
curl -X PUT http://localhost:8000/prompts/update/customer-support \
-H "Content-Type: application/json" \
-d '{
"content": [
{
"role": "system",
"content": "You are a knowledgeable customer support agent. Be polite, professional, and thorough in your responses."
}
],
"model_name": "claude-sonnet-4-5-20250929",
"description": "Updated prompt for customer support (more detailed)",
"tags": ["production"],
"metadata": {"project_name": "composable-agents", "agent_type": "deep_agent"}
}'Response (200):
{
"status": "success",
"prompt": {
"identifier": "customer-support",
"description": "Updated prompt for customer support (more detailed)",
"current_version": {
"version_id": "v2",
"content": [...],
"model_name": "claude-sonnet-4-5-20250929",
"created_at": "2025-01-15T10:31:00.000000",
"tags": ["support", "production"]
}
},
"message": "Prompt 'customer-support' updated successfully"
}Connect to the WebSocket endpoint and send JSON messages:
const ws = new WebSocket("ws://localhost:8000/api/v1/ws/<thread_id>");
ws.onopen = () => ws.send(JSON.stringify({ message: "Hello" }));
ws.onmessage = (event) => {
if (event.data === "[END]") {
console.log("Response complete");
return;
}
const data = JSON.parse(event.data);
if (data.type === "error") { console.error("Error:", data.data); return; }
switch (data.type) {
case "THINKING": console.log("[Thinking]", data.content); break;
case "CONTENT": process.stdout.write(data.content); break;
case "AI_MESSAGE": console.log("Final message:", data.content); break;
case "TOOL_CALL": console.log("Tool call:", data.name, data.content); break;
case "TOOL_RESULT":console.log("Tool result:", data.name, data.content); break;
}
};The WebSocket stream emits TraceEvent JSON objects (same shape as the /stream SSE endpoint), then [END]. On error, emits {"type":"error","data":"..."} before [END].
To enable prompt management in Phoenix:
uv sync --extra phoenixOr add to pyproject.toml:
arize-phoenix-otel = ">=0.1.0"
openinference-instrumentation-langchain = ">=0.1.0"
httpx = ">=0.27.0"Add to .env:
PROVIDER=phoenix
PHOENIX_COLLECTOR_ENDPOINT=http://localhost:6006
PHOENIX_PROMPT_ENABLED=true
PHOENIX_API_KEY=your-api-key-herePrompt management follows the Clean Architecture pattern:
- Domain Entity (
src/domain/entities/prompt.py):Prompt,PromptVersion - Domain Port (
src/domain/ports/prompt_manager.py):PromptManagerinterface - Use Cases (
src/application/use_cases/):CreatePromptUseCase,GetPromptUseCase,GetPromptContentUseCase,UpdatePromptUseCase - Request DTOs (
src/application/requests/prompt.py): Request models for each endpoint - Response DTOs (
src/application/responses/prompt.py):PromptResponse,PromptVersionResponse - Routes (
src/application/routes/prompt.py): FastAPI endpoint handlers - Infrastructure Adapter (
src/infrastructure/prompt_management/adapter.py): Phoenix REST API implementation
All prompt management operations are async and fully integrated with the FastAPI dependency injection system.
composable-agents exposes a small REST surface for managing files in the LangGraph store. Files are stored as UTF-8 text blobs keyed by a path string (e.g. /skills/my-skill/SKILL.md). The store is shared across all agents and backed by the same BaseStore instance configured per agent (store_backend: memory or postgres).
| Method | Path | Description | Success Status |
|---|---|---|---|
GET |
/api/v1/store/files?prefix=<prefix> |
List file paths matching prefix (default / = all) |
200 |
GET |
/api/v1/store/files/{path} |
Retrieve a single file's content | 200 |
PUT |
/api/v1/store/files/{path} |
Create or replace a file (body: {"content": "..."}) |
200 |
DELETE |
/api/v1/store/files/{path} |
Delete a file (idempotent) | 204 |
The {path} segment uses FastAPI's :path converter, so it can contain slashes (e.g. skills/my-skill/SKILL.md). Do not include a leading slash in the URL.
curl 'http://localhost:8000/api/v1/store/files?prefix=/skills/'Response (200) — a JSON array of path strings:
["skills/code-review/SKILL.md", "skills/debugging/SKILL.md"]curl http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.mdResponse (200):
{"path": "skills/code-review/SKILL.md", "content": "# Code Review Skill\n\n..."}If the file does not exist, the API returns 404 with {"detail": "File not found: skills/code-review/SKILL.md"}.
curl -X PUT http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.md \
-H "Content-Type: application/json" \
-d '{"content": "# Code Review Skill\n\nReview code for correctness and security."}'Response (200):
{"path": "skills/code-review/SKILL.md", "content": "# Code Review Skill\n\nReview code for correctness and security."}curl -X DELETE http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.mdResponse: 204 No Content. The operation is idempotent — deleting a non-existent path does not raise.
When an agent is created (or updated) with skills or memory configured, the selected files are copied into a dedicated namespace in the store, scoped to that agent:
- Skills:
/agents/{agent_name}/skills/{skill_name}/SKILL.md - Memories:
/agents/{agent_name}/memories/{filename}
This ensures each agent only loads the skills and memories explicitly selected in its configuration, not every file in the global /skills/ and /memories/ directories.
When an agent is updated and a skill or memory is removed from the selection, the corresponding copy in the agent namespace is deleted automatically.
To discover which agents reference a given skill, use the usage-tracking endpoint:
curl http://localhost:8000/api/v1/store/skills/my-skill/usageResponse (200) — the list of agent names that have my-skill in their namespace:
["code-reviewer", "research-assistant"]Skills are SKILL.md files stored in the LangGraph store under the /skills/ prefix. They describe reusable capabilities that an agent can load via its skills config field. Skills can now be created, edited, and deleted via the Store File API or the frontend UI (dedicated "Skills" page in the sidebar).
curl -X PUT http://localhost:8000/api/v1/store/files/skills/my-skill/SKILL.md \
-H "Content-Type: application/json" \
-d '{"content": "# my-skill\n\n## Description\nA skill for ..."}'In the agent YAML, point the skills field at the skill path (without the leading slash):
name: my-agent
skills:
- "skills/my-skill/"In the frontend agent form, the Skills field is a multi-select dropdown (PillMultiSelect) that lists all available skills discovered in the store (paths matching /skills/), replacing the previous free-text input.
Memories are Markdown files (e.g. AGENTS.md) stored in the LangGraph store, typically under the /memories/ prefix. They provide persistent context that an agent loads via its memory config field. Memories can now be created, edited, and deleted via the Store File API or the frontend UI (dedicated "Memories" page in the sidebar).
curl -X PUT http://localhost:8000/api/v1/store/files/memories/AGENTS.md \
-H "Content-Type: application/json" \
-d '{"content": "# Project Guidelines\n\n- Always write tests.\n- Follow the hexagonal architecture."}'In the agent YAML, point the memory field at the memory path:
name: my-agent
memory:
- "memories/AGENTS.md"In the frontend agent form, the Memory field is a multi-select dropdown (PillMultiSelect) that lists all available memories from the store, replacing the previous free-text input.
composable-agents follows a strict hexagonal architecture (ports and adapters). The domain layer has zero dependencies on frameworks or infrastructure.
+---------------------------+
| HTTP / WebSocket |
| (FastAPI application) |
+------------+--------------+
|
+------------+--------------+
| Use Cases |
| (application/use_cases/) |
+------+----------+---------+
| |
+-----------+ +-----------+
| |
+----------+---------+ +-----------+---------+
| Domain Ports | | Domain Entities |
| (abstract classes) | | AgentConfig, Thread |
+----------+---------+ | Message |
| +---------------------+
+----------+---------+
| Infrastructure |
| (adapters) |
+--------------------+
| - DeepAgentRunner |
| - DeepAgentRegistry|
| - YamlConfigLoader |
| - PostgresThreads |
| - Alembic (migrate)|
+--------------------+
composable-agents/
agents/ # YAML agent configuration files
example-agent.yaml # Basic example agent
minimal.yaml # Minimal agent (name only)
mcp-agent.yaml # Agent with MCP server tools
research-assistant.yaml # Research assistant with tools
code-reviewer.yaml # Code reviewer with HITL + subagents
src/
main.py # FastAPI app creation and lifespan (runs migrations)
config.py # Pydantic Settings (env vars, DATABASE_URL normalization)
dependencies.py # Dependency injection wiring
alembic.ini # Alembic configuration
alembic/
env.py # Alembic env (async engine, model imports)
versions/
001_create_agent_configs_table.py
002_create_threads_and_messages_tables.py
005_create_trace_events_table.py # Create trace_events table
006_migrate_messages_to_trace_events.py # Backfill trace_events from messages
007_drop_messages_table.py # Drop legacy messages table
application/
requests/
chat.py # Request models (ChatRequest, CreateThreadRequest, HITLDecisionRequest)
responses/
thread_history.py # ThreadHistory response DTO (thread + turns)
routes/
health.py # GET /health
threads.py # CRUD /api/v1/threads
chat.py # POST /api/v1/chat/{id} and /stream
trace.py # GET /api/v1/threads/{id}/history and /trace
agents.py # GET /api/v1/agents
store.py # Store File API — /api/v1/store/files
websocket.py # WS /api/v1/ws/{id}
use_cases/
send_message.py # Invoke agent synchronously
stream_message.py # Stream agent response
get_thread_history.py # Build ThreadHistory from trace_events (group by turn_id)
manage_store_file.py # ListStoreFiles / GetStoreFile / PutStoreFile / DeleteStoreFile use cases
create_agent_config.py # Create agent config (MinIO + Postgres)
update_agent_config.py # Update agent config
delete_agent_config.py # Delete agent config
get_agent_config.py # Get agent config from MinIO
list_agent_configs.py # List agent configs from Postgres
load_agent_config.py # Load and validate a YAML config
seed_agents.py # Seed built-in agents from agents/ dir
thread_management.py # Create / get / list / delete threads
domain/
entities/
agent_config.py # AgentConfig, BackendConfig, HITLConfig, SubAgentConfig
agent_config_metadata.py # AgentConfigMetadata
mcp_server_config.py # McpServerConfig, McpTransportType
message.py # Message (role, content, timestamp, tool_calls) — projection model
thread.py # Thread (id, agent_name, timestamps) — no more MessageModel
trace_event.py # TraceEvent entity + TraceEventType enum (6 types)
tracing_config.py # TracingConfig, TracingProviderType
ports/
agent_config_loader.py # Abstract: load config from file
agent_config_repository.py # Abstract: CRUD for agent config metadata
agent_config_store.py # Abstract: object storage for YAML blobs
agent_registry.py # Abstract: get_runner(name), list_agents(), close()
agent_runner.py # Abstract: invoke, stream, HITL operations
mcp_tool_loader.py # Abstract: load MCP tools
store_file_repository.py # Abstract: file CRUD on the LangGraph store (StoreFileRepository port)
thread_repository.py # Abstract: CRUD for threads
trace_event_repository.py # Abstract: persist/append/list TraceEvents
tracing_provider.py # Abstract: tracing lifecycle
exceptions.py # DomainError hierarchy (incl. AgentNotFoundError, StorageError)
infrastructure/
env_utils.py # ${VAR_NAME} environment variable resolution
database/
models/
base.py # SQLAlchemy DeclarativeBase
agent_config.py # AgentConfigModel (ORM)
thread.py # ThreadModel (ORM) — MessageModel removed
trace_event.py # TraceEventModel (ORM)
deepagent/
adapter.py # DeepAgentRunner (LangGraph adapter) — emits TraceEvent
factory.py # create_agent_from_config (resolves tools, backend)
registry.py # DeepAgentRegistry (lazy loading + caching from agents/ dir)
example_tools.py # Example tools: current_time, word_count
mcp/
adapter.py # LangchainMcpToolLoader
minio_store/
adapter.py # MinioAgentConfigStore (YAML blob storage)
persistent_registry/
adapter.py # PersistentAgentRegistry (MinIO + Postgres backed)
store_file/
adapter.py # LangGraphStoreFileRepository (LangGraph BaseStore adapter)
postgres_repository/
adapter.py # PostgresAgentConfigRepository
postgres_thread/
adapter.py # PostgresThreadRepository (thread persistence)
models.py # Re-exports ThreadModel
postgres_trace/
adapter.py # PostgresTraceEventRepository (trace_events persistence)
yaml_config/
adapter.py # YamlAgentConfigLoader
tracing/
langfuse_adapter.py # Langfuse tracing provider
phoenix_adapter.py # Phoenix tracing provider
noop_adapter.py # No-op tracing provider (default)
tests/
conftest.py
fixtures/
external.py # External service fixtures
in_memory_thread_repository.py # In-memory thread repository for tests
unit/
test_agent_config.py
test_agent_crud.py
test_deep_agent_runner.py
test_env_utils.py
test_factory.py
test_factory_mcp_integration.py
test_langfuse_adapter.py
test_load_agent_config_use_case.py
test_mcp_adapter.py
test_mcp_lifecycle.py
test_mcp_server_config.py
test_message.py
test_minio_store.py
test_noop_tracing.py
test_persistent_registry.py
test_phoenix_adapter.py
test_postgres_repository.py
test_postgres_thread_repository.py
test_registry.py
test_routes.py
test_runner_tracing.py
test_seed_agents.py
test_send_message.py
test_thread.py
test_thread_management.py
test_tracing_config.py
test_tracing_di.py
test_tracing_lifecycle.py
test_yaml_loader.py
.env.example # Environment variable template
Dockerfile # Container image
pyproject.toml # Project metadata and dependencies
CONTRIBUTING.md # Contributor guide
uv.lock # Lockfile
agents/minimal.yaml -- the simplest possible agent. Uses all defaults (Claude Sonnet, no tools, state backend).
name: minimal-agentagents/example-agent.yaml -- a basic agent using an OpenAI-compatible model via OpenRouter.
name: example-agent
model: "openai:anthropic/claude-haiku-4.5:nitro"
system_prompt: "You are a helpful assistant."agents/mcp-agent.yaml -- an agent connected to an MCP filesystem server.
name: mcp-agent
model: claude-sonnet-4-5-20250929
system_prompt: "You are an agent with MCP tool access."
mcp_servers:
- name: filesystem
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]agents/research-assistant.yaml -- an agent with custom tools.
name: research-assistant
model: "claude-sonnet-4-5-20250929"
system_prompt: |
You are a research assistant specialized in technical documentation.
Always cite your sources and provide structured summaries.
tools:
- "src.infrastructure.deepagent.example_tools:current_time"
- "src.infrastructure.deepagent.example_tools:word_count"
backend:
type: state
debug: falseagents/code-reviewer.yaml -- a multi-agent system with human-in-the-loop approval. The Sub-Agent middleware is installed automatically because subagents is non-empty.
name: code-reviewer
model: "claude-sonnet-4-5-20250929"
system_prompt: |
You are an expert code reviewer. Analyze code for correctness,
performance, security, and maintainability.
backend:
type: state
hitl:
rules:
write_file: true
execute:
allowed_decisions:
- approve
- reject
subagents:
- name: security-auditor
description: "Specialized in security vulnerability analysis"
instructions: "Focus on OWASP Top 10 and common security patterns"
- name: performance-analyst
description: "Specialized in performance optimization"
instructions: "Analyze time complexity, memory usage, and bottlenecks"Thread and agent config persistence is backed by PostgreSQL, accessed via SQLAlchemy's async ORM (asyncpg driver).
The database uses a flat normalized schema. Thread persistence relies on a single trace_events table as the source of truth for all conversation activity (the legacy messages table has been dropped — see Breaking changes).
| Table | Description |
|---|---|
threads |
One row per conversation thread. Columns: id (PK, VARCHAR 36), agent_name, created_at, updated_at. |
trace_events |
One row per trace event. Columns: id (PK), thread_id (FK to threads.id, CASCADE delete), turn_id, type (enum: HUMAN_MESSAGE, AI_MESSAGE, THINKING, CONTENT, TOOL_CALL, TOOL_RESULT), source (sub-agent name or null), name (tool name or null), content (text), metadata (JSONB), timestamp, sequence (int, monotonic per thread). |
agent_configs |
Agent configuration metadata. |
Indexes on trace_events:
ix_trace_events_thread_id— fast lookup of all events for a thread.ix_trace_events_thread_id_sequence— ordered retrieval of events within a thread (used by/traceand/history).ix_trace_events_thread_id_turn_id— grouping events by turn (used by/history).
The messages table has been dropped (migration 007). Its data was backfilled into trace_events by migration 006 (each old Message row became a HUMAN_MESSAGE or AI_MESSAGE event). The legacy GET /api/v1/threads/{id}/messages endpoint is preserved as a backward-compatible projection that filters trace_events to HUMAN_MESSAGE + AI_MESSAGE rows.
Alembic migrations live in src/alembic/versions/ and run automatically at startup (via asyncio.to_thread() in the FastAPI lifespan). You never need to run alembic upgrade manually in normal operation.
Relevant migrations for the trace events refactor:
| Migration | Description |
|---|---|
005_create_trace_events_table |
Creates the trace_events table with the 3 indexes above. |
006_migrate_messages_to_trace_events |
Backfills trace_events from existing messages rows (role = "human" → HUMAN_MESSAGE, role = "ai" → AI_MESSAGE). |
007_drop_messages_table |
Drops the legacy messages table. |
To create a new migration manually:
cd src
uv run alembic revision -m "describe_your_change"To run migrations manually (useful for debugging):
cd src
uv run alembic upgrade headTo check current migration status:
cd src
uv run alembic current- Hexagonal architecture:
ThreadRepository(port) ->PostgresThreadRepository(adapter),TraceEventRepository(port) ->PostgresTraceEventRepository(adapter). The domain layer has no knowledge of SQLAlchemy. - Session-per-method: Each repository method creates its own
AsyncSessionfrom the engine, ensuring thread-safety for concurrent FastAPI requests. - Connection pooling:
AsyncAdaptedQueuePoolwithpool_size=20,max_overflow=20, andpool_pre_ping=True. - Cascade deletes: Deleting a thread automatically deletes all its
trace_eventsviaON DELETE CASCADEat both the SQL and ORM level. - Event ordering:
trace_eventsare sorted bysequence(monotonic per thread). The adapter applies a defensive Python sort as well. - JSONB columns:
metadatais stored as PostgreSQLJSONB, allowing structured data (tool call IDs, structured responses) without additional join tables. - Single source of truth:
trace_eventsis the only persistence for conversation activity.messagesis no longer a table; the/messagesendpoint is a read-only projection.
This release replaces the legacy StreamEvent / messages-based model with a unified TraceEvent model.
The old SSE payload format ({"type": "thinking" | "content" | "message" | "structured" | "error", "data": "..."}) is removed. The /stream and WebSocket endpoints now emit TraceEvent.model_dump_json() objects (see TraceEvent format). Clients must be updated to parse the new schema. The only non-TraceEvent payload is the error object {"type": "error", "data": "..."} emitted on failure (followed by [DONE]).
The messages PostgreSQL table has been dropped (migration 007). All conversation activity is now stored in trace_events. Migration 006 backfills trace_events from existing messages rows, so no data is lost when upgrading. The GET /api/v1/threads/{id}/messages endpoint is preserved as a backward-compatible projection (filters trace_events to HUMAN_MESSAGE + AI_MESSAGE).
The AgentRunner port signatures have changed:
invoke(thread_id, message, turn_id) -> tuple[Message, list[TraceEvent]]stream(thread_id, message, turn_id) -> AsyncIterator[TraceEvent]
Adapters and tests calling the old invoke(thread_id, message) -> Message / stream(...) -> AsyncIterator[StreamEvent] signatures must be updated.
Migrations 005, 006, 007 run automatically on startup. They are idempotent and safe to run on an existing database with data.
Configured via .env file or environment variables. See .env.example.
| Variable | Default | Description |
|---|---|---|
AGENTS_DIR |
./agents |
Directory containing agent YAML configuration files. |
OPENAI_API_KEY |
-- | API key for OpenAI models. |
OPENAI_BASE_URL |
https://api.openai.com/v1 |
Base URL for OpenAI-compatible endpoints. Set to use OpenRouter, LiteLLM, vLLM, etc. |
HOST |
0.0.0.0 |
Server bind host. |
PORT |
8000 |
Server bind port. |
MCP_RAGANYTHING_API_KEY |
-- | Shared API key for authenticating to mcp-raganything MCP servers. Must match the API_KEY set on the raganything server. |
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
required | PostgreSQL connection URL (e.g. postgresql://user:pass@host:5432/db). Automatically normalized to postgresql+asyncpg:// for async SQLAlchemy. sslmode and channel_binding query params are extracted and passed via connect_args (asyncpg doesn't accept them in the URL). |
POSTGRES_STATEMENT_CACHE_SIZE |
100 (asyncpg default) |
Set to 0 when using a connection pooler (Neon, PgBouncer, etc.). |
⚠️ Breaking change:POSTGRES_HOST,POSTGRES_PORT,POSTGRES_USER,POSTGRES_PASSWORD, andPOSTGRES_DATABASEare no longer used. Migrate toDATABASE_URL.
| Variable | Default | Description |
|---|---|---|
MINIO_ENDPOINT |
localhost:9040 |
MinIO server endpoint. |
MINIO_ACCESS_KEY |
minioadmin |
MinIO access key. |
MINIO_SECRET_KEY |
minioadmin |
MinIO secret key. |
MINIO_BUCKET |
composable-agents |
Bucket for YAML config blob storage. |
MINIO_SECURE |
false |
Use HTTPS for MinIO connections. |
| Variable | Default | Description |
|---|---|---|
TRACING_PROVIDER |
none |
Tracing backend: none, langfuse, or phoenix. |
TRACING_PROJECT_NAME |
composable-agents |
Project name for the tracing backend. |
PHOENIX_COLLECTOR_ENDPOINT |
http://localhost:6006 |
Phoenix collector endpoint. |
PHOENIX_API_KEY |
-- | Phoenix API key. |
uv syncuv run pytest tests/ -vuv run pytest tests/ -v --cov=srcuv run ruff check .uv run mypy src/for f in agents/*.yaml; do uv run python -m src validate "$f"; doneThe project provides optional dependency groups for tracing support:
# Langfuse tracing only
uv sync --extra langfuse
# Phoenix tracing only
uv sync --extra phoenix
# All tracing providers
uv sync --extra tracingRailway is a deployment platform that supports Docker-based services with managed PostgreSQL.
Railway project
├── PostgreSQL (Railway managed plugin)
├── composable-agents (Dockerfile deploy)
└── (mcp-raganything — separate Railway service or project)
-
Create a Railway project and add a PostgreSQL plugin.
-
Deploy composable-agents:
- New Service → GitHub Repo → select this repository.
- Railway detects the
Dockerfileautomatically. - Set the port to
8000.
-
Deploy mcp-raganything (separate Railway service or project):
- See the mcp-raganything README for Railway deployment instructions.
- Note the generated public domain (e.g.
mcp-raganything-production.up.railway.app).
-
Configure environment variables in the Railway dashboard:
Variable Example Notes OPENAI_API_KEYsk-...OpenAI API key OPENAI_BASE_URLhttps://openrouter.ai/api/v1OpenAI-compatible endpoint DATABASE_URLpostgresql://postgres:pass@roundhouse.proxy.rlwy.net:33019/railwayRailway PostgreSQL connection URL AGENTS_DIR./agentsDirectory containing agent YAML configs MCP_RAGANYTHING_API_KEYyour-shared-secretMust match API_KEYon mcp-raganythingTRACING_PROVIDERphoenixTracing backend: none,langfuse, orphoenixPHOENIX_COLLECTOR_ENDPOINThttps://phoenix.xxx.railway.appPhoenix collector URL -
Update agent YAML configs to point MCP server URLs to the Railway-deployed mcp-raganything domain:
mcp_servers: - name: bricks transport: http url: https://mcp-raganything-production.up.railway.app/bricks/mcp headers: X-API-Key: "${MCP_RAGANYTHING_API_KEY}"
The
${MCP_RAGANYTHING_API_KEY}placeholder is resolved from the environment variable at runtime. -
MinIO (optional — only if using MinIO for agent config storage):
- Deploy MinIO as a separate Railway service or use an external S3-compatible service.
- Set
MINIO_ENDPOINT,MINIO_ACCESS_KEY,MINIO_SECRET_KEY,MINIO_BUCKETaccordingly.
-
Verify deployment:
curl https://composable-agents-production.up.railway.app/health
- Railway automatically generates a public domain for each service.
- The
agents/directory is baked into the Docker image at build time. To update agent configs without redeploying, use MinIO-backed agent config storage (seeAGENTS_DIRand MinIO variables). - Alembic migrations run automatically on startup.
See CONTRIBUTING.md for details on:
- Project architecture and dependency rules
- How to add custom tools and backends
- How the YAML schema works
- Running tests and linting
- Code style conventions
This project does not currently include a license file. Contact the maintainers for licensing information.