A security-first personal AI agent that lives in your chat. Built in Go as a single binary, designed to run anywhere from a Raspberry Pi to a cloud VM.
Denkeeper connects to your Telegram or Discord, routes messages through LLM providers via Anthropic, OpenAI, OpenRouter, or a local Ollama instance, and remembers conversations across sessions using a local SQLite database. It enforces per-session cost budgets, user allowlists, and a tiered permission system — so you stay in control of what it can do and how much it can spend.
curl -fsSL https://raw.githubusercontent.com/Temikus/denkeeper/main/install.sh | shTo install to a custom prefix (e.g. without sudo):
curl -fsSL https://raw.githubusercontent.com/Temikus/denkeeper/main/install.sh | sh -s -- --prefix ~/.localThe installer detects OS/arch, downloads the correct release archive, verifies the SHA-256 checksum, and places the binary in <prefix>/bin.
VERSION=$(curl -fsSL https://api.github.com/repos/Temikus/denkeeper/releases/latest | grep '"tag_name"' | sed 's/.*"\(v[^"]*\)".*/\1/')
curl -fsSL "https://github.com/Temikus/denkeeper/releases/download/${VERSION}/denkeeper_${VERSION#v}_linux_amd64.deb" -o denkeeper.deb
sudo dpkg -i denkeeper.debConfigure and start the service:
sudo cp /etc/denkeeper/denkeeper.toml.example /etc/denkeeper/denkeeper.toml
sudoedit /etc/denkeeper/denkeeper.toml
sudo systemctl enable --now denkeeper
journalctl -u denkeeper -fVERSION=$(curl -fsSL https://api.github.com/repos/Temikus/denkeeper/releases/latest | grep '"tag_name"' | sed 's/.*"\(v[^"]*\)".*/\1/')
curl -fsSL "https://github.com/Temikus/denkeeper/releases/download/${VERSION}/denkeeper_${VERSION#v}_linux_amd64.rpm" -o denkeeper.rpm
sudo rpm -i denkeeper.rpmdocker pull ghcr.io/temikus/denkeeper:latest
docker run -d --name denkeeper \
-v ~/.denkeeper:/data \
ghcr.io/temikus/denkeeper:latestThe container reads config from DENKEEPER_CONFIG (default /data/denkeeper.toml). Override with -e DENKEEPER_CONFIG=/path/to/config.toml.
A Helm chart is available in deploy/helm/denkeeper/ with support for Ingress, PVC persistence, secrets management, and security-hardened pod defaults:
helm install denkeeper deploy/helm/denkeeper/ \
--set secrets.llmAnthropicApiKey=sk-ant-... \
--set secrets.telegramToken=123456:ABC...brew install Temikus/denkeeper/denkeeperAll release archives are signed with cosign (keyless OIDC — no long-lived keys):
cosign verify-blob \
--signature checksums.txt.sig \
--certificate checksums.txt.pem \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
--certificate-identity-regexp='https://github.com/Temikus/denkeeper/.github/workflows/release.yml.*' \
checksums.txtDocker images are signed and carry SLSA build provenance attestations:
cosign verify \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
--certificate-identity-regexp='https://github.com/Temikus/denkeeper/.github/workflows/release.yml.*' \
ghcr.io/temikus/denkeeper:latest- Single binary — no runtime dependencies, no containers required
- Multi-agent routing — run multiple named agents, each with their own persona, skills, LLM model, and permission tier
- Telegram + Discord — chat with your agent from your phone or Discord server, including inline Approve/Deny buttons for supervised actions; both adapters can run simultaneously
- User allowlist — only approved user IDs can interact (per-adapter)
- LLM routing — pluggable provider interface; Anthropic (direct), OpenAI (direct + Azure/vLLM-compatible), OpenRouter (cloud, hundreds of models), and Ollama (local inference) built-in
- Fallback strategies — automatic model/provider switching on errors, rate limits, or low funds
- Cost tracking — per-session budgets with automatic cutoff
- Conversation memory — SQLite-backed, persistent across restarts
- Scheduler — cron expressions, named intervals, and
@daily/@hourlyshorthand; per-schedule agent targeting and session modes - Skills — flat markdown files with TOML frontmatter; trigger-based filtering (
command:/schedule:), per-agent skill merging, and per-skillmax_tool_roundscaps on the tool-call loop - MCP tools — spawn MCP servers via stdio (subprocess) or SSE/Streamable HTTP (remote), discover tools, and execute tool calls in an agentic loop; auto-restart on crash with configurable backoff; OAuth 2.1 authorization for remote MCP servers; within-turn memoization of identical calls to read-only tools (built-ins always eligible; external servers opt in via
idempotent,idempotent_tools, ortrust_annotations) - Web tools — built-in
web_search(DuckDuckGo or Tavily) andweb_fetch(HTML→Markdown conversion with configurable per-call page size, size/timeout caps, robots.txt/agents.txt respect, and optional Jina Reader fallback for JS-heavy pages) - MCP security — SSRF protection (blocks localhost, link-local, and cloud metadata endpoints), HTTP header injection prevention, redirect target validation, env var denylist for secrets, and URL/arg redaction in API responses
- Plugin system — subprocess and Docker-sandboxed plugins with capability declarations and Ed25519 signature verification; tools capability wires plugin tools into the agent's LLM loop
- Runtime tool management — add and remove MCP tools and plugins at runtime without restarting; changes are persisted to TOML config
- Agent KV store — per-agent key-value storage with optional TTL, exposed as MCP tools (
kv_get/kv_set/kv_delete/kv_list/kv_set_nx); useful for locks, counters, caches, and cross-session state - Supervisor agents — a supervised agent can designate another agent as its supervisor via
supervisor = "agent-name"in TOML; the supervisor sits between auto-approve rules and human approval, returning APPROVE/DENY/ESCALATE for each tool call; supervisor prompt includes skill/schedule context for scheduled invocations; configurable timeout (supervisor_timeout, default 30s) and context message count (supervisor_context_messages, default 5); LLM failures emit asupervisor_errorevent before falling through to human approval - Dry runs — preview what a schedule or skill would actually do without letting it do anything: the real persona, skills, and read-only tools run, while every write is suppressed and nothing is persisted, sent to an adapter, or remembered; returns a full transcript with suppressed calls marked, and accepts an
as_ofclock so a preview of a dated task is reproducible - Audit log — unified audit trail with buffered emitter, SQLite storage, and 12 event categories (
tool_call,skill,channel,approval,schedule,llm,config,session,mcp,safety,supervisor,eval); web UI page with timeline and table views, category/status/agent/time filters, and a source-exclusion filter that hides dry-run noise by default - Channels — named routing endpoints (
[[channels]]) that decouple sessions from adapters; cross-adapter session sharing, ephemeral session mode,/sessioncommand for runtime switching; auto-synthesized from agentadaptersbindings when absent (backward compatible) - Safety commands —
/stopcancels the current in-flight request,/panicemergency-stops all in-flight requests and pauses the scheduler,/resumeclears panic state; available in Telegram, Discord, web UI, and REST API - Session history management —
/clearremoves all messages from a session,/compactsummarises via LLM and replaces all messages with a single summary; available in Telegram, Discord, web UI, and REST API - OpenAPI spec — generated via
swaggo/swag, served atGET /api/v1/openapi.json(no auth required); the committed spec is kept in sync with the handler annotations by a CI freshness gate - Web dashboard — embedded Svelte UI (served via the API server) with 17 pages: overview, chat, sessions, approvals, schedules, skills, tools, browser, KV store, costs, agents, API keys, providers, server config, settings, audit log, and channels; includes dark mode toggle and warm light theme
- Voice — speech-to-text and text-to-speech via OpenAI (Whisper + TTS)
- Permission tiers — autonomous, supervised (default), and restricted; configurable per-agent or per-schedule
- Approval workflows — supervised-tier actions (profile updates, skill creation, schedule additions, tool installation) require explicit human approval via chat buttons (Telegram/Discord) or REST API; auto-approve rules in three scopes:
config(declared per agent in TOML viaauto_approve_tools, immutable at runtime),session(in-memory, 15m TTL), andpermanent(SQLite) - Config MCP server — per-agent in-process MCP tools let the LLM manage skills, schedules, tools, plugins, KV storage, and inspect its own permission tier at runtime
- Deterministic compute (
run_javascript) — per-agent in-process tool that runs a short JavaScript snippet (sandboxed goja runtime, no network/filesystem) against JSON input to transform, format, classify, or bucket data off the completion-token path; bounded by[script]timeout and input/output size caps (defaulttimeout = "2s",max_output_chars = 16000,max_input_bytes = 262144); disabled in restricted tier - External REST API — HTTP server with scoped API key auth, rate limiting, CORS, and TLS support; chat endpoint with real-time token streaming (SSE + WebSocket), session management, approval CRUD, tool/plugin CRUD, LLM provider management, server reload/restart, and API key management
- Dashboard authentication — password login (bcrypt), OAuth2/OIDC SSO (PKCE), session cookies (AES-256-GCM)
- OpenTelemetry observability — Prometheus
/metricsendpoint and optional OTLP trace export - CLI plugin signing —
denkeeper plugin keygen/sign/verifycommands for Ed25519 plugin binary signing and verification - CLI password hashing —
denkeeper passwdgenerates a bcrypt hash for dashboard password login - Personality — ships with a
SOUL.mdthat gives the agent character (editable)
Adapter (Telegram/Discord) ─┐
Web Dashboard (WS/SSE) ─────┼→ Dispatcher → Engine (per agent) → LLM Router → Provider (Anthropic/OpenAI/OpenRouter/Ollama)
REST API (/api/v1/chat) ────┘ ↕ ↕
MemoryStore CostTracker
(SQLite) + Pricing Registry
Scheduler ──────────────────────────────────────┘
The Dispatcher routes incoming messages to named agent Engines based on adapter bindings. Each Engine checks permissions, loads conversation history, builds the system prompt (persona + skills), calls the LLM (with tool-call loop if MCP tools are configured), stores the response, and sends it back through the adapter.
- Go 1.26+ (managed via mise — see
.mise.toml) - A Telegram bot token (from @BotFather)
- An API key for your chosen LLM provider: OpenRouter, Anthropic, or a local Ollama instance
- Your Telegram user ID (from @userinfobot)
# Clone
git clone https://github.com/Temikus/denkeeper.git
cd denkeeper
# Copy and edit the config
mkdir -p ~/.denkeeper
cp denkeeper.toml.example ~/.denkeeper/denkeeper.toml
# Fill in your token, API key, and user ID
$EDITOR ~/.denkeeper/denkeeper.toml
# Build and run
just build
./pkg/bin/denkeeper serveOr run directly without building:
just serveDenkeeper uses a single TOML file (default ~/.denkeeper/denkeeper.toml). See denkeeper.toml.example for all options. The config path can be set via --config flag or DENKEEPER_CONFIG env var.
Health check: GET /api/v1/health returns {"status":"ok"} with no authentication required. Use this for Docker HEALTHCHECK or Kubernetes liveness/readiness probes (requires api.enabled = true).
Key sections:
| Section | Purpose |
|---|---|
[telegram] |
Bot token and allowed user IDs |
[discord] |
Bot token and allowed user snowflake IDs |
[llm] |
Default provider name, model, and per-session cost limits (cost_limit_soft, cost_limit_hard) |
[[llm.providers]] |
Named provider instances — multiple instances of the same type allowed (e.g. OpenAI + LM Studio) |
[llm.anthropic] |
Anthropic API key — legacy single-slot syntax, auto-converted to [[llm.providers]] |
[llm.openrouter] |
OpenRouter API key — legacy single-slot syntax |
[llm.ollama] |
Ollama base URL — legacy single-slot syntax |
[[llm.fallback]] |
Fallback strategies (error/rate_limit/cost_limit triggers) |
[session] |
Default permission tier (supervised/autonomous/restricted) |
[[agents]] |
Multi-agent definitions (persona, skills, LLM provider/model override, adapter bindings, supervisor, supervisor_timeout, supervisor_context_messages, cost limits) |
[[channels]] |
Named routing endpoints — bind adapter chats to agents with session identity; session_mode (shared/ephemeral) |
[audit] |
Audit log settings (enabled, retention_days, cleanup_interval, buffer_size) |
[mcp] |
Global MCP settings — request timeout, auto-restart, max restart attempts, restart cooldown, SSE URL allowlist |
[tools.*] |
MCP tool server definitions — stdio (subprocess) or SSE (remote) transport, URL, headers, per-server timeout override |
[plugins.*] |
Plugin definitions — subprocess or Docker-sandboxed (capability declarations) |
[security] |
Ed25519 plugin signing config (trusted_keys, allow_unsigned) |
[voice] |
STT/TTS configuration (OpenAI) |
[api] |
External REST API (listen addr, TLS, CORS, rate limiting, API keys with scopes) |
[api.auth] |
Dashboard authentication (bcrypt password, session secret, OIDC SSO) |
[otel] |
OpenTelemetry observability (Prometheus metrics, OTLP trace export) |
[[schedules]] |
Recurring tasks (cron, interval, or named schedules) |
[kv] |
Agent KV store limits (max_keys_per_agent, max_value_bytes, list_max_bytes, list_value_head_bytes, cleanup_interval) |
[script] |
run_javascript deterministic-compute tool (enabled, timeout, max_output_chars, max_input_bytes) |
[web] |
Built-in web tools — [web.search] provider/API key/result count, [web.fetch] timeout, size caps, max_response_chars page size, robots/agents.txt policy, [web.fetch.jina] fallback |
[memory] |
SQLite database path |
[log] |
Log level and format |
Secrets and select config fields can be set via environment variables, which take precedence over values in denkeeper.toml. This enables the standard Kubernetes pattern of using a ConfigMap for config and a Secret for credentials.
| Env Var | Config Field |
|---|---|
DENKEEPER_CONFIG |
Config file path (replaces --config flag) |
DENKEEPER_TELEGRAM_TOKEN |
telegram.token |
DENKEEPER_DISCORD_TOKEN |
discord.token |
DENKEEPER_LLM_PROVIDER |
llm.default_provider |
DENKEEPER_LLM_MODEL |
llm.default_model |
DENKEEPER_LLM_OPENROUTER_API_KEY |
llm.openrouter.api_key |
DENKEEPER_LLM_ANTHROPIC_API_KEY |
llm.anthropic.api_key |
DENKEEPER_LLM_ANTHROPIC_BASE_URL |
llm.anthropic.base_url |
DENKEEPER_LLM_OLLAMA_BASE_URL |
llm.ollama.base_url |
DENKEEPER_LLM_OPENAI_API_KEY |
llm.openai.api_key |
DENKEEPER_LLM_OPENAI_BASE_URL |
llm.openai.base_url |
DENKEEPER_VOICE_OPENAI_API_KEY |
voice.openai.api_key |
DENKEEPER_LOG_LEVEL |
log.level |
DENKEEPER_LOG_FORMAT |
log.format |
DENKEEPER_MEMORY_DB_PATH |
memory.db_path |
DENKEEPER_API_ENABLED |
api.enabled (accepts "true" or "1") |
DENKEEPER_API_LISTEN |
api.listen |
DENKEEPER_SESSION_TIER |
session.tier |
DENKEEPER_API_AUTH_SESSION_SECRET |
api.auth.session_secret (AES-256 hex key) |
DENKEEPER_OIDC_CLIENT_ID |
api.auth.oidc.client_id |
DENKEEPER_OIDC_CLIENT_SECRET |
api.auth.oidc.client_secret |
DENKEEPER_API_WEBSOCKET_ENABLED |
api.websocket_enabled (accepts "true" or "false") |
DENKEEPER_OTEL_ENABLED |
otel.enabled (accepts "true" or "false") |
DENKEEPER_OTEL_TRACES_ENDPOINT |
otel.traces_endpoint (OTLP HTTP endpoint) |
A Helm chart is available in deploy/helm/denkeeper/ for Kubernetes deployments.
Skills are markdown files that teach the agent how to handle specific tasks. They use TOML frontmatter enclosed in +++ delimiters:
+++
name = "daily-briefing"
description = "Compile and deliver a daily briefing"
version = "1.0.0"
triggers = ["schedule:daily:08:00", "command:briefing"]
+++
# Daily Briefing
When triggered, compile a briefing with:
1. Weather forecast for the user's location
2. Top 3 news headlines
3. Any pending remindersPlace skill files in ~/.denkeeper/skills/ (configurable via [agent] skills_dir). Subdirectories with a SKILL.md file are also supported. Skills with triggers are only injected when matched; skills without triggers are always included.
Agent-specific skills in <persona_dir>/skills/ override global skills of the same name.
A sample help skill is included in agents/default/skills/.
Every skill change made at runtime — by the agent about itself, over REST, or over MCP — is recorded in an undo journal before it happens, capturing the file's exact prior bytes. The skill_revert MCP tool rolls the most recent change back, so an agent that botches an edit to its own skill can be fixed with one call instead of by hand. Reverting restores the skill file only: it does not undo messages already sent, tool calls already made, or KV keys already written while the changed skill was live.
Define multiple agents, each with their own persona, skills, LLM model, and adapter bindings:
[[agents]]
name = "default"
persona_dir = "~/.denkeeper/agents/default"
adapters = ["telegram"] # wildcard: all Telegram messages
[[agents]]
name = "work-assistant"
persona_dir = "~/.denkeeper/agents/work-assistant"
adapters = ["telegram:987654321"] # specific chat only
llm_model = "openai/gpt-4o"
session_tier = "supervised"
supervisor = "default" # optional: auto-review tool calls before human approvalIf no [[agents]] section is present, a single "default" agent is synthesized from [agent]/[session].
Schedules support three expression formats, per-schedule agent targeting, and configurable session modes:
[[schedules]]
name = "daily-briefing"
type = "agent"
schedule = "0 8 * * *"
skill = "daily-briefing"
agent = "default" # target agent (default: "default")
session_tier = "supervised"
session_mode = "isolated" # fresh context each run (default: "shared")
channel = "telegram:YOUR_CHAT_ID"
enabled = true
[[schedules]]
name = "hourly-check"
type = "agent"
schedule = "@every 1h" # or @daily, @hourly, @weekly
channel = "telegram:YOUR_CHAT_ID"session_mode = "isolated" creates a fresh conversation context for each run so scheduled jobs don't mix into your regular chat history.
Schedules and skills both have a preview that runs the real turn with its hands tied. Hit Test now on a schedule row (or Dry run on a skill) in the dashboard, or call the API directly:
curl -X POST localhost:8080/api/v1/schedules/nightly-digest/dry-run \
-H "Authorization: Bearer $DENKEEPER_API_KEY" \
-d '{"as_of":"2026-07-06T07:00:00Z"}'The turn goes through the whole engine — persona, matched skills, tool loop, budget hints. What changes is the execution policy:
- Read-only tools execute for real, so the model reasons about the actual world. "Read-only" is the same idempotency signal the engine already uses for within-turn memoization: the built-in allowlist plus whatever external servers opted into via
idempotent/idempotent_tools/trust_annotations. - Everything else is suppressed and gets
[dry-run: write suppressed — <name> not executed; assume success]back, so the model keeps planning against a plausible world. Unknown tools are suppressed too — the allowlist is the only thing that vouches for a call. - Nothing is persisted. No conversation row, no messages, no telemetry, no memory extraction, no reviewer trigger. The transcript is returned to you and lives nowhere else.
- Approvals are skipped. Suppressed calls execute nothing to approve, and the calls that do run are read-only by definition.
as_ofpins the clock for both places a date reaches the model (the scheduled-message header and the## Current Dateprompt section), so previewing a July task in September doesn't silently drift.
A skill fires three ways and only one of them involves a message, so the skill endpoint takes a mode rather than always asking for one:
| mode | what it sends | for |
|---|---|---|
schedule |
the scheduler's [Scheduled: …] fire-time header, with the skill named |
skills a schedule runs — no user message exists |
command |
the skill's own command: trigger, plus optional args |
skills invoked by typing /name |
message |
an ordinary chat turn from message |
ambient skills that ride along on normal conversation |
Omit mode and it is inferred from the skill's triggers, so the default is always the entry point the skill actually has. Only schedule names the skill (which forces its body to be injected); command and message leave it unnamed so ordinary trigger matching runs — which means a command preview exercises the trigger, not just the body.
Both endpoints also accept a model override, which runs the preview against a model other than the agent's live one and changes nothing about the agent:
curl -X POST localhost:8080/api/v1/skills/pamela/heartbeat/dry-run \
-H "Authorization: Bearer $DENKEEPER_API_KEY" \
-d '{"message":"summarise yesterday","model":"moonshotai/kimi-k3"}'The override clones the router rather than mutating it, so previewing a candidate model can never retarget a live turn already in flight. The response echoes requested_model alongside the model that actually answered, so a transcript is self-describing.
In the dashboard this is the panel's first model slot; the second slot is empty by default, and filling it runs the same turn against both models and shows the deltas (rounds, bad tool args, cost, latency). One sample per side is a smoke test, not a verdict — the panel says so, and points at a full eval for anything you intend to decide on.
Dry runs still cost real tokens, so both endpoints sit behind their parent resource's write scope (schedules:write / skills:write).
Every audit event a dry run emits carries source = "dryrun" and a pseudo-agent like pamela#dryrun — the record is complete, but ?agent=pamela never returns them and the dashboard hides them until you flip Previews → Show dry runs & evals. Turn the volume down with:
[eval]
audit = "summary" # lifecycle events and errors only; default is "full"A dry run answers one question once. An eval asks it across a saved set of real turns, on both your current config and a candidate, and does the arithmetic. What is being measured is the model inside your harness — your persona, your skills, your tools — which is the thing a leaderboard cannot tell you.
The Evals page in the dashboard drives the loop; everything it does is REST underneath.
1. Build a test set. Three fill paths: "Save as test case" from the Chat page's message menu (optionally pinning the preceding turns as context), Suggest from history on the Evals page, which offers past turns worth keeping (failed or rejected tool calls, three-plus rounds, top-decile cost, command-triggered skills — stratified across the four categories, not ranked overall, and backed by GET /api/v1/eval/suggest), or JSONL import. Sets export and import as JSONL (GET/POST /eval/task-sets/{name}/export|import), so a curated set can be hand-edited, committed to git, or moved between instances. A test set is an appreciating asset — the next candidate model starts here rather than at a blank page.
2. Run it. Pick the agent, a candidate model, and a test set, then Quick check (10 cases sampled, one run each) or Full eval (the whole set at [eval] default_k). The launcher shows a cost estimate beside the editable cap; POST /api/v1/eval/estimate prices it from the tasks' own history where there is telemetry, list price otherwise, and says unknown rather than fabricating a number. The same run over curl:
curl -X POST localhost:8080/api/v1/eval/runs \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"task_set":"regression","base_agent":"pamela","k":3,"sample_tasks":10,
"variants":[{"name":"incumbent"},{"name":"candidate","llm_model":"moonshotai/kimi-k3"}]}'The run proceeds in the background on the agent's live engine — real persona, real skills, real tools — under the same policy dry runs use, so reads happen and writes do not. sample_tasks draws a stratified subset server-side and pins the drawn ids on the run, so a task added later cannot retroactively change what it measured.
Leaving the page loses nothing. The run's card shows a status chip, turns done against expected, spend against the cap, and an ETA while it is active, with a Stop button behind a confirmation. GET /api/v1/eval/runs/{id} is the authoritative view; the eval_progress WebSocket frame only nudges the page to refresh sooner.
Runs are bounded twice, by spend and by rate. Crossing cost_cap stops dispatching new samples, lets the in-flight ones finish, and keeps the partial results as capped — never a silent truncation. POST /api/v1/panic cancels active runs along with everything else, and resume deliberately does not revive them: a panic is not a pause. A sample that fails takes only itself down; the summary says how many of the expected samples landed and calls the run inconclusive below completeness_floor rather than reading a verdict off thin data.
3. Read the scorecard. GET /eval/runs/{id}/summary reports the objective half with no judge involved: per-variant rejected and failed tool-call rates, mean rounds, wrap-up count, cost per task, latency, and per-task deltas against the incumbent. The Evals page shows the same thing in three layers — the verdict with its gate table and reason, the scorecard with a completeness line, and a row per test case expanding to the two responses side by side with their tool traces and the judge's call on that pair. An upgrade offers Apply to agent; a run with pairs still outstanding says so and hands you the command to judge them.
The objective half can reject a candidate on its own, but it can't promote one: "cheap and quiet" is not the same as "better". When a run finishes it pairs the incumbent and candidate samples for each test case, assigns each pair a random A/B identity that never leaves the server, and queues two judgment items per pair with the presentation order swapped — so position bias splits the vote instead of deciding it. A pair only counts once both orders have been judged, and if the two calls name different sides the pair records as a tie.
The judge is Claude Code over Denkeeper's MCP server, driven by the /judge-eval skill, which works the queue with eval_pending → eval_get_pair → eval_verdict and then reads eval_summary. Everything that would identify a side — model, provider, variant name, cost, latency, token usage, even the sample's conversation id — is withheld; the payload is built from scratch rather than filtered, so a new field can't leak into it by default. Give the judge a key scoped to eval:read,eval:write and nothing more.
The rubric lives in that same skill file, where you can read and edit it: four dimensions in priority order (task_success, tool_path, persona_fit, length), with instructions to cite the specific persona or skill clause behind any deduction. On a new rubric, judge a random ~20-item calibration subset interactively and record your own call alongside the judge's (judge_ident: "operator"); eval_summary reports the agreement rate. Below roughly 80 %, fix the rubric before letting it run headless — a drifted rubric quietly devalues every later run.
GET /eval/runs/{id}/summary then returns the verdict with its work: the gate table (each value, delta, threshold and pass/fail), a one-line reason, the win-rate, and a per-category breakdown. The rule is conjunctive and asymmetric — a candidate is an upgrade only if the judge win-rate reaches win_threshold and no objective gate regressed; a failed gate is a downgrade whatever the judge thought. A candidate that wins overall while regressing on tool-heavy tasks says so out loud rather than hiding inside an average:
downgrade: mean rounds regressed +35.0% against a +20.0% threshold
upgrade: judge win-rate 62% over 45 judged pair(s) meets the 55% threshold, and no objective gate regressed
↳ wins overall; regresses on tool_heavy
GET /eval/runs/{id}/pairs is the unblinded view of the same evidence — which variant produced each side, every verdict with its dimensions and notes, and the resolved outcome per pair. It is REST-only on purpose: the judge's MCP tools must not be able to look up which side was which.
The Evals page renders the same evidence: the verdict with its gate table, the scorecard, and each judged pair with the judge's dimensions and notes. Full docs: Evals.
The API server and web dashboard are enabled by default (listening on :8080). All endpoints (except /health) require a Bearer token matching a configured API key.
[api]
listen = "0.0.0.0:8080"
[[api.keys]]
name = "my-client"
key = "dk-your-secret-key"
scopes = ["chat", "sessions:read", "costs:read"]Available scopes: chat, admin, agents:read, agents:write, sessions:read, sessions:write, costs:read, skills:read, skills:write, schedules:read, schedules:write, approvals:read, approvals:write, tools:read, tools:write, kv:read, kv:write, channels:read, channels:write, audit:read, eval:read, eval:write
Endpoints:
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/v1/health |
— | Health check (no auth) |
GET |
/api/v1/openapi.json |
— | OpenAPI 2.0 spec (no auth) |
GET |
/llms.txt |
— | LLM-readable instance summary: base URL, auth notes, key endpoints, configured agents (no auth) |
GET |
/api/v1/setup |
— | First-run setup status |
POST |
/api/v1/setup |
— | Initialize first-run configuration |
POST |
/api/v1/chat |
chat |
Send a message; returns { session_id, response }. Add Accept: text/event-stream for SSE. |
GET |
/api/v1/ws |
chat |
WebSocket upgrade for bidirectional streaming (auth via ?token= or session cookie) |
GET |
/api/v1/models |
agents:read |
List available LLM models from all providers |
GET |
/api/v1/models/details |
agents:read |
Model details with pricing info |
GET |
/api/v1/llm/providers |
admin |
List LLM providers with current config |
POST |
/api/v1/llm/providers |
admin |
Create a named provider instance |
PATCH |
/api/v1/llm/providers/{name} |
admin |
Update provider config (API key, base URL) |
DELETE |
/api/v1/llm/providers/{name} |
admin |
Remove a provider instance |
PATCH |
/api/v1/llm/config |
admin |
Update global LLM config (default provider, model) |
GET |
/api/v1/server/config |
admin |
Server config (version, build info, CORS, WebSocket) |
PATCH |
/api/v1/server/config |
admin |
Update server config (CORS origins, WebSocket settings) |
POST |
/api/v1/server/reload |
admin |
Reload config from disk |
POST |
/api/v1/server/restart |
admin |
Restart the server process |
GET |
/api/v1/auth/status |
admin |
Auth config summary (password, OIDC, sessions) |
GET |
/api/v1/auth/sessions |
admin |
List active sessions |
DELETE |
/api/v1/auth/sessions/{id} |
admin |
Revoke a session |
POST |
/api/v1/auth/password |
admin |
Change password |
GET |
/api/v1/auth/oidc/test |
admin |
Test OIDC provider reachability |
POST |
/api/v1/auth/preferences |
admin |
Set preferred login method |
GET |
/api/v1/onboarding |
admin |
Setup checklist status |
POST |
/api/v1/onboarding/dismiss |
admin |
Dismiss onboarding card |
GET |
/api/v1/sessions |
sessions:read |
List all conversations |
GET |
/api/v1/sessions/{id}/messages |
sessions:read |
Get messages for a session |
GET |
/api/v1/sessions/{id}/stats |
sessions:read |
Session telemetry summary |
GET |
/api/v1/sessions/{id}/tool-calls |
sessions:read |
Tool call records for a session |
GET |
/api/v1/sessions/{id}/skills |
sessions:read |
Skill usage for a session |
POST |
/api/v1/sessions/{id}/clear |
sessions:write |
Clear all messages in a session (keeps conversation row) |
POST |
/api/v1/sessions/{id}/compact |
sessions:write |
Compact session into LLM summary |
POST |
/api/v1/sessions/{id}/stop |
chat |
Cancel in-flight request for a session |
DELETE |
/api/v1/sessions/{id} |
sessions:read |
Delete a session and its history |
POST |
/api/v1/panic |
admin |
Emergency stop — cancel all in-flight requests, pause scheduler |
POST |
/api/v1/resume |
admin |
Clear panic state, resume scheduler |
GET |
/api/v1/panic |
admin |
Get panic state ({panicked, panic_time}) |
GET |
/api/v1/telemetry/summary |
costs:read |
Aggregate telemetry (?since=&until=) |
GET |
/api/v1/agents |
agents:read |
List agents with metadata |
POST |
/api/v1/agents |
admin |
Create a new agent at runtime |
GET |
/api/v1/agents/{name} |
agents:read |
Agent details and skills |
PATCH |
/api/v1/agents/{name} |
agents:write |
Mutate agent config (tier, model, supervisor, cost limits) |
DELETE |
/api/v1/agents/{name} |
admin |
Remove an agent (rejects if referenced by channels/schedules) |
GET |
/api/v1/skills |
skills:read |
List all skills across agents |
GET |
/api/v1/skills/{agent} |
skills:read |
List skills for a specific agent |
POST |
/api/v1/skills/{agent} |
skills:write |
Create a skill |
PUT |
/api/v1/skills/{agent}/{name} |
skills:write |
Update a skill |
DELETE |
/api/v1/skills/{agent}/{name} |
skills:write |
Delete a skill |
GET |
/api/v1/schedules |
schedules:read |
List schedules with run times |
POST |
/api/v1/schedules |
schedules:write |
Create a schedule |
PATCH |
/api/v1/schedules/{name} |
schedules:write |
Update a schedule |
DELETE |
/api/v1/schedules/{name} |
schedules:write |
Delete a schedule |
GET |
/api/v1/costs |
costs:read |
Cost summary |
GET |
/api/v1/approvals |
approvals:read |
List approval requests (filter by ?status=pending) |
GET |
/api/v1/approvals/{id} |
approvals:read |
Get a single approval request |
POST |
/api/v1/approvals/{id}/approve |
approvals:write |
Approve; ?auto_approve=session|permanent to create auto-approve rule |
POST |
/api/v1/approvals/{id}/deny |
approvals:write |
Deny a pending request |
GET |
/api/v1/auto-approve |
approvals:read |
List auto-approve rules (filter by ?agent=) |
POST |
/api/v1/auto-approve |
approvals:write |
Create an auto-approve rule |
DELETE |
/api/v1/auto-approve/{id} |
approvals:write |
Delete an auto-approve rule |
GET |
/api/v1/keys |
admin |
List API keys (secrets not returned) |
POST |
/api/v1/keys |
admin |
Create a new API key |
DELETE |
/api/v1/keys/{id} |
admin |
Revoke an API key |
DELETE |
/api/v1/keys/{id}/permanent |
admin |
Permanently delete a revoked key |
POST |
/api/v1/keys/{id}/rotate |
admin |
Rotate an API key |
GET |
/api/v1/tools |
tools:read |
List MCP tool servers |
GET |
/api/v1/tools/{name} |
tools:read |
Get tool server details |
POST |
/api/v1/tools |
tools:write |
Add a tool server |
PUT |
/api/v1/tools/{name} |
tools:write |
Edit a tool server |
DELETE |
/api/v1/tools/{name} |
tools:write |
Remove a tool server |
GET |
/api/v1/tools/{name}/health |
tools:read |
Tool server health status |
POST |
/api/v1/tools/{name}/restart |
tools:write |
Manually restart a tool server |
GET |
/api/v1/plugins |
tools:read |
List plugins |
GET |
/api/v1/plugins/{name} |
tools:read |
Get plugin details |
POST |
/api/v1/plugins |
tools:write |
Add a plugin |
DELETE |
/api/v1/plugins/{name} |
tools:write |
Remove a plugin |
GET |
/api/v1/kv/{agent} |
kv:read |
List KV keys for an agent |
GET |
/api/v1/kv/{agent}/{key} |
kv:read |
Get a KV key value |
PUT |
/api/v1/kv/{agent}/{key} |
kv:write |
Set a KV key value (body: {"value":"...","ttl":"5m"}) |
DELETE |
/api/v1/kv/{agent}/{key} |
kv:write |
Delete a KV key |
GET |
/api/v1/channels |
channels:read |
List all channels |
POST |
/api/v1/channels |
channels:write |
Create a channel |
GET |
/api/v1/channels/{name} |
channels:read |
Channel detail |
PATCH |
/api/v1/channels/{name} |
channels:write |
Update a channel |
DELETE |
/api/v1/channels/{name} |
channels:write |
Remove a channel |
POST |
/api/v1/channels/{name}/activate |
channels:write |
Set active channel for an adapter key |
DELETE |
/api/v1/channels/{name}/activate |
channels:write |
Clear active channel override |
GET |
/api/v1/audit |
audit:read |
List audit events (filter by ?category=&agent=&status=&since=&until=) |
GET |
/api/v1/audit/stats |
audit:read |
Aggregate counts by category/status |
Chat example:
# Non-streaming
curl -X POST http://localhost:8080/api/v1/chat \
-H "Authorization: Bearer dk-your-secret-key" \
-H "Content-Type: application/json" \
-d '{"message": "Hello!", "session_id": "my-session"}'
# SSE streaming
curl -X POST http://localhost:8080/api/v1/chat \
-H "Authorization: Bearer dk-your-secret-key" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"message": "Hello!", "session_id": "my-session"}'Pass the same session_id in subsequent requests to continue the conversation. Omit it to start a new session with an auto-generated ID.
Denkeeper can expose itself as an MCP server, letting external MCP clients (Claude Code, Claude Desktop, Cursor, etc.) use its agents, skills, schedules, sessions, and tools directly. The endpoint supports both Streamable HTTP (default) and SSE transports.
Enable in config:
[api.mcp_server]
enabled = true
# transport = "streamable" # or "sse" for legacy clients
# session_timeout = "30m"
# stateless = falseClient configuration:
Claude Code (~/.claude/settings.json or project .claude/settings.json):
{
"mcpServers": {
"denkeeper": {
"type": "http",
"url": "http://localhost:8080/api/v1/mcp",
"headers": {
"Authorization": "Bearer dk-your-secret-key"
}
}
}
}Cursor (.cursor/mcp.json):
{
"mcpServers": {
"denkeeper": {
"type": "streamable-http",
"url": "http://localhost:8080/api/v1/mcp",
"headers": {
"Authorization": "Bearer dk-your-secret-key"
}
}
}
}Other MCP clients that support Streamable HTTP or SSE transports can connect to http://<host>:8080/api/v1/mcp with a Bearer token in the Authorization header.
The API key must have scopes matching the tools you want to use (e.g. chat, agents:read, sessions:read, skills:read, schedules:read). Available MCP tools: chat, agent_list, agent_info, session_list, session_messages, session_search, session_clear, session_compact, skill_list, skill_get, skill_create, skill_update, skill_delete, skill_revert, schedule_list, schedule_create, schedule_update, schedule_delete, channel_list, approval_list, approval_resolve, tool_list, tool_health, tool_restart, kv_get, kv_set, kv_list, kv_delete, cost_summary, telemetry_summary, panic, resume, panic_status.
just is used as the command runner. Run just to see all available recipes:
just build # Build the denkeeper binary (requires web/dist/ to exist)
just build-ui # Build the Svelte web dashboard (requires Node.js)
just build-full # Build web dashboard then Go binary in one step
just serve # Start the agent (just serve ./path/to/config.toml)
just web-dev # Start Vite dev server for dashboard hot-reload
just test # Run all tests with race detector
just test-v # Verbose test output
just test-pkg <pkg> # Test a single package (e.g. just test-pkg internal/agent)
just test-cover # Tests with coverage report
just test-cover-html # Open coverage in browser
just test-ui # Web UI tests (Vitest + jsdom + MSW)
just test-integration # E2E integration tests (full in-process server + mock LLM)
just lint # Run golangci-lint
just lint-fix # Lint with auto-fix
just fmt # Format all Go files
just fmt-check # CI-friendly format check
just vet # Run go vet
just check # Run all checks (fmt + vet + lint + test + openapi-check)
just openapi # Generate OpenAPI spec (requires swag CLI)
just openapi-check # Fail if the committed OpenAPI spec is stale (same gate as CI)
just tidy # go mod tidy
just clean # Remove build artifacts
just loc # Count lines of source vs test code
cmd/denkeeper/ Entry point
internal/
adapter/ Platform integrations
telegram/ Telegram bot adapter
discord/ Discord bot adapter
agent/ Dispatcher, engine, and conversation memory
api/ External REST API server
approval/ Approval workflow manager, store, registry, and callback handler
config/ TOML config parsing and validation
configmcp/ Per-agent Config MCP server (skill/schedule/tier/tool/KV tools)
kv/ Per-agent key-value store with TTL
llm/ Provider interface, router, cost tracking
anthropic/ Anthropic direct client
openai/ OpenAI direct client (Azure/vLLM-compatible)
openrouter/ OpenRouter client
ollama/ Ollama local inference client
persona/ Persona file loader (SOUL.md, USER.md, MEMORY.md)
plugin/ Plugin manager (subprocess and Docker-sandboxed)
sandbox/ Pluggable sandbox runtime (Docker and Kubernetes backends)
scheduler/ Cron and interval scheduling
security/ Permission engine (tiers) and Ed25519 plugin signing
skill/ Skill file loader, trigger matching, merging
tool/ MCP tool server manager
oauth/ MCP OAuth 2.1 authorization for remote tool servers
voice/ STT/TTS provider interface
openai/ OpenAI Whisper + TTS client
web/ Embedded web dashboard handler (serves web/dist/)
web/ Svelte dashboard source (npm build → web/dist/)
pkg/bin/ Build output (gitignored)
agents/default/
skills/ Bundled skills (e.g. help.md)
SOUL.md Agent personality
