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
}
ClaimPresenceMeaning
subalwaysUser id. Refresh tokens must match the session row by this field.
emailalwaysAccount email.
exp, iatalwaysExpiry and issue time as Unix seconds. Validation allows a 60-second clock-skew leeway.
jtirefresh tokens onlyRandom UUID that identifies one refresh session. Access tokens omit it.
tenant_idtenant-scoped tokens onlyTenant 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:

RouteFailureResponse
POST /auth/registerEmpty email or password under 8 characters400 {"error":"Email required and password must be at least 8 characters"}
POST /auth/registerEmail already registered400 {"error":"User already exists"}
POST /auth/loginUnknown email or wrong password401 {"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):

  1. Verify the refresh token's HS256 signature and expiry.
  2. Hash it and look up the session row. No row answers 401 {"error":"Refresh token has been revoked or expired"}.
  3. Compare the session's user id with the sub claim. A mismatch answers 401 {"error":"Token mismatch"}.
  4. Delete the old session row.
  5. 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

PropertyJWT bearerAdmin secretTenant API key
CredentialAccess token from login/registerStatic value of ADMIN_API_KEY env varKey created via POST /v1/api-keys, prefix ares_
HeaderAuthorization: Bearer <access_token> or ?token=X-Admin-Secret: <value>; a JWT with an admin role claim also worksAuthorization: Bearer ares_<key>
Route group/chat, /research, /user/agents, /conversations, /workflows, .../admin/*/v1/*
IdentityUser id in sub claimNone (operator)Tenant resolved from the key row
MeteringNo quota gate at the middlewareNot meteredMonthly and daily quota checks run before the handler
RevocationRefresh rotation plus logout deletes the sessionRotate the environment variable and restartRevoke 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>"}:

ConditionMessage
No Authorization headerMissing Authorization header
Header not valid ASCIIInvalid 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 unknownInvalid 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 variantHTTP statuscodeExample message prefix
Database500DATABASE_ERRORDatabase error:
LLM500LLM_ERRORLLM error:
Auth401AUTHENTICATION_FAILEDAuthentication error:
NotFound404NOT_FOUNDNot found:
InvalidInput400INVALID_INPUTInvalid input:
Configuration500CONFIGURATION_ERRORConfiguration error:
External502EXTERNAL_SERVICE_ERRORExternal service error:
Internal500INTERNAL_ERRORInternal error:
Unavailable503INTERNAL_ERRORService unavailable:
RateLimited429INTERNAL_ERRORRate limited:
FeatureDisabled400INTERNAL_ERRORFeature 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):

FieldTypeNotes
messagestringRequired. The user message.
agent_typestringOptional. Defaults to the router agent.
context_idstringOptional. Continues a conversation.
workspace_idstringOptional. Eruka workspace scope.
modelstringOptional 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:

EventFields setProducer behavior
startagent ("<name> (system)"), context_idSent once before any model output, after agent resolution succeeds.
tokencontentOne per streamed token chunk. No agent or context_id.
doneagent ("{AgentType:?} ({source})", for example "Sales (system)"), context_idFinal event of a successful run.
errorerror; context_id when knownTerminal. 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)

MethodPathPurpose
GET/agentsPublic list of shared agents.
GET/user/agentsList the caller's agents.
POST/user/agentsCreate 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/importImport an agent from TOON format.
GET/user/agents/{name}/exportExport 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 /workflows lists available workflows.
  • POST /workflows/{workflow_name} executes one.

With the skills feature enabled:

  • GET /skills lists skills.
  • GET /skills/{name} reads one skill.

Admin surfaces manage runtime tools and skills with the X-Admin-Secret header:

MethodPathPurpose
GET / POST/admin/runtime-toolsList or create tools.
GET/admin/runtime-tools/capabilitiesList tool capability descriptors.
GET / PUT / DELETE/admin/runtime-tools/{id}Manage one tool.
POST/admin/runtime-tools/{id}/testExecute a tool with sample input.
GET/admin/runtime-tools/{id}/versionsList versions.
POST/admin/runtime-tools/{id}/rollback/{version}Roll back.
GET / POST/admin/skillsList or create skills.
POST/admin/skills/runRun 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/collections lists collections as CollectionInfo objects.
  • DELETE /rag/collection deletes 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:

MethodPathPurpose
POST/v1/chatChat completion.
POST/v1/researchDeep research run.
POST/v1/agents/{name}/runRun a named agent.
POST/v1/agents/{name}/sandbox-runSandbox execution.
GET/v1/agentsList agents visible to the tenant.
GET/v1/agents/{name}Read one agent.
GET/v1/agents/{name}/runsList run history.
GET/v1/agents/{name}/logsList run logs.
GET/v1/usageTenant usage summary.
GET / POST/v1/api-keysList or create API keys.
DELETE/v1/api-keys/{id}Revoke a key.
POST/v1/search/semanticSemantic search (feature-gated).
DELETE/v1/tenant/dataDelete 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
}
ParameterNormalizationNotes
pageDefaults to 1; values under 1 clamp to 1
per_pageDefaults to 20 for agents, 25 for runs; caps at 100Logs 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 groupParametersClamping
GET /admin/alertslimit, severity, resolvedDefault limit 50, cap 200; filter by severity string and resolved flag
GET /admin/audit-loglimit, offsetDefault limit 50, cap 200
GET .../tenants/{tenant_id}/usage/dailydaysDefault 30, cap 90
Tenant agent runs (.../agents/{name}/runs)limit, offsetDefault 50, cap 200
Feedback summary (.../{agent_name}/feedback/summary)daysDefault 30, clamped to 1..366
Missed runs (GET .../schedules/{id}/missed-runs)limitDefault 10, clamped to 1..100
Run history costs (POST /admin/run-history/costs)limit, offset in bodyLimit 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/authorize and GET /oauth/callback — connector OAuth flow.
  • POST /events/document-upload and POST /events/field-change — event ingestion.

Admin Surfaces

All admin routes take the X-Admin-Secret header. Route groups in routes.rs:

GroupExample routes
TenantsPOST/GET /admin/tenants, GET /admin/tenants/{tenant_id}, POST/GET /admin/tenants/{tenant_id}/api-keys, GET .../usage, PUT .../quota, GET .../usage/daily
ProvisioningPOST /admin/provision-client
Tenant agentsGET/POST /admin/tenants/{tenant_id}/agents, PUT/DELETE .../agents/{agent_name}, .../versions, .../rollback/{version}, .../test, .../runs, .../stats, .../feedback/*
Cross-tenant agentsGET/POST /admin/agents, GET/PUT/DELETE /admin/agents/{tenant_id}/{agent_name}, .../versions, .../rollback/{version}, GET/POST /admin/agents/emergency-stop
Templates and modelsGET/POST /admin/agent-templates, DELETE /admin/agent-templates/{id}, GET /admin/models
Alerts and auditGET /admin/alerts, POST /admin/alerts/{alert_id}/resolve, GET /admin/audit-log
DeploymentPOST /admin/deploy, GET /admin/deploy/{deploy_id}, GET /admin/deploys, GET /admin/services, GET /admin/services/{service_name}/logs
Model tiersGET/POST /admin/tenants/{tenant_id}/model-tiers, GET/PUT/DELETE .../{tier_name}
AllowlistsGET/POST .../allowed-tools, .../allowed-models, .../allowed-rag-sources, each with DELETE .../{name}
Triggers and pipelinesGET/POST .../triggers, PUT/DELETE .../triggers/{id}, same shape for pipelines and platform-wide /admin/triggers, /admin/pipelines
Fleet providersGET /admin/fleet-providers, GET .../capabilities, PUT/DELETE .../{provider_name}, POST .../verify
SchedulesGET/POST /admin/schedules, PUT/DELETE /admin/schedules/{id}, tenant variants and GET .../missed-runs
ConnectorsGET/POST /admin/connectors, PUT/DELETE /admin/connectors/{id}, tenant connectors and oauth-creds
BillingGET .../billing/summary, GET .../billing/line-items, GET /admin/billing/model-rates, GET /admin/billing/unit-rates
BudgetsGET/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 historyGET/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 providersGET/POST /admin/runtime_providers, GET/DELETE /admin/runtime_providers/{name}
Platform statsGET /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/services lists every tracked fiber with fiber_id, state (the debug form of FiberState: Active, Inactive, Loading, Failed, Reloading, Unloading), error when the fiber rests in a terminal state with a message, disposed, and pending_undo_count.
  • GET /admin/cordis/undo lists the labeled undo closures still pending per fiber, in registration order. Only labeled undos surface; anonymous ones count toward pending_undo_count only.

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:

MethodPathPurpose
GET/admin/cordis/entriesList the entry tree.
PUT/admin/cordis/entriesUpsert an entry.
PATCH/admin/cordis/entries/{id}Partial update.
DELETE/admin/cordis/entries/{id}Remove an entry.
POST/admin/cordis/entries/{id}/toggleEnable or disable.
POST/admin/cordis/entries/reloadReload from disk.
POST/admin/cordis/entries/{id}/moveRelocate an entry.
GET/admin/cordis/eventsPer-event dispatch counters.
GET/admin/cordis/undoPending 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, or null to 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": null moves the entry to the tree root and strips any parent prefix from it and its descendants.
  • Omitting the parent field behaves like null: the entry moves to the tree root. To reorder within the current parent without relocating, use PATCH with a position only.
  • "position" must be a non-negative integer. A wrong type answers 400 {"error":"\"position\" must be a non-negative integer"}. A non-string, non-null parent answers 400 {"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.