HTTP API
ARES exposes one Axum HTTP service. The main router mounts every application route under the /api prefix (crates/ares-http/src/lib.rs). The server also answers GET /health outside /api. This chapter documents the routes as implemented in crates/ares-http/src/api/routes.rs.
Base URL
The server binds to server.host and server.port from its configuration file. Defaults are 127.0.0.1 and port 3000 (crates/ares-http/src/config.rs). All paths below are relative to http://localhost:3000/api.
curl -s http://localhost:3000/health
The /health route returns the plain text OK. The server binary adds GET /health/detailed and GET /config/info next to it.
Authentication
Three schemes exist. Pick the scheme that matches the route group.
JWT bearer tokens (user routes)
Register or log in to get a token pair:
curl -s -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "secret", "name": "User"}'
{
"access_token": "<jwt>",
"refresh_token": "<jwt>",
"expires_in": 3600
}
Send the access token as a bearer token:
Authorization: Bearer <access_token>
Token anatomy
Claims lives in crates/ares-types/src/types/mod.rs; the signing logic lives in crates/ares-http/src/auth/jwt.rs. One decoded access token:
{
"sub": "9b2f3c58-4b1e-4a7d-9c11-0f5a6b8c2d10",
"email": "user@example.com",
"exp": 1756221600,
"iat": 1756220700
}
| Claim | Presence | Meaning |
|---|---|---|
sub | always | User id. Refresh tokens must match the session row by this field. |
email | always | Account email. |
exp, iat | always | Expiry and issue time as Unix seconds. Validation allows a 60-second clock-skew leeway. |
jti | refresh tokens only | Random UUID that identifies one refresh session. Access tokens omit it. |
tenant_id | tenant-scoped tokens only | Tenant that issued or owns the session. |
Defaults from AuthConfig (crates/ares-http/src/config.rs): access tokens live 900 seconds (15 minutes), refresh tokens 604800 seconds (7 days). expires_in in the response echoes the configured access expiry, so read it instead of hard-coding 900.
Register, login, refresh, logout
Validation runs before any database call:
| Route | Failure | Response |
|---|---|---|
POST /auth/register | Empty email or password under 8 characters | 400 {"error":"Email required and password must be at least 8 characters"} |
POST /auth/register | Email already registered | 400 {"error":"User already exists"} |
POST /auth/login | Unknown email or wrong password | 401 {"error":"Invalid credentials"} |
Passwords hash with Argon2id; refresh tokens are stored only as SHA-256 hashes.
The refresh flow rotates sessions — each refresh token works exactly once (refresh_token, crates/ares-http/src/api/handlers/auth.rs):
- Verify the refresh token's HS256 signature and expiry.
- Hash it and look up the session row. No row answers
401 {"error":"Refresh token has been revoked or expired"}. - Compare the session's user id with the
subclaim. A mismatch answers401 {"error":"Token mismatch"}. - Delete the old session row.
- Issue and return a fresh pair; the new refresh token lands in its own session.
curl -s -X POST http://localhost:3000/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "<refresh_token>"}'
The response is a full TokenResponse. Reuse of an already-rotated refresh token fails at step 2, which is why clients must persist the newest pair after every call.
POST /auth/logout takes {"refresh_token": "..."}, deletes the matching session by hash, and returns {"message":"Logged out successfully"} even when the session is already gone.
Admin secret (admin routes)
Admin routes check the X-Admin-Secret header against the ADMIN_API_KEY environment variable. As an alternative, admin routes accept a JWT with an admin role claim.
curl -s http://localhost:3000/api/admin/stats \
-H "X-Admin-Secret: $ADMIN_API_KEY"
A rejected request answers 401 with:
{"error":"Admin access requires X-Admin-Secret header or JWT with admin role"}
Tenant API keys (/v1 routes)
Routes under /v1 authenticate machine clients with tenant API keys. Keys start with ares_ and travel in the same bearer header:
Authorization: Bearer ares_<key>
Scheme matrix
| Property | JWT bearer | Admin secret | Tenant API key |
|---|---|---|---|
| Credential | Access token from login/register | Static value of ADMIN_API_KEY env var | Key created via POST /v1/api-keys, prefix ares_ |
| Header | Authorization: Bearer <access_token> or ?token= | X-Admin-Secret: <value>; a JWT with an admin role claim also works | Authorization: Bearer ares_<key> |
| Route group | /chat, /research, /user/agents, /conversations, /workflows, ... | /admin/* | /v1/* |
| Identity | User id in sub claim | None (operator) | Tenant resolved from the key row |
| Metering | No quota gate at the middleware | Not metered | Monthly and daily quota checks run before the handler |
| Revocation | Refresh rotation plus logout deletes the session | Rotate the environment variable and restart | Revoke with DELETE /v1/api-keys/{id} |
The middleware rejects malformed /v1 credentials before touching the database (crates/ares-http/src/middleware/api_key_auth.rs). All format failures answer 401 {"error": "<message>"}:
| Condition | Message |
|---|---|
No Authorization header | Missing Authorization header |
| Header not valid ASCII | Invalid Authorization header |
Value does not start with Bearer (case-sensitive) | Invalid Authorization format. Expected: Bearer ares_... |
Key does not start with ares_ | Invalid API key format. Must start with ares_ |
| Key is well formed but unknown | Invalid API key |
Quota breaches answer 429: Monthly request quota exceeded or Daily rate limit exceeded. The monthly check wins when both are exhausted. Tier limits come from the tenant's quota row; the unit tests pin examples — a Free-tier tenant blocks at 1,000 requests per month or 50 per day, a Dev-tier tenant at 2,000 per day, Enterprise tiers allow large volumes. Infrastructure faults answer 500 with messages such as Tenant database not configured, Failed to verify API key, Failed to check usage, or Failed to check rate limit.
Response Envelope
Successful handlers return the documented payload directly. Errors return one consistent shape with two fields, error and code (crates/ares-http/src/error.rs):
{
"error": "agent my-agent not found",
"code": "NOT_FOUND"
}
Error catalog
Handlers return HttpError, which wraps AppError (crates/ares-http/src/error.rs). The status comes from AppError::status_code() and the code from AppError::code(), both in crates/ares-types/src/types/mod.rs. The mapping is fixed:
AppError variant | HTTP status | code | Example message prefix |
|---|---|---|---|
Database | 500 | DATABASE_ERROR | Database error: |
LLM | 500 | LLM_ERROR | LLM error: |
Auth | 401 | AUTHENTICATION_FAILED | Authentication error: |
NotFound | 404 | NOT_FOUND | Not found: |
InvalidInput | 400 | INVALID_INPUT | Invalid input: |
Configuration | 500 | CONFIGURATION_ERROR | Configuration error: |
External | 502 | EXTERNAL_SERVICE_ERROR | External service error: |
Internal | 500 | INTERNAL_ERROR | Internal error: |
Unavailable | 503 | INTERNAL_ERROR | Service unavailable: |
RateLimited | 429 | INTERNAL_ERROR | Rate limited: |
FeatureDisabled | 400 | INTERNAL_ERROR | Feature disabled: |
Three variants carry status codes that do not match their code class: Unavailable answers 503 but reports INTERNAL_ERROR, and RateLimited answers 429 while FeatureDisabled answers 400, both also reporting INTERNAL_ERROR. Match on the status plus the message prefix, not on code alone.
Chat
POST /chat
Runs one agent turn. Requires a JWT bearer token.
Request fields (ChatRequest, crates/ares-types/src/types/mod.rs):
| Field | Type | Notes |
|---|---|---|
message | string | Required. The user message. |
agent_type | string | Optional. Defaults to the router agent. |
context_id | string | Optional. Continues a conversation. |
workspace_id | string | Optional. Eruka workspace scope. |
model | string | Optional per-request model override. |
curl -s -X POST http://localhost:3000/api/chat \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "Summarize my notes", "agent_type": "researcher"}'
Response (ChatResponse):
{
"response": "Here is the summary...",
"agent": "researcher",
"context_id": "8f14e45f-ea9b-4d2a-9c3b-1f6a2b7c9d01",
"sources": [
{"title": "Meeting notes", "url": null, "relevance_score": 0.87}
]
}
POST /chat/stream and GET /chat/stream
Streams Server-Sent Events with the same request body. The GET variant reads the fields from query parameters for EventSource clients. Each event is a StreamEvent object with fields event, content, agent, context_id, and error; absent optional fields are omitted:
data: {"event":"start","agent":"researcher","context_id":"8f14e45f-..."}
data: {"event":"token","content":"Here "}
data: {"event":"done","agent":"researcher","context_id":"8f14e45f-..."}
Event anatomy
StreamEvent and its four constructors live in crates/ares-http/src/api/handlers/chat.rs. Absent optional fields are omitted from the JSON, never sent as null:
| Event | Fields set | Producer behavior |
|---|---|---|
start | agent ("<name> (system)"), context_id | Sent once before any model output, after agent resolution succeeds. |
token | content | One per streamed token chunk. No agent or context_id. |
done | agent ("{AgentType:?} ({source})", for example "Sales (system)"), context_id | Final event of a successful run. |
error | error; context_id when known | Terminal. Failures before a context exists (admission denial, missing Llm service) omit context_id entirely. |
An admission failure yields an error event with no other fields:
data: {"event":"error","error":"monthly quota exceeded"}
A mid-stream failure carries the conversation scope:
data: {"event":"start","agent":"product (system)","context_id":"8f14e45f-..."}
data: {"event":"token","content":"Here "}
data: {"event":"error","context_id":"8f14e45f-...","error":"Stream error: provider closed connection"}
The endpoint attaches an SSE keep-alive comment every 15 seconds (Sse::keep_alive in chat_stream_response). Idle connections therefore never time out silently; clients should ignore comment frames.
The GET variant takes the request fields as query parameters (ChatStreamQuery): message (required), plus optional agent_type, context_id, and workspace_id. Authenticate it with Authorization: Bearer or the ?token= fallback:
curl -N -s "http://localhost:3000/api/chat/stream?message=Summarize%20my%20notes&agent_type=researcher&token=$ACCESS_TOKEN"
POST /research
Runs deep research. Body is {"query": "...", "depth": 3, "max_iterations": 10}; both limits are optional.
GET /memory
Returns stored facts and preferences for the authenticated user:
{
"user_id": "42",
"preferences": [
{"category": "communication", "key": "style", "value": "concise", "confidence": 0.9}
],
"facts": [
{
"id": "f-1", "user_id": "42", "category": "work",
"fact_key": "timezone", "fact_value": "UTC+1", "confidence": 0.95
}
]
}
An empty memory returns no body content.
Agents
User agents (JWT)
| Method | Path | Purpose |
|---|---|---|
| GET | /agents | Public list of shared agents. |
| GET | /user/agents | List the caller's agents. |
| POST | /user/agents | Create an agent. |
| GET | /user/agents/{name} | Read one agent. |
| PUT | /user/agents/{name} | Update one agent. |
| DELETE | /user/agents/{name} | Delete one agent. |
| POST | /user/agents/import | Import an agent from TOON format. |
| GET | /user/agents/{name}/export | Export an agent to TOON format. |
Create body (CreateUserAgentReq):
{
"name": "my-agent",
"display_name": "My Agent",
"description": "Answers billing questions",
"model": "gpt-4o-mini",
"system_prompt": "You are a billing assistant.",
"tools": ["calculator"],
"max_tool_iterations": 10,
"parallel_tools": false,
"is_public": false,
"extra": {}
}
Responses carry id, usage_count, average_rating, created_at, and updated_at alongside the input fields.
Loop-mode agents (JWT)
POST /loops/start starts a loop run, GET /loops lists loops, DELETE /loops/{id} stops one.
Conversations (JWT)
GET /conversations lists conversations. GET, PUT, and DELETE on /conversations/{id} read, rename, and delete one.
Workflows, Skills, Tools
Workflows require a JWT:
GET /workflowslists available workflows.POST /workflows/{workflow_name}executes one.
With the skills feature enabled:
GET /skillslists skills.GET /skills/{name}reads one skill.
Admin surfaces manage runtime tools and skills with the X-Admin-Secret header:
| Method | Path | Purpose |
|---|---|---|
| GET / POST | /admin/runtime-tools | List or create tools. |
| GET | /admin/runtime-tools/capabilities | List tool capability descriptors. |
| GET / PUT / DELETE | /admin/runtime-tools/{id} | Manage one tool. |
| POST | /admin/runtime-tools/{id}/test | Execute a tool with sample input. |
| GET | /admin/runtime-tools/{id}/versions | List versions. |
| POST | /admin/runtime-tools/{id}/rollback/{version} | Roll back. |
| GET / POST | /admin/skills | List or create skills. |
| POST | /admin/skills/run | Run a skill. |
| GET / PUT / DELETE | /admin/skills/{id} | Manage one skill. |
Tool test example. The body field input_args holds the JSON arguments passed to the tool's execute method:
curl -s -X POST http://localhost:3000/api/admin/runtime-tools/7/test \
-H "X-Admin-Secret: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input_args": {"x": 2, "y": 3}}'
{"ok": true, "output": {"sum": 5}, "error": null, "latency_ms": 4}
Skill run example. tenant_id must name an existing tenant:
curl -s -X POST http://localhost:3000/api/admin/skills/run \
-H "X-Admin-Secret: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"skill_id": "summarize", "tenant_id": "tenant-a", "input": {"text": "..."}}'
RAG
These routes need the local-embeddings and ares-vector features at build time. They require a JWT. Collections are scoped per user; the server prefixes your collection name with your user id internally.
POST /rag/ingest
Body fields come from RagIngestRequest: collection, content, plus optional title, source, tags, and chunking_strategy.
curl -s -X POST http://localhost:3000/api/rag/ingest \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"collection": "notes",
"content": "Quarterly review text...",
"title": "Q3 review",
"tags": ["finance"]
}'
{
"chunks_created": 4,
"document_ids": ["d1", "d2", "d3", "d4"],
"collection": "notes"
}
POST /rag/search
Strategy is one of semantic, bm25, fuzzy, or hybrid. Defaults: limit 10, threshold 0.0, rerank false.
curl -s -X POST http://localhost:3000/api/rag/search \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"collection": "notes", "query": "budget owners", "limit": 5, "strategy": "hybrid"}'
{
"results": [
{
"id": "d1",
"content": "Budget owners meet on Mondays.",
"score": 0.91,
"metadata": {}
}
],
"total": 5,
"strategy": "hybrid",
"reranked": false,
"duration_ms": 23
}
Collection management
GET /rag/collectionslists collections asCollectionInfoobjects.DELETE /rag/collectiondeletes one. Body:{"collection": "notes"}. Response:{"success": true, "collection": "notes", "documents_deleted": 12}.
MCP
Model Context Protocol surface is read-only today:
curl -s http://localhost:3000/api/mcp/runtime_tool_capabilities \
-H "X-Admin-Secret: $ADMIN_API_KEY"
The route lives behind the admin middleware because it merges into the admin router set (build_routes).
/v1 External API
Machine clients use tenant API keys. Metered routes record usage per call:
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/chat | Chat completion. |
| POST | /v1/research | Deep research run. |
| POST | /v1/agents/{name}/run | Run a named agent. |
| POST | /v1/agents/{name}/sandbox-run | Sandbox execution. |
| GET | /v1/agents | List agents visible to the tenant. |
| GET | /v1/agents/{name} | Read one agent. |
| GET | /v1/agents/{name}/runs | List run history. |
| GET | /v1/agents/{name}/logs | List run logs. |
| GET | /v1/usage | Tenant usage summary. |
| GET / POST | /v1/api-keys | List or create API keys. |
| DELETE | /v1/api-keys/{id} | Revoke a key. |
| POST | /v1/search/semantic | Semantic search (feature-gated). |
| DELETE | /v1/tenant/data | Delete all tenant data. |
Example:
curl -s -X POST http://localhost:3000/api/v1/chat \
-H "Authorization: Bearer ares_$API_KEY" \
-H "Content-Type: application/json" \
-d '{"message": "Hello"}'
Quota breaches answer with a quota-exceeded error body.
Pagination and Filtering
List endpoints use two different parameter conventions.
/v1 page-based pagination
GET /v1/agents, GET /v1/agents/{name}/runs, and GET /v1/agents/{name}/logs take page and per_page query parameters and return a Paginated<T> envelope (crates/ares-http/src/api/handlers/v1/shared.rs):
{
"items": [],
"total": 0,
"page": 1,
"per_page": 20,
"total_pages": 0
}
| Parameter | Normalization | Notes |
|---|---|---|
page | Defaults to 1; values under 1 clamp to 1 | |
per_page | Defaults to 20 for agents, 25 for runs; caps at 100 | Logs default to 50 |
Example:
curl -s "http://localhost:3000/api/v1/agents?page=2&per_page=50" \
-H "Authorization: Bearer ares_$API_KEY"
Admin limit/offset pagination
Admin list endpoints in crates/ares-http/src/api/handlers/admin/audit.rs and siblings take limit and offset. The handler clamps the values before querying:
| Route group | Parameters | Clamping |
|---|---|---|
GET /admin/alerts | limit, severity, resolved | Default limit 50, cap 200; filter by severity string and resolved flag |
GET /admin/audit-log | limit, offset | Default limit 50, cap 200 |
GET .../tenants/{tenant_id}/usage/daily | days | Default 30, cap 90 |
Tenant agent runs (.../agents/{name}/runs) | limit, offset | Default 50, cap 200 |
Feedback summary (.../{agent_name}/feedback/summary) | days | Default 30, clamped to 1..366 |
Missed runs (GET .../schedules/{id}/missed-runs) | limit | Default 10, clamped to 1..100 |
Run history costs (POST /admin/run-history/costs) | limit, offset in body | Limit clamped to 1..10000 |
Tenant-scoped list routes such as /admin/triggers, /admin/pipelines, and /admin/schedules require ?tenant_id=. An empty value answers 400 {"error":"tenant_id query param is required"}.
Webhooks, OAuth, Events
Public routes without authentication:
POST /webhooks/{trigger_id}— webhook receiver for triggers.GET /oauth/authorizeandGET /oauth/callback— connector OAuth flow.POST /events/document-uploadandPOST /events/field-change— event ingestion.
Admin Surfaces
All admin routes take the X-Admin-Secret header. Route groups in routes.rs:
| Group | Example routes |
|---|---|
| Tenants | POST/GET /admin/tenants, GET /admin/tenants/{tenant_id}, POST/GET /admin/tenants/{tenant_id}/api-keys, GET .../usage, PUT .../quota, GET .../usage/daily |
| Provisioning | POST /admin/provision-client |
| Tenant agents | GET/POST /admin/tenants/{tenant_id}/agents, PUT/DELETE .../agents/{agent_name}, .../versions, .../rollback/{version}, .../test, .../runs, .../stats, .../feedback/* |
| Cross-tenant agents | GET/POST /admin/agents, GET/PUT/DELETE /admin/agents/{tenant_id}/{agent_name}, .../versions, .../rollback/{version}, GET/POST /admin/agents/emergency-stop |
| Templates and models | GET/POST /admin/agent-templates, DELETE /admin/agent-templates/{id}, GET /admin/models |
| Alerts and audit | GET /admin/alerts, POST /admin/alerts/{alert_id}/resolve, GET /admin/audit-log |
| Deployment | POST /admin/deploy, GET /admin/deploy/{deploy_id}, GET /admin/deploys, GET /admin/services, GET /admin/services/{service_name}/logs |
| Model tiers | GET/POST /admin/tenants/{tenant_id}/model-tiers, GET/PUT/DELETE .../{tier_name} |
| Allowlists | GET/POST .../allowed-tools, .../allowed-models, .../allowed-rag-sources, each with DELETE .../{name} |
| Triggers and pipelines | GET/POST .../triggers, PUT/DELETE .../triggers/{id}, same shape for pipelines and platform-wide /admin/triggers, /admin/pipelines |
| Fleet providers | GET /admin/fleet-providers, GET .../capabilities, PUT/DELETE .../{provider_name}, POST .../verify |
| Schedules | GET/POST /admin/schedules, PUT/DELETE /admin/schedules/{id}, tenant variants and GET .../missed-runs |
| Connectors | GET/POST /admin/connectors, PUT/DELETE /admin/connectors/{id}, tenant connectors and oauth-creds |
| Billing | GET .../billing/summary, GET .../billing/line-items, GET /admin/billing/model-rates, GET /admin/billing/unit-rates |
| Budgets | GET/PUT/DELETE /admin/run-history/budgets/{tenant_id}, GET/PUT /admin/token-budgets/{tenant_id}, GET .../status, POST .../reset, GET .../usage, GET /admin/run-history/alerts, POST /admin/run-history/alerts/{id}/acknowledge |
| Run history | GET/POST /admin/run-history/llm-calls, GET .../llm-calls/{id}, same shape for tool-calls, GET /admin/run-history/costs/{run_id}, GET .../costs, GET/POST .../health-metrics, GET .../model-metrics, GET /admin/runs/live (active-run stream) |
| Runtime providers | GET/POST /admin/runtime_providers, GET/DELETE /admin/runtime_providers/{name} |
| Platform stats | GET /admin/stats |
Cordis Service Lifecycle
These routes manage the plugin runtime. Unknown loader state answers 503.
Retire removes a service; provide re-registers a known direct service:
curl -s -X POST http://localhost:3000/api/admin/cordis/services/events_service/provide \
-H "X-Admin-Secret: $ADMIN_API_KEY"
{"provided": true, "service": "events_service", "type": "cordis::events::EventsService"}
POST /admin/cordis/services/{name}/retire answers 200 {"retired": true, ...} on removal and 200 {"retired": false, ...} when the service was already absent. Guarded withdrawal refuses the removal while active consumer fibers still rely on the provider; it answers 409 {"retired": false, "reason": "guarded", "consumers": <N>}. Names that are not direct Cordis services answer 409 as well — wrapper types are not supported today (crates/ares-http/src/api/handlers/admin/cordis.rs, retire_cordis_service).
Two read-only routes help interpret those outcomes:
GET /admin/cordis/serviceslists every tracked fiber withfiber_id,state(the debug form ofFiberState:Active,Inactive,Loading,Failed,Reloading,Unloading),errorwhen the fiber rests in a terminal state with a message,disposed, andpending_undo_count.GET /admin/cordis/undolists the labeled undo closures still pending per fiber, in registration order. Only labeled undos surface; anonymous ones count towardpending_undo_countonly.
Both answer 503 {"error":"RegistryService is not provided on this context"} on library deployments without a registry.
POST /admin/cordis/services/{name}/replace
Rolling drain-and-shift replacement of a journaled provider. The body must be {"config": <value>} carrying the new configuration. Success answers:
{"replaced": true, "plugin": "calc", "fiber_id": 17}
A refusal (unknown plugin label, untracked provider, failing trial) leaves the old provider serving untouched and answers 409 {"replaced": false, "service": "calc", "reason": "..."}. A missing config field answers 400.
Cordis Entries
Entries live in a TOML program file. Routes:
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/cordis/entries | List the entry tree. |
| PUT | /admin/cordis/entries | Upsert an entry. |
| PATCH | /admin/cordis/entries/{id} | Partial update. |
| DELETE | /admin/cordis/entries/{id} | Remove an entry. |
| POST | /admin/cordis/entries/{id}/toggle | Enable or disable. |
| POST | /admin/cordis/entries/reload | Reload from disk. |
| POST | /admin/cordis/entries/{id}/move | Relocate an entry. |
| GET | /admin/cordis/events | Per-event dispatch counters. |
| GET | /admin/cordis/undo | Pending undo labels per fiber. |
PATCH /admin/cordis/entries/
Applies only the present fields: config, disabled, isolate, intercept. An empty body is a validated no-op that still persists and re-applies the tree. Present parent or position fields move the entry first. Invalid moves answer 409; unknown ids answer 404.
When the new configuration fails the factory pre-flight, the response carries a structured issues array next to the legacy error string. Each issue has a message and a path. The error string is the loader's marker plus the rendered error ("config pre-flight failed: {error}", crates/cordis/src/loader.rs; a validation issue renders as - <message> (at <path>)):
{
"applied": [],
"patched": false,
"reloaded": false,
"error": "config pre-flight failed: invalid config: - missing url (at calc.url)",
"issues": [{"message": "missing url", "path": ["calc", "url"]}]
}
Move-then-update in one call
A body with a present parent or position field relocates the entry first, then applies the remaining fields. One request can rename a subtree and reconfigure its root:
curl -s -X PATCH http://localhost:3000/api/admin/cordis/entries/calc \
-H "X-Admin-Secret: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"parent": "tools-group", "config": {"precision": 2}}'
The response carries the post-patch entry plus renamed old-to-new id pairs from the move phase:
{
"applied": [],
"patched": true,
"renamed": [["calc", "tools-group:calc"]],
"entry": {"id": "tools-group:calc"}
}
The live fiber keeps its identity across the structural move. The journal re-keys the record to the new id while preserving the fiber id, so consumers never observe a restart. The config update then lands under the new id. This sequence comes from the test patch_endpoint_moves_entry in crates/ares-http/src/api/handlers/admin/cordis.rs.
A position-only body reorders within the current parent; an explicit "parent": null moves to the tree root.
Failed pre-flight: the issues array
When the patched config fails the factory trial pre-flight, the loader stashes machine-readable issues for the entry and the handler attaches them to the failure body shown above. The status is 422 (patch_endpoint_returns_structured_issues_on_bad_config).
The failed trial leaves nothing behind: a follow-up well-formed patch succeeds with 200 and no issues field, instead of tripping stale issues from the earlier attempt.
POST /admin/cordis/entries/{id}/move
Relocates the entry and its whole {id}:* descendant namespace under a new parent. Body fields:
parent— string entry id, ornullto move to the tree root.position— non-negative integer child index. Omit it to append after the target's existing children.
curl -s -X POST http://localhost:3000/api/admin/cordis/entries/calc/move \
-H "X-Admin-Secret: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"parent": "tools-group", "position": 0}'
Pure structural moves preserve fiber identity; running fibers never restart. Renamed descendant ids appear as old-to-new pairs:
{
"moved": true,
"noop": false,
"renamed": [["calc", "tools-group:calc"]],
"applied": []
}
Parent semantics in detail (move_cordis_entry and EntryTree::move_entry):
"parent": "<id>"moves under that entry. Every descendant id prefixed{moved-id}:renames mechanically; ids without the prefix stay untouched."parent": nullmoves the entry to the tree root and strips any parent prefix from it and its descendants.- Omitting the
parentfield behaves likenull: the entry moves to the tree root. To reorder within the current parent without relocating, usePATCHwith apositiononly. "position"must be a non-negative integer. A wrong type answers400 {"error":"\"position\" must be a non-negative integer"}. A non-string, non-nullparentanswers400 {"error":"\"parent\" must be a string or null"}.
Pure structural moves never restart fibers: the loader detects that plugins, configs, disabled flags, and isolates are identical on both sides, takes the noop path, re-keys journal records while keeping fiber ids, and reports "noop": true when nothing but placement changed.