# Demeterics — AI Agent Integration Guide

**Version 6 · 2026-08-28**
Human-facing companion page: <https://demeterics.ai/docs/agents>

You are an AI coding agent (Claude Code, Claude Cowork, Codex, or similar) helping a
user work with **demeterics.ai** — an LLM observability/proxy platform. This file
tells you what you can do on the user's behalf, with which key, and how.

Paste the relevant section into the repo's `CLAUDE.md` or `AGENTS.md` if you want
this capability remembered across sessions without re-reading this file each time.

## The two kinds of keys

The user's account has (up to) two different kinds of Demeterics API key. Check
their `.env` (never committed — gitignored, lives only on their machine) to see
which one(s) they have:

| Env var | Prefix | What it's for | What it can NOT do |
|---|---|---|---|
| `DEMETERICS_API_KEY` | `dmt_...` | Proxy LLM calls (chat completions) through Demeterics to Groq/OpenAI/Anthropic/Gemini, with tracking, tagging, and optional knowledge-base tools | Cannot manage prompts or agent config |
| `DEMETERICS_CONSOLE_API_KEY` | `dmc_...` (older keys: `dmt_a_...`) | Self-service account management: read and edit the user's own live prompts and prompt history, list the user's own agents, maintain the user's own knowledge base (read, write, delete, move, label, search, re-index), read their LLM call history, read their LLM keys' metadata — and, with `widgets:chat`, send a real message to one of their own AI Chat agents | Cannot reach the LLM API (no proxy, no ingest, no evaluations), cannot touch any account but the one that created it. Its one way to spend is `widgets:chat` on the user's own agent |

**Both keys act only on the user's own account.** Neither ever grants access to
Demeterics' staff admin panel or to any other user's data — that boundary is
structural, not a permission you can request.

If the user only has one of the two, some sections below won't apply — say so
rather than guessing at a workflow the key can't perform.

### Console key naming — both spellings are valid

Console keys minted from 2026-08-28 on read `dmc_…` (the "c" is for Console) and
their public key ID — the value in the `/api-keys/{id}` URL — reads `con_…`.
Keys issued before that date read `dmt_a_…` with an `apk_…` ID.

**Neither spelling is deprecated and there is nothing to migrate.** If the user's
`.env` holds a `dmt_a_` key, leave it alone; it authenticates exactly as it always
did. What decides whether a key is a Console key is the scopes it carries, never
its prefix — so do not write any code that infers a key's kind from its text.
(A key does pick up the new naming if the user *regenerates* it, because that
mints a fresh secret. Expect the ID in the URL to change too.)

### The user can hold several Console keys at once

There is no limit. Suggest a second, narrower key rather than widening an
existing one whenever the two uses differ — the usual split being one short-lived
key for CI and one for the laptop session you are running in. Each key has its
own scopes, its own expiry, and its own audit trail, so revoking one never
disturbs the other. `whoami` reports the scopes of the key you are actually
holding, which is the fastest way to tell two of them apart.

---

## Part 1 — `DEMETERICS_API_KEY`: proxying LLM calls

### Base URL by provider

| Provider | Base URL |
|---|---|
| Groq | `https://api.demeterics.com/groq/v1` |
| OpenAI | `https://api.demeterics.com/openai/v1` |
| Anthropic | `https://api.demeterics.com/anthropic/v1` |
| Gemini (OpenAI-compatible) | `https://api.demeterics.com/google/v1` |
| Gemini (native) | `https://api.demeterics.com/gemini/v1` |
| Unified (any provider by model prefix, e.g. `openai/gpt-4o`) | `https://api.demeterics.com/chat/v1` |

Everything downstream of the base URL matches each provider's own OpenAI-style API
(`/chat/completions`, etc.) — this is a transparent proxy, not a new API to learn.

```bash
curl -s https://api.demeterics.com/groq/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $DEMETERICS_API_KEY" \
  -d '{
    "model": "openai/gpt-oss-20b",
    "messages": [{"role": "user", "content": "Hello"}]
  }'
```

### Tagging recommendation (do this on every call you make)

Demeterics attributes cost and usage in its dashboard by tag, not just by key. An
untagged call is still tracked, but lands in an "untagged" bucket that's much
harder for the user to make sense of later. **Tag every call you send** using
inline `///` comments inside the prompt text (system prompt is a good place — they
are stripped before the model ever sees them):

```
/// APP my-project
/// FLOW code-review
/// ENVIRONMENT dev
/// USER agent:claude-code
```

Recommended minimum tag set for an AI agent's own calls:

- `APP` — the project/repo you're working in
- `FLOW` — the specific task or workflow step (`code-review`, `commit-message`, `test-generation`, ...)
- `ENVIRONMENT` — `dev` / `staging` / `prod`, whichever applies to what you're doing
- `USER` — identify yourself distinctly from the human, e.g. `agent:claude-code` or `agent:codex`, so the dashboard can separate agent-driven usage from the user's own interactive usage

Accepted syntax variants (case-insensitive key): `/// APP my-app`, `/// APP: my-app`,
`/// APP = my-app`, `/// APP [my-app]`. Full tag list and details:
`https://demeterics.com/docs/instrumentation`.

**Gotcha:** tagging is silently ignored on any call that also uses
`demeterics_tools` (Part 2 below) — that code path skips the tracking pipeline
entirely. Don't expect tagged knowledge-base calls to show up in dashboard
analytics by tag.

### Knowledge base (retrieval over the user's documents)

If the key has a Knowledge Project attached (set on the key's detail page under
"Knowledge Project" — ask the user to attach one there if `demeterics_tools` calls
come back saying no project is configured), you enable it per-request by adding a
top-level `demeterics_tools` field to the chat request body sent to
`https://api.demeterics.com/chat/v1/chat/completions`:

```bash
curl -s https://api.demeterics.com/chat/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $DEMETERICS_API_KEY" \
  -d '{
    "model": "openai/gpt-4o",
    "messages": [{"role": "user", "content": "What does our onboarding doc say about SSO?"}],
    "demeterics_tools": {"knowledge": true}
  }'
```

Demeterics runs the retrieval loop server-side (search → read → answer) — you do
not define or handle any tool-call schema yourself; just set the flag and read the
final answer out of the normal `choices[0].message.content` field, same shape as
any OpenAI-style chat completion. `demeterics_tools` is stripped before the request
reaches the upstream model provider, so it never leaks into the LLM's own context.

Optional: `{"knowledge": {"max_iterations": 5}}` to raise the default retrieval
loop cap (3) for a harder question.

---

## Part 2 — `DEMETERICS_CONSOLE_API_KEY`: managing the user's own account

Base: `https://demeterics.com/api/v1/console/`. Every call needs
`Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY`. A call needing a scope the key
wasn't granted returns `403` — check the key's scopes with `whoami` first if
something is unexpectedly refused.

```bash
# 1. Verify the key is wired correctly
curl -s https://demeterics.com/api/v1/console/whoami \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# 2. Find your agent_key values (needs agents:read)
curl -s https://demeterics.com/api/v1/console/agents \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# 3. List a prompt's version history (needs prompts:read)
curl -s "https://demeterics.com/api/v1/console/prompt-history/list?agent_key=AGENT&prompt_name=system" \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# 4. Read one specific version (needs prompts:read)
curl -s "https://demeterics.com/api/v1/console/prompt-history/get?agent_key=AGENT&prompt_name=system&history_id=123" \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# 5. Restore a version (needs prompts:write)
# X-Request-ID makes a retry idempotent instead of restoring twice — always set it.
curl -s -X POST https://demeterics.com/api/v1/console/prompt-history/restore \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Request-ID: $(uuidgen)" \
  -d '{"agent_key":"AGENT","prompt_name":"system","history_id":123}'

# 6. Relabel a version (needs prompts:write)
curl -s -X PUT https://demeterics.com/api/v1/console/prompt-history/label \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_key":"AGENT","prompt_name":"system","history_id":123,"label":"pre-refactor baseline"}'
```

Everything this key touches is scoped to its own owner automatically — there is no
`user_id` or account parameter to pass, and none would let you reach a different
account even if you tried.

### Live prompts — the text the widget actually serves

The history endpoints above version the prompt. These endpoints ARE the prompt:
`system_prompt`, `temperature`, `max_tokens` and the linked markdown docs, as the
widget will use them on the next message. If a user says "the widget is still
saying the old thing", this is the surface to look at — history can be empty while
the live prompt is fine, and every agent created before version history existed
has exactly that shape.

Each agent has one or more NAMED prompts. `default` is the one served unless the
widget selects another, and it cannot be deleted.

```bash
# List an agent's prompts (needs prompts:read)
curl -s https://demeterics.com/api/v1/console/agents/AGENT/prompts \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# Read one prompt (needs prompts:read)
curl -s https://demeterics.com/api/v1/console/agents/AGENT/prompts/default \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# Create or overwrite a named prompt (needs prompts:write).
# POST is an UPSERT: posting an existing name replaces it.
curl -s -X POST https://demeterics.com/api/v1/console/agents/AGENT/prompts \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"checkout","system_prompt":"You help with checkout questions.","temperature":0.3,"max_tokens":1024}'

# Edit one prompt in place (needs prompts:write).
# Every field is optional; omitted fields are left alone.
curl -s -X PUT https://demeterics.com/api/v1/console/agents/AGENT/prompts/default \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"system_prompt":"You are the support assistant for ACME."}'

# Delete a named prompt (needs prompts:write — there is no separate delete scope)
curl -s -X DELETE https://demeterics.com/api/v1/console/agents/AGENT/prompts/checkout \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"
```

Three things to know before you edit one:

- **Every write is versioned.** A create or an edit through these endpoints
  leaves a history entry of type `console`, so the user can see what you changed
  and roll it back from the portal — and so can you, with
  `prompt-history/restore`. Nothing extra to call.
- **`default` cannot be deleted.** `DELETE` on it returns an error. Edit it
  instead.
- **Drive-sourced prompts are read-only here.** See below — this one has bitten
  people.

**If a prompt comes from Google Drive, you cannot edit it through this API.** The
user has linked that prompt to a `.dmt` file in their Drive; the file is the
source of truth and the widget reads it at serve time. `PUT`, `DELETE`, an
overwriting `POST`, and `prompt-history/restore` all answer:

```json
{
  "error": "prompt is sourced from Google Drive (support-prompt.dmt); edit the Drive file or detach it in the portal",
  "drive_file_id": "1AbC..."
}
```

with status **`422`**, not `403`. The distinction is deliberate and worth reading:
your key is not missing a permission and adding scopes will not help. The prompt's
content simply lives somewhere this API does not reach. Tell the user to edit that
Drive file, or to detach the prompt from Drive on the agent's page — and do not
retry. (Before this check existed, such a write returned `200` with your new text
echoed back while the widget went on serving the Drive file, which is the failure
mode the `422` replaces.)

### Interaction history — what the LLM calls actually did

`interactions:read` is the one LLM data-plane scope a Console key may hold. It
reads history and spends nothing; it does **not** let the key make an LLM call,
and it never will — a Console key is refused on every LLM route regardless of
what it holds.

```bash
# The last 50 calls, newest first (needs interactions:read)
curl -s https://demeterics.com/api/v1/console/interactions \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# Filtered: errors from one application in a date window
curl -s "https://demeterics.com/api/v1/console/interactions?application=my-app&status=error&date_from=2026-08-01&date_to=2026-08-28" \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# Text search over the request/response content, paged
curl -s "https://demeterics.com/api/v1/console/interactions?q=refund&limit=100&offset=100&sort_by=tokens&sort_order=desc" \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"
```

Parameters: `model`, `application`, `interaction_type` (`llm`/`tts`/`imagen`),
`status`, `date_from` / `date_to` (`YYYY-MM-DD`, inclusive), `q` (substring search
over the message text), `limit` (default 50, max 200), `offset`, `sort_by`
(`time`/`tokens`/`duration`/`cost`, default `time`), `sort_order`
(`desc`/`asc`). The response carries `total` alongside `interactions`, so you can
page without guessing where the end is.

**The response is metadata only** — `transaction_id`, `question_time`, `model`,
`provider`, `application`, `interaction_type`, `status`, `tokens`, `latency_ms`,
`total_cost`. The prompts and completions themselves are **not** returned. `q`
still *searches* them server-side, which is the useful half; if the user needs to
read a specific exchange, send them to the Interactions page in the portal.

This route is account-wide. Unlike agents and Knowledge projects, an interaction
does not belong to one resource, so a key narrowed to a single agent still sees
the whole account's call history here.

### LLM key metadata — which keys exist and how they are configured

```bash
# List the account's LLM keys (needs llm_keys:read)
curl -s https://demeterics.com/api/v1/console/llm-keys \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"
```

Answers the question you actually have when a call billed the wrong way: which
keys exist, which is `byok` vs `managed`, what `application` each is tagged with,
whether each is active or expired, and — as booleans per provider — whether a
vendor key is configured on it:

```json
{"success": true, "keys": [{
  "key_id": "apk_…", "name": "Production", "application": "my-app",
  "key_type": "byok", "status": "active", "is_expired": false,
  "permissions": ["interactions:read", "interactions:write"],
  "providers": {"openai": true, "groq": false, "anthropic": false,
                "google": false, "openrouter": false}
}]}
```

**No key material is ever returned** — not the Demeterics secret, not the stored
vendor keys, not a masked or truncated form of either. `providers` is presence
only. There is no way to read a key's value through any API; if the user has lost
one, the answer is to regenerate it in the portal.

This route is **read-only by design**. There is no Console endpoint to create,
rotate or revoke an LLM key, and there will not be one: those actions mint or
destroy a credential that can spend money, and they stay in the browser. It also
does not list the user's *Console* keys — use `whoami` to see the one you hold.

### Asking the user's own AI Chat agent a real question (`widgets:chat`)

This is the **one Console endpoint that spends the user's money**. Everything
else on this key reads or edits configuration; this runs the agent.

```bash
# Ask one of the user's own agents a question (needs widgets:chat)
curl -s -X POST https://demeterics.com/api/v1/console/agents/AGENT/chat \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"what are your opening hours?"}]}'
```

```json
{
  "success": true,
  "agent_key": "DEM-CUGCZGYMIJZ6",
  "prompt_name": "default",
  "reply": "<p>We're open 9am to 6pm, Monday to Friday.</p>",
  "model": "llama-3.3-70b-versatile",
  "interaction_id": "4f2c1a9e7b3d5608"
}
```

`AGENT` is the `DEM-…` key from `/console/agents`. There is **no `key` or
`agent_key` field in the body** — the URL names the agent, and a body field
would be a second, unchecked place to name a target.

Optional body fields, all mirroring what the embedded widget sends:
`prompt_name` (which of the agent's named prompts to use; defaults to
`default`), `session_id` (groups a multi-turn conversation in the user's
interaction history; defaults to a stable per-key value), `page_url` and
`page_title` (the page context a visitor's browser would have supplied — set
these when you are reproducing a visitor's exact conditions). `messages` is the
full conversation so far, oldest first, and the **last message must have role
`user`** or the call is refused `400`.

**What actually happens.** The agent's system prompt, its markdown documents,
its knowledge-base retrieval, its safety guards and its configured provider —
the complete pipeline a visitor on the customer's website gets, not a
simulation. Consequences follow from that, and you should tell the user before
you start calling it in a loop:

- **It costs real money.** The call is billed to the account's credits exactly
  as a visitor's message would be. `402` means the balance is exhausted.
- **It appears in the real interaction history.** Every call shows up in
  `/console/interactions` and in the portal's Interactions page, with `domain`
  recorded as `console-api` so the user can tell your calls from their visitors'.
- **`reply` is HTML**, because that is literally what the widget renders. Do not
  expect markdown.
- The agent's own **daily cost limit** still applies (`429` when exhausted), and
  so does the Console key's 60-requests-per-minute limit.
- A deactivated agent answers `403`, and an agent belonging to another account
  is not reachable at all.

**Why this exists.** The public `DEM-…` key on a widget is normally protected by
a domain allow-list, which is there to stop an unrelated website from embedding
someone else's widget. It was never meant to stop the account owner's own
tooling from talking to the owner's own agent. A Console key is exactly that
tooling — per-account, expiring, revocable, optionally restricted to named
agents — so `widgets:chat` skips the domain check and relies on the key instead.

Use it to verify a prompt you just edited actually changed the answer, to
reproduce a complaint the user received, or to smoke-test an agent after a
knowledge-base update. Ask before running large batches: each one is a real
charge on their account.

### Knowledge base — read and edit the user's documents

Seven scopes, all optional and independent: `knowledge:read`, `knowledge:write`,
`knowledge:delete`, `knowledge:move`, `knowledge:metadata`, `knowledge:vectorize`,
`knowledge:test`. They apply on top of the account's
Knowledge Engine access — if that feature is not enabled for the user, every call
below returns `403 FEATURE_ACCESS_REQUIRED` no matter what the key's scopes say.
That is not something scopes can fix; the user has to have Knowledge Engine turned
on for their account.

```bash
# List the user's knowledge projects — start here, you need a proj_… ID (knowledge:read)
curl -s https://demeterics.com/api/v1/console/knowledge/projects \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# Browse a project's file tree (knowledge:read)
curl -s https://demeterics.com/api/v1/console/knowledge/projects/proj_ABC/tree \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# Read one file. JSON by default; Accept: text/markdown streams the raw file. (knowledge:read)
curl -s https://demeterics.com/api/v1/console/knowledge/projects/proj_ABC/files/onboarding/setup.md \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# Create or overwrite a file (knowledge:write)
curl -s -X PUT https://demeterics.com/api/v1/console/knowledge/projects/proj_ABC/files/onboarding/setup.md \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: text/markdown" \
  --data-binary @setup.md

# Delete a file, with its topic summary / index / vectors cleaned up (knowledge:delete)
curl -s -X DELETE https://demeterics.com/api/v1/console/knowledge/projects/proj_ABC/files/onboarding/old.md \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY"

# Move a file. Both paths go in the body; the search index follows the file. (knowledge:move)
curl -s -X POST https://demeterics.com/api/v1/console/knowledge/projects/proj_ABC/move \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"src_path": "kb/onboarding/setup.md", "dst_path": "kb/install/setup.md"}'

# Label a file. Absent field = leave alone, null = clear. (knowledge:metadata)
curl -s -X POST https://demeterics.com/api/v1/console/knowledge/projects/proj_ABC/file-metadata \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"path": "kb/install/setup.md", "display_name": "Setup guide", "tags": ["install", "v2"]}'

# Search the knowledge base and see WHICH FILES matched, with scores. (knowledge:test)
curl -s -X POST https://demeterics.com/api/v1/console/knowledge/projects/proj_ABC/search \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "how do I rotate an API key", "limit": 10}'

# Rebuild the WHOLE project's embeddings — minutes, and real embedding spend (knowledge:vectorize)
curl -s -X POST https://demeterics.com/api/v1/console/knowledge/projects/proj_ABC/resync \
  -H "Authorization: Bearer $DEMETERICS_CONSOLE_API_KEY" \
  -H "Content-Type: application/json" -d '{}'
```

Things worth knowing before you use these:

- **A write already re-indexes.** Every `PUT` re-embeds the file it wrote and
  refreshes the project index by itself. You do **not** need `knowledge:vectorize`
  after a write, and you should not call `resync` in a loop — it rebuilds every
  vector in the project each time. Reach for it only when the user says search
  results look stale. (`clear_cache` is always forced on, so a resync replaces the
  project's vectors instead of adding a second copy of them; there is no additive
  mode and you do not need to pass the flag.)
- **Auto-generated files are read-only.** Any file whose name starts with `_`
  (`_summary.md` and friends) and `system/index.md` are produced by the Knowledge
  Engine. `PUT` and `DELETE` on them return `403`. Do not try to hand-maintain
  them; write the source documents and let the summaries regenerate.
- **PDFs and images convert on upload.** `PUT` a PDF or an image and it is
  converted to markdown before it is stored and indexed. Files are capped at 10 MB.
- **A `409` on resync means one is already running** for that project. Wait, do not
  retry immediately.
- Scopes are independent: a key can hold `knowledge:read` alone (a safe default for
  a CI job that only needs to consult the docs), or `read` + `write` without
  `delete`. Suggest the narrowest set that does the job.

**Moving a file** (`knowledge:move`). Use the `move` endpoint rather than emulating
it with a `PUT` plus a `DELETE`. The endpoint re-indexes the destination *before*
it removes the source, so the file is never missing from search, and it cleans up
the old path's vectors, the old topic's summary and the index afterwards; the
two-call emulation leaves the file indexed twice in between and, if the delete
fails, leaves it at both paths for good. The response reports each consequence
separately — `moved`, `destination_written`, `source_deleted`, `vectors_updated`,
`src_vectors_deleted`, `src_topic_deleted`, `index_updated`, plus a `warnings`
array. Read them; a `200` does **not** mean everything worked. In particular
`src_vectors_deleted: 0` with a warning about the streaming buffer means search
will return both the old and the new path for up to about half an hour. Two hard
rules: auto-generated files are refused as *either* endpoint of a move, and a move
onto an existing file returns `409` rather than overwriting it — delete the
destination first if replacing it is really what the user wants.

**Labelling a file** (`knowledge:metadata`). `display_name` and `tags` are stored
on the file and returned by the tree and file-read endpoints. Two things to be
precise about. The merge is three-state: a field you **omit** is left alone, a
field set to **`null`** (or `""`/`[]`) is cleared, and a field with a value
replaces what was there — so editing tags alone will not wipe the display name.
And these labels are **not searchable**: they are not embedded, so a metadata edit
costs nothing and changes no search result. If the user wants a term to affect
retrieval, it has to go in the file. Renaming the *path* is `knowledge:move`;
`display_name` is a label, not a filename.

**Searching** (`knowledge:test`). This is a real vector search — the query is
embedded and matched against the project's index — and the answer is grouped by
**file**: each entry has `file_path`, `topic_slug`, `best_score`, `match_count`,
and up to three `matches` with their `heading`, line range and individual `score`,
sorted best first. That is the endpoint to use to check whether the knowledge base
can actually answer a question, and to find which document to read next. It
returns line ranges rather than passage text, so read the file (with
`knowledge:read`) if the passage itself is needed.

### A Console key may be limited to specific resources

Scopes grant whole categories — `agents:read` means "the user's agents". When the
user created the key they could additionally narrow it to *named* resources:
specific AI Chat agents, specific Knowledge projects, specific LLM API keys. Most
keys are not narrowed, and a key created before this existed never is.

Two things follow for you:

- `GET /api/v1/console/agents` returns only the agents this key may act on. If an
  agent the user talks about is missing from that list, the key is scoped away
  from it — the agent still exists.
- A request naming an out-of-scope agent fails with
  `403 {"error": "this key is not scoped to agent DEM-…"}`. That wording is the
  tell: it is **not** a missing-scope error, and adding scopes will not fix it.
  `whoami` will still list `agents:read`. The fix is on the key's detail page in
  the console, under *Resource access* — ask the user to add that agent, or to
  mint a second key. Do not retry; the answer will not change.

### What this key structurally cannot do

- Cannot call any LLM route — it will get `403` on every `/groq`, `/openai`,
  `/anthropic`, `/google`, `/gemini`, and `/chat` path. **This holds even if the
  key carries `interactions:read`**: on a Console key that scope means the
  `/console/interactions` route and nothing else. The confinement is by key kind,
  not by scope, so no combination of scopes reaches the LLM API.
- Cannot read or write vendor (BYOK) keys, or touch billing. `llm_keys:read`
  shows that keys exist and which providers are configured; it never shows a
  key's value, and there is no create/rotate/revoke over the API.
- Cannot reach Demeterics' own staff admin panel or any other user's account,
  regardless of the key owner's role. This is enforced at the request-handling
  layer, not a setting that can be toggled on.

**The one exception, stated plainly:** `widgets:chat` *can* spend. It is the
single scope on this key that costs money, and it can only ever do so by asking
one of the user's **own** AI Chat agents a question through
`POST /console/agents/{key}/chat`, on the user's own credits, recorded in the
user's own history. It does not open the LLM API, it cannot use a different
model or provider than the agent is configured with, and a key that does not
carry it cannot reach that route at all. If the user's role is Viewer, the
route answers `403` no matter what the key holds.

---

## Handling both keys safely

- Keys live in the user's local `.env`, on their machine — Demeterics never reads,
  writes, or syncs that file. Make sure `.env` is in `.gitignore` before you ever
  touch a repo that has one; never print a raw key value into a commit, log line,
  or chat transcript.
- `DEMETERICS_CONSOLE_API_KEY` is shown to the user exactly once, at creation, and
  is never stored in a form Demeterics can redisplay. If it's lost, the fix is
  regenerating it from the key's detail page in the console — the old value stops
  working the moment that happens.
- `DEMETERICS_CONSOLE_API_KEY` always has an expiration (7/30/90 days, never
  "forever") — if calls that used to work start returning `401`, check expiry
  before assuming something broke.

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 1 | 2026-08-28 | First versioned edition. Console keys renamed `dmt_a_…` → `dmc_…` (secret) and `apk_…` → `con_…` (public ID), with both spellings valid indefinitely; documented that a user may hold any number of concurrent Console keys; added a pointer to the human-facing `/docs/agents` page. |
| 2 | 2026-08-28 | Console keys can now be limited to specific agents, Knowledge projects or LLM keys. Documented the new `403 "this key is not scoped to …"` response and that `/console/agents` returns only in-scope agents. Existing keys are unaffected — no stored restriction means unrestricted. |
| 3 | 2026-08-28 | Knowledge base over the Console API: new `knowledge:read`/`write`/`delete`/`vectorize` scopes and the `/console/knowledge/projects…` endpoints (list, tree, read/write/delete a file, project resync). Noted that writes self-index, that auto-generated files are read-only, and that `knowledge:move`/`metadata`/`test` are named but not yet grantable. |
| 4 | 2026-08-28 | The last three knowledge scopes are now grantable, with endpoints behind them: `knowledge:move` (`/move` — re-indexes the destination, cleans up the source, reports each consequence separately, refuses to overwrite), `knowledge:metadata` (`/file-metadata` — three-state merge of `display_name` and `tags`, which are stored on the file but deliberately not embedded), and `knowledge:test` (`/search` — real vector search returning matched **files** with scores and line ranges). File reads and the project tree now surface `display_name` and `tags`. |
| 5 | 2026-08-28 | **Live prompts** over the Console API (`/console/agents/{key}/prompts…` — list, read, create, edit, delete under the existing `prompts:read`/`prompts:write`), with Drive-sourced prompts refused `422` and every write versioned as a `console` history entry. **Interaction history** (`/console/interactions`, new `interactions:read` — the one LLM data scope a Console key may hold; metadata only, no message content). **LLM key metadata** (`/console/llm-keys`, new `llm_keys:read` — read-only, provider presence as booleans, never key material). A prompt with no versions now answers `{"success":true,"history":[]}` instead of `500`. |
| 6 | 2026-08-28 | **The Console API can now run an AI Chat agent.** New `widgets:chat` scope and `POST /console/agents/{key}/chat`: send a real message to one of your own agents and get the real answer — same system prompt, same knowledge retrieval, same provider, same billing, same interaction history as a visitor on your website. This is the first Console scope that spends credits, and it is deliberate: the widget's domain allow-list exists to stop other people's websites, not the account owner's own tooling. `reply` is HTML; the agent's daily cost limit and the key's rate limit both still apply; a Viewer-role owner is refused. Every other Console scope still spends nothing, and no Console key of any shape can reach the LLM API. |
