On this page
- Health
- Discovery
- Chat
- LLM Providers
- Decision Models
- Server Admin
- Sessions
- Telemetry
- Agents
- Persona
- Channels
- Audit
- Turn traces
- Evals
GET /api/v1/eval/configGET /api/v1/eval/suggestGET /api/v1/eval/probesPOST /api/v1/eval/estimatePOST /api/v1/eval/task-setsGET /api/v1/eval/task-setsGET /api/v1/eval/task-sets/{name}PATCH /api/v1/eval/task-sets/{name}DELETE /api/v1/eval/task-sets/{name}POST /api/v1/eval/task-sets/{name}/tasksPATCH/DELETE /api/v1/eval/task-sets/{name}/tasks/{id}GET /api/v1/eval/task-sets/{name}/exportPOST /api/v1/eval/task-sets/{name}/importPOST /api/v1/eval/runsGET /api/v1/eval/runsGET /api/v1/eval/runs/{id}POST /api/v1/eval/runs/{id}/stopGET /api/v1/eval/runs/{id}/summaryGET /api/v1/eval/runs/{id}/pairsGET /api/v1/eval/runs/{id}/samplesPOST /api/v1/eval/runs/{id}/judge
- Safety
- Skills
- Schedules
- Costs
- Approvals
- Auto-Approve Rules
- Setup
- API Keys
- Authentication
- Auth Admin
GET /api/v1/auth/statusGET /api/v1/auth/sessionsDELETE /api/v1/auth/sessions/{id}DELETE /api/v1/auth/sessionsPOST /api/v1/auth/passwordGET /api/v1/auth/oidc/testPOST /api/v1/auth/preferencesGET /api/v1/onboardingPOST /api/v1/onboarding/dismissPOST /api/v1/onboarding/wizard-completePOST /api/v1/onboarding/wizard-skipPOST /api/v1/onboarding/chat-app/verifyPOST /api/v1/onboarding/chat-app/pairPOST /api/v1/onboarding/chat-app/save
- KV Store
- Auth Endpoints
- Metrics
- Tools & Plugins
GET /api/v1/toolsGET /api/v1/tools/{name}POST /api/v1/toolsPUT /api/v1/tools/{name}DELETE /api/v1/tools/{name}GET /api/v1/tools/{name}/healthGET /api/v1/tools/{name}/defsPOST /api/v1/tools/{name}/enablePOST /api/v1/tools/{name}/disablePUT /api/v1/tools/{name}/disabled-toolsPOST /api/v1/tools/{name}/restartGET /api/v1/pluginsGET /api/v1/plugins/{name}POST /api/v1/pluginsDELETE /api/v1/plugins/{name}
- MCP OAuth
- Browser
- Rate limiting
REST API Reference
The REST API is enabled with [api] enabled = true in your config. All authenticated endpoints require a Authorization: Bearer dk_... header.
Health
GET /api/v1/health
No authentication required. Returns 200 OK when the server is running.
Discovery
GET /llms.txt
No authentication required. Returns a plain-text summary of this Denkeeper instance intended for LLM clients — base URL, authentication notes, key endpoints, and a list of configured agents with their descriptions. Useful for programmatic discovery when connecting an AI assistant to a Denkeeper instance.
GET /api/v1/openapi.json
No authentication required. Returns the generated OpenAPI 2.0 specification for this instance. This spec is the canonical machine-readable reference — it is generated from the handler annotations and CI-gated against drift, so it is authoritative where this page and the spec disagree.
Chat
POST /api/v1/chat
Scope: chat
Send a message to an agent and receive a response.
Request body:
{
"agent": "default",
"session_id": "optional-session-id",
"message": "Hello!",
"user_id": "api-user",
"user_name": "API User",
"channel": "optional-channel-name"
}
session_idis auto-generated if omitted. Pass the same value in subsequent requests to continue the conversation.agentdefaults to"default"if omitted.channelis optional; when set, the message routes through the named channel instead of a direct agent binding.
Response (JSON):
{
"session_id": "abc123",
"response": "Hello! How can I help you?"
}
Response (SSE): Set Accept: text/event-stream for streaming:
data: {"type":"content","text":"Hello! "}
data: {"type":"content","text":"How can I help you?"}
data: {"type":"done","session_id":"abc123"}
SSE event types: content, thinking, tool_start, tool_end, tool_approval, usage, done, error.
GET /api/v1/ws
Scope: chat
Upgrades to a bidirectional WebSocket connection. Authentication is via ?token= query parameter (API key auth) or session cookie. The WebSocket carries the same event types as SSE, plus supports sending chat requests and approval responses as JSON frames.
The web dashboard connects via WebSocket by default and falls back to SSE after 3 failed reconnect attempts. Configure with api.websocket_enabled, api.websocket_max_connections, and api.websocket_replay_buffer_ttl in your config.
GET /api/v1/models
Scope: agents:read
List available LLM models from all configured providers.
GET /api/v1/models/details
Scope: agents:read
Get detailed model information including pricing data.
LLM Providers
GET /api/v1/llm/providers
Scope: admin
List all LLM providers with their current configuration (API keys are redacted). Each provider has serves_decisions, true for types that can back a
decision model. The response also carries deciders, the configured [[llm.deciders]] entries (name, provider, model, timeout, max_input_tokens), each with used_by: agent:<name>, eval.judge_decider or decide.decider.
POST /api/v1/llm/providers
Scope: admin
Create a named provider instance. Agents can use it straight away; no restart is needed. If the config has no default_provider yet, the new provider becomes the default ("default": true in the response).
Request body:
{
"name": "lmstudio",
"type": "openai",
"base_url": "http://localhost:1234/v1",
"api_key": "lm-studio"
}
POST /api/v1/llm/providers/test
Scope: admin
Check a key and base URL without saving anything. Send the provider fields, or {"name": "..."} to test a saved provider’s key against its saved URL. A finished probe answers 200, and a refused key is a result, not an error. A malformed request gets 400, and an unknown name gets 404:
{ "status": "ok", "message": "Key works. 14 models available.", "model_count": 14, "models": ["..."] }
status is ok, rejected (the provider refused the key), unreachable or error. The key and the provider’s response body are never logged or returned.
DELETE /api/v1/llm/providers/{name}
Scope: admin
Delete a provider instance. Rejected with 409 while anything uses it: default_provider, a fallback rule, a decision model, the eval judge_provider, or an agent’s llm_provider, reviewer_provider or fallback rule. The response’s used_by lists each one, e.g. agent:pamela.reviewer_provider.
PATCH /api/v1/llm/providers/{name}
Scope: admin
Update a provider’s configuration (API key, base URL, etc.). Changes take effect immediately and are persisted to config.
Request body:
{
"api_key": "sk-...",
"base_url": "https://api.openai.com/v1"
}
PATCH /api/v1/llm/config
Scope: admin
Update global LLM configuration (default provider, default model).
Request body:
{
"default_provider": "anthropic",
"default_model": "claude-sonnet-4-5"
}
Decision Models
Create, change and remove [[llm.deciders]] entries. Every change is written to the TOML and applies to the running agents, the eval judge and the decide tool without a restart. Only providers with serves_decisions (OpenRouter) can back a decision model.
POST /api/v1/llm/deciders
Scope: admin
{ "name": "jev", "provider": "openrouter", "model": "typesafe/jev-1.13", "timeout": "5s", "max_input_tokens": 30000 }
timeout and max_input_tokens are optional (defaults 5s and 30000). Returns 201 with the stored entry, 409 if the name is taken, 400 for an invalid name, a provider that does not serve decisions or has no API key.
PATCH /api/v1/llm/deciders/{name}
Scope: admin
Change provider, model, timeout or max_input_tokens. An empty timeout or a zero max_input_tokens restores the default. Decision models cannot be renamed (400). Changing the model means thresholds calibrated on the old model may no longer fit.
DELETE /api/v1/llm/deciders/{name}
Scope: admin
Returns 204, or 409 with used_by while an agent, the eval judge or the decide tool still uses it.
POST /api/v1/llm/deciders/test
Scope: admin
Asks one trivial question, either through a configured decision model ({"name": "jev"}) or an unsaved one ({"provider": "openrouter", "model": "typesafe/jev-1.13"}). Returns 200 with status (ok or error), message, latency_ms and cost_usd. The provider bills the call; no agent is charged for it.
Server Admin
GET /api/v1/server/config
Scope: admin
Server configuration including version, build info, CORS origins, and WebSocket settings.
PATCH /api/v1/server/config
Scope: admin
Update server config (CORS origins, WebSocket settings).
POST /api/v1/server/reload
Scope: admin
Reload the server configuration from disk without restarting.
POST /api/v1/server/restart
Scope: admin
Restart the server process.
Sessions
GET /api/v1/sessions
Scope: sessions:read
List all conversations.
GET /api/v1/sessions/{id}/messages
Scope: sessions:read
Get all messages for a session.
GET /api/v1/sessions/{id}/stats
Scope: sessions:read
Session telemetry summary (model, provider, cost, token breakdown per message).
GET /api/v1/sessions/{id}/tool-calls
Scope: sessions:read
Tool call records for a session (name, server, duration, success/error, round).
GET /api/v1/sessions/{id}/skills
Scope: sessions:read
Skill usage records for a session.
DELETE /api/v1/sessions/{id}
Scope: sessions:write
Delete a conversation and all its messages. Returns 204 No Content. Idempotent.
POST /api/v1/sessions/{id}/clear
Scope: sessions:write
Delete the session’s messages and telemetry, but keep the conversation row — session identity survives, so the same ID continues to work. Accepts an optional ?agent= hint. This is what /clear does in Telegram.
POST /api/v1/sessions/{id}/compact
Scope: sessions:write
Replace the session’s history with a single [Session compacted] summary message. Accepts an optional ?agent= hint.
Response:
{ "summary": "..." }
POST /api/v1/sessions/{id}/stop
Scope: chat
Cancel the in-flight request for a session, if any.
Telemetry
GET /api/v1/telemetry/summary
Scope: costs:read
Aggregate telemetry summary. Accepts ?since= and ?until= query parameters for date filtering.
Agents
GET /api/v1/agents
Scope: admin
List all agents with metadata.
GET /api/v1/agents/{name}
Scope: admin
Get agent details including persona directory, loaded persona sections, and MCP tool names. When a
decision model is set, the response carries supervisor_decider, supervisor_decider_mode, supervisor_decider_approve_at, and supervisor_decider_deny_at.
POST /api/v1/agents
Scope: admin
Create an agent. Creates the persona directory and persists an [[agents]] block to the TOML config.
Request body:
{
"name": "research",
"llm_provider": "anthropic",
"llm_model": "claude-sonnet-4-5",
"session_tier": "supervised",
"description": "Research assistant",
"create_supervisor": {
"name": "research-supervisor",
"llm_model": "claude-haiku-4-5",
"timeout": "30s",
"context_messages": 5
}
}
create_supervisor is optional and only valid when session_tier is "supervised". When present, the companion supervisor agent is created atomically with the agent it reviews.
PATCH /api/v1/agents/{name}
Scope: agents:write
Update an agent’s configuration. Mutable fields: name (rename), session_tier, llm_provider, llm_model, description, max_tool_rounds, browser_url_allowlist, fallbacks, cost_limit_soft, cost_limit_hard, supervisor, supervisor_timeout, supervisor_context_messages, supervisor_body_excerpt_len, supervisor_tool_desc_len, supervisor_max_args_bytes, supervisor_decider, supervisor_decider_mode, supervisor_decider_approve_at, supervisor_decider_deny_at, reviewer_model, reviewer_provider, review_max_iterations, review_timeout, nudge_memory_interval, nudge_skill_interval. Every field is optional and only present ones change; omit a field to leave it as-is.
The supervisor_decider* fields set the agent’s
decision model and apply to the running agent at once. supervisor_decider names a [[llm.deciders]] entry and "" removes it; supervisor_decider_mode is "shadow" or "enforce"; a threshold of 0 restores its default. A request that includes supervisor_decider resets the mode and thresholds to their defaults unless it sets them too, so a cleared decider’s tuning cannot come back on a later rebind. The request is rejected with 400 if the result would not be a valid config: an unknown decider, thresholds outside 0 < deny_at < approve_at < 1, or a session_tier other than supervised while a decider is set. A decider added to the config file can be selected after a reload.
DELETE /api/v1/agents/{name}
Scope: admin
Delete an agent and remove its [[agents]] block from the config. Rejected if the agent is referenced by a channel or schedule, is the last remaining agent, or is another agent’s supervisor.
Persona files on disk are not deleted.
GET /api/v1/agents/{name}/decider-reviews
Scope: audit:read
List the agent’s shadow-mode decision model reviews from the audit log, newest first. Each review is paired with the supervisor’s verdict on the same tool call. This is the data behind the calibration panel on the agent’s Permission card. Nothing is re-scored, so the call costs nothing.
Query parameters: decider (default: the agent’s supervisor_decider) and since (RFC3339, default: 30 days ago). The response carries reviews, failed (decider reviews that errored) and truncated (only the newest reviews fit). Each review has:
scoresfor thealigned,safe_argsandscopedchecks, pluslowestandmin_score. The lowest score decides the verdict.min_scoreisnullwhen an answer was missing, and such a call always escalates.supervisor:APPROVE,DENY,ESCALATE, or""when there is no supervisor verdict for the call.decider_costandsupervisor_cost.supervisor_costisnullfor reviews recorded before the supervisor’s cost was audited.
Enforce-mode reviews are not listed. In enforce mode the supervisor only sees the calls the decider escalated, so a comparison would be skewed.
Persona
GET /api/v1/agents/{name}/persona/{section}
Scope: agents:read
Read one persona section. {section} is soul, user, or memory — corresponding to SOUL.md, USER.md, and MEMORY.md.
PUT /api/v1/agents/{name}/persona/{section}
Scope: agents:write
Replace a persona section’s contents.
PUT /api/v1/agents/{name}/identity
Scope: agents:write
Set the display name, emoji and theme in IDENTITY.md. The server writes the frontmatter and keeps the markdown body, so send plain values. If the existing frontmatter does not parse, the server answers 409 and leaves the file alone:
{ "name": "Den", "emoji": "🦊", "theme": "helpful general-purpose assistant" }
Channels
Named routing endpoints. See the
config reference for the [[channels]] schema.
GET /api/v1/channels
Scope: channels:read
List channels with their agent, adapter bindings, implicit flag, and currently-active adapter keys.
GET /api/v1/channels/{name}
Scope: channels:read
Get one channel. The detail response adds conversation_id.
POST /api/v1/channels
Scope: channels:write
Create a channel.
PATCH /api/v1/channels/{name}
Scope: channels:write
Update a channel.
DELETE /api/v1/channels/{name}
Scope: channels:write
Delete a channel.
POST /api/v1/channels/{name}/activate
Scope: channels:write
Make this channel the active one for a given adapter key — the API equivalent of /session <name>.
Request body:
{ "adapter_key": "telegram:12345" }
DELETE /api/v1/channels/{name}/activate
Scope: channels:write
Clear the active override for an adapter key. Returns 409 Conflict if that key is not currently active on this channel.
Audit
GET /api/v1/audit
Scope: audit:read
List audit events. Filters: ?category=, ?agent=, ?status=, ?source=, ?search=, ?since=, ?until=, ?limit=, ?offset=.
?detail_max_chars=N cuts each event’s detail to its first N characters and appends …[truncated, M chars total]. Tool-call and supervisor details carry full tool arguments, so use this when you only need a preview.
?exclude_source=eval,dryrun omits preview turns. Dry-run and eval events carry the ordinary llm and tool_call categories, so source is the only axis that separates them from live traffic — which is why the exclusion applies to the statistics endpoint too.
GET /api/v1/audit/stats
Scope: audit:read
Aggregate audit statistics. Accepts ?since= and the same ?exclude_source= filter as the list endpoint.
Turn traces
GET /api/v1/traces
Scope: sessions:read
Turn trace headers, newest first, without their payloads. total counts the filtered set, and limit echoes the effective page size after clamping. Filters: ?agent=, ?conversation_id=, ?source= (live/eval — a dry run’s trace rides out on its response and is never stored), ?since=/?until= (RFC3339), ?limit= (default 50, max 200), ?offset=.
The response repeats capture, retention_days and max_trace_bytes alongside the rows, so a caller can tell “nothing recorded yet” from “recording is off” without a second request.
GET /api/v1/traces/{id}
Scope: sessions:read
One trace in full: system_prompt as it was assembled post-skill-injection, history as it went on the wire, prompt, response, and tool_calls with each call’s round, arguments, result and outcome. When the trace exceeded [eval] max_trace_bytes, truncation reports what was dropped — oldest rounds first — so a trimmed trace is never read as a short turn.
Traces sit behind sessions:read, not eval:read: a trace is turn content, and the eval scopes exist for a judge that must never resolve a live prompt. Live turns are recorded only when [eval] capture is on; eval samples always are.
Evals
An eval run compares two or more config variants of one agent over a saved set of test cases and reports an objective scorecard. The loop these endpoints serve is described in
Evals. Samples execute on the agent’s live engine under the same execution policy dry runs use: reads run for real, writes are suppressed, and nothing is persisted to conversations, telemetry, or memory. Runs spend real tokens, bounded by a per-run cost cap and by [eval] max_concurrent.
GET /api/v1/eval/config
Scope: eval:read
Returns the [eval] defaults and gate thresholds used to size and judge a run — default_k, max_cost_per_run, max_concurrent, completeness_floor, win_threshold, and the rest of the config used by POST /eval/runs and the verdict rule when a request doesn’t override them. When the internal judge is available it also carries judge_model, judge_decider, judge_decider_record_at, judge_max_cost_per_run and rubric_version; the two judge keys are omitted when unset, and either one present means POST /eval/runs/{id}/judge will work.
GET /api/v1/eval/suggest
Scope: eval:read
Past turns worth saving as test cases: any rejected or failed tool call, three or more tool rounds, a reply cost in the pool’s top decile, or a command-triggered skill. Filters: ?agent=, ?limit= (default 20, max 100), ?since= (RFC3339, default 90 days ago).
Each candidate carries prompt, category, conversation_id, message_id, created_at, the signals that earned it a place, and preceding — the turns before it, ready to pin as the test case’s history.
It also carries enough of the source turn to judge the offer without opening the conversation it came from: agent, the reply_preview that answered it, and the telemetry the signals are drawn from as numbers — tool_calls, max_round, faults, cost_usd. Scheduled turns add trigger, the skill or schedule name parsed out of the prompt the scheduler generated; a scheduled prompt is a label rather than anything a person wrote, so the name is the only part of it that identifies the run. Every other category returns trigger empty.
Candidates are stratified across the four history categories rather than ranked overall — a set drawn purely by interestingness would be all failures and represent nothing the agent normally does. Turns already saved as a task are skipped, and a turn carrying no signal is never offered. Nothing is written: accepting a candidate is a separate call to the task create endpoint. 501 when the store carries no telemetry.
GET /api/v1/eval/probes
Scope: eval:read and agents:read
Test cases generated top-down from an agent’s own written intent — its permission tier, auto-approve policy, persona sections, and skill frontmatter — rather than mined from history. Parameters: ?agent= (required), ?set= (exclude probes that set already carries), ?limit= (max 100).
The response carries agent, permission_tier, and probes[]. Each probe has prompt, category (always probe), kind (the behaviour family), source (the piece of configuration it came from, e.g. tier:supervised, skill:briefing), notes, tags, and optional preceding turns to pin as history.
The six families are denial_compliance, tier_boundary, budget_hint, approval_policy, skill_instruction, and persona_fidelity. The first three need no configuration and ship as the canned starter set; the rest are derived from the agent’s own config and are absent when it has nothing to derive them from. A cap is served family at a time, so a small limit never truncates a family away entirely.
Probes quote configuration back — a skill’s own description, the auto-approve list — so generation stays inside the caller’s read scopes: agents:read is required on top of eval:read (403 without it), skill_instruction is omitted without skills:read, and approval_policy without tools:read. approval_policy is only ever generated for the supervised tier, the only one with an approval gate to respect.
This covers what a history-sampled set structurally cannot. A well-behaved current model never retried a denied call, so no past turn shows one being respected — but a worse candidate might, and only a probe would catch it. Notes are free-text “what good looks like” for the judge, never parsed as assertions. Generation is deterministic for a given agent, which is what makes set= enough to keep a second pass quiet. Nothing is written: accepting a probe is a separate call to the task create endpoint.
POST /api/v1/eval/estimate
Scope: eval:read
Prices a prospective run before creating it — same request shape as POST /eval/runs — so a cost cap can be sized sensibly ahead of spending real tokens.
Per (task, variant) the basis is, in order: history (the task’s source conversation has real telemetry, giving an honest per-exchange average, scaled by the list-price ratio when the variant runs a different model), list_price (the variant’s advertised per-million-token price against a nominal per-turn token budget), or unknown. Nothing is fabricated — a variant priceable neither way reports unknown with a zero range and the caller shows the cap alone. The response carries low, high, currency, basis, tasks, k, a per_variant breakdown, and a note when a sampled subset or an unpriceable task makes the figure less than a straight sum.
POST /api/v1/eval/task-sets
Scope: eval:write
Create a named, empty test set. 409 Conflict if the name is taken.
GET /api/v1/eval/task-sets
Scope: eval:read
List test sets with their task counts.
GET /api/v1/eval/task-sets/{name}
Scope: eval:read
One test set with its tasks, in creation order.
PATCH /api/v1/eval/task-sets/{name}
Scope: eval:write
Rename a set or change its description.
DELETE /api/v1/eval/task-sets/{name}
Scope: eval:write
Delete a set and its tasks. Returns 409 Conflict while any run references it — a run’s samples are only interpretable against the tasks that produced them.
POST /api/v1/eval/task-sets/{name}/tasks
Scope: eval:write
Add a test case. This is what the Chat UI’s “Save as test case” calls.
Request body:
{
"prompt": "what's on my plate today",
"category": "chat",
"pinned_history": [{ "role": "user", "content": "earlier turn" }],
"notes": "should list the standup before anything else"
}
category is one of chat, skill_command, scheduled, tool_heavy, probe (default chat). The first four are the history axis; probe is the spec-derived one written by GET /eval/probes. pinned_history is captured now and replayed verbatim at run time rather than re-read from the source conversation, which drifts. notes are judge context, never parsed as assertions.
PATCH / DELETE /api/v1/eval/task-sets/{name}/tasks/{id}
Scope: eval:write
Edit or remove one test case.
GET /api/v1/eval/task-sets/{name}/export
Scope: eval:read
JSONL, one task per line, for hand-editing or git-versioning a curated set.
POST /api/v1/eval/task-sets/{name}/import
Scope: eval:write
Append JSONL tasks. All-or-none: every line is validated first, so a typo halfway down leaves the set untouched and the 400 names the offending line.
POST /api/v1/eval/runs
Scope: eval:write
Create and start a run.
Request body:
{
"task_set": "regression",
"base_agent": "pamela",
"variants": [
{ "name": "incumbent" },
{ "name": "candidate", "llm_model": "moonshotai/kimi-k3" }
],
"k": 3,
"sample_tasks": 10,
"cost_cap": 2.0
}
At least two variants are required. An empty variant runs the agent’s live config; by convention the incumbent is listed first, and per-task deltas are measured against it. k and cost_cap default to [eval] default_k and max_cost_per_run. An unregistered llm_provider is rejected here rather than failing every sample later.
sample_tasks runs a stratified random subset of the set instead of all of it; 0 or a value at or above the set size runs everything. The server draws the subset, because the drawn ids are pinned on the run and every expected-sample figure counts what was drawn — which also means a task added to the set afterwards cannot change what an existing run was measuring. as_of (RFC3339) pins the clock the samples see, so a replay is date-deterministic.
GET /api/v1/eval/runs
Scope: eval:read
List runs newest first. Filters: ?task_set=, ?status=.
GET /api/v1/eval/runs/{id}
Scope: eval:read
Status and progress: samples done out of expected, spend against the cap, and a rough ETA. This is the authoritative view; the eval_progress WebSocket frame is a droppable convenience on top of it.
POST /api/v1/eval/runs/{id}/stop
Scope: eval:write
Cancel an active run. In-flight calls die on the context, queued samples never start, and the run finishes stopped with the samples it already produced. 409 Conflict if the run is already terminal. The panic switch stops every active run the same way; resume does not revive them.
GET /api/v1/eval/runs/{id}/summary
Scope: eval:read
The objective scorecard. Rates are tool-call level with cached and suppressed calls excluded, because nothing executed in either case. A run below [eval] completeness_floor still reports its numbers but is flagged inconclusive. The judgment block carries the win rate against its threshold, the operator agreement figure when calibration marks exist, and rubric_versions, the distinct set of rubric revisions the counted verdicts were made under.
GET /api/v1/eval/runs/{id}/pairs
Scope: eval:read
The judged pairs with the blinding lifted: which variant produced each side, every recorded verdict with the presented letter resolved back to a variant name, its per-dimension winners, notes and rubric version, and the pair’s resolved outcome from the candidate’s point of view. Filter to one task with ?task_id=.
Outcomes follow the aggregation rules exactly — win or loss only when both presentation orders carry a judge verdict naming the same variant, tie when the orders disagree (the judge tracked position, not quality), pending while half-judged. Operator calibration marks (judge_ident operator) are listed but never drive the outcome.
This is the operator’s results view and is deliberately not reachable from the judge’s MCP tools, which must not be able to look up which variant produced which response.
GET /api/v1/eval/runs/{id}/samples
Scope: eval:read
Per-sample transcripts, including the full tool trace with arguments and results.
POST /api/v1/eval/runs/{id}/judge
Scope: eval:write
Starts a server-side judging pass over the run’s outstanding blinded pairs with the internal judge, so a run can be judged unattended instead of only from Claude Code over MCP. Requires [eval] judge_model or judge_decider; without either the endpoint returns 503 and the MCP judge path is unaffected. With a decider configured it is asked first and the judge model only sees the items it could not settle. Only a terminal run can be judged. The pass runs in the background — progress shows up as completeness.pairs_judged on the summary — is bounded by [eval] judge_max_cost_per_run across both backends, and its spend is recorded separately on the run’s judge_cost. 202 Accepted with the pass’s item count, the decider and model it runs on and the judge_idents it may record under; 409 Conflict if the run isn’t terminal or is already being judged; optional body {"sample_n": N, "limit": N} to judge a calibration subset or cap the pass.
Safety
POST /api/v1/panic
Scope: admin
Emergency stop: cancels all in-flight requests and pauses the scheduler.
POST /api/v1/resume
Scope: admin
Clear the panic state and resume the scheduler.
GET /api/v1/panic
Scope: admin
Response:
{ "panicked": true, "panic_time": "2026-08-11T09:15:00Z" }
Panic state is transient — it is cleared by a restart.
Skills
GET /api/v1/skills
Scope: skills:read
List all skills across all agents.
GET /api/v1/skills/{agent}
Scope: skills:read
List skills for a specific agent.
GET /api/v1/skills/{agent}/{name}
Scope: skills:read
Get full skill details including body content.
POST /api/v1/skills/{agent}
Scope: skills:write
Create a new skill. The skill file is written to the agent’s skills directory and registered in memory.
Request body:
{
"name": "daily-report",
"description": "Generate daily summary",
"version": "1.0.0",
"triggers": ["command:report"],
"body": "# Daily Report\nGenerate a summary of today's events."
}
PUT /api/v1/skills/{agent}/{name}
Scope: skills:write
Update an existing skill. Fields are merged with existing values — only provided fields are changed.
Request body:
{
"description": "Updated description",
"version": "2.0.0",
"body": "# Updated content"
}
POST /api/v1/skills/{agent}/{name}/dry-run
Scope: skills:write
Preview what a skill would do without persisting anything. The turn stores no messages, telemetry, or memory; only idempotent tools actually execute, and every other tool call returns a suppressed marker instead of running.
It sits behind the write scope despite persisting nothing, because it executes read tools and spends real tokens.
Request body:
{
"message": "log $12 for coffee",
"mode": "command",
"model": "claude-haiku-4-5",
"as_of": "2026-08-11T08:00:00+10:00",
"args": "--verbose"
}
All fields are optional. mode is schedule, command, or message; omitted, it is inferred — an explicit message implies message semantics, otherwise a command: trigger or a [[schedules]] entry naming the skill decides, defaulting to message. The modes are not cosmetic: only schedule injects the skill body directly, while command and message leave normal trigger matching to run, so a command preview genuinely exercises the trigger.
as_of pins the ## Current Date in the system prompt. model runs the preview against a different model without touching the agent’s live configuration.
Response: a transcript containing prompt, response, rounds, model, requested_model, mode, scheduled_by, tokens_total, cost_usd, suppressed_count, and a tool_calls array in which each entry carries suppressed alongside its outcome.
DELETE /api/v1/skills/{agent}/{name}
Scope: skills:write
Delete a skill. Removes it from memory and deletes the skill file. Returns 204 No Content.
Schedules
GET /api/v1/schedules
Scope: schedules:read
List all schedules with next/last run times.
POST /api/v1/schedules
Scope: schedules:write
Create a new schedule. The schedule is registered in the scheduler and persisted to TOML config.
Request body:
{
"name": "morning-report",
"schedule": "@daily",
"channel": "telegram:123456",
"skill": "daily-report",
"session_mode": "isolated",
"session_tier": "autonomous",
"agent": "default",
"tags": ["reporting"],
"enabled": true
}
schedule: cron expression (0 8 * * 1-5), named (@daily,@hourly), or interval (@every 5m).channel: formatadapter:externalID(e.g.telegram:123456).session_mode:isolated(default) orshared.enabled: defaults totrueif omitted.
PATCH /api/v1/schedules/{name}
Scope: schedules:write
Partially update a schedule. Only provided fields are changed. The schedule is unregistered and re-registered with the new configuration.
POST /api/v1/schedules/{name}/dry-run
Scope: schedules:write
Preview what a schedule would do when it fires, with the same no-persistence semantics as the skill dry-run above. Accepts the optional model and as_of fields.
The preview message is built by the same code the live scheduler uses, so the header, skill, cron expression, and tier match a real firing rather than being reconstructed.
DELETE /api/v1/schedules/{name}
Scope: schedules:write
Delete a schedule. Unregisters it from the scheduler and removes it from the TOML config. Returns 204 No Content.
Costs
GET /api/v1/costs
Scope: costs:read
Get cost summary.
Approvals
GET /api/v1/approvals
Scope: approvals:read
List all approval requests.
GET /api/v1/approvals/{id}
Scope: approvals:read
Get a single approval request.
POST /api/v1/approvals/{id}/approve
Scope: approvals:write
Approve a pending request. Add ?auto_approve=session or ?auto_approve=permanent to simultaneously create an auto-approve rule for future tool calls of the same type.
POST /api/v1/approvals/{id}/deny
Scope: approvals:write
Deny a pending request.
Auto-Approve Rules
GET /api/v1/auto-approve
Scope: approvals:read
List all auto-approve rules. Filter by agent with ?agent=name. The list includes TOML-declared config-scoped rules (auto_approve_tools on [[agents]]); these are read-only and returned with an empty id.
POST /api/v1/auto-approve
Scope: approvals:write
Create an auto-approve rule.
Request body:
{
"agent": "default",
"tool_name": "web_search",
"scope": "permanent"
}
scope:"session"(in-memory, cleared on restart) or"permanent"(persisted in SQLite)."config"is rejected with400— config-scoped rules can only be declared in TOML.
DELETE /api/v1/auto-approve/{id}
Scope: approvals:write
Delete an auto-approve rule. Returns 204 No Content.
Setup
GET /api/v1/setup
No authentication required. Returns the first-run setup status.
POST /api/v1/setup
No authentication required — the endpoint locks itself after first use. Creates the first API key, defaulting to name: "admin" / scopes: ["admin"] if the body omits them, and returns the plaintext token (201). Returns 409 Conflict once any active key already exists.
Request body:
{ "name": "admin", "scopes": ["admin"] }
POST /api/v1/setup/account
No authentication required, but gated by the one-time setup PIN written to the server logs on first run. Creates the admin account.
Request body:
{ "pin": "482937", "password": "..." }
The PIN is single-use and is never exposed through any API endpoint — reading it requires access to the logs, which is what stops someone who can merely reach the port from claiming the instance.
API Keys
POST /api/v1/keys
Scope: admin
Create a new API key. The plaintext key is returned once in the response.
GET /api/v1/keys
Scope: admin
List all API keys (secrets are never returned).
DELETE /api/v1/keys/{id}
Scope: admin
Revoke an API key by ID.
DELETE /api/v1/keys/{id}/permanent
Scope: admin
Permanently delete a revoked API key.
POST /api/v1/keys/{id}/rotate
Scope: admin
Rotate an API key. Returns the new plaintext key once.
Authentication
All API endpoints (except health, setup, auth, and metrics) require authentication. Two mechanisms are supported:
- Bearer token —
Authorization: Bearer dk_...header. API keys are scoped; a key with onlychatscope cannot access/api/v1/approvals. - Session cookie — set by the password or OIDC login flow. Used by the web dashboard.
curl -H "Authorization: Bearer dk_yourkey" https://localhost:8080/api/v1/approvals
Auth Admin
These endpoints require admin scope.
GET /api/v1/auth/status
Returns auth configuration summary (password enabled, OIDC enabled, session settings, preferred login method).
GET /api/v1/auth/sessions
List all active sessions.
DELETE /api/v1/auth/sessions/{id}
Revoke a session.
DELETE /api/v1/auth/sessions
Revoke every active session at once, forcing all dashboard users to log in again.
POST /api/v1/auth/password
Change the server password. Verifies the current password before re-hashing.
{ "current_password": "old", "new_password": "new" }
GET /api/v1/auth/oidc/test
Test OIDC provider reachability (fresh discovery, 10 s timeout).
POST /api/v1/auth/preferences
Set preferred login method (auto, password, or apikey).
{ "preferred_method": "password" }
GET /api/v1/onboarding
Checklist of 5 setup milestones. show_onboarding is false when all milestones are complete or the card has been dismissed.
The wizard block is the setup wizard’s progress, worked out from the config: four steps (provider, agent, persona, chat_app) with a detail object for each, done_count, the primary agent, restart_required (a chat app is saved but its adapter isn’t running yet), and restart.managed (whether a service manager will bring the server back after POST /server/restart).
POST /api/v1/onboarding/dismiss
Persist onboarding_dismissed = true to the TOML config and hide the onboarding card.
POST /api/v1/onboarding/wizard-complete
Persist wizard_completed = true to the TOML config, marking the guided setup wizard as finished.
POST /api/v1/onboarding/wizard-skip
Persist wizard_completed = true and wizard_skipped = true: the wizard stops opening on login, and the dashboard keeps offering to resume it.
POST /api/v1/onboarding/chat-app/verify
Check a Telegram or Discord bot token: {"type": "telegram", "token": "..."}. Answers 200 with status and, when it’s ok, the bot’s username.
POST /api/v1/onboarding/chat-app/pair
Wait (up to 50 seconds per call) for the first private message to the bot, so the wizard can fill allowed_users without asking for a numeric ID. Messages sent before the first call are ignored. On "status": "timeout", call again with the returned cursor. Returns 409 if that adapter is already running on this server. Discord answers "manual": enter the user ID by hand.
POST /api/v1/onboarding/chat-app/save
Save the token, allowed_users and the agent binding in one checked write. The adapter starts on the next restart.
KV Store
GET /api/v1/kv/{agent}
Scope: kv:read
List KV keys for an agent. Accepts optional ?prefix= query parameter.
GET /api/v1/kv/{agent}/{key}
Scope: kv:read
Get a value by key. Returns 404 if not found.
PUT /api/v1/kv/{agent}/{key}
Scope: kv:write
Set a value. Body: {"value": "...", "ttl": "5m"} (ttl is optional; omit for no expiry).
DELETE /api/v1/kv/{agent}/{key}
Scope: kv:write
Delete a key.
Auth Endpoints
These endpoints do not require authentication.
GET /auth/config
Returns the server’s authentication configuration.
{
"password_enabled": true,
"oidc_enabled": false
}
POST /auth/login
Password login. Sets a session cookie on success.
Request body:
{
"password": "your-password"
}
Response:
{
"authenticated": true,
"email": "admin"
}
Rate limited: 5 attempts per 15 minutes per IP. Returns 429 Too Many Requests when exceeded.
POST /auth/logout
Clears the session cookie.
{
"ok": true
}
GET /auth/session
Check the current session status.
{
"authenticated": true,
"email": "user@example.com"
}
Returns {"authenticated": false} when no valid session exists.
GET /auth/oidc/login
Redirects to the OIDC provider’s authorization endpoint. Only available when [api.auth.oidc] enabled = true.
GET /auth/callback
OIDC callback. Exchanges the authorization code, verifies the ID token (including nonce), creates a session cookie, and redirects to /#/overview.
Metrics
GET /metrics
Prometheus metrics endpoint. No authentication required. Only available when [otel] enabled = true.
Tools & Plugins
GET /api/v1/tools
Scope: tools:read
List all configured MCP tool servers.
GET /api/v1/tools/{name}
Scope: tools:read
Get details for a specific tool server.
POST /api/v1/tools
Scope: tools:write
Add a new MCP tool server. The tool is started immediately and its configuration is persisted to TOML.
Request body:
{
"name": "filesystem",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
}
PUT /api/v1/tools/{name}
Scope: tools:write
Edit a tool server’s configuration. The server is restarted with the new settings and the configuration is persisted to TOML.
DELETE /api/v1/tools/{name}
Scope: tools:write
Remove a tool server. The process is stopped and the configuration is removed from TOML.
GET /api/v1/tools/{name}/health
Scope: tools:read
Get health status for a specific tool server. Returns connected, error, or disabled status with restart count, last error, and uptime.
GET /api/v1/tools/{name}/defs
Scope: tools:read
List the tool definitions this server advertises.
POST /api/v1/tools/{name}/enable
Scope: tools:write
Enable a tool server, starting its MCP process. Persisted to the TOML config.
POST /api/v1/tools/{name}/disable
Scope: tools:write
Disable a tool server, stopping its process without removing its configuration. Persisted to the TOML config.
PUT /api/v1/tools/{name}/disabled-tools
Scope: tools:write
Set which individual tools from this server are hidden from agents, leaving the server itself running.
POST /api/v1/tools/{name}/restart
Scope: tools:write
Manually restart a tool server.
GET /api/v1/plugins
Scope: tools:read
List all configured plugins.
GET /api/v1/plugins/{name}
Scope: tools:read
Get details for a specific plugin.
POST /api/v1/plugins
Scope: tools:write
Add a new plugin (subprocess or Docker).
DELETE /api/v1/plugins/{name}
Scope: tools:write
Remove a plugin.
Every /api/v1/tools/{name} endpoint returns 404 when the named tool is not registered. Other failures keep their own codes: 400 for malformed or rejected input, 500 for a failed restart or removal, 503 when the lifecycle manager is not wired.
MCP OAuth
For remote SSE tool servers configured with auth = "oauth".
GET /api/v1/tools/{name}/oauth
Scope: tools:read
Get the OAuth token status for a tool.
POST /api/v1/tools/{name}/oauth/connect
Scope: tools:write
Begin the authorization code flow with PKCE. Returns the URL to send the operator to.
DELETE /api/v1/tools/{name}/oauth/token
Scope: tools:write
Revoke and delete the stored token for a tool.
GET /api/v1/tools/oauth/pending
Scope: tools:read
List authorizations awaiting completion.
GET /api/v1/tools/oauth/callback
No authentication — this is the browser redirect target for the provider. Set api.external_url so the callback URL is constructed correctly behind a reverse proxy.
Browser
Available when [browser] enabled = true. These endpoints return 503 when browser automation is not configured.
GET /api/v1/browser/config
Scope: browser:read
Get the effective browser configuration.
GET /api/v1/browser/profiles
Scope: browser:read
List browser profiles.
GET /api/v1/browser/profiles/{name}
Scope: browser:read
Get one browser profile.
DELETE /api/v1/browser/profiles/{name}
Scope: browser:write
Delete a browser profile.
GET /api/v1/browser/sessions
Scope: browser:read
List active browser sessions.
Rate limiting
Per-key rate limiting is configured via api.rate_limit (requests per second). When exceeded, the API returns 429 Too Many Requests.