ares-server 0.10.1

ARES agent server with multi-provider LLM support, tool calling, RAG, and MCP integration
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
# 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`.

```bash
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:

```bash
curl -s -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "secret", "name": "User"}'
```

```json
{
  "access_token": "<jwt>",
  "refresh_token": "<jwt>",
  "expires_in": 3600
}
```

Send the access token as a bearer token:

```bash
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:

```json
{
  "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`):

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.

```bash
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.

```bash
curl -s http://localhost:3000/api/admin/stats \
  -H "X-Admin-Secret: $ADMIN_API_KEY"
```

A rejected request answers `401` with:

```json
{"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:

```bash
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`):

```json
{
  "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. |

```bash
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`):

```json
{
  "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:

```bash
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:

```json
{
  "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`):

```json
{
  "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:

| 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:

```bash
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}}'
```

```json
{"ok": true, "output": {"sum": 5}, "error": null, "latency_ms": 4}
```

Skill run example. `tenant_id` must name an existing tenant:

```bash
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`.

```bash
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"]
  }'
```

```json
{
  "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.

```bash
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"}'
```

```json
{
  "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:

```bash
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:

```bash
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`):

```json
{
  "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:

```bash
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/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`:

| 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:

```bash
curl -s -X POST http://localhost:3000/api/admin/cordis/services/events_service/provide \
  -H "X-Admin-Secret: $ADMIN_API_KEY"
```

```json
{"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:

```json
{"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/{id}

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>)`):

```json
{
  "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:

```bash
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:

```json
{
  "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.

```bash
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:

```json
{
  "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.