AgentX docs

API Endpoints

Base URL: http://localhost:12319/api/

All endpoints return JSON. POST endpoints accept application/json bodies. All endpoints handle CORS preflight (OPTIONS) automatically.

A machine-readable OpenAPI 3.0 spec mirroring this reference lives at OpenApi.yaml in the repo root (lint it with task api:spec:lint) — browse it interactively in the API Explorer.


Core

Index

GET /api/index

Returns {"message": "Hello, AgentX AI!"}.

Health Check

GET /api/health
GET /api/health?include_memory=true

Returns API, compute device, translation, embedding engine, and (optionally) memory system health.

Response:

{
  "status": "healthy",
  "api": {"status": "healthy"},
  "compute": {"device": "cuda", "cuda_available": true},
  "translation": {
    "status": "healthy",
    "models": {
      "language_detection": "eleldar/language-detection",
      "translation": "facebook/nllb-200-distilled-600M"
    }
  },
  "embeddings": {"provider": "local", "model": "BAAI/bge-m3", "dimensions": 1024},
  "memory": {
    "neo4j": {"status": "healthy"},
    "postgres": {"status": "healthy"},
    "redis": {"status": "healthy"}
  }
}

Translation status is "not_loaded" if models haven’t been initialized yet. compute.device is cuda/cpu; embeddings reports the (locked, local by default) embedding engine. Memory section only appears when include_memory=true.


Translation

Language Detection

GET  /api/tools/language-detect-20
POST /api/tools/language-detect-20

Request (POST):

{"text": "Bonjour, comment allez-vous?"}

Response:

{
  "original": "Bonjour, comment allez-vous?",
  "detected_language": "fr",
  "confidence": 98.5
}

GET accepts ?text=... query parameter.

Translation

POST /api/tools/translate

Request:

{
  "text": "Hello, world!",
  "targetLanguage": "fra_Latn"
}

Uses NLLB-200 language codes (e.g., eng_Latn, fra_Latn, deu_Latn, spa_Latn).

Response:

{
  "original": "Hello, world!",
  "translatedText": "Bonjour, monde!",
  "targetLanguage": "fra_Latn"
}

Search-backend health

GET /api/tools/search-health

Probes the active web-search backend (Tavily/Brave) with a trivial query to confirm it is configured and reachable. Powers the “Test connection” button in Settings → Web Search.

Response:

{
  "ok": true,
  "backend": "tavily",
  "count": 1,
  "error": null
}

MCP (Model Context Protocol)

List Servers

GET /api/mcp/servers

Returns configured MCP servers and connection status.

Response:

{
  "servers": [
    {
      "name": "filesystem",
      "transport": "stdio",
      "status": "connected",
      "tools": ["read_file", "write_file", "list_directory"],
      "tools_count": 3,
      "resources_count": 0
    }
  ]
}

Validate / Server Detail

POST /api/mcp/servers/validate
GET  /api/mcp/servers/{name}

validate checks a server config without saving it ({"server": {...}}). The detail endpoint returns the stored config plus live status for a single server.

GET /api/mcp/registry/search?q=notion&limit=20

Searches the official MCP registry (registry.modelcontextprotocol.io) through a server-side proxy: only active + latest entries, flattened to {name, description, version, repository_url, remotes: [{type, url}], packages: [{registry_type, identifier, runtime_hint}]}, cached ~15 minutes per query. 502 when the public registry is unreachable. Registry data is untrusted — the client uses it only to prefill the Add Server form for user review (nothing is created or connected automatically).

List Tools

GET /api/mcp/tools
GET /api/mcp/tools?server=filesystem

Returns available tools from connected servers. Filter by server name with ?server=.

List Resources

GET /api/mcp/resources
GET /api/mcp/resources?server=filesystem

Returns available resources from connected servers. Filter by server name with ?server=.

Connect

POST /api/mcp/connect

Connect to one or all configured MCP servers.

Request (single):

{"server": "filesystem"}

Response:

{
  "status": "connected",
  "server": "filesystem",
  "tools_count": 3,
  "resources_count": 0
}

Response (OAuth server needing consent — HTTP 202):

{
  "status": "auth_required",
  "server": "remote-oauth",
  "authorization_url": "https://provider.example.com/authorize?..."
}

Open authorization_url in a browser; the connect completes in the background once the user authorizes (poll GET /api/mcp/servers for the transition to connected).

Request (all):

{"all": true}

Response:

{
  "results": {
    "filesystem": {"status": "connected", "tools_count": 3},
    "github": {"status": "error", "error": "..."}
  }
}

Disconnect

POST /api/mcp/disconnect

Request (single): {"server": "filesystem"} Request (all): {"all": true}

OAuth 2.1 (remote servers)

Remote servers (sse / streamable_http) can require OAuth 2.1. Add an auth block to the server config:

{"auth": {"type": "oauth", "scope": "mcp:tools", "client_id": "optional", "client_secret": "${VAR}"}}

With no client_id, AgentX registers itself dynamically (RFC 7591) after discovering the authorization server via protected-resource metadata (RFC 9728); client_id/client_secret are for providers without dynamic registration. Tokens + the registration persist per server under data/mcp_oauth/ and refresh automatically.

GET  /api/mcp/oauth/callback              # OAuth redirect target (public; state-validated)
POST /api/mcp/servers/{name}/auth/reset   # forget tokens + registration (fresh sign-in)
POST /api/mcp/servers/{name}/auth/cancel  # abort an in-flight sign-in (leaves stored tokens)

The callback is the loopback redirect URI (RFC 8252) registered with the authorization server — override the advertised URL with AGENTX_OAUTH_REDIRECT_URL when the API is not on http://localhost:12319. Server payloads from GET /api/mcp/servers carry an auth_state object (authorized / expired / refreshable / pending / error) for OAuth servers. authorized means real tokens are stored — not merely that a registration file exists (the SDK writes that at dynamic-registration time, before consent), so a cancelled or denied sign-in never reads as authorized. expired is tri-state (null = expiry unknown: legacy token file or a provider without expires_in); with refreshable: true an expired session still reconnects headlessly via the stored refresh token, while expired && !refreshable means the next connect goes back through the browser — clients should surface that as “session expired”, not “signed in”. auth/cancel aborts the pending browser consent flow so a late completion can’t retroactively flip the server to signed-in.


Providers

List Providers

GET /api/providers

Returns configured model providers and their status.

Response:

{
  "providers": [
    {
      "name": "lmstudio",
      "status": "configured",
      "models": ["llama3.2", "deepseek-r1"]
    },
    {
      "name": "anthropic",
      "status": "not_configured",
      "error": "API key not set"
    }
  ]
}

List Models

GET /api/providers/models
GET /api/providers/models?provider=openai

Returns all available models with capabilities. Filter by provider with ?provider=.

Response:

{
  "models": [
    {
      "name": "claude-3-5-sonnet-latest",
      "provider": "anthropic",
      "context_window": 200000,
      "supports_tools": true,
      "supports_vision": true,
      "supports_streaming": true,
      "cost_per_1k_input": 0.003,
      "cost_per_1k_output": 0.015
    }
  ],
  "count": 5
}

Provider Health

GET /api/providers/health

Async health check of all configured providers.

Response:

{
  "status": "healthy",
  "providers": {
    "lmstudio": {"status": "healthy"},
    "anthropic": {"status": "healthy"},
    "openai": {"status": "unhealthy", "error": "..."}
  }
}

status is "healthy" if all pass, "degraded" if any fail.

Provider Catalog

GET /api/providers/catalog

Every reachable backend — the five built-ins plus any custom endpoint the user registered. Secrets never leave: a stored key is reduced to a fingerprint and only header names are returned.

Response:

{
  "providers": [
    {
      "id": "groq",
      "kind": "openai_compatible",
      "label": "Groq Cloud",
      "base_url": "https://api.groq.com/openai/v1",
      "key_fingerprint": "····3f21",
      "header_names": ["X-Org"],
      "enabled": true,
      "builtin": false,
      "credential": "api_key",
      "configured": true
    }
  ],
  "count": 6,
  "configured": 4
}

Custom Providers

POST   /api/providers/custom
DELETE /api/providers/custom/{id}
POST   /api/providers/test

Register any OpenAI-compatible endpoint (Groq, Together, DeepSeek, Fireworks, xAI, Mistral, Cerebras, Ollama, vLLM, a private gateway). The id becomes the left half of a model reference — an entry registered as groq resolves groq:llama-3.3-70b. Ids are 2–32 characters, lowercase alphanumeric plus -/_, and may not collide with a built-in.

POST /api/providers/custom merges onto the stored record, so a partial update (renaming the label) keeps the existing key. Both write endpoints hot-reload the provider registry — no restart.

POST /api/providers/test dry-runs an unsaved draft by timing a /models listing, so the client can validate before committing:

{ "reachable": true, "elapsed_ms": 412, "models_available": 364, "models": ["..."], "error": null }

Deleting a provider that agent profiles still reference is safe: those references degrade through the fallback chain instead of failing. The delete response names the affected profiles in referencing_profiles.

:::note[Endpoint egress policy] /providers/test and /providers/custom fetch a URL the caller supplies, so they run behind an egress guard. A local install allows private addresses — LAN LM Studio and localhost Ollama are the point. Once the API is cluster-exposed (AGENTX_PUBLIC_HOST or AGENTX_GATEWAY_TOKEN set), private, loopback, link-local and metadata addresses are blocked and the endpoints answer 403. Override either way with providers.policy.allow_private_endpoints. :::

OpenRouter Account Linking

POST /api/providers/openrouter/oauth/start
GET  /api/providers/openrouter/oauth/callback/{nonce}     ← PUBLIC
GET  /api/providers/openrouter/oauth/status?flow_id=
POST /api/providers/openrouter/oauth/cancel
POST /api/providers/openrouter/unlink

OpenRouter mints a user-controlled API key over OAuth PKCE, so a new user doesn’t have to leave the app, create a key by hand and paste it back. start returns a consent URL to open in the user’s real browser; the PKCE verifier and the minted key never leave the server. The client polls status until it reads linked, error, or expired.

The nonce rides the callback path. OpenRouter’s /auth accepts only callback_url, code_challenge and code_challenge_method — there is no state parameter — so a path segment is the only correlation channel guaranteed to survive the redirect. It plays the same role state would: unguessable, single-use, and expiring after 10 minutes. A nonce that has already settled can’t be claimed again, so a replayed callback cannot mint a second key.

The callback address comes from AGENTX_PUBLIC_HOST (→ https://host), else loopback. It deliberately does not use request.build_absolute_uri: without SECURE_PROXY_SSL_HEADER that yields http:// behind a TLS-terminating proxy, and OpenRouter requires https for any non-localhost callback. Override with AGENTX_OPENROUTER_CALLBACK_BASE.

:::caution[Cluster gateways need updating] The callback must reach the API without the gateway token. Clusters created before this release need the new tokenless location ^~ /api/providers/openrouter/oauth/callback/ block from clusters/template/nginx.conf.example, then docker compose … up -d --force-recreate — a plain restart will not pick up a changed bind-mounted file. :::

Unlinking is local. Revoking a user-controlled key needs an OpenRouter management key, which AgentX does not hold and does not ask for. unlink forgets our copy and returns revoke_url so the user can revoke it upstream themselves.

On a local install the consent screen is titled localhost:12319 — OpenRouter names localhost apps by host:port. start reports this as local_callback so the UI can say so before the browser hop rather than letting it read as phishing.

OpenRouter Intelligence

GET  /api/providers/openrouter/account
GET  /api/providers/openrouter/alias-migration      ← scan
POST /api/providers/openrouter/alias-migration      ← repair (opt-in)
GET  /api/providers/openrouter/models/{author}/{slug}/endpoints
GET  /api/providers/openrouter/generation/{id}

Account returns balance and spend from /key (+ /credits when OpenRouter answers it for an ordinary key). Best effort throughout — an unconfigured or rejected key answers {"available": false} rather than erroring, because this drives a supporting strip. limit is null on an uncapped account: that means no cap, not zero, so the UI shows usage without a gauge. Cached 60s, cleared on unlink so a forgotten key can’t keep reporting a balance.

Alias migration repairs the ~ rename. OpenRouter moved its *-latest aliases to a tilde prefix; ids stored before that still run (OpenRouter resolves them) but miss our catalog, so capabilities fall back to conservative defaults — an 8192 window reported for a 1M model, surfacing much later as premature compaction and “spotty memory”.

The reading half is fixed automatically (resolve_catalog_id follows the prefix at lookup time). The stored half is opt-in: GET scans agent profiles, global model settings and memory-stage models and reports what it would change; POST rewrites only the refs handed back to it. These are the user’s own model choices — silently rewriting them would be the “don’t change the user’s defaults” failure.

:::caution catalog_available: false with an empty refs means “couldn’t check”, not “nothing to fix”. A client must not report an all-clear on it. :::

A replacement is only ever proposed when it is verified present in the live catalog — never one broken id rewritten into another.

Model endpoints gives per-provider serving detail (real context_length, max_completion_tokens, quantization, price). It answers “why is my context 200k and not 1M?”: the top-level catalog reports the best endpoint, while a turn may be routed to a smaller one.

Generation cost returns OpenRouter’s own billing for a completed turn — accounting for cache reads/writes, long-context rate tiers, and the endpoint that actually served it, none of which a catalog-rate estimate can see. 404 is a normal answer: the record is written asynchronously, so a lookup right after the turn legitimately misses. Keep the estimate and retry.

Model Route

GET /api/providers/route?model=openrouter:~anthropic/claude-sonnet-latest

Resolves a model reference exactly as a live turn would — role expansion, provider health, then the fallback chain — without making a call. Optional &fallback= supplies the caller’s known-good model, tried before the global default.

Response:

{
  "requested": "openrouter:~anthropic/claude-sonnet-latest",
  "resolved": {
    "model": "openrouter:~anthropic/claude-sonnet-latest",
    "provider": "openrouter",
    "provider_label": "OpenRouter",
    "configured": true,
    "healthy": true,
    "known": true,
    "context_window": 1000000,
    "max_output_tokens": 128000
  },
  "substituted": false,
  "candidates": ["..."],
  "fallback_enabled": true
}

Two fields carry the diagnostic weight:

  • substituted — true when the requested model is unavailable and something else would run. This substitution is invisible at runtime and shows up later as premature compaction.
  • known — whether the provider’s catalog actually lists the model id. false means its capabilities are unknown, not small, so context_window and max_output_tokens come back null rather than reporting a provider default as if it were fact.

Provider catalogs are warmed before resolving, so a cold cache can’t report a default window as the model’s real one.

Model Roles

GET /api/models/roles

The three model roles (fast_utility, deep_reasoning, summarizer) with their configured models, plus every member setting’s current resolution chain. A member with an empty value follows its role; an explicit value always wins; any model setting may also be set to role:<name> explicitly.

Response:

{
  "roles": {
    "summarizer": {"label": "Summarizer", "description": "…", "model": ""}
  },
  "members": [
    {"member": "compression", "label": "Tool-output compression",
     "role": "summarizer", "kind": "config", "source": "compression.model",
     "explicit": "", "role_model": "", "effective": "", "following": "fallback"}
  ]
}

following is explicit (member’s own value wins), role (inheriting the role’s model), or fallback (neither set — the provider fallback chain decides). Set roles via POST /api/config/update with {"models": {"roles": {"summarizer": "provider:model"}}}"" clears a role; role: refs and non-provider:model strings are rejected.

POST /api/models/roles/adopt

Resets the memory consolidation stage models (extraction, relevance, contradiction, correction, entity-linking, combined extraction, procedural distillation) to inherit so each follows its model role. Fresh installs already ship these as inherit; this one-click action lets an existing install adopt the family without hand-clearing each stage. Recall and document-summary members keep their own fast defaults and are not touched. Returns {"success": true, "adopted": [...]}.


Agent

Run Task

POST /api/agent/run

Execute a task using the full agent pipeline (planning + reasoning).

Request:

{
  "task": "Analyze the sentiment of this text: I love this product!",
  "reasoning_strategy": "chain_of_thought"
}
FieldTypeRequiredDescription
taskstringyesTask description
reasoning_strategystringno"auto", "cot", "tot", "react", "reflection"

Response:

{
  "task_id": "a1b2c3d4",
  "status": "complete",
  "answer": "The sentiment is positive...",
  "plan_steps": 2,
  "reasoning_steps": 3,
  "tools_used": [],
  "models_used": ["llama3.2"],
  "total_tokens": 450,
  "total_time_ms": 1234.5
}

Chat

POST /api/agent/chat

Conversational interaction with session management.

Request:

{
  "message": "What can you help me with?",
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "model": "claude-3-5-sonnet-latest",
  "profile_id": "default",
  "temperature": 0.7,
  "use_memory": true
}
FieldTypeRequiredDefaultDescription
messagestringyesUser message
session_idstringnoauto-generatedSession ID for continuity
modelstringnofrom configModel to use
profile_idstringno"default"Prompt profile ID
temperaturefloatno0.7Sampling temperature
use_memoryboolnotrueEnable memory recall/storage. false makes the conversation ephemeral — turns aren’t persisted to the durable transcript at all (no reload survival, no extraction)

Response:

{
  "task_id": "a1b2c3d4",
  "status": "complete",
  "response": "I can help you with...",
  "answer": "I can help you with...",
  "thinking": "Let me consider what this user needs...",
  "has_thinking": true,
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "reasoning_trace": 0,
  "reasoning_steps": 0,
  "tokens_used": 150,
  "total_tokens": 150,
  "total_time_ms": 892.3
}

Chat (Streaming)

POST /api/agent/chat/stream

Same request body as /agent/chat. Optional extras: workflow_id (run an Agent Alloy workflow — a “Team” in the UI), target_agent_id (route this turn to a specific agent by its Docker-style agent_id), disable_delegation (Solo mode — suppress ad-hoc delegation for this turn: no delegate_to tool, no roster prompt; ignored when workflow_id is set), research_mode (Research Mode — elevated per-turn search budget + a rigorous, evidence-grounded, self-reviewing research prompt that lands a durable cited report in the attached Project; forces the flat single-agent path; gated server-side by research.enabled), thinking_pattern (Thinking Patterns — per-turn override: native | cot | step_back | reflection | deep_reflection | self_consistency, legacy tot/react degrade honestly; unset ⇒ profile/auto chain; gated by reasoning.chat_patterns_enabled), workspace_id (attach a document workspace), images (vision input — an array of {workspace_id, doc_id, media_type} refs to images already uploaded via POST /agent/chat/images; a vision-capable model receives them as image blocks, a non-vision model gets the text only with a status notice), and audio (audio input — the same ref shape plus an optional cached transcript; capability-driven dual path: an audio-capable model hears natively-accepted containers ≤10MB as input_audio blocks, anything else is transcribed server-side via the shared Ambassador STT seam and inlined as a labeled transcript with status: transcribing/audio_fallback notices — the transcript persists onto the turn’s metadata and is honored on resend, so a regenerate never re-transcribes). A media-only turn may send an empty message. Routing priority: workflow_id > target_agent_id > agent_profile_id > default; an unknown target_agent_id yields an error event. Returns Server-Sent Events (SSE).

Response: Content-Type: text/event-stream

SSE events in order:

EventDataWhen
run_started{"run_id": "..."}First event — identifies the detached run for re-attach
start{"task_id": "...", "model": "..."}Generation begins
chunk{"content": "token text"}Each token
status{"phase": "running_tool", "label": "Running web_search…", "detail"?, "group"?, "progress"?}Coarse per-phase activity (recalling/composing/thinking/running_tool/reading) for a live status line; rides the run bus, so it replays on re-attach
steer{"id": "...", "message": "..."}A user steered the running turn (folded in as a fresh user turn at the next safe boundary); echoed so every client shows the steer bubble inline
tool_call{"tool": "name", "arguments": {...}}Tool invocation starts
tool_result{"tool": "name", "content": "..."}Tool result (truncated to 500 chars)
workspace_attached{"workspace_id": "..."}A generated image landed in a workspace; emitted after the image exhibit so a conversation with no workspace can durably attach the personal Home store client-side
done`{“task_id”: ”…”, “thinking”: ”…”, “has_thinking”: bool, “total_time_ms”: float, “session_id”: ”…”, “context_window”: int, “context_used”: int, “context_summarized”: bool, “context_dropped_turns”: int, “thinking_pattern”: “cot”null, …}`
error{"error": "message"}On failure
close{}Run settled; tail ends

The streaming endpoint supports the same tool-use loop as the non-streaming endpoint (up to 10 rounds). Memory storage happens after the stream completes.

During a delegation (delegation_* events — see the Agent Teams guide), a specialist’s exhibit and workspace_attached events pass through top-level so the client renders them like any exhibit; delegation_complete carries an optional exhibits array (cap 5) used to rebuild the cards on conversation restore.

Detached execution. The run is driven by a server-side daemon thread that fans SSE events into a Redis stream and persists turns on completion — independent of the HTTP connection. Closing or switching the tab does not stop the run; it plays to completion and can be re-attached. This is what prevents the in-flight response (and, for a new chat, the whole conversation) from being lost on disconnect.

Example with curl:

curl -N -X POST http://localhost:12319/api/agent/chat/stream \
  -H "Content-Type: application/json" \
  -d '{"message": "Hello!", "model": "llama3.2"}'

Re-attach to a Run

GET /api/agent/chat/stream/attach?run_id=<id>

Replays the buffered SSE events for a detached run from the start, then follows live until completion. A reopened tab uses this to resume an in-flight conversation. Emits a run_missing event (instead of replaying) when the run’s buffer has expired — the client then restores from conversation history.

List Detached Runs

GET /api/agent/chat/runs

Lists the caller’s detached chat runs (newest first, capped at 50). Recovery surfaces — the Relay inbox and the conversation selector — use this to find runs whose owning tab was closed and offer to re-attach. Runs are indexed per user, so callers only see their own. Each entry: {run_id, status, message, session_id, created_at, updated_at}. Orphaned runs — a driver process that died mid-run stops refreshing its liveness beacon — are settled to failed here instead of listing as eternally running.

Cancel a Run

POST /api/agent/chat/runs/{run_id}/cancel

Cooperatively cancels a detached run; the runner checks the flag at SSE-event boundaries and stops pulling from the provider. An orphaned run (no driver left to honor the flag) is settled to cancelled immediately. Returns {"run_id": "...", "cancel_requested": bool, "status": "cancelled" | "running" | null}.

Steer a Run (live steering)

POST /api/agent/chat/runs/{run_id}/steer

Body: {"message": "...", "mode"?: "queue"}. Folds a message into an in-flight turn without stopping it: the message is queued and the streaming tool loop drains it at the next safe boundary (after a tool round, or instead of ending) and folds it in as a fresh user turn so the agent course-corrects. Owner-only (it injects content). The message is echoed onto the run’s event bus as a steer event so every connected client (live + re-attached) shows the steer bubble inline. Returns {"run_id": "...", "steer_accepted": bool}. 400 blank message, 403 not the owner, 404 unknown run, 409 run not active.

Ambassador — Brief a Turn

POST /api/agent/ambassador/brief-turn
POST /api/agent/ambassador/ask
POST /api/agent/ambassador/draft
POST /api/agent/ambassador/voice-command
POST /api/agent/ambassador/speak
POST /api/agent/ambassador/transcribe
GET  /api/agent/ambassador/stream?run_id=<id>
GET  /api/agent/names?pool=&count=               # deal names for the profile editor's name deck (pool: random|preferred; excludes in-use names)
GET  /api/agent/names/preferred                  # the starred name pool
POST /api/agent/names/preferred                  # {add?, remove?} — star/unstar a name; returns the updated pool
POST /api/agent/avatar/generate                  # generate an agent avatar (OpenRouter) → Home workspace; subject optional (empty → synthetic face), uses images.avatar_model
POST /api/agent/chat/images                       # upload chat media (multipart `file`: image for vision, audio for audio input, video render-only) → Home workspace; returns a media ref
POST /api/agent/chat/media                        # alias of /agent/chat/images (the historical path predates audio/video)
POST /api/agent/ambassador/relay
POST /api/agent/ambassador/dispatch
GET  /api/agent/ambassador/threads
POST /api/agent/ambassador/threads
GET    /api/agent/ambassador/thread/{thread_id}
PATCH  /api/agent/ambassador/thread/{thread_id}
DELETE /api/agent/ambassador/thread/{thread_id}
GET  /api/agent/ambassador/{conversation_id}

The Ambassador (Phase 16.6/16.7, v3) is a dedicated agent that runs parallel to your conversations — a conversational operator with its own persistent thread (an “Inquiry”), never a reaction to your last turn. It observes the conversation world through a curated tool belt that never executes a write: reads (summarize/read/survey conversations, the structured conversation state, what a conversation produced — its exhibits and cited sources, full-text search across all conversations, live runs + queued jobs, the usage ledger, long-term memory recall, the agent roster) auto-run, while conversation-management writes (rename/archive/delete a conversation) and task dispatches (dispatch_task — hand a self-contained task to a worker agent) only file a proposal you confirm on screen — the write executes solely via the user-confirmed meta/dispatch endpoints, a confirmed dispatch landing as your user turn. Its answers are grounded in facts: aide digests preserve load-bearing specifics verbatim (a Facts: tail that survives clipping), it drills back into the source conversation when a summary can’t answer a specific, it distinguishes what it read directly from what a digest reports — and a reasoning model’s <think> blocks are stripped before they can reach the thread, the replay, or the voice. It briefs you on demand, relays messages into conversations as your turns, and dispatches tasks to workers — all without polluting any conversation (it writes only to its Redis sidecar, never conversation_logs or the rolling summary). It is any agent profile with an ambassador section; the global default is config.ambassador.profile_id (falls back to the default agent profile). It mounts both against a conversation and as the standalone Command Deck (a conversation-less per-user thread for cross-conversation questions).

  • POST /brief-turn — body {conversation_id, message_id, assistant_text, user_text?, agent_name?, artifacts?}. Starts a parallel briefing of one turn and returns {run_id}. The run is detached and un-indexed (indexed=false), so it never appears in /agent/chat/runs. It reads conversation context read-only and writes only to a Redis sidecar (the ambassador: key prefix) — never conversation_logs or the rolling summary, so nothing it produces re-enters the main agent’s context. agent_name lets the briefing speak of the agent by name; artifacts ({tools?, sources?, exhibits?}, gathered + compacted client-side) carries what the agent actually did this turn so the briefing grounds on the turn’s substance, not just its prose.
  • POST /ask — body {conversation_id, qa_id, question, agent_name?, artifacts?}. Ask the ambassador a free-form question about the conversation; returns {run_id, qa_id}. Same detached/un-indexed/sidecar machinery as brief-turn, persisting under the qa: key family so Q&A replays independently of per-turn briefings. The answer is grounded only in the conversation (read-only).
  • POST /draft — body {conversation_id, intent, agent_name?, artifacts?, fresh?}. The outbound relay (you → agent): the ambassador shapes a rough intent into a clear, first-person message and returns {draft} for you to review/edit. The client then relays it into the conversation as a real user turn (or steers the running turn) — the ambassador never speaks into the transcript itself, so the no-pollution invariant holds. With fresh: true (dispatch) the draft is a self-contained task for a worker (agent_name) to start cold — no conversation_id required. Degrades to the raw intent when no provider is configured.
  • POST /dispatch — body {agent_id, text, conversation_id?}. The ambassador write-side (hand a task to a worker): runs the chosen worker (resolved by agent_id, agents only) headless on text as a user turn (enqueue_background_chat). By default a brand-new conversation is minted; pass conversation_id to run the task inside an existing conversation instead (unknown id → 404, the relay guard). Returns {ok, conversation_id, job_id} so the client opens + watches it — the client echoes the task into the tab immediately (a seeded user bubble; the worker’s reply lands on reload). You authored the task — the ambassador never speaks into the transcript as itself (INV-2). Gated by config.ambassador.dispatch.enabled (default on; 422 when off); unknown/non-worker agent_id400. Confirm-first, reached two ways: the composer’s dispatch mode (you pick the worker + send) or confirming an ambassador-filed dispatch_task proposal.
  • POST /relay — body {conversation_id, text}. Deliver a relayed message into any conversation as a real user turn — even one with no open client tab — by running its agent headless via the background-chat worker (warm-hydrates, appends the user turn + the agent’s reply to the durable history). The person is the author; the ambassador never speaks into the transcript. The target agent is recovered from the conversation’s most recent stamped turn (latest_agent_nameget_profile_by_name), else the default profile. Returns {ok, job_id}; unknown conversation → 404. The client (lib/ambassadorRelay.ts::planRelay) prefers the live in-tab handler when the target is the active tab and uses this endpoint otherwise (command deck, off-tab focus).
  • GET /threads / POST /threads — the command deck’s standalone Inquiries. GET lists the user’s threads newest-first ({threads: [{thread_id, title, created_at?, updated_at?}], deck_thread_id} — the home deck:{user} thread is always present and pinned first); POST mints + registers a new standalone Inquiry (inq:{user}:{uuid}) → {thread_id} (titled later via PATCH /thread/{id} or auto-titled from its first question). Chat-bound Inquiries are reached through their conversation id, not here. Backed by a per-user Redis registry (amb_user:{user}:threads, self-healing).
  • POST /voice-command — body {conversation_id, transcript, agent_name?, artifacts?}. Voice-mode intent routing: the ambassador interprets a spoken command and returns {action: "answer"|"relay"|"tool", text, qa_id?, tool?} — a question it answers (spoken; persisted to the qa: sidecar so the Text tab shows it), an instruction it drafts as a first-person relay the user reviews and sends into the conversation as a real user turn (never auto-sent), or a conversation-management / dispatch command (rename/archive/delete, or “have the researcher dig into…”) it files as a tool proposal (tool: {proposal_id, action, conversation_id?, title?, agent_id?, agent_name?, task?}, action now including dispatch) the user confirms on screen — nothing executes here; the client applies a confirmed proposal via PATCH /api/memory/conversations/{id}/meta, the conversation DELETE, or POST /dispatch for a dispatch. Never fails the call (degrades to a spoken notice; an unknown tool or invalid args degrade to an answer). text for a relay is shaped via the draft persona; the JSON is forward-compatible with a target for future cross-agent delegation.
  • POST /speak — body {text, agent_profile_id?, voice?, model?}. Voice (TTS): synthesizes spoken audio for a briefing summary or Q&A answer via the ambassador’s speech model (OpenRouter /audio/speech) and returns the raw MP3 bytes (audio/mpeg). Model/voice precedence: request override → the ambassador profile’s speech_model/voiceconfig.ambassador.* → shipped default (microsoft/mai-voice-2); the speech model resolves strictly (no chat fallback). Stateless — the client already holds the text, so nothing is written to the transcript or sidecar. Degrades to a structured 422 {error, code} (e.g. voice_unconfigured when no OpenRouter key is set) rather than failing. Played by the client’s SpeechPlayer (synthesized once, cached for replay); opted-in ambassadors get an immersive voice mode that auto-speaks new briefings.
  • POST /transcribe — body {audio: <base64>, format?, agent_profile_id?, model?, language?}. Voice input (STT): transcribes a push-to-talk recording via the ambassador’s STT model (OpenRouter /audio/transcriptions) and returns {text}. Model precedence: request override → the ambassador profile’s transcription_modelconfig.ambassador.* → shipped default (openai/whisper-1), strict. The client routes the transcript into the reviewable input — it is never auto-sent (pre-send confirmation), and a flubbed take can be re-recorded (retake). Degrades to a structured 422 {error, code} (e.g. transcription_unconfigured). Client capture: lib/audioRecorder.ts (getUserMedia + MediaRecorder) behind hooks/useDictation.ts; hold-Space or the immersive voice-mode record button drives it.
  • GET /stream?run_id= — tail a briefing or Q&A run’s namespaced SSE: ambassador_start, ambassador_chunk (one per streamed delta), ambassador_tool_call ({message_id, tool, args}) / ambassador_tool_result ({message_id, tool, ok}) — the agentic answer core driving its tool belt; rendered as live tool chips and persisted on the thread entry so they survive a reload. A confirmed-write tool (rename/archive/delete a conversation, or a dispatch_task) additionally emits ambassador_tool_proposal ({message_id, tool, proposal: {proposal_id, action, conversation_id?, title?, agent_id?, agent_name?, task?}}) between call and result — the belt only files the proposal (the tool result tells the model it’s awaiting confirmation); the client renders a confirm strip (the proposal is also stamped on the persisted chip so a replay rebuilds it) and executes a confirmed one via PATCH /api/memory/conversations/{id}/meta, the conversation DELETE, or POST /dispatch for a dispatch. Then ambassador_done (status done | empty_provider | cancelled), ambassador_error; run_missing if the buffer expired. Cancel via POST /api/agent/chat/runs/{run_id}/cancel (settles the sidecar to cancelled, preserving partial text). A missing/unreachable provider degrades gracefully to an empty_provider notice rather than failing.
  • GET /thread/{thread_id} — replay the unified ambassador thread (an “Inquiry”): {thread_id, title, entries: [{id, kind: "briefing"|"qa", question, content, status, toolCalls, message_id?, run_id?, created_at, updated_at}]}. Briefings and Q&A are one ordered conversation (oldest-first), each entry carrying its persisted tool-call chips (they now survive a reload), plus the thread’s own title. thread_id defaults to the conversation id. The client renders this as one stream and lets you rename the Inquiry.
  • PATCH /thread/{thread_id} — rename the Inquiry, body {title}{thread_id, title}. An empty title clears it (the client falls back to the chat conversation’s title).
  • DELETE /thread/{thread_id} — clear the Inquiry (its briefings, answers, and title) → {thread_id, cleared: true}.
  • GET /{conversation_id}back-compat shim: replay as the old {conversation_id, briefings: [...], qa: [...]} shape (now projected from the unified thread). New clients use /thread/{thread_id}.

Agent Status

GET /api/agent/status

Returns current agent status (idle, planning, reasoning, executing, complete, failed, cancelled).

Cancel Plan

POST /api/agent/plans/cancel

Requests cancellation of the agent’s active plan execution.

Plan Status

GET /api/agent/plans/{plan_id}/status?session_id=<sid>

Reads the Redis-tracked state of a plan (status, completed count, per-subtask state) so a client can reconcile a persisted “running” plan after a reload. Returns { "found": false } with HTTP 200 when the state has expired (1h TTL). The resumable flag is true when the plan is active with non-terminal work left and carries a structural snapshot.

Resume Plan

POST /api/agent/plans/{plan_id}/resume

Resumes an interrupted plan. Rebuilds it from Redis (PlanStateStore.load_plan) and continues executing only its not-yet-terminal subtasks, streaming SSE the same way the chat endpoint does (first event plan_resumed, then subtask events → plan_completedone). The run is detached (survives client disconnect) and the synthesis is persisted as an assistant turn. Single-agent only for now (alloy plan resumption is a follow-up). Returns 404 when the plan is not resumable (missing, expired, or already finished).

Body: { "session_id": str, "agent_profile_id"?: str, "model"?: str, "temperature"?: number, "use_memory"?: bool }


Background Chat

Long-running conversations can be queued and polled instead of streamed.

POST /api/chat/background
GET  /api/chat/background              # list recent jobs (?limit=, max 50)
GET  /api/chat/background/{job_id}     # fetch one job
DELETE /api/chat/background/{job_id}   # dismiss from the inbox

Request (POST): message is required; the rest mirror /agent/chat.

{
  "message": "Summarize today's research notes",
  "session_id": "optional",
  "agent_profile_id": "optional",
  "workflow_id": "optional",
  "model": "optional",
  "use_memory": true
}

Response (POST): 202 Accepted

{ "job_id": "a1b2c3d4", "status": "queued" }

Poll GET /api/chat/background/{job_id} for the job record (status + result once complete); 404 if unknown.


Tool Outputs

Oversized tool outputs are compressed and stored for later retrieval (see Context Gating).

GET    /api/tool-outputs                 # ?pattern=<tool-name-glob> (default *)
GET    /api/tool-outputs/{storage_key}   # ?offset= &limit= &metadata_only=true
DELETE /api/tool-outputs/{storage_key}

Response (list):

{ "outputs": [ { "storage_key": "...", "tool": "...", "size": 18342, "created_at": "..." } ], "count": 1 }

The detail endpoint returns the full content, a paginated slice (offset/limit), or metadata only (metadata_only=true). 404 if the key is missing or expired.


Workspaces (Projects)

File workspaces & document RAG — surfaced in the client as Projects. A workspace is a named, persistent container of uploaded files with a searchable manifest, plus user-authored description and instructions (the instructions ride every chat turn’s context), and durable conversation membership (a conversation belongs to at most one project). Bytes live in a content-addressed blob store; the manifest in Postgres; chunk vectors in pgvector. Upload validates type/size/quota then ingests in the background (parse → chunk → embed → auto tag + summary), so a document moves pendingready (or failed).

GET    /api/workspaces                               # list
POST   /api/workspaces                               # { "name": "..." } → 201
GET    /api/workspaces/{workspace_id}                # detail (description, instructions, document_count, used_bytes, allow_shell, shell_backend)
PATCH  /api/workspaces/{workspace_id}                # { name?, description?, instructions?, allow_shell?, shell_backend? }
DELETE /api/workspaces/{workspace_id}                # delete (cascades documents + blobs + memberships + shell container)
GET    /api/workspaces/{workspace_id}/documents      # manifest list (tags/summary/status)
POST   /api/workspaces/{workspace_id}/documents      # multipart field `file` → 201 (status=pending)
POST   /api/workspaces/{workspace_id}/documents/text # { filename, content } → 201 (create md/txt doc; 409 on filename collision)
PUT    /api/workspaces/{workspace_id}/documents/{document_id}/text  # { content, expected_sha256? } → 200 (replace content, re-ingests; 409 on ETag mismatch)
GET    /api/workspaces/{workspace_id}/documents/{document_id}
PATCH  /api/workspaces/{workspace_id}/documents/{document_id}       # { name } → rename base name (keeps folder + ext; doc_id unchanged); 409 on collision
DELETE /api/workspaces/{workspace_id}/documents/{document_id}
GET    /api/workspaces/{workspace_id}/documents/{document_id}/raw   # serve blob bytes (e.g. generated avatars)
POST   /api/workspaces/{workspace_id}/documents/{document_id}/reingest  # retry ingestion (blob reused) → 202
POST   /api/workspaces/{workspace_id}/avatars/prune                 # delete `avatars/` files no agent profile references → { deleted[], count }
GET    /api/workspaces/{workspace_id}/conversations                 # the project's conversations (same shape as /api/conversations)
PUT    /api/workspaces/{workspace_id}/conversations/{conversation_id}    # add to project (upsert — moves from any other project)
DELETE /api/workspaces/{workspace_id}/conversations/{conversation_id}    # remove from project
GET    /api/workspaces/{workspace_id}/shell/container          # container backend: status + live stats
POST   /api/workspaces/{workspace_id}/shell/container/{action} # action ∈ start|stop|reset|remove

description is capped at 500 chars and instructions at 8000 (400 beyond). Membership PUT returns 400 for a non-UUID conversation id or for ws_home (the reserved personal Home space is not a project); DELETE returns 404 if the conversation isn’t linked to that workspace. Conversation rows from /api/conversations carry a workspace_id field (null when the conversation is in no project).

Response (manifest list):

{ "documents": [ {
  "id": "doc_…", "filename": "report.pdf", "content_type": "application/pdf",
  "size_bytes": 174892, "sha256": "…", "tags": ["…"], "summary": "…",
  "status": "ready", "error": null, "created_at": "…", "updated_at": "…"
} ] }

Upload errors: 415 unsupported file type, 413 per-file size limit or workspace quota exceeded. Supported v1 types: PDF + text/markdown/code.

Text documents (create/update): the /documents/text endpoints accept JSON and are limited to agent-writable types (md/markdown/txt; filenames may carry one folder level, e.g. research/notes.md). Create returns 409 with { code: "conflict", document_id } when the filename already exists — update is an explicit, separate act. Update replaces the whole content and re-ingests (status returns to pending); pass the document’s last-known expected_sha256 for optimistic concurrency — a mismatch returns 409 with current_sha256. Identical content is a no-op (no re-ingest). These endpoints back the Projects hub editor and mirror the agent’s create_document/update_document tools.

Attaching to a conversation: pass workspace_id in the /api/agent/chat/stream request body, or link the conversation durably via PUT /api/workspaces/{id}/conversations/{conversation_id}. Turn precedence: an explicit request workspace_id wins (and self-heals the membership record); otherwise the server falls back to the conversation’s stored membership and emits a workspace_attached SSE event so the client re-learns the binding. The agent then sees a project identity block (always, even for an empty project), the project’s instructions, and the file manifest in its context, and can call the project tools (project_search — legacy alias workspace_search still executes — document_query, read_document, plus create_document/update_document for durable writes); document hits are auto-cited (source_type: "doc").

Agent shells (opt-in, per workspace). Set allow_shell: true on a workspace to expose sandboxed shell tools (run_command, write_file/read_file/list_files) to agents whose conversation is attached to it. shell_backend picks the sandbox:

  • bubblewrap (default) — a locked-down jail: filesystem limited to a per-conversation working copy of the workspace, no network, scrubbed env, time-limited. Needs bubblewrap on the server.
  • container — a persistent per-workspace Docker container the agent can pip/apt-install into, with network on (its own bridge; no access to AgentX’s DBs/secrets). Requires shell.docker.enabled and a reachable Docker daemon (dev: host Docker; prod: the dind sidecar in docker-compose.shell.yml). Manage it via the /shell/container endpoints (status/stats + start/stop/reset/remove); the container is removed when the workspace is deleted.
// GET /api/workspaces/{id}/shell/container
{ "container": { "state": "running", "image": "python:3.14-slim",
  "memory_usage": "48MiB / 2GiB", "cpu_percent": "0.10%", "install_size": "12MB (virtual 160MB)",
  "last_used_at": 1718900000, "idle_gc_at": 1719504800 } }

Agent Profiles

Agent profiles define identity plus per-agent settings (model, temperature, prompt, memory channel).

GET    /api/agent/profiles
POST   /api/agent/profiles
POST   /api/agent/profiles/reorder
GET    /api/agent/profiles/{profile_id}
PATCH  /api/agent/profiles/{profile_id}
DELETE /api/agent/profiles/{profile_id}
POST   /api/agent/profiles/{profile_id}/set-default
POST   /api/agent/profiles/{profile_id}/set-default-ambassador

Profiles carry a kind (agent or ambassador). Agents and ambassadors have separate defaults: set-default marks the default agent; set-default-ambassador marks the default ambassador (the one briefings use). Ambassador-kind profiles are hidden from the chat agent selector, delegation, and @-mention routing.

Profile object (returned by GET/POST):

{
  "id": "default",
  "name": "AgentX",
  "agent_id": "bold-cosmic-falcon",
  "avatar": null,
  "description": "General-purpose assistant",
  "tags": ["research", "fast"],
  "default_model": "lmstudio:llama3.2",
  "temperature": 0.7,
  "prompt_profile_id": "default",
  "system_prompt": null,
  "reasoning_strategy": "auto",
  "enable_memory": true,
  "memory_channel": "_global",
  "enable_tools": true,
  "direct_mode": false,
  "allowed_tools": null,
  "blocked_tools": [],
  "available_for_delegation": false,
  "delegation_hint": null,
  "org_level": "agent",
  "is_default": true,
  "created_at": "2026-04-01T12:00:00",
  "updated_at": "2026-04-01T12:00:00"
}

POST requires name (an id and agent_id are generated if omitted) and returns 201. PATCH accepts a partial object; agent_id is immutable.

available_for_delegation (opt-in, default false) puts the profile on the ad-hoc delegation roster (“Join the team roster” in the UI); delegation_hint is a one-line specialty shown to teammates deciding whom to delegate to (falls back to description; trimmed, max 200 chars).

org_level ("executive" | "manager" | "lead" | "agent", default "agent") is the Agentic Organizations tier — orthogonal to kind, and structural only via workflows (manager_agent_id). Transitioning a profile to manager merges the report-only blocked-tools template once (manual-work tools: document writes, shell, media generation — editable afterward). executive is reserved: accepted, no semantics, never offered in the UI. The default ambassador is system-owned — DELETE on it returns 400.


Skills

Skills are named instruction packs — know-how, not tools. Agents see a compact index (id — name: description) in their chat system prompt and load a skill’s full markdown body on demand with the use_skill internal tool (progressive disclosure: per-turn prompt cost stays flat as the library grows). Managed in Connectors & Tools → Skills; stored in data/skills.yaml with one-time seeded defaults.

GET    /api/agent/skills
POST   /api/agent/skills
GET    /api/agent/skills/{skill_id}
PUT    /api/agent/skills/{skill_id}
DELETE /api/agent/skills/{skill_id}

Skill object (responses wrap one skill as {"skill": {...}}; the list returns {"skills": [...]}):

{
  "id": "decision-brief",
  "name": "Structured Decision Brief",
  "description": "Turn a fuzzy choice between options into a compact, opinionated decision brief.",
  "body": "When a decision needs to be made…",
  "tags": ["thinking", "writing"],
  "enabled": true,
  "allowed_agent_ids": null,
  "created_at": "2026-07-10T12:00:00",
  "updated_at": null
}

POST requires name; the id is server-assigned (slug of the name, unique-suffixed) and immutable. PUT accepts a partial object. allowed_agent_ids mirrors MCP servers: null = all agents, [] = none, else an agent_id whitelist — enforced both in the prompt index and inside use_skill. Disabled skills disappear from both.


Multi-Agent (Agent Alloy)

Supervisor + specialist workflows with delegation over shared memory channels (Phase 16, v1). “Agent Teams” is the user-facing name for this system — routes, payloads, and config keys keep the alloy vocabulary. See the Agent Teams feature guide for concepts.

GET    /api/alloy/workflows
POST   /api/alloy/workflows
GET    /api/alloy/workflows/{workflow_id}
PATCH  /api/alloy/workflows/{workflow_id}
DELETE /api/alloy/workflows/{workflow_id}

Workflow object (responses wrap a single workflow as {"workflow": {...}}):

{
  "id": "research-team",
  "name": "Research Team",
  "description": "Supervisor delegates lookups to a researcher",
  "supervisor_agent_id": "bold-cosmic-falcon",
  "manager_agent_id": null,
  "members": [
    { "agent_id": "bold-cosmic-falcon", "role": "supervisor", "delegation_hint": null },
    { "agent_id": "calm-lunar-otter", "role": "specialist", "delegation_hint": "web research" }
  ],
  "routes": [],
  "shared_channel": "_alloy_research-team",
  "canvas": {},
  "created_at": "2026-04-28T04:18:41",
  "updated_at": "2026-04-29T03:28:38"
}

POST validates the workflow (kebab-case id, exactly one supervisor, known agent_ids) and returns 201; 400 on validation failure. To run a workflow, pass workflow_id to POST /api/agent/chat/stream. DELETE returns {"deleted": true}.

manager_agent_id (nullable) is the Agentic Organizations edge: the manager that owns this team (chain of command: manager → this team’s lead → its members, member ↑ lead for escalation). It must resolve to an agent-kind profile that is neither the lead nor a member (400 otherwise); tier mismatches (org_level not manager/lead) are warn-only. Teams with a manager put their participants in the org: with alloy.chain_of_command on (config key, default true), their ad-hoc delegate_to targets become chain adjacency only (enforced at the tool enum AND the executor), and a delegated lead works its own members as nested delegation_* events (parent_delegation_id links them). PATCHing manager_agent_id: null makes the team org-free again — org-free installs keep the flat opt-in roster unchanged.


Conversations

GET   /api/conversations                          # ?limit= (max 100) &offset= &channel= &include_archived=
GET   /api/conversations/{conversation_id}/messages
GET   /api/conversations/{conversation_id}/state   # structured working memory (goals/decisions/open_threads/artifacts/narrative)
PATCH /api/conversations/{conversation_id}/state   # {slot, entries[]} — replace a slot (user edit; author=user)
PATCH /api/memory/conversations/{conversation_id}/meta   # {title?, archived?} — user-set overlay (rename/archive)

Response (list): conversations summarized from conversation_logs, overlaid with conversation_meta (Alembic 0007): title is the user-set custom title when one exists, else the derived first-user-message title; archived conversations are excluded by default from the rows and the pagination total (?include_archived=1 opts back in).

{
  "conversations": [
    {
      "conversation_id": "…",
      "title": "…",
      "preview": "…",
      "created_at": "…",
      "last_message_at": "…",
      "message_count": 12,
      "channel": "_global",
      "archived": false,
      "first_user_message": "…",
      "last_message": "…"
    }
  ],
  "total": 1
}

Conversation meta (PATCH …/meta): body {title?, archived?} (at least one; title non-empty ≤ 200 chars, archived boolean). The durable overlay behind rename + archive — and the execution half of the Ambassador’s confirmed-write proposals: the belt’s rename_conversation / archive_conversation tools only file proposals, and the client calls this endpoint after you confirm. Meta-only — transcript turns are never touched. Returns the applied fields; a failed persist is an honest 503 (never a phantom success).

Response (messages): each message carries role, content, timestamp, turn_index, and a metadata object. For assistant turns produced under a known agent profile, metadata.agent_id (Docker-style id) and metadata.agent_name (resolved display name) attribute the message — used to reconstruct multi-agent transcripts on reload. Absent on historical/unattributed turns.


Prompts

List Profiles

GET /api/prompts/profiles

Response:

{
  "profiles": [
    {
      "id": "default",
      "name": "Default Assistant",
      "description": "General-purpose AI assistant",
      "is_default": true,
      "sections_count": 3,
      "enabled_sections": 2
    }
  ]
}

Profile Detail

GET /api/prompts/profiles/{profile_id}

Returns the full profile with all sections and the composed prompt preview.

Response:

{
  "profile": {
    "id": "default",
    "name": "Default Assistant",
    "description": "General-purpose AI assistant",
    "is_default": true,
    "sections": [
      {
        "id": "identity",
        "name": "Identity",
        "type": "system",
        "content": "You are a helpful AI assistant.",
        "enabled": true,
        "order": 0
      }
    ]
  },
  "composed_prompt": "You are a helpful AI assistant.\n\n..."
}

Get Global Prompt

GET /api/prompts/global

Now a back-compat shim. The global system prompt is composed from a durable layer stack (see Prompt Stack below). This endpoint returns the composed stack; global/update persists the posted blob as the reserved legacy-global layer.

Response:

{
  "global_prompt": {
    "content": "Always be helpful and concise.",
    "enabled": true
  }
}

Update Global Prompt

POST /api/prompts/global/update

Request:

{
  "content": "Always be helpful and concise.",
  "enabled": true
}

Prompt Stack (Layers)

The conversational global system prompt is composed from an ordered stack of editable layers. Built-in layers ship a versioned default (the sidecar); the user’s edit is stored separately as an override (effective = override ?? default). Untouched built-ins keep receiving release improvements; edited layers are pinned and never silently overwritten. A released bump to a built-in’s default surfaces update_available (review the diff, then acknowledge to keep your text or reset to adopt the new default).

EndpointMethodDescription
/api/prompts/layersGET / POSTList the stack ({layers, composed}) or create a custom layer ({title, content?})
/api/prompts/layers/reorderPOSTReorder the stack ({order: [id, …]})
/api/prompts/layers/{id}PATCH / DELETEUpdate ({content?, title?, enabled?}content sets the override) or delete a custom layer (built-ins can’t be deleted)
/api/prompts/layers/{id}/resetPOSTReset a built-in’s override back to the shipped default
/api/prompts/layers/{id}/acknowledgePOSTMark a bumped built-in default as seen (keep the override, clear the badge)

Layer shape:

{
  "id": "core-principles",
  "title": "Core Principles",
  "kind": "builtin",
  "default": "You are an intelligent AI assistant…",
  "default_version": 1,
  "override": null,
  "base_version": null,
  "effective": "You are an intelligent AI assistant…",
  "enabled": true,
  "order": 0,
  "modified": false,
  "update_available": false
}

List Sections

GET /api/prompts/sections

Returns all available prompt sections with their content and ordering.

Compose Preview

GET /api/prompts/compose
GET /api/prompts/compose?profile_id=default

Returns the fully composed system prompt that would be sent to the model.

Response:

{
  "system_prompt": "You are a helpful AI assistant.\n\nAlways be helpful and concise.\n\n...",
  "profile_id": "default"
}

MCP Tools Prompt

GET /api/prompts/mcp-tools

Returns the auto-generated prompt describing available MCP tools for injection into the system prompt.

Response:

{
  "mcp_tools_prompt": "You have access to the following tools:\n\n- read_file: Read a file...",
  "tools_count": 5
}

Prompt Templates

Reusable prompt snippets with tags, plus an LLM-backed prompt enhancer.

EndpointMethodDescription
/api/prompts/templatesGET / POSTList or create templates
/api/prompts/templates/tagsGETList template tags
/api/prompts/templates/{template_id}GET / PUT / DELETEGet, update, or delete a template
/api/prompts/templates/{template_id}/resetPOSTReset a template to its default
/api/prompts/enhancePOSTRewrite/enhance a prompt via the LLM
/api/prompts/titlePOSTGenerate a concise conversation title (state + first/last message) via the LLM
/api/prompts/feature-defaultsGETShipped defaults for the overridable feature prompts (extraction, relevance, planner, enhancement)

Memory

Channels

GET  /api/memory/channels
POST /api/memory/channels

GET — List all channels with item counts per type.

Response:

{
  "channels": [
    {
      "name": "_global",
      "is_default": true,
      "item_counts": {
        "turns": 42,
        "entities": 15,
        "facts": 28,
        "strategies": 3,
        "goals": 2
      }
    }
  ]
}

POST — Create a new channel. Name must be alphanumeric with hyphens/underscores.

Request: {"name": "my-project"}

Delete Channel

DELETE /api/memory/channels/{name}

Deletes a channel and all associated data across Neo4j, PostgreSQL, and Redis. Cannot delete _global.

Response:

{
  "message": "Channel 'my-project' deleted successfully",
  "deleted": {
    "turns": 10, "entities": 5, "facts": 8,
    "strategies": 1, "goals": 0, "conversations": 2,
    "postgres_rows": 15, "redis_keys": 3
  }
}

Delete Conversation

DELETE /api/memory/conversations/{conversation_id}

Deletes a single conversation and its turns from all databases — plus its conversation_meta row and its sidecar families (structured state, rolling summary, and the ambassador’s Inquiry thread; each best-effort — the response’s deleted.sidecars lists what cleared). Nothing strands.

List Entities

GET /api/memory/entities
ParamTypeDefaultDescription
channelstring_allFilter by channel
pageint1Page number
limitint20Items per page (max 100)
searchstringText search on entity name
typestringFilter by entity type

Response:

{
  "entities": [...],
  "total": 45,
  "page": 1,
  "limit": 20,
  "has_next": true
}

Entity Graph

GET /api/memory/entities/{entity_id}/graph
GET /api/memory/entities/{entity_id}/graph?depth=3

Returns the entity with connected facts and relationships.

ParamTypeDefaultDescription
depthint2Traversal depth (max 3)

Returns 404 if entity not found.

List Facts

GET /api/memory/facts
ParamTypeDefaultDescription
channelstring_allFilter by channel
pageint1Page number
limitint20Items per page (max 100)
min_confidencefloat0.0Minimum confidence (0.0–1.0)
searchstringText search on fact claim

List Strategies

GET /api/memory/strategies
ParamTypeDefaultDescription
channelstring_allFilter by channel
pageint1Page number
limitint20Items per page (max 100)

List Procedures

GET /api/memory/procedures

Distilled procedural memory — the “how we work here” deltas the distill_procedures consolidation job mints from corrections/steers and explicit user rules. Each procedure has a natural-language trigger, a replayable body, a rationale, a scope (channel), and a strength (replay/reinforce count).

ParamTypeDefaultDescription
channelstring_allFilter by channel
pageint1Page number
limitint20Items per page (max 100)

Memory Stats

GET /api/memory/stats

Returns total counts and per-channel breakdowns.

Response:

{
  "totals": {"entities": 45, "facts": 120, "strategies": 8, "turns": 300, "procedure_candidates": 5},
  "by_channel": {
    "_global": {"entities": 30, "facts": 80, "strategies": 5, "turns": 200},
    "my-project": {"entities": 15, "facts": 40, "strategies": 3, "turns": 100}
  }
}

Usage Metrics

GET /api/metrics/usage

Aggregates the unified usage_events spend ledger (chat, multi-agent, ambassador, and voice TTS/STT) into overall totals plus per-model, per-agent, and per-source breakdowns and a daily time series. avg_latency_ms is read from conversation_logs. Degrades gracefully (zeros + "unavailable": true, HTTP 200) when the database is offline.

ParamTypeDefaultDescription
daysint14Window size in days (clamped 1–90)

Response:

{
  "totals": {
    "turns": 42, "tokens_input": 18500, "tokens_output": 9200,
    "tokens_total": 27700, "cost_total": 0.184, "cost_currency": "USD",
    "avg_latency_ms": 2310.5
  },
  "by_model": [
    {"model": "claude-opus-4-7", "turns": 30, "tokens_input": 15000,
     "tokens_output": 7000, "tokens_total": 22000, "cost_total": 0.16}
  ],
  "by_agent": [
    {"agent_id": "bold-cosmic-falcon", "turns": 18, "tokens_input": 9000,
     "tokens_output": 4500, "tokens_total": 13500, "cost_total": 0.11},
    {"agent_id": "_default", "turns": 4, "tokens_input": 1200,
     "tokens_output": 600, "tokens_total": 1800, "cost_total": 0.012}
  ],
  "by_source": [
    {"source": "chat", "turns": 30, "tokens_input": 15000,
     "tokens_output": 7000, "tokens_total": 22000, "cost_total": 0.16},
    {"source": "ambassador_tts", "turns": 6, "tokens_input": 0,
     "tokens_output": 0, "tokens_total": 0, "cost_total": 0.004}
  ],
  "daily": [
    {"date": "2026-05-28", "turns": 20, "tokens_total": 13000, "cost_total": 0.09}
  ],
  "days": 14
}

Consolidation Settings

GET  /api/memory/settings
POST /api/memory/settings

GET — Returns consolidation settings (extraction, relevance filter, entity linking, quality thresholds) plus default prompts, and settings_file_status ({path, exists, error}error is non-null when a corrupt overrides file forced the defaults fallback).

POST — Update consolidation settings. Accepts partial updates. Values are schema-validated, and against any bounds declared in settings_registry.MEMORY_KEY_SPECS: an invalid value rejects the whole update with 400 {"error", "errors": {key: message}} (nothing persisted). Saved changes apply live — no API restart needed.

Display-only fields returned by GET (entity_types, relationship_types, the default_*_prompt pair, settings_file_status) are not writable here.

Retired: trajectory_compression_* keys are no longer accepted. They used to be written through to data/config.json from this endpoint without validation and against a second copy of the defaults. Send them to POST /api/config/update under the trajectory_compression section instead — posting one here returns 400 naming the replacement route.

Recall Settings

GET  /api/memory/recall-settings
POST /api/memory/recall-settings

GET — Returns recall layer settings (hybrid search, entity-centric, query expansion, HyDE, self-query technique toggles and parameters).

POST — Update recall settings. Accepts partial updates, with the same schema validation and 400 {"error", "errors"} reject-whole contract as /api/memory/settings.

Run Consolidation

POST /api/memory/consolidate

Manually triggers the consolidation pipeline.

Request (optional):

{"jobs": ["consolidate", "patterns", "promote"]}

If no jobs specified, runs the default set: consolidate, patterns, promote.

Reset Consolidation

POST /api/memory/reset

Clears consolidated timestamps from all conversations, allowing reprocessing.

Request (optional):

{"delete_memories": true}

When delete_memories is true, also deletes all entities, facts, and strategies. Useful when extraction logic has changed.

Export Memory

POST /api/memory/export

Serializes the user’s memory graph (conversations/turns, facts/entities, strategies, procedures, goals, tool-invocations) plus the PostgreSQL audit mirror into a single round-trippable envelope keyed by stable node ids. Re-import with /api/memory/import.

Request (optional):

{"channel": "_all"}

channel defaults to "_all" (every channel); pass "channels": ["_self_x", "_project_y"] instead to export a channel set (it wins over channel). Exports are text-only — embeddings are regenerated from text on import, so files are small, deterministic, git-diffable, and portable across embedding models.

Response: {"export": { ...envelope... }}schema_version, embedder (provenance), and per-type node collections.

Scriptable equivalent: task memory:export -- --channel _global.

Import Memory

POST /api/memory/import

Restores an export idempotently by MERGE-ing each node on its stable id. Embeddings are regenerated from each node’s canonical text with the importing instance’s model (exports are text-only).

Request:

{"data": { ...envelope... }, "mode": "merge", "channel": "_global"}
  • mode: "merge" (default) upserts and leaves other data untouched; "replace" wipes the target channel for the user first, so it ends up matching the file exactly.
  • channel / channels: overrides the wipe scope for replace mode (defaults to the file’s channel(s)); a channels list wipes exactly that channel set.

Response: {"imported": {"mode", "channel", "recomputed_embeddings", "imported": {<type>: {"created", "total"}}, ...}}. Returns 400 for a missing envelope, bad mode, or an unsupported (newer) schema_version. Replace mode also resets the PostgreSQL audit mirror for the wiped channel(s) (pg_wiped in the summary).

Scriptable equivalent: task memory:import -- --input snapshot.json --mode replace --channel _global.

Extract Memory

POST /api/memory/extract

Extract = export with verified deletion: serializes the named channels to the server-side vault (data/vault/), verifies the written artifact against the live stores, and only then wipes those channels from Neo4j and the PostgreSQL mirror — in the strict order export → write → verify → wipe → receipt. The export is exact-channel (never includes _global), so the artifact equals the wiped set. On any verify mismatch the artifact is kept and nothing is deleted. Channels are consolidation-frozen while the extract runs.

Request:

{"channels": ["_self_myagent"], "confirm": true, "dry_run": false}
  • channels: the channels to extract ("_all" is refused — name them explicitly).
  • confirm: must be true for a real run (400 otherwise).
  • dry_run: report counts + the prospective vault path; write and wipe nothing (no confirm needed).

Response: {"receipt": {"file", "sha256", "channels", "counts", "wiped_counts", "verified", "wiped", "dry_run", "error"}}. The artifact and a .receipt.json sidecar stay in the vault (nothing hard-deletes them automatically).

Scriptable equivalent: task memory:extract -- --channel _self_myagent --yes (preview with --dry-run). Client: Memory Workbench header → Extract (enabled when a specific channel is selected; shows the dry-run counts before confirming).

Detail & Streaming Endpoints

EndpointMethodDescription
/api/memory/entities/{entity_id}GETSingle entity detail
/api/memory/facts/{fact_id}GETSingle fact detail
/api/memory/facts/{fact_id}/rememberPOSTBoost a fact’s salience (“remember this”); {to?} clamped to [0,1]
/api/memory/facts/{fact_id}/forgetPOSTForget a fact — soft-retire (default) or {hard:true} to delete
/api/memory/facts/{fact_id}/entitiesPOST/DELETELink/unlink an entity to a fact ({entity_id}, ABOUT edge); returns the fact’s updated {id,name,type} entity list
/api/memory/facts/{fact_id}/provenanceGETWhere the fact was learned (origin conversation + turn snippet)
/api/memory/consolidate/streamGETConsolidation progress via SSE
/api/memory/checkpointsGET/DELETEList or clear a conversation’s model-authored checkpoints (?conversation_id=)
/api/memory/user-historyPOSTBrowse the user’s past turns + top facts ({topic?, limit?, channel?})

Jobs

List Jobs

GET /api/jobs

Returns all registered consolidation jobs with their status and worker info.

Response:

{
  "jobs": [
    {
      "name": "consolidate",
      "status": "idle",
      "enabled": true,
      "last_run": "2026-03-10T14:30:00Z",
      "last_duration_ms": 1500,
      "run_count": 42
    }
  ],
  "worker": {
    "status": "running",
    "uptime_seconds": 3600
  }
}

Job Detail

GET /api/jobs/{job_name}

Returns job details with recent execution history (last 10 runs).

Response:

{
  "job": {...},
  "history": [
    {"timestamp": "...", "duration_ms": 1200, "success": true, "metrics": {...}}
  ]
}

Run Job

POST /api/jobs/{job_name}/run

Manually triggers a specific job. Returns execution result with metrics.

Toggle Job

POST /api/jobs/{job_name}/toggle

Enable or disable a scheduled job.

Request: {"enabled": false}

Clear Stuck Jobs

POST /api/jobs/clear-stuck

Clears jobs stuck in running state (e.g., after a crash).

Response:

{
  "success": true,
  "cleared_jobs": ["consolidate"],
  "message": "Cleared 1 stuck job(s)"
}

Config

Get Config

GET /api/config

Returns the current runtime configuration with secrets (API keys) redacted.

Context Limits

GET /api/config/context-limits

Returns the resolved context-window limit per configured model — used by the client for token budgeting. POST accepts {"lmstudio": {...}, "models": {"<model-id>": {"context_window", "max_output_tokens"}}} to set a per-model override (works for any provider — the escape hatch when a provider reports the wrong window, e.g. an OpenRouter :latest route); a model mapped to null removes its override.

Settings Manifest

GET /api/settings/manifest

Canonical machine-readable registry of every user-tunable setting across both stores (memory settings + config): per key — store, type, default, current value (secrets redacted), writable_via (which endpoint changes it, null = server-side only), and model-role linkage (role_member/role). Derived from settings_registry.py — the same declaration the write path uses — so what the manifest advertises and what /api/config/update accepts cannot disagree.

Version 2 adds the declaration axes, each present only on keys that declare it: constraints (min/max/step/enum/unit), tier (essential | advanced | experimental), ui_section (which settings screen it appears on), write_mode: "whole" (the dict is written atomically — patching one leaf would drop its siblings), nullable (an explicit null is a real write), and empty_means (where "" is a meaningful value, such as follow_role). Keys with authored documentation also carry help (summary/what/how/why/manage) from settings_help.yaml.

Version 3 adds a top-level sections array — one entry per settings screen, in nav order: id (what an entry’s ui_section points at), label, writable_count (writable keys the screen owns; 0 for screens that configure their own subsystem, like themes or the template library), and help (summary/what/why). Counts are derived from the entries in the same response, so a screen’s total moves the moment a key joins or leaves it.

The settings UI reads this to show each control’s shipped default, mark what you’ve changed, and render help; the docs-site Settings Reference is generated from the same source, so the two cannot drift.

Update Config

POST /api/config/update

Updates runtime configuration. Persists to data/config.json and hot-reloads providers.

Sectioned partial update. The writable surface is declared once in settings_registry.CONFIG_SECTIONS; rather than trusting a list here, query GET /api/settings/manifest (each entry’s writable_via) or read the generated Settings Reference. This paragraph used to enumerate every allowlist by hand, which is precisely how it drifted from the handler — in both directions.

Behaviour worth knowing:

  • Undeclared keys are ignored — they are not errors, and they are not written.
  • Validation is all-or-nothing. The whole payload is checked before anything is applied, so a request rejected partway leaves the stored configuration untouched. Failures come back 400 with a per-key errors map.
  • Only keys that declare bounds are validated. Everything else is written as given, exactly as before.
  • models.roles.* additionally requires "" (clear) or a concrete provider:modelrole: references and bare model names are rejected.
  • Empty strings can be meaningful. For role-following keys (models.roles.*, session.rolling_summary.model, compression.model, the reasoning.*_model family) "" means “follow the role”, and is stored rather than skipped.

Request:

{
  "providers": {
    "anthropic": {"api_key": "sk-ant-..."},
    "lmstudio": {"base_url": "http://localhost:1234/v1", "timeout": 300}
  },
  "preferences": {
    "default_model": "claude-3-5-sonnet-latest",
    "enable_memory_by_default": true
  },
  "llm_settings": {
    "default_temperature": 0.7,
    "default_max_tokens": 4096
  }
}

All fields are optional — only provided fields are updated.

Response:

{
  "status": "ok",
  "message": "Config updated and applied",
  "updated": ["providers.anthropic.api_key", "preferences.default_model"]
}

API keys are redacted from GET /api/config responses.


Logs

Read-only access to the server’s logs for the client Log panel. Backed by an in-memory ring buffer (live) and a compressed on-disk archive (history). Records are redacted of secrets at capture time. The whole group is gated by AGENTX_LOG_API_ENABLED (returns 404 when off) and is auth-gated by the normal middleware when AGENTX_AUTH_ENABLED is set.

Recent

GET /api/logs
ParamTypeDefaultDescription
levelstringFilter by level (DEBUG/INFO/WARNING/ERROR)
categorystringFilter by category key (provider/memory/stream/mcp/…)
run_idstringFilter to a single chat run
searchstringCase-insensitive substring match
sinceintOnly records with id greater than this
limitint500Max records (1–2000)

Response:

{
  "available": true,
  "logs": [
    {
      "id": 1421, "ts": 1749200000.12, "level": "INFO",
      "logger": "agentx_ai.providers.anthropic", "category": "provider",
      "run_id": "chat_run_ab12cd", "conversation_id": null, "agent_id": null,
      "message": "request model=anthropic:claude-opus-4 messages=14"
    }
  ]
}

Live stream (SSE)

GET /api/logs/stream

Replays the current buffer, then follows live. Emits log events (one record each) plus heartbeat comments. Content-Type: text/event-stream. Returns 503 if the per-process subscriber cap is reached.

Categories

GET /api/logs/categories

Returns the category registry (key, label, emoji, color) the client uses to color rows.

Archive

GET /api/logs/archive
GET /api/logs/archive/status
GET /api/logs/archive/{name}

GET /api/logs/archive/status reports the vault state — keyring_present, unlocked (is a key cached in memory, i.e. are sealed segments downloadable right now), sealed_segments / pending_segments, encryption_enabled, and retention_days.

List the daily archive segments (data/logs/agentx-YYYY-MM-DD.log.gz) and download one. Segment names are validated against path traversal.

When authentication is set up, completed days are sealed with AES-256-GCM keyed to the login password (envelope encryption; see the Logging notes). Sealed segments carry encrypted: true in the list and end in .gz.enc. Downloading one decrypts it on the fly to the inner gzip — but only while the vault is unlocked (a key is cached from a recent login); otherwise the download returns 423 Locked (re-authenticate to unlock). Pass ?raw=true to download the encrypted bytes untouched. With auth disabled, archives stay redacted-plaintext gzip. Manage keys with task logs:keys:status | logs:seal | logs:rotate-keys | logs:rotate-keys:deep.


Authentication

Authentication is optional and disabled by default. When AGENTX_AUTH_ENABLED=true, all /api/* routes require a valid session (single root user, bcrypt password, Redis-backed sessions — Phase 17). Run task auth:setup to set the root password.

EndpointMethodDescription
Authenticated routes expect the session token in the X-Auth-Token header. See the
Authentication guide for the full model.
EndpointMethodDescription
/api/auth/statusGET / POSTWhether auth is enabled and setup is complete
/api/auth/setupPOSTSet the initial root password
/api/auth/loginPOSTLog in with the root password → session token
/api/auth/logoutPOSTDestroy the current session
/api/auth/sessionGETValidate the current session
/api/auth/change-passwordPOSTChange the root password

Status

GET /api/auth/status

{ "auth_required": true, "setup_required": false, "auth_bypass_active": false }

Setup

POST /api/auth/setup — only allowed while setup_required is true.

{ "password": "at-least-8-chars", "confirm_password": "at-least-8-chars" }

Returns {"message": "Root password configured successfully"}; 403 if already set up.

Login

POST /api/auth/login

{ "username": "root", "password": "…" }

Response:

{ "token": "<url-safe-token>", "expires_at": "2026-05-28T12:34:56+00:00", "username": "root" }

401 on bad credentials; 403 if setup is required first.

Session / Logout / Change Password

  • GET /api/auth/session{ "user_id": 1, "username": "root", "session_created": "…", "last_active": "…" } (401 if not authenticated).
  • POST /api/auth/logout{ "message": "Logged out successfully" }.
  • POST /api/auth/change-password — body { "old_password": "…", "new_password": "…" }; invalidates all other sessions; 401 on a wrong old password.