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).
The server enforces per-user isolation via dual authentication (JWT via Logto OIDC or per-user API keys), PostgreSQL Row-Level Security (RLS) on every per-user table, per-user LLM credentials (each user brings their own provider/key), and per-user LangGraph Store namespaces for skills and memories. See Authentication, Row-Level Security (RLS), Per-User LLM Settings, and Store Namespaces per User.
composable-agents uses dual authentication: every protected endpoint accepts either a JWT bearer token (validated against the Logto OIDC JWKS) or a per-user API key. The chosen credential determines the authenticated user_id that is propagated to PostgreSQL Row-Level Security and to the per-user LLM credential resolver.
| Method | Header | user_id source |
Validated by |
|---|---|---|---|
| JWT | Authorization: Bearer <jwt> |
the JWT sub claim |
Logto OIDC JWKS (LOGTO_URL + JWT_AUDIENCE) |
| API key | X-API-Key: cpk_... |
the user_id column on the matching api_keys row |
SHA-256 hash lookup in the api_keys table |
The two methods are mutually exclusive on a given request — send one of the two headers. If neither validates, the server returns 401 {"detail": "Invalid or missing credentials"}.
Deprecated for auth: The old single master
OPENAI_API_KEY/API_KEYsettings are no longer used to authenticate requests.OPENAI_API_KEYis also no longer used to call the LLM — each user configures their own provider via Per-User LLM Settings.
In production, the user authenticates against Logto (via an oauth2-proxy in front of composable-agents) and receives a JWT. The Authorization: Bearer <jwt> header is then forwarded to composable-agents, which validates the signature against the Logto JWKS and the aud claim against JWT_AUDIENCE. The JWT sub becomes the user_id for RLS and per-user LLM resolution.
A user first authenticates with a JWT, then creates an API key they can reuse for script/automation use:
# Requires a JWT first (the user must be logged in via Logto).
curl -X POST http://localhost:8000/api/v1/api-keys \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"name": "my-ci-key"}'
# → 201 Created
# {
# "id": "8f3c...",
# "name": "my-ci-key",
# "key_prefix": "cpk_abcde",
# "plaintext": "cpk_abcdefghijklmnopqrstuvwxyz0123456789...", # shown ONCE
# "created_at": "2026-07-27T10:00:00Z"
# }The plaintext is returned exactly once; only its SHA-256 hash is persisted in api_keys (column key_hash). Store the plaintext securely — it cannot be recovered. The cpk_... prefix identifies composable-agents keys.
In QA and local dev, there is no Logto instance. The QA stack seeds two API keys directly in the api_keys table via the composable-agents-qa-init one-shot service (see Testing). The two seed keys are:
user_id |
plaintext X-API-Key |
|---|---|
qa-user-1 |
cpk_qa_test_key_12345 |
qa-user-2 |
cpk_qa_test_key_67890 |
When LOGTO_URL is empty, the JWT path is disabled and only the per-user API key path is active.
# JWT
curl http://localhost:8000/api/v1/agents \
-H "Authorization: Bearer <jwt>"
# Per-user API key
curl http://localhost:8000/api/v1/agents \
-H "X-API-Key: cpk_..."- Python 3.11+
- UV package manager
- PostgreSQL 15+ (required for thread, agent config, API key, and LLM-settings persistence, with Row-Level Security enabled)
- A Logto instance (OIDC issuer) for JWT validation in production — optional in QA/local (see Authentication)
- Each end-user configures their own LLM provider credentials via Per-User LLM Settings; there is no longer a server-wide LLM key
git clone https://github.com/your-org/composable-agents.git
cd composable-agents
uv sync
cp .env.example .envEdit .env and add your database and dual-auth credentials:
# PostgreSQL (required)
DATABASE_URL=postgresql://raganything:raganything@localhost:5433/raganything
# Dual auth (JWT via Logto OIDC + per-user API keys)
# Leave LOGTO_URL empty in local QA (only X-API-Key auth is used).
# In prod, set both to enable JWT validation.
LOGTO_URL=
JWT_AUDIENCE=
# Fernet key used to encrypt per-user LLM API keys at rest.
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
SECRET_ENCRYPTION_KEY=<your-fernet-key>
⚠️ 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 (auth): The single masterX-API-Key/API_KEYmodel has been replaced by dual JWT + per-user API keys. TheOPENAI_API_KEYenv var is no longer used to call the LLM — each user configures their own provider viaPUT /api/v1/settings/llm. See Authentication and Per-User LLM Settings.
⚠️ 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.
Replace
<API_KEY>with a per-user API key (e.g.cpk_qa_test_key_12345in QA) or use-H "Authorization: Bearer <jwt>". See Authentication.
# Health check (public, no auth)
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" \
-H "X-API-Key: <API_KEY>" \
-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" \
-H "X-API-Key: <API_KEY>" \
-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" \
-H "X-API-Key: <API_KEY>" \
-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 \
-H "X-API-Key: <API_KEY>"
# Get the flat trace of events for a thread
curl http://localhost:8000/api/v1/threads/<thread_id>/trace \
-H "X-API-Key: <API_KEY>"composable-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/errors/agent.py |
Raised when a requested agent name has no corresponding YAML file. |
All requests require
X-API-KeyorAuthorization: Bearer(see Authentication).
# Create a thread using the research assistant agent
curl -X POST http://localhost:8000/api/v1/threads \
-H "Content-Type: application/json" \
-H "X-API-Key: <API_KEY>" \
-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" \
-H "X-API-Key: <API_KEY>" \
-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" \
-H "X-API-Key: <API_KEY>" \
-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" \
-H "X-API-Key: <API_KEY>" \
-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). |
description |
string |
null |
Optional human-readable description of the agent (max 500 characters). Stored in the agent_configs table and exposed via AgentConfigMetadata. |
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": "postgres"} |
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. |
agent_ref |
string |
null |
Name of an existing agent to reference. When set, the backend resolves the referenced agent's model, system_prompt, mcp_servers, tools, and response_format at runner-build time. Explicit values on the SubAgentConfig override the referenced agent's values. See Agent references (agent_ref). |
A SubAgentConfig can reference another existing agent by name via the agent_ref field. This lets you compose agents without duplicating their configuration.
Resolution rules:
- At runner-build time, the backend looks up the referenced agent's
AgentConfigand copies itsmodel,system_prompt,mcp_servers,tools, andresponse_formatinto the sub-agent. - Any field set explicitly on the
SubAgentConfig(e.g.model,instructions) overrides the value inherited from the referenced agent. - One level only — the referenced agent's own
subagentsare ignored. References are not resolved recursively.
Validation (enforced at create/update time):
- Self-reference (
agent_refequal to the parent agent'sname) is rejected with aConfigError. - Non-existent reference (
agent_refpointing to an agent that does not exist) is rejected with aConfigError.
Cache invalidation:
When an agent is updated or deleted, the registry also invalidates any agents that reference it via agent_ref in their subagents. This ensures stale runners are rebuilt on next use.
Example:
name: orchestrator
subagents:
- name: researcher
agent_ref: research-assistant # inherits model, system_prompt, mcp_servers, tools, response_format
instructions: "Focus on recent papers." # overrides system_prompt| 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:
Registry migration: The MCP server registry (CRUD API, OpenAPI→MCP generation, Swagger 2.0 conversion, Fernet encryption, mounter, startup rehydration) and the
mcp_serverstable schema used to live in this brick. Both have been moved to mcp-raganything, which now owns the/api/v1/mcp/serversREST surface and the table's Alembic migrations (001_create_mcp_servers_table, tracked in theraganything_alembic_versiontable). composable-agents no longer ships the registry routes, use cases, repository, OpenAPI factory, Swagger 2.0 converter, Fernet cipher, mounter, or anymcp_serversmigration.What remains in this brick is:
McpServerConfig— the entity used by agent YAML to declare an MCP server connection.McpToolLoader(port) /LangchainMcpToolLoader(adapter) — loads tools from an MCP server by URL at agent build time. The URLs come from the agent YAML directly, or from entries registered in mcp-raganything's registry (which composable-agents reads via its UI/backend when needed).McpConnectionError/McpToolLoadError— domain errors raised when an MCP server cannot be reached or its tools fail to load.
SECRET_ENCRYPTION_KEYis now optional in composable-agents (it is no longer used here); it must still be set on the mcp-raganything service that owns the registry.
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.
In addition to env-var placeholders, MCP server headers support two per-user placeholders that are resolved from the authenticated caller's credential at agent-build time:
| Placeholder | Resolved to | When |
|---|---|---|
${USER_JWT} |
the caller's raw JWT (without Bearer prefix) |
the request was authenticated with a JWT |
${USER_API_KEY} |
the caller's raw per-user API key (cpk_...) |
the request was authenticated with an API key |
This lets an agent forward the caller's identity to a downstream MCP server (e.g. raganything) without storing a shared secret, so the downstream server can apply its own per-user RLS:
mcp_servers:
- name: raganything
transport: http
url: https://raganything.soludev.tech/bricks/mcp
headers:
Authorization: "Bearer ${USER_JWT}"
X-API-Key: "${USER_API_KEY}"Resolution rules:
- The placeholder is filled with
current_credentialonly whencurrent_auth_methodmatches ("jwt"for${USER_JWT},"api_key"for${USER_API_KEY}). - If the method does not match (e.g. the caller used an API key but the header uses
${USER_JWT}), or no credential is set, the resolved value is empty. - Empty resolved headers are dropped from the outgoing MCP request, so the downstream server receives no spurious empty auth header.
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"] |
"postgres" |
Where the LangGraph checkpointer lives. memory = in-process, postgres = PostgreSQL-backed (singleton reused across all agent builds). Defaults to postgres for durability; falls back to memory with a warning if Postgres is unreachable at agent-build time. |
| 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.
Auth: Every endpoint except
GET /healthrequires eitherAuthorization: Bearer <jwt>orX-API-Key: cpk_...(see Authentication). TheX-API-Keyshown in the curl examples below is a placeholder — replace it with your per-user key. Each authenticated user only sees rows they own (see Row-Level Security (RLS)).
| Method | Path | Description | Success Status |
|---|---|---|---|
GET |
/health |
Health check (public, no auth) | 200 |
POST |
/api/v1/api-keys |
Create a new per-user API key (returns plaintext once) | 201 |
GET |
/api/v1/api-keys |
List the authenticated user's API keys (no plaintext) | 200 |
DELETE |
/api/v1/api-keys/{key_id} |
Revoke a per-user API key (idempotent) | 204 |
GET |
/api/v1/settings/llm |
Get the authenticated user's LLM provider settings (masked key) | 200 |
PUT |
/api/v1/settings/llm |
Insert or update the authenticated user's LLM provider settings | 200 |
DELETE |
/api/v1/settings/llm |
Delete the authenticated user's LLM provider settings (idempotent) | 204 |
POST |
/api/v1/threads |
Create a new conversation thread (bound to an agent) | 201 |
GET |
/api/v1/threads |
List all threads owned by the authenticated user | 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/chat/{thread_id} |
Submit a human-in-the-loop decision (approve/reject/edit, single or multi decisions) |
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 |
401 |
Missing or invalid credentials (no JWT, no API key, or unknown/revoked key) |
404 |
Thread not found, agent not found, or config file not found |
422 |
Validation error (bad request body, invalid config schema, or no LLM provider configured for the user — LlmNotConfiguredError) |
502 |
Agent execution error (LLM failure) |
500 |
Unexpected domain error |
curl http://localhost:8000/healthResponse:
{"status": "ok"}curl http://localhost:8000/api/v1/agents \
-H "X-API-Key: <API_KEY>"Response (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-agent \
-H "X-API-Key: <API_KEY>"Response (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/nonexistent \
-H "X-API-Key: <API_KEY>"Response (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" \
-H "X-API-Key: <API_KEY>" \
-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" \
-H "X-API-Key: <API_KEY>" \
-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" \
-H "X-API-Key: <API_KEY>" \
-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" \
-H "X-API-Key: <API_KEY>" \
-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/history \
-H "X-API-Key: <API_KEY>"Response (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/trace \
-H "X-API-Key: <API_KEY>"Response (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/threads \
-H "X-API-Key: <API_KEY>"Response (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-ef1234567890 \
-H "X-API-Key: <API_KEY>"curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/messages \
-H "X-API-Key: <API_KEY>"Response (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 one or more tool calls are
interrupted, submit decisions via POST /api/v1/chat/{thread_id}. The preferred
payload is a decisions list (one entry per interrupted tool call):
curl -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-H "X-API-Key: <API_KEY>" \
-d '{
"decisions": [
{"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,
"status": "completed"
}A legacy single-decision shape (tool_call_id + action) is still accepted and
internally converted to a one-element decisions list.
curl -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-H "X-API-Key: <API_KEY>" \
-d '{
"decisions": [
{"tool_call_id": "call_abc123", "action": "reject", "reason": "This operation is too risky for production."}
]
}'curl -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-H "X-API-Key: <API_KEY>" \
-d '{
"decisions": [
{"tool_call_id": "call_abc123", "action": "edit", "edits": {"filename": "safe_output.txt", "content": "sanitized content"}}
]
}'When several tool calls are interrupted in the same turn, provide one decision per tool call (positional order matches the interrupted actions):
curl -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-H "X-API-Key: <API_KEY>" \
-d '{
"decisions": [
{"tool_call_id": "call_001", "action": "approve"},
{"tool_call_id": "call_002", "action": "reject", "reason": "Not safe"}
]
}'curl -X DELETE http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "X-API-Key: <API_KEY>"Response: 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" \
-H "X-API-Key: <API_KEY>" \
-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-support \
-H "X-API-Key: <API_KEY>"Optional 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" \
-H "X-API-Key: <API_KEY>" \
-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. The WebSocket handshake accepts either Authorization: Bearer <jwt> or X-API-Key: cpk_... as headers; if neither validates, the server rejects the upgrade with HTTP 401.
const ws = new WebSocket("ws://localhost:8000/api/v1/ws/<thread_id>", {
headers: { "X-API-Key": "<API_KEY>" } // or "Authorization": "Bearer <jwt>"
});
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/' \
-H "X-API-Key: <API_KEY>"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.md \
-H "X-API-Key: <API_KEY>"Response (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" \
-H "X-API-Key: <API_KEY>" \
-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.md \
-H "X-API-Key: <API_KEY>"Response: 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.
Per-user isolation: As of the
feat/dual-auth-rls-llm-peruserbranch, both the Store File API (/api/v1/store/files) and the agent namespace copies (/agents/{name}/skills/,/agents/{name}/memories/) are scoped per authenticated user via a namespace prefix(user_id, "filesystem"). A user only ever sees the skills/memories they own. See Store Namespaces per User.
To discover which agents reference a given skill, use the usage-tracking endpoint:
curl http://localhost:8000/api/v1/store/skills/my-skill/usage \
-H "X-API-Key: <API_KEY>"Response (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" \
-H "X-API-Key: <API_KEY>" \
-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" \
-H "X-API-Key: <API_KEY>" \
-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 enforces per-user isolation at the database level using PostgreSQL Row-Level Security. RLS is enabled and forced (so even the table owner is subject to the policies) on every per-user table.
| Table | user_id column added by |
|---|---|
agent_configs |
migration 012_add_user_id_to_rls_tables |
threads |
migration 012_add_user_id_to_rls_tables |
trace_events |
migration 012_add_user_id_to_rls_tables |
api_keys |
migration 011_create_api_keys_table (column present at creation) |
user_llm_settings |
migration 014_create_user_llm_settings_table (column is the PK) |
For each authenticated request, the ComposableAgentsSecurity.verify_credentials dependency resolves the user_id (JWT sub or the API key's user_id) and stores it in a contextvars.ContextVar (current_user_id). A SQLAlchemy before_cursor_execute event listener on the engine then emits:
SET LOCAL app.user_id = '<user_id>';inside the current transaction. The RLS policies compare each row's user_id against this GUC:
CREATE POLICY <table>_user_isolation ON <table>
USING (user_id = current_setting('app.user_id', true))
WITH CHECK (user_id = current_setting('app.user_id', true));When the GUC is unset (unauthenticated session), current_setting(..., true) returns NULL and the policy filters out every row — the defensive default.
System operations that must read across all users (Alembic migrations, cron jobs) wrap their work in the system_rls_context() async context manager, which sets a bypass_rls contextvar that makes the listener emit SET LOCAL row_security = off for that transaction.
The LangGraph store table is not RLS-protected. It is accessed via its own asyncpg connection pool (the PostgresStore from langgraph-checkpoint-postgres), not via the SQLAlchemy engine that sets the app.user_id GUC. Per-user isolation for skills and memories is therefore enforced at the application layer via namespace prefixes — see Store Namespaces per User.
Each authenticated user configures their own OpenAI-compatible LLM provider (provider label, base URL, and API key). The agent factory resolves the credentials per request from the authenticated user_id and builds a per-user ChatOpenAI instance. The server-wide OPENAI_API_KEY env var is no longer used to call the LLM.
| Method | Path | Description | Success Status |
|---|---|---|---|
GET |
/api/v1/settings/llm |
Get the authenticated user's LLM settings (masked key) | 200 (or null body if not configured) |
PUT |
/api/v1/settings/llm |
Insert or update the user's LLM settings | 200 |
DELETE |
/api/v1/settings/llm |
Delete the user's LLM settings (idempotent) | 204 |
The api_key is plaintext on the wire (HTTPS) and stored encrypted at rest with Fernet using SECRET_ENCRYPTION_KEY. GET responses return only a masked preview (api_key_masked), never the plaintext.
curl http://localhost:8000/api/v1/settings/llm \
-H "X-API-Key: <API_KEY>"Response (200) — null when nothing is configured yet:
{
"user_id": "qa-user-1",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_key_masked": "sk-or-v1-8e1a…b147",
"created_at": "2026-07-27T10:00:00Z",
"updated_at": "2026-07-27T10:00:00Z"
}curl -X PUT http://localhost:8000/api/v1/settings/llm \
-H "Content-Type: application/json" \
-H "X-API-Key: <API_KEY>" \
-d '{
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-v1-..."
}'Response (200):
{
"user_id": "qa-user-1",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_key_masked": "sk-or-v1-8e1a…b147",
"created_at": "2026-07-27T10:00:00Z",
"updated_at": "2026-07-27T10:05:00Z"
}curl -X DELETE http://localhost:8000/api/v1/settings/llm \
-H "X-API-Key: <API_KEY>"Response: 204 No Content (idempotent — deleting non-existent settings does not raise).
If the user invokes an agent (e.g. POST /api/v1/chat/{thread_id}) before configuring an LLM provider, the agent factory raises LlmNotConfiguredError and the API returns:
{"detail": "No LLM provider configured for user <user_id>. Configure via PUT /api/v1/settings/llm."}with HTTP status 422. The user must call PUT /api/v1/settings/llm first.
The LangGraph Store (used by the Store File API and the per-agent skill/memory namespaces) is not RLS-protected (see Row-Level Security (RLS)). Instead, per-user isolation is enforced at the application layer via namespace prefixes.
When a request is authenticated, the LangGraphStoreFileRepository resolves its namespace as:
(user_id, "filesystem")so a file written by qa-user-1 is stored under the namespace ("qa-user-1", "filesystem") and is invisible to qa-user-2. The same prefixing applies to the agent namespace copies (/agents/{name}/skills/, /agents/{name}/memories/).
When no user is authenticated (tests, system context), the namespace falls back to the static default ("filesystem",) so existing tests stay green.
GET /api/v1/store/files?prefix=/skills/lists only the calling user's skills.PUT /api/v1/store/files/skills/my-skill/SKILL.mdwrites into the calling user's namespace.- An agent's
/agents/{name}/skills/and/agents/{name}/memories/copies are scoped to the user who owns the agent config (RLS onagent_configsensures the agent itself is per-user).
Per-user API keys allow a user to authenticate without a JWT (e.g. from a CI pipeline or a script). Keys are SHA-256 hashed at rest; the plaintext is returned exactly once at creation time.
| Method | Path | Description | Success Status |
|---|---|---|---|
POST |
/api/v1/api-keys |
Create a new API key (returns plaintext once) | 201 |
GET |
/api/v1/api-keys |
List the user's API keys (no plaintext) | 200 |
DELETE |
/api/v1/api-keys/{key_id} |
Revoke an API key (idempotent) | 204 |
Creating an API key requires an existing authenticated session (JWT). In QA/local, keys are seeded directly in the database (see Testing).
curl -X POST http://localhost:8000/api/v1/api-keys \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt>" \
-d '{"name": "my-ci-key"}'Response (201):
{
"id": "8f3c1d2e-...",
"name": "my-ci-key",
"key_prefix": "cpk_abcde",
"plaintext": "cpk_abcdefghijklmnopqrstuvwxyz0123456789...",
"created_at": "2026-07-27T10:00:00Z"
}curl http://localhost:8000/api/v1/api-keys \
-H "X-API-Key: <API_KEY>"Response (200) — a list of safe projections (no hash, no plaintext):
[
{
"id": "8f3c1d2e-...",
"name": "my-ci-key",
"key_prefix": "cpk_abcde",
"created_at": "2026-07-27T10:00:00Z",
"last_used_at": "2026-07-27T11:30:00Z",
"revoked_at": null
}
]curl -X DELETE http://localhost:8000/api/v1/api-keys/8f3c1d2e-... \
-H "X-API-Key: <API_KEY>"Response: 204 No Content (idempotent — revoking an already-revoked key returns 204). Revoking a key owned by another user returns 404 (RLS hides it).
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
006_migrate_messages_to_trace_events.py
007_drop_messages_table.py
010_add_description_to_agent_configs.py
011_create_api_keys_table.py # Per-user API keys (SHA-256 hashed)
012_add_user_id_to_rls_tables.py # Add user_id to agent_configs/threads/trace_events
013_enable_rls_policies.py # Enable + force RLS, create per-user policies
014_create_user_llm_settings_table.py # Per-user LLM settings (Fernet-encrypted)
security.py # Dual-auth FastAPI dependency (JWT + per-user API key)
application/
requests/
chat.py # Request models (ChatRequest, CreateThreadRequest, HITLDecisionRequest)
api_key.py # CreateApiKeyRequest
user_llm_settings.py # UpsertUserLlmSettingsRequest
responses/
thread_history.py # ThreadHistory response DTO (thread + turns)
routes/
health.py # GET /health (public)
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
api_keys.py # POST/GET/DELETE /api/v1/api-keys (per-user API keys)
user_llm_settings.py # GET/PUT/DELETE /api/v1/settings/llm (per-user LLM)
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
api_key/ # create / list / revoke per-user API keys
user_llm_settings/ # get / upsert / delete per-user LLM settings
domain/
entities/
agent_config.py # AgentConfig, BackendConfig, HITLConfig, SubAgentConfig
agent_config_metadata.py # AgentConfigMetadata (incl. description)
mcp_server_config.py # McpServerConfig, McpTransportType
message.py # Message (role, content, timestamp, tool_calls) — projection model
thread.py # Thread (id, agent_name, user_id, timestamps)
trace_event.py # TraceEvent entity + TraceEventType enum (6 types)
tracing_config.py # TracingConfig, TracingProviderType
user_llm_settings.py # UserLlmSettings, UserLlmSettingsInput
auth/ # AuthContext, ApiKeyView, CreatedApiKey
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
api_key_repository.py # Abstract: per-user API key CRUD + hash lookup
user_llm_settings_repository.py # Abstract: per-user LLM settings CRUD
jwt_validator.py # Abstract: JWT validation against Logto JWKS
services/auth/ # AuthService (dual JWT + API key orchestration)
errors/ # DomainError hierarchy (incl. AuthenticationError, LlmNotConfiguredError)
infrastructure/
env_utils.py # ${VAR_NAME} + ${USER_JWT}/${USER_API_KEY} resolution
database/
rls_context.py # Per-request RLS contextvars + system_rls_context bypass
rls_listener.py # SQLAlchemy before_cursor_execute listener (SET LOCAL app.user_id)
models/
base.py # SQLAlchemy DeclarativeBase
agent_config.py # AgentConfigModel (ORM, incl. user_id)
thread.py # ThreadModel (ORM, incl. user_id)
trace_event.py # TraceEventModel (ORM, incl. user_id)
api_key.py # ApiKeyModel (ORM)
user_llm_settings.py # UserLlmSettingModel (ORM)
deepagent/
adapter.py # DeepAgentRunner (LangGraph adapter) — emits TraceEvent
factory.py # create_agent_from_config (per-user LLM credential resolution)
registry.py # DeepAgentRegistry (lazy loading + caching from agents/ dir)
example_tools.py # Example tools: current_time, word_count
mcp/
adapter.py # LangchainMcpToolLoader (resolves ${USER_JWT}/${USER_API_KEY})
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, agent config, API key, and LLM-settings persistence is backed by PostgreSQL, accessed via SQLAlchemy's async ORM (asyncpg driver). Per-user tables are protected by Row-Level Security (see Row-Level Security (RLS)).
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 | RLS? |
|---|---|---|
threads |
One row per conversation thread. Columns: id (PK, VARCHAR 36), agent_name, user_id, 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), source, name, content, metadata (JSONB), timestamp, sequence, user_id. |
✅ |
agent_configs |
Agent configuration metadata (incl. description, user_id). |
✅ |
api_keys |
Per-user API keys (SHA-256 hashed). Columns: id (PK), user_id, name, key_hash, key_prefix, revoked_at, last_used_at, created_at. Unique index on key_hash. |
✅ |
user_llm_settings |
Per-user LLM provider settings (Fernet-encrypted api_key_encrypted). PK is user_id — one configured provider per user. |
✅ |
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).ix_trace_events_user_id— per-user RLS filter.
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:
| 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. |
010_add_description_to_agent_configs |
Adds a description VARCHAR(500) column to the agent_configs table. |
011_create_api_keys_table |
Creates the api_keys table (per-user API keys, SHA-256 hashed). Unique index on key_hash, index on user_id. |
012_add_user_id_to_rls_tables |
Adds a user_id VARCHAR(255) column to agent_configs, threads, and trace_events so RLS policies can filter rows per user. Adds an index on user_id for each table. |
013_enable_rls_policies |
Enables and forces Row-Level Security on agent_configs, threads, trace_events, and api_keys. Creates the per-user user_isolation policy on each table. |
014_create_user_llm_settings_table |
Creates the user_llm_settings table (per-user LLM provider settings, Fernet-encrypted API key) and enables RLS with a per-user policy. |
The
mcp_serverstable is not managed here. It is owned by mcp-raganything's Alembic migration001_create_mcp_servers_table(tracked in theraganything_alembic_versiontable).
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, and switches auth from a single master X-API-Key to dual JWT + per-user API keys with Row-Level Security and per-user LLM credentials.
The single master X-API-Key / API_KEY / OPENAI_API_KEY model is removed for authentication. Every protected endpoint now requires either Authorization: Bearer <jwt> (validated against Logto OIDC JWKS, LOGTO_URL + JWT_AUDIENCE) or X-API-Key: cpk_... (per-user, SHA-256 hashed, created via POST /api/v1/api-keys). See Authentication. Existing clients sending the old master key will receive 401 {"detail": "Invalid or missing credentials"}.
The server-wide OPENAI_API_KEY env var is no longer used to call the LLM. Each authenticated user must configure their own provider via PUT /api/v1/settings/llm. If a user invokes an agent before configuring a provider, the API returns 422 LlmNotConfiguredError. See Per-User LLM Settings.
Migrations 011–014 add a user_id column to agent_configs, threads, and trace_events, create the api_keys and user_llm_settings tables, and enable forced Row-Level Security on all five tables. Existing rows become user_id = '' and are invisible under RLS (the policy compares against current_setting('app.user_id', true) which is NULL for unauthenticated sessions). Backfill existing rows to a real user_id before enabling RLS in production if you need to preserve access. See Row-Level Security (RLS).
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, 010, 011, 012, 013, 014 run automatically on startup. They are idempotent and safe to run on an existing database with data, except that 012/013 will make pre-existing rows with user_id = '' invisible under RLS — backfill them first.
Configured via .env file or environment variables. See .env.example.
| Variable | Default | Description |
|---|---|---|
AGENTS_DIR |
./agents |
Directory containing agent YAML configuration files. |
HOST |
0.0.0.0 |
Server bind host. |
PORT |
8000 |
Server bind port. |
LOG_LEVEL |
INFO |
Application log level. |
UVICORN_LOG_LEVEL |
info |
Uvicorn log level. |
ALLOWED_ORIGINS |
["http://localhost:8080"] |
JSON array of CORS allowed origins. |
OPENAI_BASE_URL |
https://api.openai.com/v1 |
Base URL for OpenAI-compatible endpoints. Set to use OpenRouter, LiteLLM, vLLM, etc. Per-user base URLs configured via PUT /api/v1/settings/llm override this for authenticated requests. |
MCP_RAGANYTHING_API_KEY |
-- | Shared API key for authenticating to mcp-raganything MCP servers via ${MCP_RAGANYTHING_API_KEY} in agent YAML. Must match the API_KEY set on the raganything server. |
| Variable | Default | Description |
|---|---|---|
LOGTO_URL |
"" (empty) |
Logto OIDC issuer URL for JWT validation (e.g. https://logto.soludev.tech). When empty, the JWT path is disabled and only the per-user API key path is active (QA/local mode). |
JWT_AUDIENCE |
"" (empty) |
Expected aud claim for incoming JWTs (typically the Logto app ID). When LOGTO_URL is set, this must be set too. |
SECRET_ENCRYPTION_KEY |
"" (empty) |
Fernet key used to encrypt per-user LLM API keys at rest. Generate with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())". Required when per-user LLM settings are enabled. Must be stable across restarts. |
| Variable | Status | Replacement |
|---|---|---|
OPENAI_API_KEY |
Deprecated for both auth and LLM calls. Still read as an env fallback by ChatOpenAI when no authenticated user is present (tests). |
Per-user LLM credentials via PUT /api/v1/settings/llm (see Per-User LLM Settings). |
API_KEY (master X-API-Key) |
Deprecated for auth. | Dual JWT + per-user API keys (see Authentication). |
| 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 sync# Unit tests (no DB / no Docker required) — 667 tests
uv run pytest tests/unit -q
# Full suite (unit + integration)
uv run pytest tests/ -vuv run pytest tests/ -v --cov=srcThe project has two test layers: unit tests (in this repo, no infrastructure) and a QA suite (in soludev-compose-apps/bricks/qa, runs against the local Docker compose stack).
cd bricks/composable-agents
uv run pytest tests/unit -q
# → 667 passedUnit tests use in-memory fixtures (Base.metadata.create_all on SQLite, in-memory repositories) — no PostgreSQL, no Logto, no MinIO. The RLS migrations do not run in unit tests (RLS is Postgres-only and validated in QA).
The QA suite exercises the full dual-auth + RLS + per-user LLM + store-namespace feature against a real PostgreSQL instance via Docker compose. It lives in soludev-compose-apps/bricks/qa.
cd soludev-compose-apps/bricks
docker compose up -d --build composable-agents bricks-db minio composable-agents-qa-init
docker compose ps # wait for composable-agents to be healthyThe composable-agents-qa-init one-shot service waits for composable-agents to be healthy (so the Alembic migrations that create api_keys have applied), then seeds two per-user API keys directly in bricks-db:
user_id |
plaintext X-API-Key |
id |
|---|---|---|
qa-user-1 |
cpk_qa_test_key_12345 |
qa-key-id-1 |
qa-user-2 |
cpk_qa_test_key_67890 |
qa-key-id-2 |
The seed is idempotent (ON CONFLICT DO UPDATE re-activates revoked rows), so re-running docker compose up -d composable-agents-qa-init is safe. In QA, LOGTO_URL is empty, so only the per-user API key path is exercised (JWT validation is unit-tested separately).
cd qa
uv run pytest # whole suite
# or just the dual-auth / RLS / per-user feature tests:
uv run pytest test_dual_auth.py test_api_keys_crud.py test_rls_isolation.py \
test_llm_settings.py test_store_isolation.py test_api_keys.py \
test_threads.py test_agents.py test_health.py --tb=shortThe QA fixtures (qa/conftest.py) read QA_API_KEY_USER_1 / QA_API_KEY_USER_2 (defaulting to the seeded plaintext keys above) and send them as X-API-Key headers automatically. COMPOSABLE_AGENTS_URL defaults to http://localhost:8010 (the port mapped in docker-compose.yml).
Note:
test_mcp_bricks_endpoints.py,test_txt_support.py,test_file_endpoints.py, andtest_extraction*.pytarget the raganything-api service and requireAPI_KEYto be set for that service — they are independent of the dual-auth feature.
uv 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 DATABASE_URLpostgresql://postgres:pass@roundhouse.proxy.rlwy.net:33019/railwayRailway PostgreSQL connection URL (required) LOGTO_URLhttps://logto.soludev.techLogto OIDC issuer URL for JWT validation. Required in prod to enable the JWT auth path. JWT_AUDIENCE<logto-app-id>Expected audclaim for JWTs. Required whenLOGTO_URLis set.SECRET_ENCRYPTION_KEYI32ylYwnej8p2Wa72G3FibHBoRNxWlVxWsC5F4LvXSU=Fernet key for encrypting per-user LLM API keys at rest. Generate with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())".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 Note:
OPENAI_API_KEYandAPI_KEY(master) are deprecated for auth and LLM. Each end-user now configures their own LLM provider viaPUT /api/v1/settings/llm(see Per-User LLM Settings).OPENAI_BASE_URLis still read as a fallback for unauthenticated/test contexts. -
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}" # Or, to forward the caller's identity to raganything: # Authorization: "Bearer ${USER_JWT}" # X-API-Key: "${USER_API_KEY}"
The
${MCP_RAGANYTHING_API_KEY}placeholder is resolved from the environment variable at runtime;${USER_JWT}/${USER_API_KEY}are resolved from the authenticated caller's credential (see Per-user credential propagation). -
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.