# REST API

The daemon listens on the loopback addresses only — `127.0.0.1:17989` and `[::1]:17989` — so LAN clients cannot reach it. The port is fixed and not configurable.

The same daemon serves the web dashboard at `/`. The dashboard is embedded into the binary at build time, so `http://127.0.0.1:17989` is the whole surface — there is no hosted front-end. `AGENVOY_PAGE_DIR` swaps the embedded copy for files on disk during development (`make dev`).

Since v1.0.1 the dashboard also runs offline. `GET /sw.js` serves a service worker that precaches the embedded assets under a versioned cache; `GET /vendor/*` serves the third-party assets (QuickUI, Font Awesome, Material Symbols, and the nanomd / voice scripts) from `~/.config/agenvoy/vendor/`, which the daemon downloads once at startup and re-downloads when the binary version changes. Nothing is fetched from a CDN at page load. In `AGENVOY_PAGE_DIR` mode `/sw.js` instead serves a teardown worker that clears its caches and unregisters itself, so a dev build is never served from a stale cache.

Endpoints marked **local** additionally require the request to originate from `127.0.0.1`/`::1` (the `localhostOnly()` guard). They manage credentials, config files, or process lifecycle and are meant for a same-machine dashboard, not remote clients. Unmatched routes carry the same guard: an unknown `/v1/` path returns a JSON 404, and any other unknown path falls back to the dashboard index.

## Agent execution

| Method | Path | Description |
|---|---|---|
| `POST` | `/v1/send` | Run an agent request |
| `POST` | `/v1/chat/completions` | Stateless OpenAI-compatible chat completion |
| `GET` | `/v1/info/version` | Build version stamped at compile time (`{version, dev}`); `dev` is true for an untagged build |
| `GET` | `/v1/log` | SSE stream. With no query it carries daemon `slog` records only (`EventDaemonLog` frames, `source` = level) — the same feed the TUI header shows. It includes new-chat verification codes, so daemon frames are attached only for loopback callers. `?sessions=a,b` adds those sessions' events on the same connection; `replay=0` skips the backlog, `daemon=0` drops the daemon frames. A remote caller, or one passing `daemon=0`, must pass `sessions` (400 otherwise). Since v1.3.8 session events carry backend-formatted text — `trace` (the thinking or tool-call line, e.g. `⏵ API(Weather)`), `tool_display` on tool calls and confirmations, and `confirm_title`, `restricted_lines` and `restricted_note` on confirmations — so every surface shows the same wording |
| `GET` | `/v1/daemon` | **local** — `{content}`: the `daemon.log` lines of the last 28 days. `?from=` / `?to=` (`yyyy-MM-dd-HH-mm`, local time) narrow the window and `?keyword=` keeps only lines containing it (case-insensitive); a malformed time returns 400 |
| `GET` | `/v1/system/update` | **local** — added in v1.0.11. `{version, latest, update_available}`; `latest` is resolved by following the GitHub `releases/latest/download/x` redirect (502 with the running `version` when that lookup fails) |
| `POST` | `/v1/system/update` | **local** — added in v1.0.11. Opens a terminal window running `agen update` and returns `202 {status:"opened"}`; the update itself runs in that terminal, not in the daemon. macOS uses Terminal.app, WSL launches the Windows terminal, Linux tries `xdg-terminal-exec`, `x-terminal-emulator`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `kitty`, `alacritty`, `wezterm`, `xterm` in that order. 501 when no terminal is reachable |
| `GET` | `/v1/mcp/tools` | List the tools registered from connected MCP servers (`mcp__*`) |

### `POST /v1/send` semantics

The body is `{content, stream?, session_id?, persist?, model?, reasoning?, skill?, work_dir?, rule?, task_hash?, exclude_tools?, enable_edit?, enable_sandbox?, enable_markdown?}`; `content` is required. Since v1.3.9 `stream`, `rule` and `task_hash` replace `sse`, `system_prompt` and `pending_task`; the old names are still accepted as aliases and will be removed later. Since v1.3.5 `model` and `reasoning` apply to this request only and never change the session default: an omitted `model` uses the session model, `auto` runs the dispatcher even when the session is pinned, and an omitted `reasoning` uses the session level. The web chat keeps its picks in browser cookies and sends them here. `work_dir` defaults to the home directory and must resolve to an existing directory (400 otherwise). `rule` adds extra system instructions for this run; `enable_markdown:false` (v1.3.9, default `true`) appends a rule asking for a plain-text reply without markdown syntax. `task_hash` resumes that pending task while it still has background jobs. A request that arrives while the session is still running is appended to that run as a steer message instead of starting a new one, and returns `{session_id}` at once (the `steer:true` field was removed in v1.3.9). Otherwise `stream:true` streams the run's events; without it the reply arrives when the run ends as `{text, model, usage, session_id}`, or `{session_id, canceled:true}` when it was canceled (500 with `error` when it failed).

Since v1.3.9 a request that sets `enable_edit` or `enable_sandbox`, or that arrives with no web confirmation listener open, runs in API auto mode: nothing prompts; the run is read-only unless `enable_edit:true` (writes and non-read-only commands); it stays in the sandbox unless `enable_sandbox:false` (paths outside `$HOME`, `sudo`, `sandbox: false`); `ask_user`, `store_secret` and the background tools are unavailable; and the session's path grants are cleared at the start of the request. A blocked call returns `[Denied]` naming the flag that would allow it. Before v1.3.9 such a request approved writes and restricted access with nobody watching.

| `persist` | `session_id` | Result |
|---|---|---|
| `false` (default) | empty | Creates `temp-<uuid>`, removed after 30 min without changes |
| `true` | empty | Creates `chat-<uuid>`, retained |
| any | provided | Uses the supplied `session_id` (`persist` is ignored) |

The `chat` flag was removed in v1.0.11 and the `http-` prefix with it: a persisted HTTP session now gets the same `chat-` prefix the dashboard uses, so one prefix covers every persisted non-CLI session. Existing `http-` sessions keep working when named explicitly through `session_id`.

```bash
curl --fail-with-body -sS \
  -H 'Content-Type: application/json' \
  -d '{"content":"List the available tools","persist":false}' \
  http://127.0.0.1:17989/v1/send
```

### `POST /v1/chat/completions`: OpenAI-compatible router endpoint

`/v1/chat/completions` is stateless: include prior messages in every request when continuity is needed. `reasoning_effort` accepts `none` `low` `medium` `high` `xhigh` `max` (plus the aliases `minimal` `extra` `ultra`); an omitted or unrecognized value falls back to the level the model selector picks for the request (`medium` when it picks none). `model` defaults to `auto`.

By default a completion runs with none of Agenvoy's own tools: only the `tools` the client sends are offered. With the header `X-Agenvoy-Agent: 1` (or `true` / `yes`) it runs as an Agenvoy agent instead — Agenvoy's system prompt plus every tool except the TUI-only ones. Tool confirmations are skipped on this endpoint either way.

Any OpenAI client can use Agenvoy as its model router: set the SDK's base URL to `http://127.0.0.1:17989/v1`. The endpoint does not check the API key, so any placeholder value works. `GET /v1/models` lists every registered model plus `auto` (see Model Endpoints).

| `model` | Which model answers | Fallback |
|---|---|---|
| `auto` (default) | The dispatcher model picks a worker model for the request's work kind (see Model Routing) | When a send fails, the next model in the priority order that is on a different provider and passes a 10-second health check takes over, for up to four rounds |
| `<provider>@<model>` | That registered model as-is; an unregistered name fails with `[Not found] model` | None |

Timeouts and rate limits are retried on the same model up to three times before any fallback. Without `stream` the reply is one `chat.completion` object, and an error that survives fallback returns `502` with `type: upstream_error`; with `stream: true` the reply is SSE `chat.completion.chunk` frames ending in `data: [DONE]`. `usage` reports `prompt_tokens` (cache reads included), `completion_tokens` and `prompt_tokens_details.cached_tokens`. When the client sends `tools` and the model calls one, the reply carries `tool_calls` with `finish_reason: "tool_calls"` and the client runs the tool itself. When the system prompt comes from Zed, the project root listed in it becomes the work directory; otherwise the home directory is used.

LiteLLM is a proxy server that puts many providers behind one OpenAI-format API, and OpenRouter is a hosted API that routes requests across providers. Agenvoy's endpoint runs on your own machine and routes across the models you registered in Agenvoy, including the Claude Code and Codex subscription providers, choosing one by work kind.
