# AbstractGateway — llms-full

This file is a single-document snapshot of the local Markdown files (`*.md`) linked in `llms.txt`, intended for LLM/agent ingestion.
It is generated by `scripts/generate-llms-full.py` in the same order the links first appear in `llms.txt` (de-duplicated).
Relative links are normalized to repo-root paths.

---

## docs/first-run.md

# AbstractGateway — First run

This page covers the shortest path from `pip install abstractgateway` (or
`uv tool install abstractgateway`) to a signed-in console on your own machine:
no environment variables, no token to copy. It also covers starting the gateway
at login. For server deployments, see [deployment.md](docs/deployment.md); for
every setting, see [configuration.md](docs/configuration.md).

## 1. Start the gateway

```bash
abstractgateway serve
```

With no auth configured, `serve`:

- binds **127.0.0.1** (port 8080; use `--port` to pick another);
- turns on **user auth** and creates the admin user `default/admin`;
- keeps its data in the per-user data folder for your OS (see
  [Where the data lives](#where-the-data-lives));
- prints a short banner:

```text
Gateway data dir: /Users/you/Library/Application Support/AbstractGateway (os_default: ...)
Gateway auth: user auth enabled automatically (bound to loopback 127.0.0.1, no auth configured). ...
Gateway admin token file: .../auth/bootstrap-admin-token
First run: open http://127.0.0.1:8080/console#claim=agclaim_...
           (one-time link, valid 10 minutes, works from this machine only; ...)
```

Open the `First run` link in a browser on the same machine. The console signs
you in as the admin and opens the **first-run guide**.

The admin token is printed just above the link, and kept in
`<data dir>/auth/bootstrap-admin-token` (file mode `0600`). Start with
`abstractgateway serve --no-print-token` to keep it out of the output.

## 2. The first-run guide

The guide has five steps. Every step is optional:

| Step | What it shows |
|---|---|
| Welcome | This machine (memory, GPU), the data folder and why it was chosen, the sign-in mode, whether the gateway starts at login |
| Local engines | The engines found on this machine (Ollama, LM Studio, MLX, llama.cpp, ...), whether each is installed and running, and an **Install** button. The confirmation shows the exact command before anything runs |
| Default model | The text model currently configured, **Use recommended defaults** (the same action as the Multimodal tab's *Apply recommended*), a **Download** button for each recommended model that is missing, and the Models tab's catalog cards with **Fits this computer** on (one card per model, every 4-bit and 8-bit build, filters, **Open in the Models tab**). A downloaded text model's **Use as default** makes it the default text model |
| Apps | One card per browser app (Flow Editor, Code, Observer, Continuum, Entity): name, status, one line of description and one row of buttons at the same height on every card: **Install and open** or **Open** (Open also starts a stopped app), plus **Open in Terminal** or **Install for Terminal** on Code. Stop, Show log, Update, versions and commands appear with **Technical details** (see [console.md](docs/console.md#apps-tab)) |
| Done | How to reopen the console, the login-service status, one line saying the console also exists as a terminal app, and (with **Technical details**) the CLI equivalents and the terminal console's install and open commands |

**What "recommended" means on this computer.** AbstractCore picks the
recommended text model; the gateway shows that pick and holds no list of its
own. On a Mac with Apple silicon the pick is an MLX build chosen by the
computer's memory:

| Memory | Recommended text model |
|---|---|
| less than 24 GB | `mlx-community/Qwen3.5-9B-MLX-4bit` (Qwen3.5 9B) |
| 24 GB up to, but not including, 128 GB | `mlx-community/Qwen3.8-27B-4bit` (Qwen3.8 27B) |
| 128 GB or more | `mlx-community/Qwen3.8-Flash-Next-4bit` (Qwen3.8 Flash-Next) |

LM Studio and Ollama builds stay in the catalog and can be downloaded, but on
a Mac they are not the recommendation. Other computers keep the LM Studio
build `qwen/qwen3.5-9b@4bit`. When AbstractCore's memory estimate says the
recommended model may not fit, the **Chat and text** card says so with the
estimate; the recommendation does not quietly switch to another model.

A typical path from a fresh install to a working local model:

1. **Local engines.** If no engine is installed, click **Install** on Ollama
   (or open LM Studio's download page). The confirmation shows the command,
   for example `brew install ollama` on a Mac with Homebrew, and that it runs
   on this machine. The install runs in the background with progress; when it
   finishes the row shows the version.
2. **Default model.** The catalog opens on models that fit this machine's
   memory, one card per model with its 4-bit and 8-bit builds. Click
   **Download** on the build you want; its progress bar shows on the row.
3. When the download finishes, the row reads *Downloaded*. Click **Use as
   default**: the gateway's text route now uses it.

The same steps are available later in the **Engines** and **Models** tabs (see
[console.md](docs/console.md#models-and-engines-tabs)) and from the command line
(`abstractgateway engines install ollama`, `abstractgateway models download
ollama qwen3:8b`). Installing an engine needs an admin and the
[`allow_engine_install`](docs/configuration.md#allow_engine_install) setting, which
is on by default for a gateway that listens on this machine only. With an
AbstractCore older than 2.14.0 the engines step shows the Ollama and LM Studio
download links and the upgrade command instead.

The guide opens by itself once per data folder. **Finish** or **Skip setup**
records that it ran (`POST /api/gateway/host/first-run`); clicking outside the
dialog closes it without recording anything. The **Setup** button (⚑, top
right, admins only) reopens it at any time.

## 3. Get a new sign-in link

A link works once and expires after 10 minutes. To get another one:

```bash
abstractgateway claim            # prints the link
abstractgateway claim --open     # prints it and opens your browser
abstractgateway-config claim-url # same command, from the config helper
```

The command finds the running gateway's port from the data folder (the gateway
writes `<data dir>/run/gateway-serve.json` while it runs). Use `--port` or
`--url` to target another gateway, `--data-dir` for another data folder, and
`--json` for machine-readable output. It exits with code `2` when the running
gateway does not use user auth (a static `ABSTRACTGATEWAY_AUTH_TOKEN`
deployment), because that gateway would refuse the link.

How the link is protected:

- only someone who can write the data folder can mint one (the CLI writes it
  under `<data dir>/auth/claims/`, stored as a SHA-256 digest only);
- `POST /api/gateway/session/claim` redeems it **only** from a loopback socket
  peer, and refuses any request carrying proxy headers (`X-Forwarded-For`,
  `X-Forwarded-Host`, `X-Real-IP`, `Forwarded`);
- the console removes the code from the address bar before sending it;
- the result is the same browser session as a normal sign-in (session cookie +
  CSRF cookie). The response's `claim.created_by` says who minted the link
  (`serve`, `cli`, `tray`, or `null` for an older link). This lets the
  console treat a tray sign-in differently from a first run.

## 4. Start the gateway at login (optional)

```bash
abstractgateway service install      # install and start
abstractgateway service status       # on | off | broken | other, and why
abstractgateway service enable       # start THIS gateway at the next login (starts nothing now)
abstractgateway service disable      # stop starting it at login (the running gateway keeps running)
abstractgateway service uninstall    # stop and remove (your data is kept)
```

The desktop tray's **Start AbstractGateway at login** item is the same switch
(`enable`/`disable`, shared module `abstractgateway.autostart`). `status`
reports `broken` when a registration exists that would not start — the
program it points at is gone (a moved or reinstalled gateway), the file is
unreadable, the unit is not enabled, or launchd / Task Manager / the desktop
switched it off — and `other` when it belongs to another data folder. It also
reports `broken` with **needs repair** when a registration starts the gateway
but pins `--host/--port` on its command line (every registration written
before 2026-09-24): "pinned to 127.0.0.1:N by the login item — run
`abstractgateway service enable` again to let the Network setting apply".

| OS | What is installed | Logs |
|---|---|---|
| macOS | LaunchAgent `~/Library/LaunchAgents/ai.abstractframework.gateway.plist` (`RunAtLoad`, restarted if it crashes), loaded with `launchctl bootstrap gui/<uid>` | `~/Library/Logs/AbstractGateway/` |
| Linux | systemd user unit `~/.config/systemd/user/abstractgateway.service` (`Restart=on-failure`), enabled with `systemctl --user enable --now` | `journalctl --user -u abstractgateway.service` |
| Linux without a systemd user manager | XDG autostart entry `~/.config/autostart/abstractgateway.desktop` (starts at graphical login) | `<data dir>/logs/gateway.log` |
| Windows (experimental) | per-user Run entry `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\AbstractGateway` that starts the gateway with `pythonw.exe` (no console window, no admin); an older `AbstractGateway.lnk` Startup shortcut is removed | `<data dir>\logs\gateway.log` |

Details:

- The service runs the absolute path of the `abstractgateway` you installed
  and sets `PATH` itself (including `~/.local/bin`, `~/.lmstudio/bin`,
  `/opt/homebrew/bin`, `/usr/local/bin`), because service managers do not read
  your shell profile.
- The login item runs plain `abstractgateway serve`: the host and port come
  from the [Network setting](docs/configuration.md#network-exposure-localhost--local-network--internet)
  (`abstractgateway network set localhost|lan|internet [--port N]`, the tray's
  Network menu, the console) at every start. `install`/`enable` store it
  first: a stored mode and port are kept; otherwise the mode is `localhost`
  (127.0.0.1, as before) and the port is `--port` when given, else the running
  gateway's (`enable`), else a previous install's, else the first free port
  from 8080 upwards. `--host 127.0.0.1|0.0.0.0` and `--port` are written into
  that setting and printed. `--pin-command-line` puts `--host/--port` on the
  command line instead (for technical setups; the Network setting then does
  not apply). The registration is recorded in `<data dir>/service.json`.
- After starting, `install` waits up to 60 seconds for `/api/health`
  (`--wait-s`, `--no-wait`) and, on a first run, prints a sign-in link
  (`--no-claim` to skip).
- `--dry-run` prints the files and commands without changing anything.
  `--no-start` registers the service without starting it now.
- On Linux, a user unit runs while you are logged in. To keep it running after
  logout or start it at boot, run `loginctl enable-linger "$USER"` once.

## Where the data lives

When `ABSTRACTGATEWAY_DATA_DIR` is not set, the gateway uses, in order:

1. `./runtime`, if it already exists in the working directory (repository
   checkouts and the AbstractFramework workspace scripts);
2. the per-user data folder for your OS:
   - macOS: `~/Library/Application Support/AbstractGateway`
   - Linux: `$XDG_DATA_HOME/abstractgateway` (default `~/.local/share/abstractgateway`)
   - Windows: `%LOCALAPPDATA%\AbstractGateway`

`abstractgateway-config status` prints the folder and the reason it was
chosen. `serve --data-dir <dir>` or `ABSTRACTGATEWAY_DATA_DIR` choose it
explicitly.

## Exposing the gateway beyond this machine

The automatic setup applies to loopback binds only. `serve --host 0.0.0.0` (or
any non-loopback address) still refuses to start until you configure auth
explicitly (`ABSTRACTGATEWAY_USER_AUTH=1` or `ABSTRACTGATEWAY_AUTH_TOKEN`); see
[security.md](docs/security.md). When any auth setting is present, `serve` keeps
its `0.0.0.0` default bind.

## Checking the setup from scripts

`abstractgateway-config status --json` includes these keys (schema
`gateway_config_status_v1`):

| Key | Meaning |
|---|---|
| `data_dir`, `data_dir_source`, `data_dir_reason` | The data folder, and `env`, `legacy_cwd_runtime` or `os_default` |
| `auth_mode`, `auth` | `users`, `token`, `users+token`, `open` or `loopback_auto` (nothing configured: `serve` on loopback enables user auth) |
| `service` | The login service: `installed`, `mechanism`, `unit_path`, `port`, `url` |
| `claim_pending`, `claims` | Whether an unused, unexpired sign-in link exists |
| `first_run` | Whether the first-run guide was completed |
| `serve` | The running gateway for this data folder (`url`, `port`, `pid`, `alive`, its `auth`), or `null` |

`GET /api/gateway/host/state` carries the same facts in its `gateway` block.

## Related docs

- [getting-started.md](docs/getting-started.md): runs, bundles, stores
- [configuration.md](docs/configuration.md): every environment variable and CLI flag
- [console.md](docs/console.md): the web console and the terminal console
- [security.md](docs/security.md): auth, origins, limits
- [tray.md](docs/tray.md): the desktop tray icon

---

## docs/api.md

# AbstractGateway — API overview

The HTTP API is implemented with FastAPI under the `/api` prefix:
- Health: `GET /api/health`
- Gateway surface: `/api/gateway/*` (durable runs + operator tooling)

The API is documented at runtime:
- OpenAPI JSON: `GET /openapi.json`
- Swagger UI: `GET /docs` (use **Authorize** to paste the bearer token)

Context:
- In the AbstractFramework ecosystem, UIs and automations call this API to operate **AbstractRuntime** runs.
- Architecture diagram and core concepts: [architecture.md](docs/architecture.md)

## Auth

By default, `/api/gateway/*` is protected by `GatewaySecurityMiddleware` (bearer token + origin allowlist).
See: [security.md](docs/security.md).

All examples below assume:

```bash
export BASE_URL="http://127.0.0.1:8080"
export AUTH="Authorization: Bearer $(cat "$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token")"
```

## Provider connections

Gateway-owned provider connections let users create reusable cloud, local, or
OpenAI-compatible endpoints without putting raw API keys in workflow JSON or
browser storage. The API route keeps the historical
`provider-endpoint-profiles` name; the Console presents them as provider
connections.

- `GET /api/gateway/config/provider-endpoint-profiles`: list visible profiles.
- `POST /api/gateway/config/provider-endpoint-profiles`: create a user- or
  admin-owned profile.
- `POST /api/gateway/config/provider-endpoint-profiles/discover-models`:
  discover models for a draft or saved profile by calling the configured
  provider family and base URL with the entered or server-side key. The raw key
  is never returned.
- `PUT` or `DELETE /api/gateway/config/provider-endpoint-profiles/{profile_id}`:
  update or delete a profile.

Enabled profiles appear in `GET /api/gateway/discovery/providers` as virtual
providers such as `endpoint:office-vllm`. Model discovery through
`GET /api/gateway/discovery/providers/{provider_name}/models` returns either the
fixed profile allowlist or the live endpoint model catalog.

## Core workflow lifecycle

### 1) List bundles (bundle mode)

```bash
curl -sS -H "$AUTH" "$BASE_URL/api/gateway/bundles"
```

Upload a bundle:

```bash
curl -sS -H "$AUTH" \
  -F "file=@./my-bundle@0.1.0.flow" \
  -F "overwrite=false" \
  -F "reload=true" \
  "$BASE_URL/api/gateway/bundles/upload"
```

### 2) Start a run

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"bundle_id":"my-bundle","input_data":{"prompt":"Hello"}}' \
  "$BASE_URL/api/gateway/runs/start"
```

If you need a specific entrypoint:

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"bundle_id":"my-bundle","flow_id":"ac-echo","input_data":{"prompt":"Hello"}}' \
  "$BASE_URL/api/gateway/runs/start"
```

Evidence: request/response models live in `src/abstractgateway/routes/gateway.py` (`StartRunRequest`, `start_run`).

For VisualFlow bundles, Gateway runs the packed JSON through AbstractRuntime.
Structured LLM/Agent schemas are Runtime/Core-owned: `response` remains textual,
and schema-conformant object values are available through the node `data` output
for data edges such as Break Object and Switch.

#### Durable session replay (`use_session_history`)

Thin clients do not need to carry conversation transcripts. Passing
`"input_data": {"use_session_history": true}` together with a `session_id`
makes the gateway seed the run's `context.messages` from the session's prior
COMPLETED root runs before the run starts — the run store is the durable
transcript (agora `durable-sessions` contract v1, 2026-07-16).

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"bundle_id":"my-bundle","session_id":"sess-1","input_data":{"prompt":"and what did I say before?","use_session_history":true}}' \
  "$BASE_URL/api/gateway/runs/start"
```

Rules (the model-vs-display divergence contract — what the model replays is
deliberately narrower than what history views display):

- Client-provided non-empty `context.messages` always win; the seed never
  overwrites them. An EMPTY client `context.messages` list does not count as
  a transcript — the seed still runs (use the cap below to disable).
- Only COMPLETED root runs of the session contribute, as strictly alternating
  user/assistant pairs. FAILED and CANCELLED turns are invisible to replay by
  design (a promptless answer or answerless prompt would seed a dangling
  message and invite re-answering a stale ask); history views still show them.
- Steering/operator guidance injected mid-run is not replayed; over-long
  messages are truncated with a labeled `#TRUNCATION` marker; whole oldest
  turns are dropped first (`session_history_max_chars` cumulative budget).
- Caps: `input_data.session_history_max_messages` (1..200; explicit `0`
  disables replay for the run) > `ABSTRACTGATEWAY_SESSION_HISTORY_MAX_MESSAGES`
  > default 40. Chars: `session_history_max_chars` >
  `ABSTRACTGATEWAY_SESSION_HISTORY_MAX_CHARS` > default 24000.
- Failures degrade to a labeled `_runtime.session_history` `#FALLBACK` note
  and an unseeded start — never a blocked run. Success records
  `_runtime.session_history = {seeded: N, ...}` on the run for observability.
- Entity lanes never ride this: their transcript authority is the entity home
  (`_visit.history` / the chat driver), not the run store.

Evidence: `_seed_session_history` in `src/abstractgateway/hosts/bundle_host.py`;
read half `abstractruntime.session_history.session_chat_messages` (>=0.4.30);
tests `tests/test_gateway_session_history_seed.py`.

### 2b) Schedule a run (bundle mode)

`POST /api/gateway/runs/schedule` starts a **scheduled parent run** that launches the target workflow as child runs over time.

Example (run 3 times, every hour, starting now):

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"bundle_id":"my-bundle","flow_id":"ac-echo","input_data":{"prompt":"Ping"},"start_at":"now","interval":"1h","repeat_count":3,"share_context":true,"session_id":"sess-1"}' \
  "$BASE_URL/api/gateway/runs/schedule"
```

Notes:
- `start_at`: ISO 8601 timestamp (recommended) or `"now"`.
- `interval`: e.g. `"15m"`, `"1h"`, `"2d"`. If omitted, runs once.
- `repeat_count`: if omitted and `interval` is set, repeats forever. Alternatively use `repeat_until` (ISO 8601).
- To stop a schedule, cancel the scheduled parent run via `POST /api/gateway/commands` with type `cancel`.

Evidence: `ScheduleRunRequest`, `start_scheduled_run` in `src/abstractgateway/routes/gateway.py`.

### 2c) Shared workflow catalog

Private `/api/gateway/bundles` routes are scoped to the signed-in user's routed
runtime, and you may change the registry you own. The gateway's own bundle
directory is shared by every user, so writing it — upload, delete, reload,
deprecate, and `POST /visualflows/{flow_id}/publish` — requires an admin
principal and otherwise returns `403`. Listing and running are unaffected. See
[security.md](docs/security.md) for the full rule.

Shared/default workflows use the Gateway workflow catalog instead:

```bash
curl -sS -H "$AUTH" "$BASE_URL/api/gateway/workflow-catalog"
```

Admin-only catalog operations live under
`/api/gateway/admin/workflow-catalog/*`:

- upload or promote immutable `.flow` versions;
- move a bundle's default pointer;
- set ACLs;
- deprecate, block, or tombstone a version without deleting bundle bytes.

Start a catalog workflow in the requesting user's runtime by setting
`registry_scope`:

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"registry_scope":"tenant_catalog","bundle_id":"basic-agent","flow_id":"root","input_data":{"prompt":"Hello"}}' \
  "$BASE_URL/api/gateway/runs/start"
```

If `bundle_version` is omitted, Gateway uses the admin-managed catalog default
pointer. Exact older versions keep working until that specific version is
deprecated, blocked, or tombstoned.

Catalog scope is explicit: omitting `registry_scope` starts only private
runtime bundles. Flow/schema inspection for catalog workflows should use the
ACL-aware catalog endpoints:

- `GET /api/gateway/workflow-catalog/{bundle_id}/versions/{bundle_version}/flows/{flow_id}`
- `GET /api/gateway/workflow-catalog/{bundle_id}/versions/{bundle_version}/flows/{flow_id}/input_schema`

`framework_catalog` is reserved but not loadable yet; use `tenant_catalog`.

### 2d) Docs Q&A (`docs-qa` catalog bundle)

`docs-qa` is the shared transport for docs-grounded assistant panels (the
unified top-bar drawers). The contract: the CALLER supplies its own corpus
(typically its `llms.txt` text) — the bundle never guesses one, so answers are
never silently grounded on another app's docs.

Fresh installs need no manual publish: the gateway ships `docs-qa` in the
wheel and boot idempotently publishes it into the tenant catalog
(publish-if-absent by exact version; an admin's default pointer, tombstones,
and publisher attribution are never touched; publisher `system:gateway-boot`).
The publish is skipped on custom-bundle deployments whose private registry
carries no LLM-bearing flow (it would add a boot requirement they never had)
and can be disabled with `ABSTRACTGATEWAY_AUTO_PUBLISH_SHIPPED=0` — the
manual upload below then remains the path.

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" -d '{
  "registry_scope": "tenant_catalog",
  "bundle_id": "docs-qa",
  "bundle_version": "0.1.0",
  "flow_id": "docsqa001",
  "input_data": {
    "question": "How do I publish a workflow bundle?",
    "history": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}],
    "docs": "<your llms.txt text>",
    "app": "MyApp"
  }
}' "$BASE_URL/api/gateway/runs/start"
```

Then poll `GET /runs/{run_id}` (or stream the ledger); the answer is
`output.response`. `provider`/`model`/`temperature` may ride `input_data` to
override gateway defaults. Answers cite section headings and say plainly when
the docs do not answer — the bundle refuses to invent endpoints or behavior.
Docs Q&A must never route through entity chat (a visit is billable and forms
memories).

### Run-level skills selection

`input_data.skills` (a list of skill NAMES) attaches curated skills to any
run started through `/runs/start`:

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" -d '{
  "bundle_id": "basic-agent",
  "input_data": {"prompt": "…", "skills": ["agora-collaboration"]}
}' "$BASE_URL/api/gateway/runs/start"
```

Trust semantics (the same abstractskill gate as `GET /skills` and the
workforce spawn lane — one gate, never a second resolver): VALIDATED skills
activate and their index lands in the run's `_runtime.skills_block`
(byte-stable for the whole run) with the `read_skill` tool made reachable;
UNVERIFIED skills are held; advisory-BLOCKED skills never ride. Every
outcome is recorded as a labeled verdict in `_runtime.skills_resolution`
(`requested`/`active`/`verdicts`/`resolved_tree_hashes`) — nothing is
silently dropped. Agent-node subruns inherit the block verbatim with
`read_skill` appended to explicit child allowlists (empty allowlists keep
registry defaults). A caller-supplied `_runtime.skills_block` is never
overwritten; the selection is then ignored with a labeled verdict.

The gateway serves its OWN corpus for the console drawer:

```bash
curl -sS -H "$AUTH" "$BASE_URL/api/gateway/docs/corpus"
```

Returns `{app, source, chars, text}`. Resolution order: the
`ABSTRACTGATEWAY_DOCS_CORPUS` env override first (set-but-missing is an honest
404 naming the checked candidates, never a silent fallback), then the repo
`llms.txt` in dev checkouts, then the corpus packaged with the wheel.

### 3) Replay the ledger (cursor-based)

Ledger pages are replayed using `after` as “number of items already consumed”.

```bash
curl -sS -H "$AUTH" "$BASE_URL/api/gateway/runs/<run_id>/ledger?after=0&limit=200"
```

Response shape:
- `items`: list of durable ledger records
- `next_after`: the next cursor to use

Evidence: `src/abstractgateway/routes/gateway.py` (`get_ledger`).

### 3b) Replay ledgers for multiple runs (batch)

Use `POST /api/gateway/runs/ledger/batch` to reduce request fanout when observing many runs/subflows.

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"limit":200,"runs":[{"run_id":"<run_id_1>","after":0},{"run_id":"<run_id_2>","after":0}]}' \
  "$BASE_URL/api/gateway/runs/ledger/batch"
```

Evidence: `src/abstractgateway/routes/gateway.py` (`get_ledger_batch`).

### 4) Stream ledger updates (SSE)

SSE is an optimization; clients should always be able to reconnect by replaying from the last `next_after`.

```bash
curl -N -H "$AUTH" "$BASE_URL/api/gateway/runs/<run_id>/ledger/stream?after=0"
```

Evidence: `src/abstractgateway/routes/gateway.py` (`stream_ledger`).

## Artifacts and filesystem handoff

Gateway artifacts are the cross-package representation for files, media, and
large payloads. Thin clients should pass artifact refs across runs instead of
raw bytes or local paths:

```json
{
  "$artifact": "abc123",
  "artifact_id": "abc123",
  "run_id": "session_memory_sess-1",
  "content_type": "image/png",
  "filename": "input.png"
}
```

Gateway uses three distinct file-like source terms:

- `Artifact`: a durable runtime-owned payload reference.
- `Local File`: a browser/client upload source. Hosted clients should upload
  bytes; browser-local paths are never interpreted as server paths.
- `Server File` / `Server Folder`: user-facing wording for a workspace-scoped
  server path under Gateway policy. The engineering contract is the canonical
  `WorkspacePath` string returned by `/files/*`, artifact import/export, and
  Runtime file nodes.

Hosted local uploads stay artifact-backed:

- one local file upload creates one artifact ref;
- multiple local files create an ordered list of artifact refs in Flow;
- a local folder uploads one artifact per file and may send `source_path`
  (for example `reports/2026/summary.md`) so relative member paths survive in
  artifact provenance without exposing browser-local absolute paths.

Upload a local file or folder member:

```bash
curl -sS -H "$AUTH" \
  -F "session_id=sess-1" \
  -F "source_path=reports/summary.md" \
  -F "file=@./summary.md" \
  "$BASE_URL/api/gateway/attachments/upload"
```

List run artifacts:

```bash
curl -sS -H "$AUTH" "$BASE_URL/api/gateway/runs/<run_id>/artifacts"
```

List artifacts visible to a session:

```bash
curl -sS -H "$AUTH" "$BASE_URL/api/gateway/sessions/sess-1/artifacts"
```

Browse server workspace files/folders:

```bash
curl -sS -H "$AUTH" \
  "$BASE_URL/api/gateway/files/list?path=&include_directories=true&limit=200"
```

Optional filters:
- `path`: browse a specific workspace folder or mount alias.
- `recursive=true`
- `family=image|video|audio|document|text|code|json|archive|other`
- `extensions=png,jpg` or newline-separated values
- `query=substring`
- `max_depth=<n>`

Search artifacts across Gateway storage:

```bash
curl -sS -H "$AUTH" \
  "$BASE_URL/api/gateway/artifacts/search?scope=all&artifact_kind=image&query=logo&tags=pin_id=image&include_stats=true&limit=500"
```

`scope` can be `all`, `session`, or `run`. Use `session_id` with
`scope=session` and `run_id` with `scope=run`; omit both for `scope=all`.
Search responses preserve the legacy row fields and also include
`artifact_envelope_v1`, a normalized projection of Runtime-owned descriptors,
access stats, and Gateway action links.

Useful query parameters:
- `artifact_kind`: UI-oriented kind filter. Comma-separated values match
  `semantic_kind`, `render_kind`, or `modality`; generic `audio` means
  unclassified audio and does not match canonical `voice`, `music`, or `sound`.
  Single canonical kinds such as `music`, `voice`, `image`, `markdown`, or
  `json` map to Runtime catalog filters. Multi-kind unions are supported, but
  may be Gateway post-filters until Runtime exposes OR filters.
- `semantic_kind` / `render_kind`: canonical descriptor filters when the caller
  wants the two dimensions separately.
- `modality`, `content_type`, `workflow_id`, `node_id`, `created_after`,
  `created_before`, and `tags`: server filters for indexed descriptor fields.
- `query`: case-insensitive metadata search. Gateway may post-filter this field
  when Runtime cannot index it directly.
- `include_stats=true`: include exact `stats.total`, byte totals, and facet
  counts for the selected server-side filter set, independent of `limit`.
- `limit`, `offset`, and `cursor`: bounded paging. The default Runtime Explorer
  page size is 500; `limit<=0` is bounded unless `debug_unlimited=true` is used
  by an admin/debug caller.

`artifact_envelope_v1` contains normalized fields such as `semantic_kind`,
`render_kind`, `workflow_id`, `node_id`, `turn_id`, `ledger_cursor`,
`generation`, `producer`, `media`, `source_refs`, `access`, and `links`.
Sparse producer metadata is represented as missing fields; Gateway does not
invent provider/model provenance from filenames.

Generated-media artifacts created by child runs and projected into the parent
run preserve Runtime descriptors and structured metadata. Direct transcription
routes store transcript artifacts with source-audio refs, language/prompt hints,
provider/model when available, and bounded route parameters.
Descriptor-provided action links are sanitized to relative Gateway/UI links
before they appear in envelopes; raw external provider URLs should be represented
as trace availability or Gateway-owned trace records.

Content reads can label the access type for Runtime access stats:

```bash
curl -sS -H "$AUTH" \
  "$BASE_URL/api/gateway/runs/<run_id>/artifacts/<artifact_id>/content?access_action=preview"
```

Supported access actions are `content`, `preview`, and `download`. The shorter
`access=preview` alias is also accepted.

Import a server workspace path into a session artifact:

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"session_id":"sess-1","source":{"kind":"workspace_path","path":"inputs/photo.png"},"pin_id":"image"}' \
  "$BASE_URL/api/gateway/artifacts/import"
```

Export an artifact back into the server workspace:

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"path":"outputs/photo.png","create_parent_dirs":true,"overwrite":false}' \
  "$BASE_URL/api/gateway/runs/<run_id>/artifacts/<artifact_id>/export"
```

Import and export use the same Gateway workspace policy as file helpers:
workspace roots, mounted roots, ignored paths, and size limits are enforced on
the server. Browser-local files should be uploaded through
`POST /api/gateway/attachments/upload`; browser-local file paths are not
interpreted as Gateway workspace paths. In hosted user-auth mode, server
workspace import/export and `/files/*` helpers require an admin principal.
Ordinary users can still upload browser-local files and list/search artifacts in
their own routed runtime.

Canonical Gateway server paths use `rel/path` for the main workspace root and
`mount_alias/rel/path` for approved mounts. When two allowed mounts share the
same basename, Gateway emits deterministic digest-suffixed aliases so the same
public path string can round-trip through `/files/*`, artifact import/export,
and Runtime file nodes.

## Durable commands (`POST /api/gateway/commands`)

Commands are appended to a durable inbox and applied asynchronously by the runner.

Request fields (see `SubmitCommandRequest` in `src/abstractgateway/routes/gateway.py`):
- `command_id`: client-supplied idempotency key (UUID recommended)
- `run_id`: target run id (or session id for some event use-cases)
- `type`: `pause|resume|cancel|emit_event|update_schedule|compact_memory`
- `payload`: command-specific object

### Pause / cancel

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"command_id":"'"$(python -c 'import uuid; print(uuid.uuid4())')"'", "run_id":"<run_id>", "type":"pause", "payload":{"reason":"operator_pause"}}' \
  "$BASE_URL/api/gateway/commands"
```

### Resume a paused run

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"command_id":"'"$(python -c 'import uuid; print(uuid.uuid4())')"'", "run_id":"<run_id>", "type":"resume", "payload":{}}' \
  "$BASE_URL/api/gateway/commands"
```

### Resume a WAITING run with a payload (WAIT resume)

When `payload.payload` is present, the runner interprets this as “resume a WAITING run with a durable payload”:

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"command_id":"'"$(python -c 'import uuid; print(uuid.uuid4())')"'", "run_id":"<run_id>", "type":"resume", "payload":{"wait_key":"<optional_wait_key>", "payload":{"approved":true}}}' \
  "$BASE_URL/api/gateway/commands"
```

Evidence: `src/abstractgateway/runner.py` (`_apply_command`, `_apply_run_control`).

### Emit an external event

Minimal form:

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"command_id":"'"$(python -c 'import uuid; print(uuid.uuid4())')"'", "run_id":"<session_id>", "type":"emit_event", "payload":{"name":"chat.message","payload":{"text":"hi"}}}' \
  "$BASE_URL/api/gateway/commands"
```

Evidence: `src/abstractgateway/runner.py` (`_apply_emit_event`).

## Beyond the core

`/api/gateway/*` also includes optional operator/tooling endpoints (reports inbox, triage queue, backlog browsing + exec runner, process manager, file/attachment helpers, embeddings, voice, discovery, …).
See: [maintenance.md](docs/maintenance.md).

## Discovery endpoints (optional)

These exist to help thin clients adapt to the deployed gateway.

- Capabilities (best-effort): `GET /api/gateway/discovery/capabilities`
- Providers/models discovery (best-effort): `GET /api/gateway/discovery/providers`, `GET /api/gateway/discovery/providers/{provider}/models`
- Tools (thin-client allowlist help): `GET /api/gateway/discovery/tools`
- Skills inventory: `GET /api/gateway/skills` — the abstractskill shelf with
  trust verdicts (roster rows `{name, description, trust_level, blocked,
  requires_review, tree_hash, source, has_scripts, reasons}`); degradations
  are labeled `warnings`, never a fabricated list. Shelf resolution:
  `ABSTRACTGATEWAY_SKILLS_SHELF`, else the triage repo's
  `abstractskill/registry`.
- MCP server inventory: `GET /api/gateway/mcp/servers` — the declared
  registry at `<data_dir>/config/mcp_servers.json`
  (`{"version": 1, "servers": [{"name", "url"?, "description"?,
  "auth_required"?, "tags"?}]}`), served with declared fields only and
  `probed: false` (connect state/tool counts require a probe lane and are
  never faked).
- Dynamic capability catalogs: `GET /api/gateway/voice/voices`, `GET /api/gateway/audio/speech/models`, `GET /api/gateway/audio/transcriptions/models`, `GET /api/gateway/audio/music/providers`, `GET /api/gateway/audio/music/models`, `GET /api/gateway/vision/provider_models`

The capabilities payload includes package presence (`abstractruntime`,
`abstractcore`, `abstractmemory`, `abstractvoice`, `abstractvision`), existing
gateway helpers (`tools`, `visualflow`, `media`), memory-store readiness, and
AbstractCore capability plugin status for `voice`, `audio`, `vision`, and
`music`.

Today the route paths and contract descriptors are the stable part of this
surface. Catalog routes now also include a stable Gateway-owned envelope:

- `catalog.contract = gateway_catalog_v1`
- `catalog.version = 1`
- `items = [...]`

Legacy lower-layer fields are still preserved for compatibility. New thin
clients should read `catalog` plus `items`; older clients can keep using route-
specific fields like `models`, `provider_models`, `profiles`, or `voices`.

Provider discovery also reports the resolved default provider/model when one is
configured. The resolver follows request values, flow pins, and the execution-host
`input.text` capability route; if no pair exists, the response includes
`default_error` rather than a hardcoded local model.

It also includes a versioned thin-client contract:

- `capabilities.contracts.version`: currently `1`
- `capabilities.contracts.common`: shared run start/list/summary/input/history,
  ledger, artifact, attachment, workspace, discovery, provider prompt-cache
  controls, and the host-visibility descriptors `model_residency` (including
  `row_schema = "model_residency_row_v1"` and the canonical `modality_ui`
  color map), `host_state`, and `session_caches`
  (see [Host state and model residency](#host-state-and-model-residency)).
  `common.artifacts` includes run listing/content, session artifact
  listing, artifact search with `artifact_envelope_v1`, exact stats/facets,
  `artifact_kind` UI filtering, workspace import, and workspace export
  descriptors when available. Permission-sensitive descriptors are principal-aware:
  ordinary users see admin-only workspace import/export and provider
  prompt-cache controls marked unavailable with `admin_required` metadata.
- `capabilities.contracts.common.readiness`: compact Gateway-owned
  `gateway_surface_readiness_v1` summary derived from the shared endpoint/media/
  residency descriptors
- `capabilities.contracts.flow_editor`: the AbstractFlow editor/runtime surface
- `capabilities.contracts.assistant`: assistant-facing voice/audio/media/cache
  feature gates
- `capabilities.contracts.abstractcode`: code-client run/history/workspace/cache
  feature gates

Contract booleans are intentionally conservative. Package `installed=true` is
not the same thing as endpoint `available=true`; clients should branch on the
versioned contract fields when enabling controls.

`common.readiness` is intentionally narrower than provider/backend health. It
summarizes Gateway surface availability from existing descriptors, but it does
not invent selected backend/provider/model truth or stable degraded-state
reason codes.

Evidence: `src/abstractgateway/routes/gateway.py` (`discovery_capabilities`, `discovery_providers`).

## AbstractFlow gateway-first editor contract

The browser editor can use AbstractGateway as its runtime and storage host.

Draft VisualFlow records:

- `GET /api/gateway/visualflows`
- `POST /api/gateway/visualflows`
- `GET /api/gateway/visualflows/{flow_id}`
- `PUT /api/gateway/visualflows/{flow_id}`
- `DELETE /api/gateway/visualflows/{flow_id}`
- `POST /api/gateway/visualflows/{flow_id}/publish`

Bundle inspection and editor run-schema helpers:

- `GET /api/gateway/bundles`
- `GET /api/gateway/bundles/{bundle_id}`
- `GET /api/gateway/bundles/{bundle_id}/flows/{flow_id}`
- `GET /api/gateway/bundles/{bundle_id}/flows/{flow_id}/input_schema`

The input-schema endpoint returns a versioned payload with:

- `version`
- `bundle_id`, `bundle_version`, `bundle_ref`, `flow_id`, `workflow_id`
- `inputs`: entrypoint input pins derived from the `on_flow_start` node
- `defaults`: pin defaults from VisualFlow JSON
- `input_data_schema`: a small JSON Schema object for the Run Flow modal

Example:

```bash
curl -sS -H "$AUTH" \
  "$BASE_URL/api/gateway/bundles/my-bundle/flows/ac-echo/input_schema"
```

### Native-loop bundles (react / codeact / memact)

Some shipped bundles declare `metadata.native_loop_factory` instead of VisualFlow
JSON (`manifest.flows` is empty). The gateway materializes an abstractagent
loop at load time. Discovery uses the same bundle list endpoint — **not**
`/discovery/workflows`.

Thin clients should:

1. `GET /api/gateway/bundles` (authenticated).
2. Filter entrypoints whose `interfaces` includes `abstractcode.agent.v1`.
3. Read `metadata.native_loop_factory` (`react`, `codeact`, or `memact`) to
   distinguish native loops from VisualFlow agent bundles.
4. Use each entrypoint's `workflow_id` (for example `react-agent@0.1.0:react`).
5. Start runs with `POST /api/gateway/runs/start` and
   `bundle_id` / `flow_id` from the bundle listing (for example
   `react-agent` + `react`).

Native-loop entrypoints do not ship VisualFlow JSON. The gateway serves a
versioned input-schema stub (`prompt` required; `provider` and `model`
optional) from
`GET /api/gateway/bundles/{bundle_id}/flows/{flow_id}/input_schema`.
Headless clients may also pass those fields without fetching the schema.

The shipped `react-agent@0.1.0` bundle is built by
`scripts/build_react_agent_bundle.py` and force-included in the wheel. A running
gateway process must restart (or call bundle reload) after the file lands on
disk before `/bundles` lists it.

### Run history bundle (`GET /runs/{run_id}/history_bundle`)

Thin clients should prefer this endpoint over stitching ledger, session, and
artifact endpoints. The export is owned by AbstractRuntime; the gateway forwards
query parameters and returns the bundle JSON unchanged (including in-band
degradations).

Query parameters:

| Parameter | Default | Notes |
|-----------|---------|-------|
| `include_subruns` | `true` | Descendant runs in the bundle tree |
| `include_session` | `false` | Root session turn list |
| `session_turn_limit` | `200` | Cap when `include_session=true` |
| `ledger_mode` | `tail` | `tail` or `full` |
| `ledger_max_items` | `2000` | Per-run ledger cap when `ledger_mode=tail` |
| `detail` | `full` | `full` (complete payloads) or `replay` (transcript-fold projection) |

**`detail=replay`** drops request-side payloads and observability paths the
transcript fold never reads; runtime marks each omission with `$omitted` inside
ledger records. Use it for session replay and thin-client folds — it is much
smaller than `full` (gzip helps further; send `Accept-Encoding: gzip`).

**`warnings`** (always present, may be empty): typed degradations the export
survived instead of failing silently. Each entry is an object with at least
`code` and `detail`; many include `run_id`. Known codes today:

| Code | Meaning |
|------|---------|
| `subtree_discovery_failed` | Could not list child runs; bundle covers root only |
| `subtree_truncated` | Descendant discovery hit the run cap |
| `ledger_read_failed` | Ledger for a run id could not be read |
| `torn_rows_skipped` | Corrupt/unparseable ledger lines skipped |
| `ledger_tail_window` | Ledger truncated to `ledger_max_items` (tail mode) |
| `input_data_offload_failed` | Input-data artifact reference could not be resolved |

Clients must surface non-empty `warnings` to the operator — a bundle that
"looks complete" but carries warnings may be missing subruns, ledger tail, or
offloaded input data.

### Session history bloc (`GET /sessions/{session_id}/history/bloc`)

Returns one cursor-bounded bloc of **root session turns**, each with an inline
`history_bundle` export — one round-trip instead of N per-turn bundle fetches
(laurent c5551). Resume pagination uses an ISO `created_at` cursor in the
`before` query parameter (never turn-count offsets).

Query parameters:

| Parameter | Default | Notes |
|-----------|---------|-------|
| `before` | *(omit)* | ISO-8601 cursor; only turns strictly **before** this timestamp |
| `limit` | `5` | Max turns in this bloc (1–50) |
| `detail` | `replay` | Forwarded to each turn's bundle export (`full` \| `replay`) |
| `include_subruns` | `true` | Per-turn bundle tree |
| `ledger_mode` | `tail` | `tail` or `full` |
| `ledger_max_items` | `2000` | Per-turn ledger cap when `ledger_mode=tail` |
| `include_drafts` | `false` | Include draft-test root runs |

Response fields: `session_id`, `cursor_before` (echo of `before`), `cursor_after`
(oldest turn returned — pass as the next `before`), `older_remaining`, `warnings`,
and `turns[]` (`run_id`, `created_at`, `status`, `bundle` or `error`).

The editor observes runs with the core lifecycle endpoints above:
`/runs/start`, `/runs/{run_id}`, `/runs/{run_id}/ledger`,
`/runs/{run_id}/ledger/stream`, `/runs/ledger/batch`,
`/runs/{run_id}/input_data`, `/runs/{run_id}/history_bundle`, and
`/runs/{run_id}/artifacts`.

## Optional multimodal scope

Current direct Gateway endpoints:
- `POST /api/gateway/runs/{run_id}/voice/tts`
- `POST /api/gateway/runs/{run_id}/voice/tts/stream`
- `POST /api/gateway/runs/{run_id}/audio/transcribe`
- `POST /api/gateway/runs/{run_id}/images/generate`
- `POST /api/gateway/runs/{run_id}/images/edit`
- `POST /api/gateway/runs/{run_id}/images/upscale`
- `POST /api/gateway/runs/{run_id}/videos/generate`
- `POST /api/gateway/runs/{run_id}/videos/from_image`
- `POST /api/gateway/runs/{run_id}/music/generate`
- `GET /api/gateway/voice/voices`
- `GET /api/gateway/audio/speech/models`
- `GET /api/gateway/audio/transcriptions/models`
- `GET /api/gateway/audio/music/providers`
- `GET /api/gateway/audio/music/models`
- `GET /api/gateway/vision/provider_models`
- `GET /api/gateway/vision/adapters`

`/voice/tts` returns a durable audio artifact after synthesis. `/voice/tts/stream`
returns JSON Lines stream events for progressive playback when discovery advertises
`capabilities.contracts.assistant.voice.tts.streaming=true`; successful streams still
finish with a Runtime-owned child-run audio artifact.

The catalog endpoints proxy AbstractCore Server routes when
`ABSTRACTCORE_SERVER_BASE_URL`
is configured. Gateway uses explicit Core auth settings for that hop and never
reuses the Gateway bearer token as a Core/provider secret. Without a configured
Core server, the voice/model routes return bounded static descriptors from
Gateway and capability-package environment variables.

Each route now adds:

- `catalog`: Gateway-owned route metadata (`contract`, `version`, `kind`,
  `scope`, `route_source`, optional `upstream_source`, and route filters)
- `items`: one canonical primary array for thin clients

Examples:

- `/voice/voices`: `items` contain voice/profile records with `id`, `label`,
  optional `provider`, optional `model`, and `voice_kind`
- `/audio/*/models`: `items` contain model records with `id`, `label`,
  optional `provider`, optional `tasks`, and optional `parameters`
- `/audio/music/providers` and `/discovery/providers`: `items` contain provider
  records with `id`, `label`, and `provider`

Generated images are available through Runtime workflows when a compatible
image backend is installed and configured. Gateway also exposes a direct image
generation endpoint that uses the Runtime/Core output-selector contract rather
than a provider-specific image client. The route creates a durable child run,
stores the generated image as a run artifact, and returns
`event_name="abstract.progress"` so thin clients can stream the child-run ledger
for progress:

- `run_id`, `request_id`, `prompt`
- optional `provider`, `model`, `size`, `width`, `height`, `format`, batch
  `count` / `n`, `seeds`, and ordered `lora_adapters`
- `image_artifact`: first generated image for compatibility
- `image_artifacts`: full ordered image artifact list for batch generation

`size`, `width`, and `height` are optional passthrough request overrides. Do
not inject a client-side default size. Different image providers/models accept
different size sets; when the client leaves dimensions unset, Runtime/Core lets
the configured backend use its default or `auto` behavior.

If the active workflow runtime already has an AbstractCore LLM client, the route
uses it. For tools-only workflows, the route can create a direct Runtime/Core
client from request `provider`/`model` or the execution-host capability route
default. Unsupported or unconfigured deployments return a structured `ok=false`
response instead of a failed run.

Gateway also exposes a direct image-edit sibling route:

- `POST /api/gateway/runs/{run_id}/images/edit`

The request uses a source `image_artifact`, optional `mask_artifact`, the same
provider/model and image backend selectors as image generation, plus optional
batch `count` / `n`, `seeds`, and ordered `lora_adapters`, and returns an
artifact-backed edited image. Batch responses also return `image_artifacts`.
Thin clients should feature-detect it from
`capabilities.contracts.flow_editor.media.edited_image` or
`capabilities.contracts.assistant.media.edited_image`. It uses the same
child-run `abstract.progress` progress contract as direct image generation.

Gateway also exposes a direct image-upscale sibling route:

- `POST /api/gateway/runs/{run_id}/images/upscale`

The request uses a run-visible source `image_artifact`, optional provider/model
selectors, and optional upscaler controls such as `scale`, `resolution`,
`softness`, `seed`, `quantize`, and `vae_tiling`; `resolution` may be a
shortest-edge integer or a scale factor such as `2x`. Thin clients should
feature-detect it from `capabilities.contracts.flow_editor.media.upscaled_image`
or `capabilities.contracts.assistant.media.upscaled_image`, list models with
`GET /api/gateway/vision/provider_models?task=image_upscale`, and stream the
returned child-run ledger for `abstract.progress` events.

Generated music follows the same direct child-run pattern. Thin clients should
discover it from `capabilities.contracts.flow_editor.media.generated_music` or
`capabilities.contracts.assistant.media.generated_music`, list providers/models
from the music catalog routes, and treat the returned `child_run_id` plus
`music_artifact` as the durable output handle.

Generated video also follows the direct child-run pattern:

- `POST /api/gateway/runs/{run_id}/videos/generate` uses the Runtime/Core
  `output.modality=video` / `task=text_to_video` contract and accepts optional
  batch `count` / `n`, `seeds`, ordered `lora_adapters`, and `flow_shift`.
- `POST /api/gateway/runs/{run_id}/videos/from_image` accepts a run-visible
  source `image_artifact`, accepts the same optional batch/adapter/video
  control fields, and uses `task=image_to_video`.
- Thin clients should discover these routes from
  `capabilities.contracts.flow_editor.media.generated_video` and
  `capabilities.contracts.flow_editor.media.image_to_video` (or the matching
  `assistant.media.*` entries), use
  `GET /api/gateway/vision/provider_models?task=text_to_video|image_to_video`
  for model catalogs, use `GET /api/gateway/vision/adapters` for compatible
  installed adapter catalogs, stream the returned `child_run_id` ledger for
  `abstract.progress` events, and read `video_artifacts` when batch generation
  is requested.

STT and listen contract notes:

- `POST /api/gateway/runs/{run_id}/audio/transcribe` accepts a run-visible
  `audio_artifact` plus optional `language`, `prompt`, `response_format`,
  `temperature`, `format`, `provider`, and `model` hints.
- `capabilities.contracts.flow_editor.voice.stt` and
  `capabilities.contracts.assistant.voice.stt` point to that upload route.
- `capabilities.contracts.flow_editor.voice.listen` and
  `capabilities.contracts.assistant.voice.listen` are host-capture contracts,
  not a live microphone socket. They tell higher apps to capture locally and
  emit an event or upload the resulting audio artifact.

## KG memory

`POST /api/gateway/kg/query` queries the configured AbstractMemory TripleStore.
Gateway resolves the store through:

- `ABSTRACTGATEWAY_MEMORY_STORE_BACKEND=lancedb|memory` (`sqlite` when the installed AbstractMemory build exposes `SQLiteTripleStore`)
- `ABSTRACTGATEWAY_MEMORY_STORE_PATH`
- `ABSTRACTGATEWAY_MEMORY_REQUIRE_VECTOR`

Structured queries work with LanceDB and in-memory stores. SQLite also works
when the installed AbstractMemory build exposes `SQLiteTripleStore`. Semantic
`query_text` requires a vector-capable backend plus the execution-host
`embedding.text` route; SQLite returns a clear 400 instead of pretending to
support semantic recall.

Capability discovery reports KG memory as available when AbstractMemory is
installed and the configured backend can be resolved. A fresh persistent store
does not need to exist yet; empty-store structured queries return an empty
result rather than making Flow authoring nodes unavailable.

## Models and engines

The gateway serves AbstractCore's models and engines payloads unchanged
(AbstractCore 2.14.0 or newer), under `/api/gateway`. The bodies and payloads
are the same as AbstractCore's own `/acore/*` routes; `abstractcore` and
`abstractgateway` render them with the same screens.

| Method and path | Access | Body / query | Returns |
|---|---|---|---|
| `GET /host/profile` | user | `refresh=1` | `host_profile_v1` |
| `GET /engines` | user | `probe=1` | `gateway_engines_v2` rows (AbstractCore's detection plus the install plan and actions) with `install_allowed` and `install_policy`; see [engines.md](docs/engines.md) |
| `GET /engines/{id}` | user | `probe=1` | one engine row plus `install_allowed`; 404 for an unknown id |
| `POST /engines/{id}/install` | admin | `{"dry_run": bool, "force": bool, "location": "auto"\|"user"\|"system"}` | an `engine_install_job_v1` job (user-level first; pauses in `needs_admin` / `needs_tools`), see [engines.md](docs/engines.md) |
| `GET /engines/jobs`, `GET /engines/jobs/{id}` | user | | engine install jobs |
| `POST /engines/jobs/{id}/continue`, `/cancel` | admin | `{"action"?}` | the job |
| `POST /engines/{id}/start`, `/stop` | admin | | Ollama / LM Studio server state |
| `GET /models/catalog` | user | `q`, `engine`, `fits=1`, `hub=1`, `tag` (repeatable) | `model_catalog_v1` |
| `GET /models/installed` | user | `provider` | `models_installed_v1` |
| `POST /models/download` | admin | `{"provider", "artifact", "dry_run", "expected_bytes"?}` or `{"recommended": true}` | `{"ok": true, "job": {...}}`; with `recommended`, `{"ok": true, "recommended": true, "jobs": [...], "group": {...}}` |
| `GET /models/download/{job}` | user | | `{"ok": true, "job": {...}}` (a `grp_...` id returns the parent job) |
| `GET /models/downloads` | user | | `{"ok": true, "jobs": [...]}`, newest first, parents included |
| `POST /models/download/{job}/cancel` | admin | none | `{"ok": true, "job": {...}}`; stops the transfer within about a second; a `grp_...` id cancels every running child; 404 when unknown |
| `GET /models/downloads/stream` | user | `job_id`, `until_idle=1` | Server-Sent Events of the same dicts, see [model-downloads.md](docs/model-downloads.md) |
| `POST /models/delete` | admin | `{"provider", "artifact", "dry_run": bool, "force": bool}` | `host_job_v1` (kind `delete`) |
| `GET /jobs` | user | `kind`, `status` | `{"schema": "host_jobs_v1", "jobs": [...], "generated_at"}`, newest first |
| `GET /jobs/{id}` | user | | `host_job_v1`; 404 when unknown |
| `POST /jobs/{id}/cancel` | admin | none (an empty `{}` is accepted) | `host_job_v1`; 404 when unknown |

Empty query values (`q=`, `engine=`) mean "no filter". `probe`, `fits` and
`hub` accept `1`/`0` and `true`/`false`.

**Catalog artifacts.** `model_catalog_v1` is AbstractCore's payload, served
unchanged (field reference: AbstractCore `docs/models.md`, "The catalog").
Besides `quant` (the artifact's own label, lowercased, or `null`) and `bits`
(effective bits per weight), every artifact carries `quant_class`, one of
`2bit`, `3bit`, `4bit`, `5bit`, `6bit`, `8bit`, `16bit`, `full`, `unknown`,
for filtering by quantization: `q4_k_m`, `4bit`, `mxfp4` and `oq4e` are
`4bit`; `q8_0` and `8bit` are `8bit`; `bf16` and `f16` are `16bit`; `f32` is
`full`. `quant_class_source` is `stated` (the reference names its quant),
`assumed` (a bare Ollama tag such as `qwen3.5:9b` or LM Studio id: the class of
the engine's default build, which the fit estimate assumes too) or `null` (no
quant information; the class is `unknown`). `options` holds the route options a
recommendation copies with the artifact (`{}` for most); `companions` lists
repos downloaded with it (an MLX build's MTP drafter, from AbstractCore's
drafter registry; `[]` for most), `companion_bytes` is their size, and
`download_bytes` already includes it; `note` is one sentence about the build. On Apple silicon the text rows pre-select the
memory tier's MLX build and exactly one text row is the `starter`.

**Jobs.** A `host_job_v1` has `schema`, `job_id`, `kind`
(`download | delete | engine_install`), `status`
(`queued | running | completed | failed | cancelled`), `provider`, `artifact`,
`engine`, `percent`, `downloaded_bytes`, `total_bytes`, `message`, `log_tail`,
`command` (the exact argv), `dry_run`, `started_at`, `finished_at`, `error`
(a string or `null`), `joined`, `result` and `cli_equivalent`, which names the
`abstractgateway` command that does the same thing. A dry run finishes before
the POST returns. On the older `/models/download` lane the job also carries
`job` (the id), `events`, `host_status`, reports `queued` as `running`, and
counts `joined` including the first request.

**Download progress.** A download job also carries `state`
(`queued | resolving | downloading | verifying | installing | done | failed |
cancelled | stalled`), `bytes_done`, `bytes_total`, `size_unknown`,
`size_note`, `bytes_per_second`, `eta_s`, `updated_at`, `files`
(`[{name, bytes_done, bytes_total, state}]`), `current_file`, a one-sentence
`message`, the tool's own `detail`, and `transitions`. "Use recommended
defaults" (`{"recommended": true}`) returns one parent job (`kind:
"download_group"`, id `grp_...`) whose bytes, percent, speed and time left
add up its children. The full contract, one real example per state and what
each source reports: [model-downloads.md](docs/model-downloads.md).

**Refusals** share one body:
`{"ok": false, "status", "reason"?, "message", "detail", "error": {"message", "type"}, ...}`.

| Status | When |
|---|---|
| 400 `invalid` | provider or artifact missing |
| 403 `refused` / `not_allowed` | a real engine install while `allow_engine_install` is off ([configuration.md](docs/configuration.md#allow_engine_install)); `install_policy` says why |
| 403 | the caller is not an admin (every POST above) |
| 404 `not_found` | unknown job id, engine id, or a model that is not installed |
| 409 `busy` | an engine install is already running (`job` is the running one) |
| 409 `refused` | the engine is not supported here or has no install command (`install` is the plan), or a delete is blocked (`delete_blockers`: `loaded`, `shared_cache:…`, `unknown_location`, `engine_not_running`, `remote_engine`; `force: true` overrides the first two) |
| 501 `unsupported` / `abstractcore_too_old` | the installed AbstractCore predates 2.14.0; `required`, `installed` and `missing` name what to upgrade |
| 503 `unavailable` | AbstractCore is not installed |

Example:

```bash
curl -s -H "Authorization: Bearer $TOKEN" "$GW/api/gateway/models/catalog?q=qwen3&fits=1" | jq '.rows[0].artifacts[0].fit'
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"dry_run": true}' "$GW/api/gateway/engines/ollama/install" | jq '.command, .cli_equivalent'
```

## Host state and model residency

Gateway exposes a host-level view of the execution machine — memory, GPU,
resident models, and session prompt caches — so consoles and agents can render
an "agentic OS" panel from one API surface.

Read endpoints (any authenticated principal):

- `GET /api/gateway/host/state` — one-call host snapshot
- `GET /api/gateway/host/metrics/memory` — host memory snapshot
- `GET /api/gateway/host/metrics/gpu` — GPU utilization probe
- `GET /api/gateway/models/loaded` — model residency listing
- `GET /api/gateway/models/context_estimate` — context/KV memory estimate for
  a provider+model
- `GET /api/gateway/sessions/prompt_cache` — session prompt-cache enumeration

Mutation endpoints (admin principal required):

- `POST /api/gateway/models/load` — load (and by default pin) a model runtime
- `POST /api/gateway/models/unload` — unload a model runtime
- `POST /api/gateway/models/lock` — lock a resident model against unload
- `POST /api/gateway/models/unlock` — release a model-residency lock
- `POST /api/gateway/models/download` — fetch model weights onto the host
- `POST /api/gateway/sessions/{session_id}/prompt_cache/clear_all` — clear
  every runtime-minted prompt cache for a session

Reads are visibility every authenticated client needs; mutations spend shared
host resources and stay operator acts. Anonymous requests are rejected on all
of these routes, like every other `/api/gateway/*` path.

### `GET /host/state`

One snapshot with `memory`, `gpu`, `models`, and `session_caches` sections:

```json
{
  "ok": true,
  "ts": 1787857000.0,
  "memory": {"ram": {"...": "..."}, "process": {"rss_bytes": 140443648}, "device": {"backend": "metal", "allocated_bytes": 0, "...": "..."}},
  "gpu": {"supported": true, "source": "ioreg", "gpus": [{"name": "...", "utilization_gpu_pct": 0.0}]},
  "models": [{"runtime_id": "...", "provider": "...", "model": "...", "resident": true, "...": "..."}],
  "session_caches": [],
  "totals": {"models": 2, "models_resident": 1, "model_bytes": 3109915433, "session_caches": 0, "session_cache_bytes": null},
  "degraded": [],
  "row_schema": "model_residency_row_v1"
}
```

- `models` rows use the frozen `model_residency_row_v1` schema described
  below; `session_caches` relays the runtime facade's cache rows verbatim.
- Every section is independently best-effort and the route never returns a
  500. A missing facade method or a failed probe nulls that section and names
  it in `degraded`; a `reasons` map (present only when non-empty) says why.
- The `gpu` section keeps its in-band `{"supported": false, "reason": "..."}`
  payload when the probe answers but reports no support; it still counts as
  degraded.
- `totals.model_bytes` sums the known `size_bytes` values and is `null` when
  no row reports a size; `totals.session_cache_bytes` behaves the same over
  the cache rows' `bytes`.
- `totals.models` counts every known row — configured / cached rows included —
  while `totals.models_resident` (additive) counts only rows with
  `resident: true`. Clients that display "N loaded" must read
  `models_resident`: default ≠ loaded, and presenting configured capability
  defaults as loaded is exactly the lie this field removes.
- When the runtime memory snapshot reports a host identity, the response also
  carries a top-level `host` object (the identity facts of the machine the
  snapshot describes). The block is omitted when the runtime does not report
  one. Together with the per-row `host_id`/`host_name` fields below, this is
  the seam a multi-machine resource pool would aggregate on; one gateway
  binds one runtime host, and the pool design is proposed in
  [backlog 0093](docs/backlog/proposed/0093_multi_machine_model_resource_pool.md).

### `GET /host/metrics/memory`

Returns `{"ok": true, "supported": true, ...}` plus the snapshot sections:
`ram` (total/available/used bytes and percent), `process` (`rss_bytes`), and
`device` (`backend`, `allocated_bytes`, `total_bytes`, `free_bytes`). When the
runtime host facade does not expose a memory snapshot, the route answers 200
with `{"ok": true, "supported": false, "reason": "..."}` — the same degraded
style as `GET /host/metrics/gpu`.

How to compare memory measurements: `process.rss_bytes` and
`device.allocated_bytes` are different axes. In-process device backends (for
example MLX on Metal) return freed buffers to the process heap and the
operating system may retain those pages, so process RSS does not shrink when a
model unloads. Use `device.allocated_bytes` to verify that an unload freed
device memory; use `ram` and `process` for overall host pressure.

### Model residency (`/models/loaded`, `/models/load`, `/models/unload`)

`GET /models/loaded` lists the model runtimes the host knows about, with
optional `task`, `provider`, `model`, and `base_url` query filters. The
response keeps the raw runtime records in `models` and adds a normalized
`rows` array in the frozen `model_residency_row_v1` schema (named by
`row_schema`), so thin clients do not need per-provider alias tables.

Each `model_residency_row_v1` row has exactly these fields (unknown values
are `null`, never guessed):

`runtime_id`, `task`, `provider`, `model`, `source`, `resident`, `state`,
`pinned`, `default`, `size_bytes`, `size_vram_bytes`, `expires_at`,
`context_length`, `loaded_at`, `last_used_at`, `locked`, `lockable`,
`modalities`, `calibrated_context_length`, `context_calibrated`, `host_id`,
`host_name`, `details`

The schema is additive-tolerant and keeps the `model_residency_row_v1` name
as optional fields are added; treat fields beyond the original 16 as
optional. The lock/calibration/host fields mean:

- `locked` / `lockable` — tri-state booleans: whether the model is locked
  against unload, and whether this runtime supports locking it at all.
- `modalities` — list of modality strings when the runtime reports one
  (`null` otherwise, including when the value is not a clean string list).
- `calibrated_context_length` / `context_calibrated` — the measured usable
  context length and whether it came from calibration rather than metadata.
- `host_id` / `host_name` — identity of the machine serving the model,
  stamped by the runtime that reported the row (see the `host` block note
  under `GET /host/state` above).

Residency truth is provider-first: `provider_resident` / `provider_loaded`
booleans in the source record outrank the runtime-lease booleans `resident` /
`loaded`, because a runtime can hold a lease on a model the provider has
already evicted. A loaded-looking `state` string (`provider_loaded`, `loaded`,
`resident`) can confirm residency, but a state string is never proof of
absence — with no boolean present and no loaded-like state, `resident` stays
`null`. `details` preserves the raw record for fields outside the schema.

`POST /models/load` accepts `task` (default `text_generation`), `provider`,
`model`, optional provider `options`, `pin` (default `true`), `base_url`,
`timeout_s`, and `lock` (default `false`) — with `lock: true` a successful
load is immediately locked against unload, and the lock outcome is reported
additively under `lock` in the response (a lock failure or a runtime without
lock support never turns the successful load into a failure). `POST
/models/unload` selects the runtime by `runtime_id` or by
`task`/`provider`/`model`. Both relay Runtime's host facade and return the
normalized residency response (`operation`, affected records, and in-band
`ok=false` errors instead of opaque failures).

One unload failure gets a real status code: when the target model is locked,
`POST /models/unload` answers **HTTP 409** with the normalized refusal payload
as the body (`ok: false`, `error: "model_locked"`, plus whatever detail the
runtime included), so clients can offer force-unload or point at
`/models/unlock`. Sending `"force": true` in the unload request unloads the
model despite the lock. Every other unload outcome stays in-band at 200.

### Model locks and context estimates

- `POST /models/lock` and `POST /models/unlock` (admin) pin a resident model
  against unload and release that pin. The body selects the target like
  unload does: `runtime_id`, or `provider` + `model`, with optional
  `base_url` and `timeout_s`. Lock requires provider-verified residency: a
  configured or merely-warm model refuses with an
  `error: "model_not_resident"` payload (load it with `lock: true` instead);
  unlock always works, even for a since-evicted model, so locks are never
  stranded. Rows report `lockable` so clients know whether
  a lock can work, and `locked` so they can render the current state.
- `GET /models/context_estimate?provider=&model=&context_length=` (any
  authenticated principal) relays the Runtime host facade's context/KV memory
  estimate for a provider+model. `provider` and `model` are required;
  `context_length` is optional and must be >= 1 (schema-rejected with 422
  otherwise). The estimate reports its `confidence` in-band — `calibrated`,
  `estimated`, or `unknown` — alongside facade fields such as
  `predicted_max_context` (the context that fits beside the weights), the
  tri-state `fits_weights` / `fits_requested_context` split, `budget_bytes`,
  `est_kv_bytes`, and `notes` (which state the budget basis and reserve).
  The estimate is advisory only — no load path gates on it.

Like the other host-facade relays, these routes never 500 on capability gaps:
a runtime without the method answers 200 with `ok: false`,
`available: false`, and `code = "model_residency_unavailable"` (lock/unlock)
or `code = "context_estimate_unavailable"` (estimate); facade exceptions use
the matching `*_error` codes.

### Session prompt-cache enumeration

- `GET /api/gateway/sessions/prompt_cache?session_id=<optional>`
- `POST /api/gateway/sessions/{session_id}/prompt_cache/clear_all` (admin)

The list route enumerates the prompt caches the runtime actually minted. Each
cache row carries the provider/model/runtime identity, byte and token counts,
and stamped attribution metadata (`session_id`, `run_id`, `workflow_id`,
`node_id`). Omit `session_id` to list every session's caches. This
enumeration lane is the recommended way to observe and reclaim session cache
state: unlike the identity-derived session lifecycle endpoints described
under the prompt-cache control plane below, it cannot miss caches whose keys
the gateway never derived.

`clear_all` unloads every runtime-minted cache for one session in a single
call. It requires an admin principal because it accepts any session id and
clears real provider cache state; the identity-derived, caller-scoped session
lifecycle endpoints remain user-level.

When the runtime facade does not expose enumeration, both routes answer 200
with `ok=false`, `available=false`, and `code="session_caches_unavailable"`
(facade errors use `code="session_caches_error"`); the list route always
carries a `caches` array and `clear_all` always carries `cleared` and `count`.

### Discovery descriptors

`GET /discovery/capabilities` advertises this surface under
`capabilities.contracts.common`:

- `model_residency`: `endpoints` (`loaded`, `load`, `unload`, `lock`,
  `unlock`, `context_estimate`), the per-task support map,
  `row_schema = "model_residency_row_v1"`, and `modality_ui` — the canonical
  modality color map (`{version: 1, colors: {...}}`, one `{color, label}`
  entry per residency task plus an `unknown` fallback) so every client
  renders the same modality palette instead of hardcoding its own. It is a
  rendering contract, not a runtime capability, so it is served even when the
  runtime facade is absent.
- `host_state`: `endpoints` (`state`, `memory`, `gpu`) plus
  `memory_available`. The state route itself always answers; per-section truth
  lives in the payload's `degraded` list.
- `session_caches`: `endpoints` (`list`, `clear_all`) plus `available`,
  reflecting whether the runtime facade supports cache enumeration.

Evidence: `src/abstractgateway/routes/gateway.py` (`host_state`,
`host_memory_metrics`, `model_residency_loaded`, `model_residency_lock`,
`model_context_estimate`, `session_prompt_caches_list`) and
`src/abstractgateway/security/authorization.py` (route-family policy).

## Host control (pause, desktop tray, restart, update)

The process's own controls — the surface behind the desktop tray icon and
the console's Gateway card (see [tray.md](docs/tray.md)). Reads are available to
any authenticated principal; writes require an admin principal.

- `GET /api/gateway/host/runner` — execution state:

```json
{"ok": true, "paused": true, "paused_at": "2026-09-05T06:38:26+00:00", "paused_by": "default/admin", "reason": "meeting",
 "inflight_ticks": 0, "scope": "workflow runner", "runner_in_process": true, "step_gate_supported": true,
 "runners": [{"status": "paused", "...": "..."}], "degraded": false,
 "capabilities": {"restart": true, "shutdown": true, "reason": null, "update_job_running": false}}
```

- `POST /api/gateway/host/pause` (body `{"reason": "..."}` optional) and
  `POST /api/gateway/host/resume` — both answer the payload above.
- `GET /api/gateway/host/metrics/live` — `{gpu, memory, runner}` in one call
  (1 s caches); `gpu`/`memory` carry the same in-band `supported` shape as
  `/host/metrics/gpu` and `/host/metrics/memory`.
- `GET /api/gateway/host/runs?limit=25&window_hours=24` — recent runs across
  every data plane on this host (admin; `/runs` answers only for the calling
  principal's plane). `{ok, items: [{run_id, workflow_id, label, status,
  created_at, updated_at, ledger_len, plane, started_epoch}], count, has_more,
  planes, skipped_entity_planes?, warnings?}`. `label` decodes a catalog
  workflow's internal id (`__catalog__v2__…<base64>`) to the name an operator
  uses. Root runs only; the gateway's own bookkeeping runs (`__`-prefixed, but
  never a catalog id) are excluded.
- `GET /api/gateway/host/tray` — `{dependencies_installed, install_hint,
  decision: {start, reason, hint}, supervisor: {running, ready, pid,
  exit_code, failure, log_path}, can_control}`. There is no setting: the icon
  is shown whenever this process and this desktop can hold it.
- `POST /api/gateway/host/tray/show` — retry the helper now (409 when this
  process cannot). No `hide` counterpart, by design.
- `POST /api/gateway/host/restart`, `POST /api/gateway/host/shutdown` —
  `{"ok": true, "restart": true, "requested_by": "...", "reason": "..."}`;
  409 with a plain reason when unsupported (`--reload`, embedded server, an
  update is installing).
- `GET /api/gateway/host/update`, `POST /api/gateway/host/update/check`,
  `POST /api/gateway/host/update/start` — `{current, install: {kind,
  upgradable, reason, command, extras}, check: {latest, update_available,
  offline, checked_at, error}, job: {state, log_tail, exit_code,
  restart_recommended, version_before, version_after}, restart_pending}`.
  `start` answers 409 when the install cannot be upgraded in place or a job
  is already running.

`GET /api/health` adds `"paused": true` while paused; `status` stays
`"healthy"`.

## Prompt-cache control plane (operator API)

The gateway exposes prompt-cache operator endpoints under `/api/gateway/prompt_cache/*`.
Provider prompt-cache controls affect process-local or remote provider state
and require an admin principal in hosted user-auth mode.

Core endpoints:

- `GET /api/gateway/prompt_cache/capabilities?provider=...&model=...`
- `GET /api/gateway/prompt_cache/stats?provider=...&model=...`
- `POST /api/gateway/prompt_cache/set`
- `POST /api/gateway/prompt_cache/update`
- `POST /api/gateway/prompt_cache/fork`
- `POST /api/gateway/prompt_cache/clear`
- `POST /api/gateway/prompt_cache/prepare_modules`

Behavior:

- These routes use the runtime's AbstractCore prompt-cache client contract rather than directly depending on provider-instance access.
- In local mode they delegate to the in-process provider.
- In remote/hybrid mode they follow whatever `/acore/prompt_cache/*` surface the configured AbstractCore server exposes.
- All core prompt-cache responses include `operation` and `capabilities`, with structured unsupported/error cases (`code="prompt_cache_unsupported"` / `code="prompt_cache_error"` / `code="prompt_cache_unavailable"`).
- These endpoints remain provider/model controls, not a Gateway-owned CachedSession persistence system.

Session lifecycle endpoints:

- `GET /api/gateway/sessions/{session_id}/prompt_cache/status`
- `POST /api/gateway/sessions/{session_id}/prompt_cache/prepare`
- `POST /api/gateway/sessions/{session_id}/prompt_cache/rebuild`
- `POST /api/gateway/sessions/{session_id}/prompt_cache/clear`

These routes derive a deterministic bounded namespace/key from `session_id`,
`bundle_id`, `bundle_version`, `flow_id`, `provider`, `model`, optional
`template_id`, and `version`. The private hash also includes the authenticated
principal scope, so two hosted users using the same session id/provider/model do
not collide in a shared provider control plane; the returned `identity` remains
portable app-level data and does not expose that private scope. These routes
expose three honest modes:

- `unsupported`: provider/model does not expose prompt-cache support; responses include `supported=false`, `ok=false`, and capabilities.
- `keyed`: gateway returns a stable `runtime_hint`/`prompt_cache_key` for Runtime/Core injection, but does not claim module preparation occurred.
- `local_control_plane`: gateway uses supported provider operations such as `prepare_modules`, `fork`, `set`, `clear`, and `stats`.

`status` is read-only. `prepare` accepts optional modules (`system_prompt`,
`workflow_instructions`, `tools`, `pinned_attachments`) and returns either
provider operation results or a key hint. `rebuild` is clear-plus-prepare for
providers that expose clear controls.

These identity-derived endpoints only see caches whose keys the gateway
derived. To enumerate or bulk-clear the caches the runtime actually minted for
a session, use the recommended
[session prompt-cache enumeration lane](#session-prompt-cache-enumeration).

Durable bloc exact-reuse endpoints:

- `POST /api/gateway/blocs/upsert_text`
- `GET /api/gateway/blocs/record`
- `GET /api/gateway/blocs`
- `POST /api/gateway/blocs/delete`
- `GET /api/gateway/blocs/kv/manifest`
- `GET /api/gateway/blocs/kv/list`
- `POST /api/gateway/blocs/kv/ensure`
- `POST /api/gateway/blocs/kv/load`
- `POST /api/gateway/blocs/kv/delete`
- `POST /api/gateway/blocs/kv/prune`

These routes are the primary app-facing durable prompt-cache path:

- create or identify a durable text bloc;
- ensure or load a KV artifact for a target local provider/model;
- use the returned `prompt_cache_binding` in later Runtime-backed generation;
- list/delete/prune artifacts without reaching into provider-private cache state.

They delegate through Runtime's public AbstractCore host facade rather than
proxying Core directly. They are operator-style host controls, so the routes
themselves are not ledgered run execution; the ledgered exact-reuse path is the
later `LLM_CALL.params.prompt_cache_binding` used inside real Runtime runs.

Host-local prompt-cache export/import admin aliases:

- `GET /api/gateway/prompt_cache/saved`
- `POST /api/gateway/prompt_cache/save`
- `POST /api/gateway/prompt_cache/load`

These remain explicitly local/operator-oriented:

- the route paths are compatibility aliases, but the implementation delegates to Runtime's public host facade:
  - `saved` -> `list_prompt_cache_exports(...)`
  - `save` -> `prompt_cache_export(...)`
  - `load` -> `prompt_cache_import(...)`
- local bundle/file runtimes store these exports under the Gateway data dir at `prompt_cache_exports/`
- remote and hybrid runtimes return `code=prompt_cache_local_only`
- response payloads follow Runtime's host-local export/import contract, including `operation`, `local_only`, `artifact_*`, `capabilities`, and `provider_response`

## Email inbox (operator UI; optional)

These endpoints power AbstractObserver’s **Inbox → Email** UI. They are **account-scoped**: the browser cannot supply arbitrary IMAP/SMTP host/user credentials. The gateway host must be configured with one or more email accounts (multi-account YAML or env vars).

Endpoints:
- `GET /api/gateway/email/accounts`
- `GET /api/gateway/email/messages?account=…&mailbox=…&since=…&status=…&limit=…`
- `GET /api/gateway/email/messages/{uid}?account=…&mailbox=…&max_body_chars=…`
- `POST /api/gateway/email/send`

Examples:

```bash
curl -sS -H "$AUTH" "$BASE_URL/api/gateway/email/accounts"
```

```bash
curl -sS -H "$AUTH" "$BASE_URL/api/gateway/email/messages?status=unread&since=7d&limit=20"
```

```bash
curl -sS -H "$AUTH" "$BASE_URL/api/gateway/email/messages/12345?max_body_chars=20000"
```

```bash
curl -sS -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"to":"you@example.com","subject":"Hello","body_text":"Hi!"}' \
  "$BASE_URL/api/gateway/email/send"
```

Configuration notes (gateway host):
- Multi-account: set `ABSTRACT_EMAIL_ACCOUNTS_CONFIG=/path/to/emails.yaml` (recommended).
- Single-account env fallback: set `ABSTRACT_EMAIL_IMAP_*` and/or `ABSTRACT_EMAIL_SMTP_*`.
- The secret itself must be present in the env var referenced by `*_PASSWORD_ENV_VAR` (e.g. `EMAIL_PASSWORD=...`).

Evidence: `src/abstractgateway/routes/gateway.py` (`/email/accounts|messages|send`) which proxies to the Runtime AbstractCore comms facade.

Troubleshooting and common questions: [faq.md](docs/faq.md).

---

## docs/console.md

# Consoles: web (`/console`) and terminal (`abstractgateway-console`)

AbstractGateway ships two operator consoles over the same admin HTTP API. Both
edit the same stores through the same endpoints with the same request bodies, so
a change made in one is immediately visible in the other.

| | Web console | Terminal console |
|---|---|---|
| Delivery | served by the gateway at `GET /console` (part of the `abstractgateway` Python package) | Rust crate [`abstractgateway-console`](https://crates.io/crates/abstractgateway-console), installed with `cargo install` |
| Sign-in | Gateway browser session (user id + token) | base URL + bearer token (`ABSTRACTGATEWAY_AUTH_TOKEN` or the Connection screen) |
| Best for | day-to-day administration in a browser, sandbox chat with media previews | SSH sessions, headless hosts, keyboard-only setup |

For how the stores behind these screens are owned (Gateway vs AbstractCore),
see [configuration.md](docs/configuration.md). For the endpoints themselves, see
[api.md](docs/api.md).

## Web console (`/console`)

Start the gateway and open `http://<host>:<port>/console`:

```bash
abstractgateway serve --host 127.0.0.1 --port 8081
# then open http://127.0.0.1:8081/console
```

The console uses the current origin, so it never asks for a gateway URL. With
user auth enabled, the first-login token is written to
`<ABSTRACTGATEWAY_DATA_DIR>/auth/bootstrap-admin-token`. The console covers:

- **Users & runtimes:** user records, token rotation, retained runtime
  reservations, the runtime inventory with sessions, data and caches.
- **Providers:** provider connections (OpenAI, Anthropic, OpenRouter, Portkey,
  LM Studio, Ollama, custom OpenAI-compatible endpoints) with write-only keys.
- **Multimodal capabilities:** capability route defaults, the text reasoning
  effort, and the MTP (speculative decoding) default.
- **Workflows:** every registered workflow with versions and entrypoints,
  import/export/delete, and versions that are not served (with the reason).
- **Sandbox:** quick chat and media generation against the configured defaults.
- **Resources:** memory/GPU meters, resident models (warm up, lock, unload),
  and session prompt caches.
- **Entities:** summoned-entity roster and management (see
  [entities.md](docs/entities.md)).
- **Models:** browse models that fit this machine, download them, and delete
  the ones you no longer need (details below).
- **Engines:** the local engines on the gateway host (Ollama, LM Studio, MLX,
  llama.cpp, vLLM, Hugging Face): installed or not, running or not, and a
  one-click install (details below).
- **Apps:** the browser apps (Flow Editor, Code, Observer, Continuum, Entity):
  install, open, and Code's terminal version (details below).

### Models and Engines tabs

Everything these two tabs show and do happens **on the gateway host**: the
machine that runs `abstractgateway serve`, not the computer your browser runs
on. The catalog data, the presence checks and the fit verdicts come from
AbstractCore (`GET /api/gateway/models/catalog`, contract `model_catalog_v1`);
downloads are the gateway's own jobs (see [Model downloads](docs/model-downloads.md)).

**Models** (tab id `catalog`; the older **Resources** tab keeps id `models`)
shows the catalog as **one card per model**:

- The card header: the model's name, organisation, parameter count and
  licence, its capabilities (Text, Thinking, Tools, Vision, Audio, Embedding,
  Voice, Image) and a **Starter** badge for the models of the recommended
  starter set.
- The card body: one row per downloadable build (artifact) of that model: the
  provider (MLX, Ollama, LM Studio, Hugging Face, ...), the artifact id (long
  ids are shortened with "..."; hover to read the whole id, click it to copy
  it), the quantization (4-bit, 8-bit, 16-bit, ... and its bits per weight),
  the download size ("about" when the size is estimated from the parameter
  count), whether the weights are already here (Downloaded, Not downloaded,
  Unknown, Remote) and whether it fits this machine (Fits, Tight, Partial
  offload, Too large; hover the pill for the numbers behind it). The build
  recommended for this computer comes first and is marked; the others are
  quieter.
- One action per row: **Download** (a download shows its progress bar with
  bytes, speed and time left, and **Cancel**, the same progress display as the
  setup guide), then **Use as default** once a text model is downloaded (it
  sets the default text model, like the Multimodal tab).
- Many models have 8-bit builds next to the 4-bit ones when upstream publishes
  them (MLX `-8bit` repositories, Ollama `-q8_0` tags, GGUF `Q8_0` files, LM
  Studio `@8bit`). Every build the catalog knows is listed; nothing is cut off.

The filter bar above the cards:

- **Search** matches model names, organisations and artifact ids (every word
  must match). **Escape** clears it.
- **Quantization**: All, 4-bit, 8-bit, Other (every other class: 16-bit, full
  precision, 2/3/5/6-bit and builds whose reference names no quantization).
  The classes come from AbstractCore's `quant_class` field. A gateway whose
  AbstractCore is older than that field shows the notice "This gateway's
  catalog does not report quant_class yet", and the quantization filter stays
  off until AbstractCore is updated on the gateway host; every build is still
  listed.
- **Catalog / Hugging Face**: the switch left of the search box. In
  **Hugging Face** mode, type a name and press **Enter** (or **Search**): the
  gateway searches the Hugging Face Hub (answers are cached for 24 hours) and
  the results show as the same cards, with a **Hugging Face** badge: provider,
  artifact id, quantization when the result names one ("Not stated"
  otherwise, never guessed), size from the Hub, fit, and **Download** with
  the same progress bar. Capabilities of a Hub result are unknown until it is
  installed, so it offers no **Use as default** from here. When the Hub has
  nothing for the query the view says so; when the gateway host cannot reach
  the Hub it says "Hugging Face could not be searched right now" (the raw
  reason sits behind **Show details**), and results the Hub only partly
  answered carry a warning. The query is part of the address:
  `/console#catalog?hf=smollm`.
- **Provider**, **Capability** and **Status** (Downloaded, Not downloaded)
  chips, each with the number of builds it would show.
- **Fits this computer** hides the builds that do not fit (only Fits and Tight
  remain).
- A live count: "12 of 77 models · 31 artifacts shown". The row with the search
  box, the count and the filters in use stays under the header while you
  scroll; **Filters** brings the chips back into view. When nothing matches,
  **Clear filters** resets them.
- The filters are part of the address: `/console#catalog?quant=8bit&provider=mlx&fits=1`
  opens the tab with exactly that view, so a link reproduces it. The keys are
  `q`, `quant` (`4bit`, `8bit`, `other`), `provider`, `cap`, `status`
  (`downloaded`, `not_downloaded`), `fits=1` and `hf` (Hugging Face mode and
  its query).

Below the cards, **On this computer** is AbstractCore's own list of the models
the local engines hold (including models that are not in the catalog), with
their size and location, and **Delete** (with a confirmation that names the
model and any blocker, such as a model that is loaded right now).

The setup guide's **Default model** step shows the same catalog cards with
**Fits this computer** already on; **Open in the Models tab** carries the
filters over. An engine card's **Browse models** opens the tab filtered to that
engine's builds.

**Engines** (tab id `engines`):

- One row per engine: supported on this host, installed, version, running,
  reachable, base URL and model count.
- **Install** opens a confirmation that shows the exact command it will run
  and the host it runs on, with a **Preview (dry run)** button that asks the
  gateway what it would run without running it.
  **Open download page** links to the vendor page (LM Studio is installed from
  its download page).

Keys (when the tab is visible and you are not typing in a field): `/` search,
`f` fits-only on/off, `r` refresh; on a focused row `w` download, `d` delete,
`i` install, `o` open the download page, `c` cancel the row's job.

Who can do what:

- Every signed-in user can browse both tabs.
- Download, Delete, Install and Cancel are for **admins** only; the buttons are
  disabled for other users, and the gateway refuses those calls from them.
- Installing an engine also needs the gateway setting
  [`allow_engine_install`](docs/configuration.md#allow_engine_install). It lives in
  the runtime configuration and is on by default when the gateway listens on
  this machine only (`127.0.0.1`), and, whatever it listens on, for someone
  using the console on the gateway machine itself. A browser on another
  computer is refused until an admin turns the setting on. A dry run
  (**Preview**) is always allowed.
- Every action is recorded in the gateway's audit log, and each job card shows
  the equivalent command, for example
  `abstractgateway models download ollama qwen3:8b`.

If the gateway's AbstractCore is older than 2.14.0, both tabs show a card saying
so, with the version installed and the upgrade command
(`pip install -U "abstractcore>=2.14.0"`); the rest of the console works as
before.

### Apps tab

The Apps tab and the setup guide's Apps step show the same cards (the apps
themselves are described in [apps.md](docs/apps.md)). What a plain user sees on
each card: the app's mark, name and status pill (Not installed, Installed,
Running, Installing, Stopped unexpectedly, Keeps crashing), one line of
description (hover it for the whole sentence), and one row of buttons. The
button rows of the cards side by side are always at the same height.

| The app is | The action row |
|---|---|
| not installed | **Install and open** (installs Node.js first when the gateway needs it, then the app, starts it and opens it) |
| installing | a progress bar above the row, and **Cancel** |
| installed and running | **Open** (a new tab, already signed in) |
| installed but stopped, or crashed | **Open** (starts it, then opens it); a crash also shows the reason, with **Show details** |

Code has a terminal version too. Next to Code's primary button: **Open in
Terminal** when the terminal version is installed and the browser is on the
gateway machine (a new terminal window opens there, signed in), or a quiet
**Install for Terminal** when a ready-made download exists for this
computer. When the terminal version needs the Rust toolchain, or when the
browser is on another computer, the plain view shows no terminal button.

A result box appears only after something you did ("Code opened in a new
Terminal window, signed in to this gateway.", "Flow Editor opened in a new
tab.") and closes itself after a few seconds. A failure stays, with the
gateway's reason and **Show details** (the full response or log).

The **Technical details** switch (bottom left of the console, and in the
guide) adds a secondary line under each card's buttons, and removes it again
when switched off:

- **Stop** (a running app), **Start** (start without opening), **Show log** /
  **Hide log** (the app's log, the log file's path, **Show more** up to 5000
  lines), **Update to X** when a newer version is published, and the version.
- For Code's terminal version: its version, **Update terminal app to X**, and
  the exact command to copy (one line, **Copy**): the command that opens it,
  `cargo install abstractcode` when it needs the Rust toolchain, or, for a
  browser on another computer, the command to run there and the one-time
  `abstractcode login` line.
- The app's local address, the `npx @abstractframework/<app>` line, and the
  log of a finished install.
- For an app started outside the gateway (the development stack, `npx`, a
  service): "Started outside the gateway on port 3001" instead of Stop,
  Start, Show log and Update; the card shows the Running pill and **Open**
  like any running app ([apps.md](docs/apps.md#apps-started-outside-the-gateway)).

Installing, starting, stopping and updating need an admin; other users see the
buttons disabled with the reason on hover.

## Terminal console (`abstractgateway-console`)

Install it from crates.io (Rust 1.87 or newer):

```bash
cargo install abstractgateway-console
```

Connect it to a running gateway. Pass the token through the environment rather
than on the command line:

```bash
ABSTRACTGATEWAY_AUTH_TOKEN=... abstractgateway-console --url http://127.0.0.1:8081
abstractgateway-console --help
```

It opens as a guided wizard on first run and as free tabs afterwards, with eight
screens: Connection, Providers, Routes (capability defaults, including the MTP
selector and a Test verb per route), Users & Entities, Runtimes (runs with
cancel and steer, data homes), Workflows, Review & Test (the session's change
journal), Resources, Models and Engines. Every write is verified with a
follow-up read and recorded in the journal. Keys: `Tab` focus, `Enter`
activate, `1`-`9` and `0` screens, `r` refresh, `q` quit; each screen lists its
actions in the footer.

### Models and Engines in the terminal console

Screens 9 (**Models**) and 0 (**Engines**) are AbstractCore's own screens,
taken from the `abstractcore-console` crate rather than rebuilt, so they look
and behave the same in `abstractcore-console` and here. In the gateway console
they act on the gateway's host, through the gateway's
`/api/gateway/host/profile`, `/engines`, `/models/catalog`,
`/models/installed`, `/models/download`, `/models/delete`,
`/engines/{id}/install` and `/jobs/{id}` routes:

- **Models:** browse the catalog with a fit verdict for the gateway host,
  download (`w`), delete after a confirm that lists any blocker (`d`), filter
  (`/`), fits only (`f`), engine (`e`), installed view (`v`), cancel (`c`).
- **Engines:** see which engines are installed and running; install one (`i`)
  after a confirm that shows the exact command and the host it runs on (a dry
  run is offered), or open its download page (`o`).

Downloads, deletes and installs are admin-only and run on the gateway host; a
refusal (for example installs disabled on a remote gateway, or a loaded model)
is shown with the gateway's reason.

The terminal console needs no gateway-side component beyond the admin API. The
crate version is independent of the Python package version; see
[`console-tui/CHANGELOG.md`](console-tui/CHANGELOG.md).

## Model residency from a shell

The same model routes the consoles drive are available from the Python CLI
against a running gateway:

```bash
abstractgateway models loaded --url http://127.0.0.1:8081
abstractgateway models load   --url http://127.0.0.1:8081 --provider ollama --model qwen3:4b
abstractgateway models unload --url http://127.0.0.1:8081 --provider ollama --model qwen3:4b
```

The token comes from `--token` or `ABSTRACTGATEWAY_AUTH_TOKEN`, and the URL from
`--url` or `ABSTRACTGATEWAY_URL`. The command prints the gateway's JSON answer
and exits non-zero when the gateway reports a failure. `unload --force` unloads
a locked model; in-flight calls on the model are cancelled first.

---

## docs/configuration.md

# AbstractGateway — Configuration

AbstractGateway is configured primarily via **environment variables** (plus a few CLI flags).

## Install extras (recommended)

The base install (`pip install abstractgateway`) is the remote-light server
profile: HTTP/SSE, durable stores, `AbstractRuntime`,
Runtime-owned provider/tool and multimodal support, AbstractAgent, AbstractFlow
compatibility (runs bundles produced by AbstractFlow; does not require the
`abstractflow` package), and AbstractMemory/LanceDB KG support. Local
sentence-transformer embeddings and hardware-local inference engines are
opt-in, so the base Linux install does not pull PyTorch/CUDA packages.

Remote embeddings are part of this base light profile. Configure
`embedding.text` for OpenAI, OpenRouter, Portkey, LM Studio, vLLM, another
OpenAI-compatible embeddings endpoint, or a remote AbstractCore server. The
`abstractgateway[embeddings]` extra is only for local HuggingFace/
sentence-transformer embeddings on the Gateway host.

Optional extras (see `pyproject.toml`):
- `abstractgateway[embeddings]`: local sentence-transformer embeddings for semantic KG queries
- `abstractgateway[apple]`: full native macOS Python profile with Apple-local engines and all non-NVIDIA framework capabilities; this is for native macOS, not Docker
- `abstractgateway[gpu]`: full native/container GPU profile with local GPU engines and all relevant framework capabilities; the NVIDIA Docker image uses this profile
- `abstractgateway[tray]`: the desktop menu bar / system tray icon shown by `abstractgateway serve` (pystray + Pillow; see [tray.md](docs/tray.md))
- `abstractgateway[docs]`: MkDocs site tooling
- `abstractgateway[dev]`: local dev/test deps

Default dependency floors:
- `AbstractRuntime>=0.4.26`
- `abstractagent>=0.3.12`
- `AbstractMemory[lancedb]>=0.2.6`

Gateway's KG resolver targets AbstractMemory's TripleStore API. It does not use
the newer memory-agent API directly.

## Configuration helper

Gateway has a first-class configuration helper:

```bash
abstractgateway-config status
abstractgateway-config init --env-file .env
abstractgateway-config bootstrap-admin --print-token
abstractgateway-config claim-url [--open]
abstractgateway config status --json
```

`claim-url` (also `abstractgateway claim`) prints a one-time console sign-in
link for this machine; see [first-run.md](docs/first-run.md). `status --json`
also reports `data_dir_source`, `auth_mode`, `service`, `claim_pending`,
`first_run` and `serve` (schema `gateway_config_status_v1`, documented in
[first-run.md](docs/first-run.md#checking-the-setup-from-scripts)).

It reports Gateway auth/data/store/runtime defaults, Core-server handoff
configuration, memory-store selection, and package readiness. `init` writes a
private env file for server/operator deployments. Gateway Console (`/console`)
is the preferred place to configure provider connections, provider API keys,
endpoint base URLs, users, and Gateway/user defaults. Provider URLs and keys
belong to the Providers tab; the Multimodal Capabilities tab only chooses an
available provider and a discovered model.
`bootstrap-admin` is the non-interactive setup path used by Docker images:
when user auth is enabled, it ensures `default/admin` exists, stores only the
token hash in `auth/users.json`, and can write the raw bootstrap token to
`auth/bootstrap-admin-token` for first login.

## Core environment variables

### Paths + workflow source

- `ABSTRACTGATEWAY_DATA_DIR`: durable data directory. When unset: `./runtime`
  if it already exists in the working directory, else the per-user data folder
  (macOS `~/Library/Application Support/AbstractGateway`, Linux
  `$XDG_DATA_HOME/abstractgateway` or `~/.local/share/abstractgateway`,
  Windows `%LOCALAPPDATA%\AbstractGateway`). `serve --data-dir` sets it for
  one process. `abstractgateway-config status` prints the folder and why it
  was chosen.
  Evidence: `src/abstractgateway/host_paths.py`
- `ABSTRACTGATEWAY_FLOWS_DIR`: workflows directory. When unset, Gateway uses the
  packaged shipped bundle directory, which carries `basic-agent`,
  `coding-agent`, `deep-research`, `co-scientist`, and more
  ([shipped-workflows.md](docs/shipped-workflows.md)). If the shipped bundles are
  unavailable, Gateway fails clearly instead of starting with an empty default
  registry. Setting this replaces the shipped registry with your own directory.
  Evidence: `src/abstractgateway/config.py`
- `ABSTRACTGATEWAY_WORKFLOW_SOURCE`: `bundle` (default) or `visualflow`
  Evidence: `src/abstractgateway/service.py` (`create_default_gateway_service`)

### Authentication and user routing

**Default on loopback.** When no auth setting is present (none of
`ABSTRACTGATEWAY_AUTH_TOKEN[S]`, `ABSTRACTGATEWAY_USER_AUTH`,
`ABSTRACTGATEWAY_MULTI_USER`, `ABSTRACTGATEWAY_AUTH_MODE`,
`ABSTRACTGATEWAY_SECURITY`, `ABSTRACTGATEWAY_PROTECT_WRITE`), `serve` binds
`127.0.0.1` and enables user auth automatically. A non-loopback bind in that
state refuses to start. When any auth setting is present, `serve` keeps the
`0.0.0.0` default bind and your settings apply unchanged.

The normal browser-console/browser-app path uses Gateway user auth:

- `ABSTRACTGATEWAY_USER_AUTH=1` or `ABSTRACTGATEWAY_AUTH_MODE=users`: enable
  file-backed user principals and per-principal runtime routing
- `abstractgateway serve`: when user auth is enabled, ensures `default/admin`
  exists and writes the first-login token to
  `<ABSTRACTGATEWAY_DATA_DIR>/auth/bootstrap-admin-token` (mode `0600`). The
  token is printed on a loopback bind and hidden on other binds;
  `serve --print-token` / `--no-print-token` override that (the older
  `ABSTRACTGATEWAY_BOOTSTRAP_PRINT_TOKEN=1` still acts as `--print-token`). Until
  the first-run guide is completed, a one-time sign-in link
  (`/console#claim=...`, 10 minutes, single use, loopback only) is printed
  instead
- `POST /api/gateway/session/claim`: redeems a one-time link code for an admin
  browser session; accepted only from a loopback peer without proxy headers.
  The response carries `claimed: true`, `first_run` (the guide state) and
  `claim: {created_by}`, which says who minted the link: `serve` (first run),
  `cli` (`claim-url` / `abstractgateway claim`), `tray` (tray sign-in), or
  `null` for a link minted before the field existed.
  `GET /api/gateway/host/first-run` / `POST` (admin) read and record the
  first-run guide state

Legacy server/operator mode uses a Gateway bearer token:

- `ABSTRACTGATEWAY_AUTH_TOKEN`: single Gateway admin token
- `ABSTRACTGATEWAY_AUTH_TOKENS`: comma-separated Gateway admin tokens

That legacy bearer token maps to `local-admin` and is not accepted by browser
sign-in flows such as `/console` or AbstractFlow. User-auth mode resolves
Gateway user bearer tokens to principals and routes each principal to a separate
service/data plane:

- `ABSTRACTGATEWAY_USER_AUTH_AUTO=1`: compatibility mode that also enables
  user auth when the registry file already exists
- `ABSTRACTGATEWAY_USERS_FILE`: optional user registry path; default:
  `<ABSTRACTGATEWAY_DATA_DIR>/auth/users.json`
- `ABSTRACTGATEWAY_SESSIONS_FILE`: optional browser session registry path;
  default: `<ABSTRACTGATEWAY_DATA_DIR>/auth/sessions.json`
- `ABSTRACTGATEWAY_SESSION_TTL_S`: default browser session lifetime
- `ABSTRACTGATEWAY_REMEMBER_SESSION_TTL_S`: browser session lifetime when a
  browser app requests "remember me"
- `ABSTRACTGATEWAY_ADMIN_USES_DEFAULT_RUNTIME`: keep the default
  `default/admin` admin principal on the Gateway's base data plane when its
  `runtime_id` is `default` or `admin` (default: enabled)
- `GET /api/gateway/me`: returns the resolved principal and routing mode
- `/api/gateway/admin/users`: admin-only user list/create/read/update/delete
- `/api/gateway/admin/runtime-reservations`: admin-only retained runtime
  list/transfer/purge lifecycle
- `/console`: built-in same-origin Gateway Console for session sign-in with
  Gateway user + token, account/runtime summary, admin user management, optional
  account email metadata, token rotation, retained runtime transfer/purge, and
  multimodal capability defaults selected from available providers

User records include `tenant_id`, `user_id`, roles/scopes, enabled state, and a
`runtime_id`. The registry stores password-grade bearer-token hashes only.
Generated or rotated user tokens are returned once from the admin response.
Gateway rejects duplicate `runtime_id` values within the same tenant when users
are created or updated, preserving `1 user = 1 runtime` for independent hosted
users. Deleting a user reserves its retained runtime id. Admins must explicitly
purge retained runtime data before the id can be reused by another user, or
transfer the retained runtime to an existing same-tenant user.

When user auth is active, `src/abstractgateway/service.py` keeps normal users
isolated in a per-principal service directory:

```text
<ABSTRACTGATEWAY_DATA_DIR>/users/<tenant_id>/<runtime_id>/runtime
<ABSTRACTGATEWAY_DATA_DIR>/users/<tenant_id>/<runtime_id>/flows
```

The bootstrap `default/admin` admin principal is a local-setup compatibility
exception by default: with `ABSTRACTGATEWAY_ADMIN_USES_DEFAULT_RUNTIME=1`, it
uses the base Gateway data plane and bundle registry. That keeps the admin
connected to the default runtime and shipped `basic-agent` bundle while regular
users remain on `1 user = 1 runtime` routing.

Browser apps should exchange a Gateway user token for an opaque Gateway browser
session through `/api/gateway/session/login`; the raw bearer token should not be
kept in browser storage, and the login response body does not expose the session
id or CSRF token. Session-authenticated writes carry
`X-AbstractGateway-Session` plus `X-AbstractGateway-CSRF`, and
`/api/gateway/session/logout` revokes the session. Apps such as AbstractFlow,
AbstractCode, AbstractAssistant, and AbstractObserver should authenticate as the
current user/session in hosted mode. They should not share one app-server
Gateway token for all users.

## Network exposure (localhost / local network / internet)

One setting decides who can reach the gateway. The console, the console TUI
(Connection screen), the tray and `abstractgateway network` all edit the same
runtime-config key (`network`); there is no environment variable for it.

| Mode | Bind | Requires |
|---|---|---|
| `localhost` ("Localhost only") | `127.0.0.1` | nothing: only this machine can connect |
| `lan` ("Local network") | `0.0.0.0` (IPv4) | user auth (accounts + console sign-in) |
| `internet` ("Internet…") | `0.0.0.0` (IPv4) | user auth **and** an explicit acknowledgement |

- **Applied at the next start.** A listening socket cannot move: after a change
  the status says `restart_required: true` with `configured` vs `effective`
  until the gateway restarts (`POST /api/gateway/network/restart`, the tray's
  *Restart AbstractGateway…*, `abstractgateway network restart`, or stop and
  start `serve`).
- **`serve --host/--port` win** over the setting and are reported as
  `effective.overridden_by_cli: true`. A restart replays the same command
  line, so it cannot apply the setting: the status says so
  (`restart.applies: false` + `restart.reason`) and the restart route refuses.
- **The login service lets the setting apply** (2026-09-24). The LaunchAgent,
  systemd unit, XDG entry and Windows Run entry written by
  `abstractgateway service install|enable` start plain `serve`: no `--host`, no
  `--port`. Install/enable first **seed** the setting through the same change
  door as `network set` (same auth refusals; a refusal registers nothing):
  nothing stored yet → `localhost` on the chosen port (the bind every earlier
  registration had); a stored mode/port → kept. `service install|enable --host H
  --port P` are written **into the setting** (`127.0.0.1` → `localhost`,
  `0.0.0.0` → `lan`, or the stored `internet`; a specific address is refused),
  never onto the command line. `--pin-command-line` is the technical escape
  hatch: `serve --host H --port P` on the command line as before, the setting
  untouched and overridden (`overridden_by_cli`, `service status` names it).
- **Registrations from before 2026-09-24 carry `--host/--port`.** `service
  status` (and the tray's *Start at login — needs repair*) says
  "pinned to 127.0.0.1:N by the login item — run `abstractgateway service enable`
  again to let the Network setting apply". `service enable` (or the tray click)
  rewrites the registration in place, keeping a stored mode. The gateway
  running at that moment still has the old command line, which a restart
  replays: `abstractgateway service install` restarts it from the new
  registration now, or log out and back in.
- **Auth is checked before anything is stored.** `lan`/`internet` are refused
  (HTTP 409, nothing written, `refused_reason` + `fix`) when the gateway was
  started with authentication switched off (`ABSTRACTGATEWAY_SECURITY=0` /
  `ABSTRACTGATEWAY_PROTECT_WRITE=0` in its launch environment), with read
  protection off (`ABSTRACTGATEWAY_PROTECT_READ=0`: unauthenticated reads would
  be answered as the admin, `reason_code: auth_disabled`), or with a posture
  without accounts (a shared token only, or `ABSTRACTGATEWAY_USER_AUTH=0`).
  The `fix` describes that state; a plain start (`abstractgateway serve`, or
  the login item `abstractgateway service enable` registers) has none of
  them: when no auth posture is configured at all (the first-run default),
  `serve` turns user auth on for the network mode and says so on stderr
  (`auth.source: network_setting`).
- **`internet` needs `acknowledge_internet: true`** (CLI
  `--acknowledge-internet`; the TUI and tray ask with a confirm). The gateway
  does not terminate TLS: put a TLS reverse proxy or a tunnel in front
  (Caddy, nginx, Cloudflare Tunnel, Tailscale Funnel, ngrok). Port forwarding
  and firewalls are yours to configure; the gateway changes neither.
- **Browser origins.** In a network mode from the setting, `serve` also allows
  the gateway's own discovered LAN origins (e.g. `http://192.168.1.23:8080`,
  `http://mymac.local:8080`) next to the loopback defaults, so the console can
  sign in from another machine. An address that appears later (new Wi-Fi)
  needs a restart. Your public origin (behind a proxy or tunnel) is a setting:
  see [Reverse proxy](#reverse-proxy-allowed-origins-and-trust-proxy) below.
- **A setting that cannot apply falls back loudly.** If the stored mode's auth
  requirement stops being met (the environment changed), `serve` binds
  `127.0.0.1`, prints `[ERROR] Network exposure 'lan' cannot be applied: … Fix: …`
  and the status carries `effective.blocked_reason`.

### Reverse proxy: allowed origins and trust proxy

Two settings a deployment behind a reverse proxy or a tunnel needs, stored in
the same `network` setting and changed through the same door
(`POST /api/gateway/network`, admin-only, audit-logged). **Both apply to the
next request: no restart.** The security middleware re-reads them per request
(one `stat()` of the settings file; parsed again only when it changed), so a
change from the console, the TUI or the CLI (another process) is live at once.

| Setting | Meaning | Default |
|---|---|---|
| `allowed_origins` | Browser origins whose pages may call the gateway, **added** to the always-allowed `http://localhost:*`, `http://127.0.0.1:*` (and, in a network mode, the gateway's own LAN origins). | none |
| `trust_proxy` | Take the client address from `X-Forwarded-For` (sign-in lockouts, audit log). Only when your own proxy sits in front of every request: otherwise any client chooses the address the gateway sees. | off |

**Validation** (one place, the gateway; every door shows its sentence
verbatim). An origin is `scheme://host[:port]`: `http` or `https`, no path, no
trailing slash, no query, no user info; IPv6 in brackets. It is stored the way
a browser sends it: scheme and host lowercased, the default port dropped
(`https://Gateway.Example.com:443` → `https://gateway.example.com`). `*` (every
origin), a leading `*.` label and a `:*` port are accepted only as typed and
are flagged with a warning. A list with any invalid entry is refused whole
(HTTP 400 `reason_code: invalid_origins`, `errors[{value, error}]`, nothing
written), e.g. `1 origin is not valid (nothing was saved): https://x.example/: no
trailing slash: an origin is scheme://host[:port] (write https://x.example)`.
An empty list clears the setting back to the default.

**The environment override.** `ABSTRACTGATEWAY_ALLOWED_ORIGINS` /
`ABSTRACTGATEWAY_TRUST_PROXY` in the environment a gateway was started with
still decide (a deployment pin, the security carve-out in `env_registry.py`),
and every surface says so: the payload carries `source: "env"` and
`overridden_by_env: true` with `env_name`/`env_value` and a `note` ("This
gateway was started with … in its environment: …"); saving is still allowed
and answers `changed.<field>.applies: "overridden_by_env"` ("Saved, but not in
effect"). The value in the settings applies once the gateway starts without
the variable. The origins `serve` itself exports for a network mode are never
counted as an override.

Status payload (`GET /api/gateway/network`, `reverse_proxy`):

```json
"reverse_proxy": {
  "allowed_origins": {"value": ["https://gateway.example.com"], "source": "setting", "overridden_by_env": false,
                      "effective": ["http://localhost:*", "http://127.0.0.1:*", "https://gateway.example.com"],
                      "builtin": ["http://localhost:*", "http://127.0.0.1:*"], "self_origins": [],
                      "applies": "live", "warnings": []},
  "trust_proxy": {"value": true, "source": "setting", "overridden_by_env": false, "effective": true,
                  "applies": "live", "warning": "Trust proxy is on: …"}
}
```

`source` is `setting` (stored), `env` (the start-time override) or `default`.
From the CLI (another process) the running gateway's environment is read from
its run record (`<data>/run/gateway-network.json`, `proxy_env`), never from the
CLI's own shell.

**Three ways, same semantics** (a headless server over SSH needs only the
last two):

| | Web console | Console TUI | CLI |
|---|---|---|---|
| Where | Network → *Advanced: reverse proxy* | Connection screen, below the addresses | `abstractgateway network …` |
| Add/replace origins | type an origin, *Add origin* (Enter); × on a chip removes it | *browser origins* line: comma-separated list, Enter saves, empty clears | `set --allowed-origins https://a,https://b` (`""` clears) |
| Trust proxy | *Trust the proxy's client address* switch | checkbox (Space) | `set --trust-proxy on\|off` |
| See values + source | pills: *Saved setting* / *Default* / *Set by the environment* | `[saved setting]` / `[default]` / `[environment override]` + the override line | `network show` (`--json` = the payload) |
| Refusal | the gateway's sentence under the input | notice `✗ reverse proxy refused: <sentence>` | `refused: <sentence>` on stderr, exit 1 |

The console and the TUI send `{allowed_origins}` / `{trust_proxy}` to
`POST /api/gateway/network`; the CLI writes the same store through the same
function (`network_exposure.apply_network_change`). The mode is untouched by a
reverse-proxy-only change (`mode` is optional).

### Addresses

`GET /api/gateway/network` lists every address a client can use, discovered on
each call: loopback; each up interface's IPv4/IPv6 (loopback, link-local and
down interfaces skipped; macOS names from `networksetup`, e.g. "Wi-Fi"; VPN
`utun`/CGNAT addresses labelled "VPN"); the Bonjour name `<LocalHostName>.local`
when it resolves. Discovery uses `psutil` when importable, else `ifconfig -a`
(macOS/BSD) or `ip -o addr show up` (Linux), else the hostname's own
resolution. Each row says whether the gateway listens there now
(`reachable`). The WAN address (`kind: public`) is looked up only on request
(`?lookup_public=1`, admin, `internet` mode only; one HTTPS GET to
`api.ipify.org`), never on a poll. `copy_hint` is the URL to copy first (the
LAN IPv4 when listening on the network, else loopback).

### API (`gateway_network_v1`)

- `GET /api/gateway/network[?lookup_public=1]`: any authenticated principal
  (`writable` says whether the caller may change it).
- `POST /api/gateway/network {mode?, port?, acknowledge_internet?, allowed_origins?, trust_proxy?}`:
  admin, any subset (at least one). 200
  `{ok, configured, effective, restart_required, restart, auth, reverse_proxy, changed, warnings, copy_hint}`
  where `changed{field: {from, to, applies: live|restart|overridden_by_env}}`;
  409 `{ok:false, reason_code: user_auth_required|auth_disabled|acknowledgement_required, refused_reason, fix?, warnings}`;
  400 invalid mode/port/`trust_proxy`, or `invalid_origins` with `errors[]`;
  422 unknown field or a non-boolean `trust_proxy`. Every attempt is one
  audit-log line (`audit_log.jsonl`) carrying `setting_change` (the fields
  changed, from/to, or the refusal).
- `POST /api/gateway/network/restart {force?}`: admin. 409 with
  `refused_reason` when a restart cannot apply the setting (CLI override, auth
  not met, nothing pending, process cannot relaunch itself).

A trimmed `GET` in `lan` mode, running and applied:

```json
{
  "schema": "gateway_network_v1",
  "configured": {"mode": "lan", "label": "Local network", "port": 8080, "bind_host": "0.0.0.0", "source": "stored"},
  "effective": {"mode": "lan", "bind_host": "0.0.0.0", "port": 8080, "overridden_by_cli": false,
                "host_source": "setting", "port_source": "setting", "running": true},
  "restart_required": false,
  "restart": {"available": true, "applies": true, "needed": false},
  "auth": {"user_auth": true, "token_auth": false, "ok_for_mode": true, "source": "env"},
  "modes": [{"id": "localhost", "allowed": true, "selected": false},
            {"id": "lan", "allowed": true, "selected": true},
            {"id": "internet", "allowed": true, "requires_acknowledgement": true}],
  "addresses": [
    {"kind": "loopback", "url": "http://127.0.0.1:8080", "reachable": true},
    {"kind": "lan", "url": "http://192.168.1.23:8080", "interface": "en0", "interface_label": "Wi-Fi", "reachable": true},
    {"kind": "hostname", "url": "http://mymac.local:8080", "reachable": true}
  ],
  "copy_hint": "http://192.168.1.23:8080",
  "warnings": ["Traffic is plain HTTP: …"]
}
```

### CLI

```bash
abstractgateway network status|show [--json] [--data-dir DIR]
abstractgateway network set [localhost|lan|internet] [--port N] [--acknowledge-internet]
                            [--allowed-origins ORIGIN[,ORIGIN...]] [--trust-proxy on|off]
abstractgateway network addresses [--copy] [--public] [--json]
abstractgateway network restart [--url URL] [--token T] [--force]
```

`status`, `set` and `addresses` work on the data dir directly (a running
gateway's bind and auth posture are read from `<data>/run/gateway-network.json`);
`restart` asks the running gateway. See [security.md](docs/security.md#network-exposure)
for what each mode changes for someone on your network.

## Two entry points, one store

AbstractCore (low level) and AbstractGateway (high level) are the two entry
points to the framework, and they share configuration. Where AbstractCore holds
a value, that value is the single source of truth: the Gateway reads and writes
it through AbstractCore, keeps no copy of it, and surfaces it alongside the
configuration the Gateway itself owns.

A fresh install starts with recommended defaults so generation works out of the
box — text on `lmstudio/qwen/qwen3.5-9b`, voice on `supertonic/supertonic-3`,
image on `mlx-gen/AbstractFramework/flux.2-klein-4b-8bit`. They appear in the
capability-defaults grid like any configured route and can be changed or
cleared from either entry point; a value supplied by an application or a run
always wins. The seed applies only when no AbstractCore configuration file
exists yet, so a store you already have is never modified.

**Which side owns what.**

| Domain | Authority | Where it is stored | Gateway surface |
| --- | --- | --- | --- |
| Capability route provider/model/base URL (text, image, video, voice, sound, music, 3D, embeddings) | AbstractCore | `capability_defaults.routes` in `abstractcore.json` | `GET/PUT/DELETE /api/gateway/config/capability-defaults[/{kind}/{modality}[/{task}]]`, console **Capability defaults** |
| Reasoning effort for text generation | AbstractCore | `reasoning` on the `output.text` route (stored as `input.text`) | the same routes and console panel |
| MTP default policy | AbstractCore | `options.speculation` on that text route | web/TUI **MTP** selector; application/run overrides remain independent |
| Plugin/provider route options (voice, profile, language) | AbstractCore | `options` on the route | the same routes and console panel |
| Provider API keys | AbstractCore | `api_keys` in `abstractcore.json` | console **Provider connections** (values are never returned) |
| Mail connection (IMAP/SMTP host, port, username, folder) | AbstractCore | `email` in `abstractcore.json` | the email bridge and inbox routes read it; `ABSTRACT_EMAIL_*` variables override it |
| Maintenance-triage LLM settings | AbstractCore | `maintenance` in `abstractcore.json` | the maintenance triage assistant; `ABSTRACT_TRIAGE_LLM_*` variables override it |
| Endpoint profiles (custom base URLs, per-profile keys, allowed models) | shared namespace | `provider_profiles` in `abstractcore.json` and `provider_endpoint_profiles` under the Gateway data dir | `/api/gateway/config/provider-endpoint-profiles` |
| Gateway auth, users, sessions, principals | Gateway | Gateway data dir | `/api/gateway/session/*`, `/api/gateway/users/*` |
| Bundles, workflow catalog, workspaces, run policy and retention | Gateway | Gateway data dir | the corresponding `/api/gateway/*` routes |
| Integrations (Agora, Telegram, process manager) | Gateway | Gateway data dir and environment | the corresponding `/api/gateway/*` routes |

Inside the Gateway, every read and write of an AbstractCore-owned value goes
through one module, `abstractgateway/core_config.py`. It is the only place that
talks to AbstractCore's configuration, which is what keeps "no Gateway copy"
true as the code grows.

Endpoint profiles are the one shared namespace: both sides can define
`endpoint:<id>` virtual providers, AbstractCore in its `provider_profiles`
section and the Gateway in its own store. A profile AbstractCore holds wins on
an id collision, and a Gateway profile resolves when AbstractCore has none — so
`abstractcore config set-default output.text --provider endpoint:<id>` and a
Gateway-defined profile of the same name always resolve to AbstractCore's
definition. Use distinct ids across the two unless you intend that.

### Capability defaults

The Gateway is a full CRUD surface over AbstractCore's per-modality
provider/model defaults (configure, surface, live-refresh) and keeps **zero**
local storage. Every read hits Core's manager and every write goes through
Core's setter, so configuring a default here configures Core's default, for text
and for every media modality: image, video, voice (TTS), voice input (STT),
sound, music, 3D.

**Where it is stored.** A JSON file under key `capability_defaults.routes`:
`~/.abstractcore/config/abstractcore.json` normally, or the Gateway-scoped
`<data_dir>/config/abstractcore.json` in hosted user-auth mode (the payload
reports both as `config_file` / `gateway_config_file` / `principal_config_file`).
`GET /api/gateway/config/capability-defaults` names the file it read.

| Route | What it defaults |
| --- | --- |
| `output.text` (stored as `input.text`) | text generation |
| `output.image[.text_to_image\|.image_to_image\|.image_upscale]` | image generation / edit / upscale |
| `output.video[.text_to_video\|.image_to_video]` | video generation |
| `output.voice` / `input.voice` | TTS **and voice cloning** / STT |
| `output.music` / `output.sound` | music / sound-effect generation |
| `output.scene3d[.text_to_scene3d\|.image_to_scene3d]` | 3D scene generation |
| `input.image` / `input.video` / `input.sound` / `input.music` | understanding (covered by `input.text` when that model is multimodal) |

The task→route mapping is stated once, in AbstractCore's capability-defaults
module, and every layer reads it from there. A `.task` suffix is only valid for
the tasks Core persists; `tts`, `stt`, `music_generation` and
`sound_generation` resolve at the modality cell.

CRUD: `GET /api/gateway/config/capability-defaults` (full grid — configured,
derived and unset rows, each naming its source),
`PUT`/`DELETE /api/gateway/config/capability-defaults/{kind}/{modality}` and
`.../{kind}/{modality}/{task}`. Every write re-applies the affected default to
the **live** runtime (`refresh_capability_defaults`), so the next run uses it
without a restart.

A `PUT` is a partial update: `provider`, `model`, `base_url`, `reasoning` and
`options` are all optional, a field you omit keeps its stored value, and `""`
clears a field. That is what lets the console edit a provider without discarding
a reasoning effort set through `abstractcore config set-default`, and the other
way round.

**The reasoning effort.** The text-generation route carries an optional
`reasoning` field beside its provider and model — the host's default reasoning
effort for reasoning-capable models. Set it in the console's capability-defaults
panel or through the route:

```bash
curl -X PUT "$GW/api/gateway/config/capability-defaults/output/text" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"provider":"lmstudio","model":"qwen3-30b","reasoning":"high"}'
```

It applies to any call that names no effort of its own. An explicit `thinking`
on a run, a Flow LLM/Agent node, or an entity's substrate wins over it,
`thinking=false` included; with no configured effort and no explicit value, no
reasoning parameter is sent at all.

**MTP defaults and overrides.** Fresh Core configurations seed native MTP at depth
2 for compatible models; existing stores are preserved. Gateway's web and terminal
capability-default editors change this Core-owned policy, not a separate Gateway
setting. Choose Off or a draft depth; clearing the policy does not reseed it.
Other route options are preserved by the dedicated selector. A default is a
policy, not proof that the selected backend or loaded model can execute it.

Flow, Assistant and Code default to inheritance. A run may supply `speculation`
on `/runs/start` or `/runs/schedule`, or `_runtime.speculation` in its input:
`false` disables MTP and a native-MTP object selects a depth. Explicit node/call
settings override inherited run settings. The sandbox selector uses execution
capabilities for the selected provider/model and reports the response's actual
MTP outcome. Selecting a depth never downloads a head or silently reloads a model.
Prepared models can change depth or switch Off without unloading; an unprepared
instance reports that provisioning/reloading is needed. Depth 2 is a starting
default, not a workload-independent speed guarantee.

**If the other entry point writes.** `abstractcore config set-default <route>
--provider … --model …` (and AbstractCore's console-TUI, which runs that
command) edits the same file with no way to notify a running Gateway. The host
therefore fingerprints the config files — `(path, mtime, size)`, one `stat`, no
parse — and re-publishes the defaults to the live runtime on the next
`start_run` when a file has moved. So both entry points are effective on the
next run, not at the next Gateway write or a restart. With a split AbstractCore
server (`ABSTRACTCORE_SERVER_BASE_URL`) there is no local file to watch, and the
write routes' push remains the freshness mechanism. Partial updates work the
same across that boundary: the AbstractCore server's own
`PUT /v1/config/capability-defaults/...` routes keep the fields a request omits
and clear the ones it sends empty, so the reasoning effort survives a
provider-only save whether AbstractCore runs in-process or as a server.

Cascade, per modality (highest wins): explicit request pins (a flow node's
`image_provider`, `tts_provider`, …) > flow defaults > this console default >
flow-scanned bootstrap (**text only**). A media node that names a provider is
never clobbered; a default only fills an absent/Auto one. See
`abstractgateway/provider_defaults.py` for the full contract.

Config beats env. The `output.image` default outranks `ABSTRACTVISION_BACKEND` /
`ABSTRACTCORE_VISION_BACKEND`, exactly as `output.voice` / `input.voice` outrank
`ABSTRACTVOICE_*`, and the voice contract's `active_model` reports the
configured route's model before any `ABSTRACTVOICE_*_MODEL` export. An
environment variable that loses to a configured value is logged once per
distinct (variable, config, env) triple, so a stale export stays visible.
Environment variables remain a labeled `#FALLBACK` for deployments that
configure nothing.

Voice-model environment variables such as `ABSTRACTGATEWAY_VOICE_TTS_MODEL` and
`ABSTRACTVOICE_OPENAI_TTS_MODEL` add entries to the **discovery catalog** — the
list of models a picker can offer. They do not select a default; the
`output.voice` route does.

### Model weights

Capability defaults say which model each route uses. These endpoints say
whether that model's weights are on the execution host, and fetch them when
they are not. They report the same four states as `abstractcore models status`
and both console-TUIs: `installed`, `not downloaded`, `unknown`, `remote`.

| Endpoint | What it does |
|---|---|
| `GET /api/gateway/models/availability` | The capability grid annotated with weight availability, plus the recommended fresh-install set — its raw counts (`total`, `installed`, `absent`, `would_download`) and `gaps`, the subset whose route has nothing else serving it. The text entry of `recommended` also carries AbstractCore's reasons: `catalog_id`, `basis` (`apple_silicon_tiers` or `portable_default`), `tier`, `fit_verdict`, `fits` and `warning` (a sentence when the model may not fit this computer). Read-only; never downloads. |
| `POST /api/gateway/models/download` | `{"provider": "...", "artifact": "..."}` or `{"recommended": true}`, with optional `"dry_run": true`. Returns a job id immediately. |
| `GET /api/gateway/models/download/{job}` | One job's progress: status, percent, byte counts and the provider tool's own recent output. |
| `GET /api/gateway/models/downloads` | Every download job this Gateway process knows about. |

The web console renders this as a **Weights** column on the capability-defaults
table, with a per-row download button and a fresh-install banner. In both
console-TUIs the verb is `w` on the Routes screen.

**The recommendation is advice for an empty route, not a standing debt.** The
banner speaks only about `gaps` — a recommended model that is absent *and*
whose route has nothing serving it. Route text generation at a model of your
own and the starter kit's LM Studio build stops being reported as missing:
nothing on this host needs it. A route whose *own* model is not downloaded is
still reported, on that row, in the Weights column. "Apply recommended" is a
standing action in the section head (`a` on the TUI Routes screen), available
whether or not the banner has anything to say.

**The artifact is not the model id.** A route stores the id the provider
*serves* (`qwen/qwen3.5-9b`); the download names the exact weights,
quantization included (`qwen/qwen3.5-9b@4bit`). The availability payload
carries `download_artifact` on any row where these differ — post that, not the
row's `model`.

**Single-flight.** A second request for an artifact already downloading joins
the running job instead of starting a second copy of the provider's tool; the
returned job's `joined` counter says so.

**Jobs run in AbstractCore's host job registry.** Downloads, deletes and
engine installs are jobs of one kind (`host_job_v1`), readable at
`GET /api/gateway/jobs/{id}` as well as through the older
`GET /api/gateway/models/download/{job}` (which keeps its `{ok, job}` envelope
and reports a queued job as `running`). AbstractCore keeps a snapshot of each
job on disk, so jobs started by `abstractcore models download` on the same
machine appear too. A job the gateway no longer knows returns 404; that is not
a lost download — the provider tool owns the bytes. Re-read
`/api/gateway/models/availability` to learn whether the weights landed.

**A default whose weights are missing does not stop the Gateway.** The host
loads, bundles register, and the failure surfaces when a run actually needs that
model — naming the capability route that configured the pair, how to change it,
and how to download it.

### Models and engines

The **Models** and **Engines** tabs of the web console, the terminal console
and the `abstractgateway models …` / `abstractgateway engines …` commands show
the same things AbstractCore shows (`abstractcore models …`, `abstractcore
engines …`): the host's hardware, the local inference engines, a model catalog
with a "fits this machine" verdict per download, the models already installed,
and the jobs that download, delete or install. The gateway does not detect
engines or size models itself; it serves AbstractCore's answers
(AbstractCore 2.14.0 or newer), so both entry points always agree.

| Endpoint | What it does |
|---|---|
| `GET /api/gateway/host/profile` | This host: OS, accelerator, RAM/VRAM, how much memory a model may use, free disk per model store. |
| `GET /api/gateway/engines?probe=1` | Ollama, LM Studio, MLX, llama.cpp, vLLM, Hugging Face: supported here, installed, version, running, and the exact install command. `install_allowed` says whether installs are enabled on this gateway. |
| `POST /api/gateway/engines/{id}/install` | `{"dry_run": true}` shows the command; `{"dry_run": false}` runs it on the gateway host as a job. Admin only, and only when `allow_engine_install` is on. |
| `GET /api/gateway/models/catalog?q=&engine=&fits=1&hub=1` | Downloadable models with presence and a fit verdict (`fits`, `tight`, `too_large`, `partial_offload`, `unknown`). |
| `GET /api/gateway/models/installed?provider=` | Every installed model per engine, with sizes and what would block a delete. |
| `POST /api/gateway/models/download` | Download one model as a job (see [Model weights](#model-weights)). Admin only. |
| `POST /api/gateway/models/delete` | `{"provider", "artifact", "dry_run", "force"}`: delete one model as a job. Admin only; refuses a loaded or shared model unless `force`. |
| `GET /api/gateway/jobs`, `GET /api/gateway/jobs/{id}`, `POST /api/gateway/jobs/{id}/cancel` | Download, delete and install jobs, newest first; cancel is admin only. |

Every job carries a `cli_equivalent` you can run by hand, for example
`abstractgateway models download ollama qwen3:8b` or
`abstractgateway engines install ollama --yes`. Payloads and refusals are
listed in [api.md](docs/api.md#models-and-engines).

<a id="allow_engine_install"></a>
#### `allow_engine_install`

Installing an engine runs its vendor installer (for example
`brew install ollama`) **on the machine that runs the gateway**, which for a
remote gateway is not the machine of the person clicking. So installs are
controlled by the runtime-config setting `allow_engine_install`:

| Gateway bound to | Default for someone at the gateway machine | Default for another computer |
|---|---|---|
| a loopback address (`127.0.0.1`, `::1`, `localhost`), which is what a bare `abstractgateway serve` and `abstractgateway service install` use | on | on |
| any other address (`0.0.0.0`, a LAN IP, a host name), or started without `abstractgateway serve` | on | off |

"Someone at the gateway machine" is a request whose socket peer is loopback
or one of this host's own interface addresses (a browser on the gateway
machine that uses its LAN address counts), with no proxy header
(`Forwarded`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Real-IP`): see
[security.md](docs/security.md). The same rule gates app installs (Apps page,
tray). `install_policy` reports `caller_on_this_machine` and, when that rule
decided, `source: "default_same_machine"`.

An admin changes it with
`POST /api/gateway/admin/runtime-config {"allow_engine_install": true}`
(`false` turns it off, `null` returns to the default). The current value and
where it came from are in `GET /api/gateway/admin/runtime-config` and in
`install_policy` on `GET /api/gateway/engines`. There is no environment
variable for it. A dry run ("show the command") is always allowed, and every
install is admin-only and recorded in the audit log.

### Browser apps settings (`apps.*`)

The browser apps (Apps page: Flow, Code, Observer, Continuum, Entity) read five
runtime-config settings. They replace the `ABSTRACTGATEWAY_APPS_*` environment
variables, which remain the labeled fallback rung (precedence **stored > env >
default**, the runtime-config rule: a saved value always wins; an env value it
shadows is reported as `env_shadowed`).

| Key | Label | Default | Value |
|---|---|---|---|
| `apps.node` | Node.js for apps | `auto` | `auto` (Node.js 18+ on this computer, else the gateway's own) · `managed` · `system` · an absolute path to `node` |
| `apps.ports` | Ports for apps | (empty) | a port or `low-high`; empty = each app's usual port, else the next free one in 3100-3199 |
| `apps.host` | Where apps listen | `127.0.0.1` | an IP or host name; `0.0.0.0` opens the apps to every network this computer is on |
| `apps.npm_registry` | npm registry | `https://registry.npmjs.org` | an http(s) URL (a mirror) |
| `apps.pypi_url` | Node.js download index | `https://pypi.org/pypi` | an http(s) URL (a mirror) |

`GET /api/gateway/admin/runtime-config` returns them under `apps` as
`{name: {key, label, help, placeholder, default, env_name, value, source,
note?, env_shadowed?, invalid_stored?, invalid_env?}}` (the registry
`runtime_config.APPS_SETTINGS`: a new knob is one row, and the TUI renders
whatever the payload lists). Writes go through the generic door, admin-only
and audit-logged (`setting_change` on the request's audit line):
`POST /api/gateway/admin/runtime-config {"apps.host": "0.0.0.0"}` (or
`{"apps": {"host": "0.0.0.0"}}`); an empty value clears back to env/default.
Each value is validated before anything is written (400 with the reason). Read
at each use: a change applies at the next app start (`node`, `host`, `ports`)
or the next download (the two URLs).

Three ways, same semantics:

| | Web console | Console TUI | CLI |
|---|---|---|---|
| Where | Apps → *Advanced: apps settings* (one field per setting, with its source pill) | Runtimes → *Runtime knobs* → *Edit apps settings* | `abstractgateway apps config get [NAME] [--json]` |
| Change | type, *Save apps settings* (only changed fields are sent; empty = clear) | one line per setting (stored value prefilled; empty = clear) | `abstractgateway apps config set NAME VALUE` (`""` clears) |
| Refusal | the gateway's sentence (*Not saved*) | the form shows the gateway's sentence | `refused: <sentence>`, exit 2 |

The CLI works on the data dir directly (`--data-dir`, default: the `serve`
resolution), so a headless server needs no browser.

### Backlog folder, exec runner and process manager (Continuum)

Continuum's Board, Backlog, Executions and Services pages read three runtime
settings. A fresh install needs none of them: the gateway keeps its own
backlog in `<data dir>/backlog/`, and creates the standard layout there the
first time the backlog is used (`docs/backlog/overview.md`,
`docs/backlog/template.md`, and the `planned/`, `proposed/`, `completed/`
folders; nothing existing is ever overwritten). Continuum then shows an empty
board with **Create your first item**.

| Key | Label | Default | Value |
|---|---|---|---|
| `triage_repo_root` | Backlog folder | `<data dir>/backlog` (created on first use) | a folder that contains `docs/backlog` (a project checkout), or the gateway's own folder |
| `backlog_exec_runner` | Backlog exec runner | off | `on` / `off`: run the items queued for execution on this machine |
| `process_manager` | Process manager | off | `on` / `off`: Continuum's Services page (process control also needs the backlog folder set to the framework checkout it manages) |

**Where a value comes from** (one resolution, `runtime_config.resolve_backlog_root`
and `resolve_exec_runner`; every consumer calls it: the backlog, report,
triage and process routes, the exec runner at each poll, the skills shelf):

1. the launch flag of the running gateway: `abstractgateway serve --backlog-root PATH`
   and `--exec-runner on|off` (for that run only; source `flag`);
2. the saved setting (source `stored`);
3. a legacy environment value, for gateways set up before these settings
   existed (source `env`; reported, never needed; saving a value replaces it);
4. the default (source `default`).

`GET /api/gateway/admin/runtime-config` serves each as `{value, source, key,
label, help, cli, flag?}`; the backlog folder also carries `available`,
`reason` (why it is not usable, without the path), `default_path` and, under a
launch flag, the `stored_value` that applies once the gateway restarts
without it. Non-admins get the posture without server paths.
`GET /api/gateway/backlog/status` answers the same question for Continuum
(any signed-in user; paths for admins only).

Changing them, three doors with one validation (a folder must exist and
contain `docs/backlog`, or be the gateway's own folder, which is created; a
switch is `on` or `off`; a refusal is one plain sentence):

| | Web console | CLI | Continuum |
|---|---|---|---|
| Where | Apps → *Advanced: backlog settings (Continuum)* | `abstractgateway config get [KEY] [--json]` | Settings → *Gateway administration* |
| Change | edit, *Save backlog settings*; *Use the gateway's own folder* | `abstractgateway config set KEY VALUE`, `abstractgateway config unset KEY` | *Change…*, *Use the gateway's own folder*, *Enable* / *Disable* |

`config set` goes through the running gateway's door when one serves this
data dir on this machine (it applies at once and lands in the audit log);
otherwise it writes the settings store and the next start reads it. The same
`config get|set|unset` covers every runtime setting (`executor`,
`apps.<name>`, …).

When a saved folder disappears (a deleted or unmounted checkout), the backlog
routes answer `404` *Backlog folder not available on this gateway: the folder
does not exist (set by the saved setting)…* and Continuum shows the folder,
the reason and, for an admin, **Use the gateway's own folder** and **Choose a
folder…**.

Evidence: `src/abstractgateway/runtime_config.py` (`resolve_backlog_root`,
`resolve_exec_runner`, `validate_backlog_root`, `BACKLOG_SETTINGS`),
`src/abstractgateway/assets/backlog_skeleton/`, `src/abstractgateway/config_cli.py`,
`tests/test_gateway_backlog_root_settings.py`.

### Host state and model residency

Beyond weights on disk, these endpoints report and control what is loaded in
memory right now:

| Endpoint | What it does |
|---|---|
| `GET /api/gateway/host/state` | One-call host snapshot: memory, GPU, resident models (frozen `model_residency_row_v1` rows), session prompt caches, and byte totals. Sections degrade independently in-band (`degraded` + `reasons`); never a 500. |
| `GET /api/gateway/host/metrics/memory` | Host RAM/process/device memory snapshot; answers `supported: false` with a reason when the runtime facade has no snapshot. |
| `GET /api/gateway/host/metrics/gpu` | GPU utilization probe with the same `supported`/degraded style. |
| `GET /api/gateway/models/loaded` | Model residency listing: raw `models` records plus normalized `rows` (`row_schema = "model_residency_row_v1"`, including lock, modality, context-calibration, and host-identity fields). |
| `GET /api/gateway/models/context_estimate` | Context/KV memory estimate for a `provider`+`model` (optional `context_length` >= 1), with in-band `confidence`: `calibrated`, `estimated`, or `unknown`. |
| `POST /api/gateway/models/load` | Load (and by default pin) a model runtime. Admin only. |
| `POST /api/gateway/models/unload` | Unload a model runtime by `runtime_id` or task/provider/model selector. A locked model answers HTTP 409 unless the request carries `"force": true`. Admin only. |
| `POST /api/gateway/models/lock` | Lock a resident model against unload (same target selector as unload). Admin only. |
| `POST /api/gateway/models/unlock` | Release a model-residency lock. Admin only. |

Both consoles render this surface as a **Resources** view — a tab in the web
console, screen 8 in the console-TUI: memory and GPU meters, the resident-model
table (modality, tri-state residency, lock state, context facts), and session
prompt caches. Any authenticated user can browse it and request context
estimates; the warm-up, lock/unlock, unload (with a force confirmation when a
locked model answers 409), and cache-clear controls appear for admins.

The reads are available to any authenticated principal; the mutations (and
`POST /models/download` above) require an admin principal, and anonymous
requests are always rejected. For local development only,
`ABSTRACTGATEWAY_DEV_READ_NO_AUTH=1` (default off) allows unauthenticated
loopback reads as a non-admin read-only principal — see
[security.md](docs/security.md).

See [api.md](docs/api.md#host-state-and-model-residency) for payload shapes and
the `model_residency_row_v1` field list.

### Host control: pause, desktop tray, restart, update

The process's own controls (system tray + console, 2026-09-05). Reads are
user-level; every write is admin-only.

| Endpoint | What it does |
|---|---|
| `GET /api/gateway/host/runner` | `paused`, `paused_at`, `paused_by`, `reason`, `inflight_ticks` (runs still finishing their current step), `scope` (`"workflow runner"`), `runner_in_process`, `step_gate_supported`, restart/shutdown `capabilities`. |
| `POST /api/gateway/host/pause` / `resume` | Pause or resume execution process-wide (persisted in `<data_dir>/gateway_paused.json`). Body `{"reason": "..."}` optional. |
| `GET /api/gateway/host/metrics/live` | GPU + memory + paused/in-flight in one call, cached 1 s server-side — the tray's fast lane. |
| `GET /api/gateway/host/runs` | Recent runs across every data plane on this machine (`limit`, `window_hours`), newest first, with a readable `label` and the step count. Admin — it crosses tenants. Cached 5 s. Entity planes are skipped and named in `skipped_entity_planes`. |
| `GET /api/gateway/host/tray` | Whether the tray helper runs (`pid`, `ready`), and the decision (`reason`, `hint`) when it does not. |
| `POST /api/gateway/host/tray/show` | Retry the helper now (admin) — the escape hatch for one that crashed. There is no `hide`. |
| `POST /api/gateway/host/restart` / `shutdown` | Graceful restart (same command, same environment) or stop; `409` with the reason when this process cannot (`--reload`, not started by `abstractgateway serve`, an update is installing). |
| `GET /api/gateway/host/update` | How the gateway was installed (`install.kind`, `upgradable`, the command), the last update check, the upgrade job, `restart_pending`. |
| `POST /api/gateway/host/update/check` / `start` | Ask pypi.org for the latest release (offline is an in-band answer) / run the upgrade in the background. |

The tray icon has **no setting**: while the gateway serves a desktop that can
hold it, it is there. It is absent only for reasons that are facts about the
machine — no display, no `tray` extra, `serve --reload`, a runner-only process
— and `GET /host/tray` names which. (The retired `desktop_tray` knob now
refuses with that explanation rather than accepting a write that does
nothing.) `GET /api/health` carries `"paused": true` while paused (status
stays `healthy`). Full description: [tray.md](docs/tray.md).

### Runtime-scoped Core capability defaults

In hosted user-auth mode, `GET /api/gateway/config/capability-defaults` returns
the execution-host Core capability routes plus the Gateway/root baseline and
any defaults configured for the current Gateway principal. The bootstrap
`default/admin` principal edits the Gateway baseline when it uses the default
runtime. Normal user writes to
`PUT /api/gateway/config/capability-defaults/{kind}/{modality}` or
`PUT /api/gateway/config/capability-defaults/{kind}/{modality}/{task}` are stored under
that principal's Gateway data plane as a Core config file and override the
Gateway baseline only for that user:

```text
$ABSTRACTGATEWAY_DATA_DIR/config/abstractcore.json
$ABSTRACTGATEWAY_DATA_DIR/users/<tenant>/<runtime>/runtime/config/abstractcore.json
```

This lets operators set a Gateway default and lets hosted users choose
remote-provider defaults for their own runtime without mutating the operator's
global AbstractCore config or other users. The route schema, normalization,
task-specific generated-media suffixes, and file format come from AbstractCore
capability-default contracts. Capability defaults live only in the AbstractCore
config file; a `config/capability_defaults.json` overlay from an older Gateway is
ignored, and its defaults are recreated with `abstractgateway-config set-default
...`. Provider API keys and raw secrets are
not returned by these routes. Use Gateway provider connections when a route
default needs an API key or custom base URL.

Gateway model discovery delegates to AbstractRuntime's AbstractCore discovery
facade. LLM and embedding default pickers can filter models with Core route keys
such as `capability_route=input.image,output.text` or
`capability_route=embedding.text`. Generated image/video/voice/sound/music
defaults continue to use their capability plugin catalogs so provider readiness,
download/setup state, and backend-specific metadata do not get written into the
raw Core model registry.

CLI examples:

```bash
# Gateway baseline Core default
abstractgateway-config set-default input.text \
  --provider endpoint:openai-prod \
  --model gpt-4.1

# One user's runtime Core override
abstractgateway-config set-default input.text \
  --scope user \
  --tenant default \
  --user alice \
  --provider endpoint:alice-openai \
  --model gpt-4.1

abstractgateway-config defaults --scope user --user alice
```

#### Modality rows and task rows

`output.image`, `output.video` and `output.scene3d` are the **parent** rows of
their `output.<modality>.<task>` siblings, not legacy duplicates of them. The
parent answers every task of that modality that has no row of its own, so
setting it alone is the simple path (one image model for generate, edit and
upscale) and is what a fresh install seeds. A task row overrides it for that
task, wholesale — route rows are single coherent backend identities and are
never field-merged with their parent.

Resolution everywhere — execution, the Sandbox, and what `/capabilities`
advertises — is **task row first, modality row second**. A modality-level
question resolves through the canonical generation task
(`output.image.text_to_image`) before falling back to `output.image`, so the
backend Gateway advertises is always the backend it will execute.

`output.voice`, `output.sound` and `output.music` have no task rows; their
modality row is the primary key, not a fallback.

In the Multimodal Capabilities grid the task rows are indented beneath their
modality row, and a modality row that is unset while every task row beneath it
is configured shows `not needed` rather than `not configured` — nothing can
reach it in that state. It stays editable, because setting it is still the
one-value-for-everything path.

`input.text` is the canonical text LLM route. `output.text` is reported as a
read-only derived view of `input.text`, and CLI/API writes to `output.text` are
canonicalized to `input.text` for compatibility. `input.image` is a fallback
image-understanding route only: when the selected `input.text` model is known
from AbstractCore model capabilities to accept image input, the console marks
`input.image` as covered by `input.text` and disables separate editing.
`input.video` follows the same coverage model when the text model can handle
visual frames, but it remains overrideable so operators can choose a dedicated
video/VLM route. `input.voice` is the speech-to-text fallback route; if it is
not configured and the selected text model cannot accept audio natively,
Gateway/Core fail clearly instead of using a hidden installed STT backend.
`input.sound` is for non-speech audio understanding and is not used as STT.
`input.music` is the corresponding music-audio understanding route. `input.sound`
and `input.music` may be shown as covered by `input.text` only when the selected
text model is known to accept those native inputs, and both rows remain
overrideable.
Audio-language candidates such as `qwen3-omni-30b-a3b-instruct`,
`qwen3-omni-30b-a3b-captioner`, `qwen2.5-omni-7b`, and
`qwen2-audio-7b-instruct` are registry-known options when the configured
provider can serve them. Qwen3.6 text/image/video defaults should not be treated
as sound or music understanding models.

### Provider connections

Gateway Console and `POST /api/gateway/config/provider-endpoint-profiles` let
signed-in users define reusable provider connections through a guided setup
flow for `openai`, `anthropic`, `openrouter`, `portkey`, `lmstudio`, `ollama`,
or `openai-compatible`. A connection includes a stable id, display name,
description, optional base URL, optional API key, and optional advanced model
allowlist. The raw API key is write-only: responses include only `api_key_set`
and a short fingerprint. AbstractCore owns model capability metadata, so normal
setup does not ask users to classify models manually.

The console's **Test** action calls the selected provider through
`POST /api/gateway/config/provider-endpoint-profiles/discover-models` and
previews model discovery before saving. Leave the advanced model restriction
empty to keep live discovery active, or select one or more models to store a
fixed allowlist. The **Multimodal Capabilities** tab shows configured provider
connections and direct providers that are already usable from scoped
AbstractCore config or environment variables. It does not collect endpoint base
URLs or API keys. Reachable default local servers such as LM Studio and Ollama
also appear automatically when Gateway can discover models from their
configured/default endpoint.

Enabled profiles appear in `GET /api/gateway/discovery/providers` as virtual
provider ids such as `endpoint:office-vllm`. Direct configured providers such
as `openai` or `anthropic` also appear automatically when their required API
key is available from scoped Core config or process environment. Use those
provider ids in Flow nodes or Gateway capability defaults. At runtime the
Gateway host resolves virtual providers to the real provider family, base URL,
and API key for the transient AbstractRuntime call; direct providers use the
scoped Core config/environment already available to the execution host.
Workflow JSON and browser storage do not contain the raw secret. Normal users
can manage user-scoped profiles. Gateway-scoped profiles require an admin
principal.

The console **Sandbox** tab reuses this configuration. It tests the selected
multimodal capability default rather than an ad hoc provider/model pair. Text
chat uses the configured text route, and generated media tests use configured
routes such as `output.image.text_to_image`, `output.video.text_to_video`,
`output.voice`, `output.sound`, and `output.music`. Image edit, image upscale,
and image-to-video are configured separately in the Multimodal Capabilities tab
through `output.image.image_to_image`, `output.image.image_upscale`, and
`output.video.image_to_video`. The Sandbox renders generated images, videos,
voice, sound, and music artifacts inline when the route completes, while keeping artifact
links available for opening the raw content. Text chat can include uploaded
attachments such as images, audio, video, PDFs, Markdown, or text documents.
Uploaded attachments are stored as Gateway artifacts and then materialized by
Runtime into provider-ready media for AbstractCore, so vision-capable
OpenAI-compatible text routes receive image uploads as native multimodal
`image_url` content. Sandbox text turns also send bounded browser-local
grounding context, including local datetime, timezone, timezone offset, and
locale. Runtime may use that browser context for prompt grounding only; it keeps
server-derived context as provenance and never uses browser metadata for auth,
runtime routing, or credential selection. Country grounding is inferred from the
browser timezone when possible, with locale only as a fallback.

### Workspace policy (filesystem scope)

The gateway enforces a server-side workspace policy so thin clients cannot expand filesystem access by sending arbitrary paths.

Operator-controlled roots:
- `ABSTRACTGATEWAY_WORKSPACE_DIR`: base directory used for `/api/gateway/files/*` helpers and to clamp run-provided `workspace_root` / `workspace_allowed_paths`.
- `ABSTRACTGATEWAY_WORKSPACE_MOUNTS`: additional allowed roots, newline-separated `name=/abs/path`.

Client scope overrides (permissive; trusted machines only):
- `ABSTRACTGATEWAY_ALLOW_CLIENT_WORKSPACE_SCOPE=1` (or `ABSTRACTGATEWAY_TRUST_CLIENT_WORKSPACE_SCOPE=1`) enables honoring client-provided `workspace_*` knobs, including `workspace_access_mode=all_except_ignored`.

Discoverability:
- `GET /api/gateway/workspace/policy` returns `{policy: {...}}` including whether client overrides are enabled (mount names only; no absolute paths).

Evidence: `src/abstractgateway/routes/gateway.py` (`_workspace_root`, `_workspace_mounts`, `_sanitize_run_workspace_policy`, `_client_workspace_scope_overrides_enabled`, `start_run`).

### Durability backend

- `ABSTRACTGATEWAY_STORE_BACKEND`: `file` (default) or `sqlite`
  Evidence: `src/abstractgateway/service.py`
- `ABSTRACTGATEWAY_DB_PATH`: SQLite DB file path (optional; default: `<DATA_DIR>/gateway.sqlite3`)
  Evidence: `src/abstractgateway/stores.py` (`build_sqlite_stores`)
  Note: for safety, when `ABSTRACTGATEWAY_STORE_BACKEND=sqlite`, the DB path must be **under** `ABSTRACTGATEWAY_DATA_DIR`.
  The gateway fails fast if `ABSTRACTGATEWAY_DB_PATH` points elsewhere (prevents cross-wiring UAT/prod durable state).

### KG memory store

Gateway selects an AbstractMemory TripleStore through a small resolver; it does
not implement memory stores itself.

- `ABSTRACTGATEWAY_MEMORY_STORE_BACKEND`: `lancedb` (default), `memory`, or `sqlite` when the installed AbstractMemory build exposes `SQLiteTripleStore`
- `ABSTRACTGATEWAY_MEMORY_STORE_PATH`: optional explicit store path
- `ABSTRACTGATEWAY_MEMORY_REQUIRE_VECTOR=1`: fail fast when the selected backend cannot satisfy semantic/vector recall

Backend behavior:

- `lancedb`: persistent and vector-capable; semantic `query_text` requires the execution-host
  `embedding.text` capability route.
- `sqlite`: persistent and structured-query only when `SQLiteTripleStore` is available; semantic `query_text` fails clearly.
- `memory`: process-local test/dev backend; non-durable.

The same resolver is used for bundle `memory_kg_*` nodes and
`POST /api/gateway/kg/query`. Capability discovery reports memory backend,
persistence, vector support, and embedder status. A missing on-disk store is not
an unavailable state by itself: when AbstractMemory is installed and the backend
resolves, fresh stores are authoring-ready and structured queries simply return
no matches until assertions are written.

### Runner tuning (advanced)

These map to `GatewayHostConfig` and `GatewayRunnerConfig`:
- `ABSTRACTGATEWAY_RUNNER`: `1` (default) / `0` to disable runner in-process
  Evidence: `src/abstractgateway/config.py`, `src/abstractgateway/cli.py`
- `ABSTRACTGATEWAY_POLL_S` (default `0.25`)
- `ABSTRACTGATEWAY_COMMAND_BATCH_LIMIT` (default `200`)
- `ABSTRACTGATEWAY_TICK_MAX_STEPS` (default `100`)
- `ABSTRACTGATEWAY_TICK_WORKERS` (default `4`)
- `ABSTRACTGATEWAY_RUN_SCAN_LIMIT` (default `200`)

Evidence: `src/abstractgateway/config.py`, `src/abstractgateway/runner.py`.

### Stop and the kill switch

A `cancel` command (the Stop button) cancels the run tree AND stops the model
call that is executing now: the runtime hands the call a cancel event and the
provider stops within one token (MLX) or one stream chunk (any streaming
provider). The stopped call is recorded as an `llm_call` step with status
`cancelled` and `cancelled_by: command`.

If a model call of the cancelled tree is still executing after the kill-switch
deadline (a provider lane that cannot observe the event, e.g. a non-streaming
HTTP request), the gateway kills THAT INFERENCE in process — never the gateway
process: other runs, sessions and the HTTP API keep working. The runtime
injects an `EffectKilled` exception into the one thread executing the call (it
unwinds within one token of a Python-level decode loop); the step is recorded
`cancelled` with `killed_by: kill_switch`. The gateway logs an ERROR line
(`STOP KILL SWITCH FIRED … killed_by=kill_switch action=kill_inference`), writes
an `abstract.status` record "Stop forced at N s: inference killed" on every run
of the tree (the web UI shows it), and ends the runs CANCELLED with that reason.
A thread blocked inside ONE native call for more than 5 s after the kill
(`KILL_GRACE_S`) is reported as "could not be interrupted" in the log and the
ledger; the pending kill fires when that call returns. Tools are never
escalated: a tool still running is named in the log and its result is never
fed to another model call.

| Knob (runtime config key / env) | Default | Meaning |
|---|---|---|
| `stop_kill_switch_s` / `ABSTRACTGATEWAY_STOP_KILL_SWITCH_S` | `10` | seconds after the cancel is applied; `0` disables (logged at ERROR on every Stop) |

It is read at every Stop (runtime config via `POST /api/gateway/admin/runtime-config`
supersedes env, env supersedes the default). Evidence: `src/abstractgateway/stop_kill_switch.py`.

## LLM/tool defaults (bundle mode)

Only needed when the loaded bundle(s) contain LLM/tool/agent nodes.

- `input.text` capability route
  Default text route for LLM execution and Gateway LLM helper endpoints. Configure it through
  `abstractgateway-config set-default input.text ...` or
  `abstractcore config set-default input.text ...`.
  If no pair is configured, helpers return a clear configuration error instead of falling back to a
  hardcoded model.
  Evidence: `src/abstractgateway/provider_defaults.py`, `src/abstractgateway/hosts/bundle_host.py`
- `ABSTRACTGATEWAY_TOOL_MODE`:
  - `approval` (default): execute safe tools locally; require explicit approval for dangerous/unknown tools
  - `passthrough`: require explicit approval for *all* tools (then execute in-process on resume)
  - `delegated`: do not execute tools; tool calls yield a durable `JOB` wait for external executors
  - `local` (or `local_all`): execute all tools inside the gateway process (dev only; higher risk)
  Evidence: `src/abstractgateway/hosts/bundle_host.py` (tool executor selection)

### Embeddings

The gateway exposes an embeddings API when the execution host has an explicit `embedding.text`
capability default. Remote/provider-backed embeddings work with the base
remote-light install; local HuggingFace/sentence-transformer embeddings require
`abstractgateway[embeddings]`.

Configure it through the same capability-default control plane used by Flow:

```bash
abstractgateway-config set-default embedding.text \
  --provider lmstudio \
  --model text-embedding-nomic-embed-text-v1.5 \
  --base-url http://127.0.0.1:1234/v1
```

In embedded deployments Gateway uses the local Core embedding manager. In split deployments it
delegates to the remote AbstractCore `/v1/embeddings` route so provider `base_url` is evaluated
from the Core host.

Evidence: `src/abstractgateway/embeddings_config.py`

### Prompt cache controls (provider-dependent)

Gateway prompt-cache endpoints are available when the AbstractCore integration
for the active provider/model exposes them. Remote providers usually provide
server-managed cache hints; local in-process providers can expose stronger
control-plane operations when installed in a custom runtime image.
Provider-level endpoints remain available for operators, and session-level
endpoints provide a deterministic gateway-owned namespace/key lifecycle for thin
apps without pretending unsupported providers have local KV state.

- `GET /api/gateway/prompt_cache/capabilities`
- `GET /api/gateway/prompt_cache/stats`
- `POST /api/gateway/prompt_cache/set`
- `POST /api/gateway/prompt_cache/update`
- `POST /api/gateway/prompt_cache/fork`
- `POST /api/gateway/prompt_cache/clear`
- `POST /api/gateway/prompt_cache/prepare_modules`
- `POST /api/gateway/blocs/upsert_text`
- `GET /api/gateway/blocs/record`
- `GET /api/gateway/blocs`
- `POST /api/gateway/blocs/delete`
- `GET /api/gateway/blocs/kv/manifest`
- `GET /api/gateway/blocs/kv/list`
- `POST /api/gateway/blocs/kv/ensure`
- `POST /api/gateway/blocs/kv/load`
- `POST /api/gateway/blocs/kv/delete`
- `POST /api/gateway/blocs/kv/prune`
- `GET /api/gateway/prompt_cache/saved`
- `POST /api/gateway/prompt_cache/save`
- `POST /api/gateway/prompt_cache/load`
- `GET /api/gateway/sessions/{session_id}/prompt_cache/status`
- `POST /api/gateway/sessions/{session_id}/prompt_cache/prepare`
- `POST /api/gateway/sessions/{session_id}/prompt_cache/rebuild`
- `POST /api/gateway/sessions/{session_id}/prompt_cache/clear`
- `GET /api/gateway/sessions/prompt_cache`
- `POST /api/gateway/sessions/{session_id}/prompt_cache/clear_all` (admin)

Session lifecycle responses distinguish `unsupported`, `keyed`, and
`local_control_plane` modes. Keyed providers receive a stable `runtime_hint`;
local-control-plane providers can prepare, clear, and rebuild when their
AbstractCore provider exposes those operations.

Treat the prompt-cache surfaces separately:

- `/prompt_cache/*`: provider/model prompt-cache controls
- `/sessions/{session_id}/prompt_cache/*`: gateway-owned volatile session
  lifecycle, derived from the session/bundle/provider identity
- `GET /sessions/prompt_cache` + `/sessions/{session_id}/prompt_cache/clear_all`:
  enumeration of the caches the runtime actually minted — the recommended lane
  for observing and reclaiming session cache state, because it cannot miss
  caches whose keys the gateway never derived
- `/blocs/*`: durable exact-reuse bloc/KV contract that returns `prompt_cache_binding`

The `saved` / `save` / `load` aliases are Runtime-backed host-local admin
operations. Local runtimes write under `<DATA_DIR>/prompt_cache_exports`; remote
and hybrid runtimes report `prompt_cache_local_only`.

### Multimodal provider/plugin controls

The base install already includes the Gateway HTTP/SSE server and the Runtime
multimodal integration layer. Direct Gateway routes for voice/audio, image/video,
and music become available when the corresponding lower-layer capability
packages are installed on the gateway host (or when Gateway is configured to
proxy to a remote AbstractCore server).

Local heavy engines remain explicit opt-ins in the provider packages; Gateway
does not implicitly install them.

- `input.text` capability route: default text model for bundle LLM nodes
- `OPENAI_BASE_URL` / `OPENAI_API_KEY`: generic OpenAI-compatible text endpoint for AbstractCore providers
  - Apple/MLX Docker deployments should point the lightweight Gateway container
    at host-native inference, for example
    `http://model-runner.docker.internal/engines/v1`,
    `http://host.docker.internal:1234/v1`, or another `/v1` endpoint.
- `LMSTUDIO_BASE_URL` / `OLLAMA_BASE_URL`: named local endpoint providers for
  LM Studio and Ollama model discovery/routing from inside the Gateway container.
- `ABSTRACTGATEWAY_VISION_BACKEND` / `ABSTRACTGATEWAY_VISION_BASE_URL` / `ABSTRACTGATEWAY_VISION_API_KEY` / `ABSTRACTGATEWAY_VISION_MODEL_ID`: Gateway-scoped image backend settings. Legacy `ABSTRACTVISION_*` names are still accepted by the lower package.
- `ABSTRACTGATEWAY_VOICE_TTS_ENGINE` / `ABSTRACTGATEWAY_VOICE_STT_ENGINE`: Gateway-scoped voice engine settings. Legacy `ABSTRACTVOICE_*` names are still accepted by the lower package.
- `ABSTRACTGATEWAY_VOICE_TTS_MODEL` / `ABSTRACTGATEWAY_VOICE_STT_MODEL`: Gateway-scoped TTS/STT model defaults.
- `ABSTRACTGATEWAY_VOICE_REMOTE_BASE_URL` / `ABSTRACTGATEWAY_VOICE_REMOTE_API_KEY`: remote voice endpoint used by AbstractVoice.
- `GET /api/gateway/discovery/capabilities`: reports installed packages plus AbstractCore capability plugins for `voice`, `audio`, `vision`, and `music`; also returns `capabilities.contracts.version=1` with thin-client feature gates for AbstractFlow, AbstractAssistant, AbstractCode, shared run input/history endpoints, artifact search/import/export, direct voice/audio/image/video/music endpoints, workflow-backed image/video generation, and provider/session prompt-cache controls
- `GET /api/gateway/voice/voices`: proxies AbstractCore `/v1/audio/voices` when `ABSTRACTCORE_SERVER_BASE_URL` is configured; otherwise returns static Gateway/env voice descriptors.
- `GET /api/gateway/audio/speech/models`: proxies AbstractCore `/v1/audio/speech/models` when configured.
- `GET /api/gateway/audio/transcriptions/models`: proxies AbstractCore `/v1/audio/transcriptions/models` when configured.
- `GET /api/gateway/audio/music/providers`: proxies AbstractCore `/v1/audio/music/providers` when configured.
- `GET /api/gateway/audio/music/models`: proxies AbstractCore `/v1/audio/music/models` when configured.
- `GET /api/gateway/vision/provider_models`: proxies AbstractCore `/v1/vision/provider_models` when configured.
- `GET /api/gateway/vision/models`: reports locally known/cached AbstractVision model ids when the in-process capability path is available.
- `GET /api/gateway/vision/adapters`: lists installed compatible vision adapters for a provider/model/task combination through Runtime's discovery facade.
- `POST /api/gateway/runs/{run_id}/images/generate`: creates a durable Runtime child run for text-to-image and returns an artifact-backed image result. Optional `size`/`width`/`height`, batch `count` / `n`, `seeds`, and ordered `lora_adapters` values are passed through only when the client supplies them. Batch responses also return `image_artifacts` alongside the compatibility `image_artifact`.
- `POST /api/gateway/runs/{run_id}/images/edit`: creates a durable Runtime child run for image-to-image edits and optional mask-guided edits. Optional `size`/`width`/`height`, batch `count` / `n`, `seeds`, and ordered `lora_adapters` values are passed through only when the client supplies them. Batch responses also return `image_artifacts`.
- `POST /api/gateway/runs/{run_id}/images/upscale`: creates a durable Runtime child run for image upscaling from a run-visible `image_artifact`. Optional `resolution` accepts a shortest-edge integer or a scale factor such as `2x`; `scale`, `softness`, `seed`, `quantize`, and `vae_tiling` values are passed through only when the client supplies them.
- `POST /api/gateway/runs/{run_id}/videos/generate`: creates a durable Runtime child run for text-to-video and returns an artifact-backed video result. Optional batch `count` / `n`, `seeds`, ordered `lora_adapters`, and `flow_shift` values are passed through only when the client supplies them. Batch responses also return `video_artifacts`.
- `POST /api/gateway/runs/{run_id}/videos/from_image`: creates a durable Runtime child run for image-to-video and returns an artifact-backed video result. Optional batch `count` / `n`, `seeds`, ordered `lora_adapters`, and `flow_shift` values are passed through only when the client supplies them. Batch responses also return `video_artifacts`.
- `POST /api/gateway/runs/{run_id}/music/generate`: creates a durable Runtime child run and returns an artifact-backed music result for thin clients.

Direct image, image-edit, image-upscale, text-to-video, and image-to-video child runs advertise
`event_name=abstract.progress`. Thin clients should stream the returned
`child_run_id` ledger and render progress when the backend reports it; image
backends that do not expose step progress still emit at least a start record and
then the final artifact.

Core catalog proxy settings:

- `ABSTRACTCORE_SERVER_BASE_URL`: explicit Core server base URL for catalog proxying.
- `ABSTRACTGATEWAY_ABSTRACTCORE_SERVER_AUTH_TOKEN` / `ABSTRACTGATEWAY_ABSTRACTCORE_SERVER_API_KEY`
  (or Core's `ABSTRACTCORE_AUTH_TOKEN` / `ABSTRACTCORE_SERVER_API_KEY`): Core server auth token.
  This is separate from Gateway auth.
- `ABSTRACTGATEWAY_CORE_CATALOG_TIMEOUT_S`: catalog proxy timeout (default `3.0` seconds).

## CLI flags

`abstractgateway --help` shows all subcommands (serve/runner/migrate/triage/…).

Most-used:
- `abstractgateway serve [--host H] [--port P] [--data-dir DIR] [--no-runner] [--reload]`
  (host/port default to the [network exposure](#network-exposure-localhost--local-network--internet)
  setting; with none stored, `--host` defaults to `127.0.0.1` when no auth is
  configured, else `0.0.0.0`, and `--port` to `8080`. Explicit flags override the setting.)
  Evidence: `src/abstractgateway/cli.py`
- `abstractgateway network status|set|addresses|restart`: who can reach the
  gateway and the URLs to copy ([network exposure](#network-exposure-localhost--local-network--internet))
- `abstractgateway claim [--open] [--port P | --url URL] [--json]`: one-time
  console sign-in link ([first-run.md](docs/first-run.md))
- `abstractgateway service install|uninstall|enable|disable|status [--port P] [--host H] [--pin-command-line] [--data-dir DIR] [--dry-run] [--json]`:
  start the gateway at login (LaunchAgent, systemd user unit or XDG autostart
  entry, Windows Run entry; `enable`/`disable` are the tray's switch;
  [first-run.md](docs/first-run.md#4-start-the-gateway-at-login-optional)). The
  registration runs plain `serve`; `--host/--port` are written into the
  [network exposure](#network-exposure-localhost--local-network--internet)
  setting, or onto the command line with `--pin-command-line`
- `abstractgateway runner` (worker only)
- `abstractgateway config status --json`
- `abstractgateway config get [KEY] [--json]`, `config set KEY VALUE`, `config unset KEY`:
  runtime settings from a terminal ([backlog folder, exec runner, process
  manager](#backlog-folder-exec-runner-and-process-manager-continuum), and every other key)
- `abstractgateway serve --backlog-root PATH --exec-runner on|off`: the backlog
  folder and the exec runner for this run (they win over the saved settings until the gateway stops)
- `abstractgateway migrate --from=file --to=sqlite --data-dir <DIR> --db-path <FILE>`
- `abstractgateway models loaded|load|unload --url <URL> [--provider P --model M] [--force]`
  (model residency on a running gateway; see [console.md](docs/console.md#model-residency-from-a-shell))

## Related docs

- First run: [first-run.md](docs/first-run.md)
- Getting started: [getting-started.md](docs/getting-started.md)
- FAQ: [faq.md](docs/faq.md)
- Security configuration: [security.md](docs/security.md)
- Deployment: [deployment.md](docs/deployment.md)
- API overview: [api.md](docs/api.md)
- Operator tooling env vars: [maintenance.md](docs/maintenance.md)

---

## docs/shipped-workflows.md

# Shipped Workflows

A fresh Gateway install serves a ready-to-use workflow registry. In bundle
mode (the default), Gateway loads the `.flow` bundles packaged with the wheel,
so you can run a proven coding agent, a deep-research pipeline, and a
co-scientist hypothesis engine out of the box — no manual bundle install.

List them on a running gateway:

```bash
curl -sS -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8080/api/gateway/bundles"
```

## The shipped set

`flow_id` values below are what you pass to the API — an entrypoint's display
name is not resolvable. A `*` marks the bundle's default entrypoint, used when
you omit `flow_id`.

| Bundle | Version | `flow_id` (interfaces) | What it does |
| --- | --- | --- | --- |
| `basic-agent` | 0.0.4 | `81795ea9`* (`abstractcode.agent.v1`) | The framework default chat agent: one Agent node with tools, memory, and status updates. Serves entity phases and chat hosts. |
| `coding-agent` | 0.2.6 | `coder` (`abstractcode.agent.v1`), `coding-agent`* (`abstractcode.coding.v1`) | Verify-gated coding: a builder agent writes code, an independent verifier runs build/execute/match gates each round, and failures feed back as reprompts until gates pass. `coder` is the chat entrypoint; `coding-agent` is the structured pipeline. |
| `deep-research` | 0.1.7 | `deep-research`* (`abstractcode.agent.v1`, `abstractresearch.deep.v1`) | Production research with adversarial review, a verified source ledger, and Markdown/PDF/DOCX export. See [deep-research.md](docs/deep-research.md). |
| `co-scientist` | 0.2.0 | `co-scientist`* (`abstractresearch.coscientist.v1`) | Multi-agent hypothesis engine: literature grounding through the deep-research investigation flows, then cycles of generation, reflection, Elo-ranked pairwise debate, and evolution into a final reviewed research overview. |
| `abstractassistant-orchestrator` | 0.0.0 | `d5d4e5a1`* (`abstractassistant.agent.v1`) | Orchestrator for the compact AbstractAssistant tray surface. |
| `docs-qa` | 0.1.0 | `docsqa001`* | Documentation Q&A grounded on the asking app's `llms.txt` corpus; also auto-published into the tenant workflow catalog at boot. |
| `react-agent` / `codeact-agent` / `memact-agent` | 0.1.0 | `react`* / `codeact`* / `memact`* (`abstractcode.agent.v1`) | Native ReAct / CodeAct / MemAct agent loops (abstractagent). |

Interfaces are how clients pick workflows without knowing bundle internals:
anything declaring `abstractcode.agent.v1` can serve a plain chat prompt.
AbstractCode TUI resolves its default agent through that contract — a saved
preference first, then `coding-agent`'s `coder` when installed, then
`basic-agent` — so a stock Gateway gives it the verify-gated coder by
default.

## Running one

Start a run with the normal runs API (see [api.md](docs/api.md) for the full
contract and streaming). `flow_id` selects an entrypoint; omit it to run the
bundle's default entrypoint:

```bash
curl -sS -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"bundle_id":"coding-agent","flow_id":"coder","input_data":{"prompt":"Write a CLI that ..."}}' \
  "http://127.0.0.1:8080/api/gateway/runs/start"
```

`deep-research` takes `request`, `viewpoint`, and `effort` inputs
([deep-research.md](docs/deep-research.md)); `co-scientist` takes a `research_goal`
and scales its effort with `max_cycles`.

Markdown, PDF, and DOCX export work on a base install. `co-scientist` also
draws two figures (an architecture diagram and an Elo trajectory) through the
`write_chart` node, which needs `matplotlib`. That package is not part of the
base install: without it the run still completes and reports its findings, and
only the figures are skipped. Install it if you want them:

```bash
pip install matplotlib
```

## Managing workflows from the console

Both consoles carry a **Workflows** surface — a tab in the web console, step 6
in the console-TUI — listing every workflow registered on this gateway with how
many published and draft versions each has. Select a workflow to see its
versions and its entrypoints with their declared interfaces.

From there you can:

- **Import** one or more `.flow` bundles. Each file reports whether the gateway
  is serving the result, not merely that the upload succeeded.
- **Export** a version as its original `.flow` bytes, byte-identical to what is
  installed, so it can be archived or re-installed elsewhere. In the TUI, `e`
  writes the file next to your working directory.
- **Delete** a single version or every version of a workflow, behind a
  confirmation that states what is irreversible. In the TUI, `d` removes the
  selected version and `D` the whole workflow.

Versions the gateway is not serving are listed separately under **Not loaded**,
with the reason and the file path. They stay on disk until you remove them.

## Who can change the registry

The gateway's own bundle directory is the shared set: every user sees it and can
run what it contains. Changing it is an operator act, so installing, replacing,
removing, deprecating and reloading workflows there require an admin principal.
Listing and running remain available to every user.

Under hosted user auth each principal also gets its own workflow registry, and
you can install and remove workflows there without admin rights — the shared
directory stays visible and read-only alongside it. The rule is ownership: you
may change the registry you own.

The same check covers every route that writes the registry, including
`POST /bundles/upload`, `DELETE /bundles/{bundle_id}`, `POST /bundles/reload`
and `POST /visualflows/{flow_id}/publish`. A non-admin request against the
shared registry returns `403`.

`basic-agent.flow` is the default framework agent and the gateway verifies it at
startup, so `DELETE` refuses to remove it and answers `409`. To replace the
default agent, install the replacement bundle first, then remove the old file.

## Customizing the registry

- The shipped registry is used when `ABSTRACTGATEWAY_FLOWS_DIR` is unset;
  point it at your own bundle directory to serve a custom set
  ([configuration.md](docs/configuration.md)).
- Shipped bundle versions are immutable pins; newer versions install alongside
  them through the normal bundle upload or workflow catalog routes.
- Changing the shipped set is a deliberate act in two places: the force-include
  pins in `pyproject.toml` and the matching `!flows/bundles/<name>` negation in
  `.gitignore`. `tests/test_gateway_shipped_default_workflows.py` fails if a
  pinned artifact is untracked or does not load.
- The editable sources for the shipped workflows are VisualFlow JSON files in
  the AbstractFlow repository (`abstractflow/examples/flows/`), packed with
  `abstractflow bundle pack`.

---

## README.md

# AbstractGateway

AbstractGateway is a **deployable Run Gateway host** for AbstractRuntime runs:
- start durable runs
- accept a durable command inbox
- replay/stream a durable ledger (replay-first)
- enforce a security baseline (token + origin allowlist + limits)

This decouples the gateway service from any specific UI (AbstractFlow, AbstractCode, web/PWA thin clients).

Start here: [docs/getting-started.md](docs/getting-started.md)

## AbstractFramework ecosystem

AbstractGateway is part of the **AbstractFramework** ecosystem:

- **AbstractRuntime** (required): durable run model + workflow registry + stores (`pyproject.toml`, `src/abstractgateway/runner.py`)
- **AbstractRuntime + transitive capability packages** (required by the default server install): Runtime owns the LLM/tool/media integration boundary; Gateway uses its discovery/run facades for prompt-cache controls, generated and edited image/video plus voice/audio/music capabilities, and KG-backed bundle execution (`src/abstractgateway/hosts/bundle_host.py`)
- Higher-level UIs (optional): AbstractFlow (authoring/bundling), AbstractCode / AbstractObserver / thin clients (rendering + operations)

Related repos:
- AbstractFramework: https://github.com/lpalbou/AbstractFramework
- AbstractCore: https://github.com/lpalbou/abstractcore
- AbstractRuntime: https://github.com/lpalbou/abstractruntime

## Quickstart: your own machine

```bash
pip install abstractgateway
abstractgateway serve
```

On a first run with nothing configured, `serve` binds `127.0.0.1:8080`, turns
on user auth, creates the admin user, and prints a one-time link
(`http://127.0.0.1:8080/console#claim=...`). Open it in a browser on the same
machine: you are signed in as the admin and a short first-run guide helps you
pick a local engine, a default model and the browser apps. A new link:
`abstractgateway claim --open`. Start the gateway at login:
`abstractgateway service install`. See [docs/first-run.md](docs/first-run.md).

### Local models and engines

The console's **Models** and **Engines** tabs (and the matching commands)
install a local engine and download a model that fits this machine, without a
terminal. They are AbstractCore's model browser and engine installer
(AbstractCore 2.14.0 or newer), shown inside the gateway:

```bash
abstractgateway engines status --probe          # Ollama, LM Studio, MLX, llama.cpp, ...
abstractgateway engines install ollama --dry-run # the exact command, nothing runs
abstractgateway models catalog --fits           # models that fit this machine
abstractgateway models download ollama qwen3:8b
abstractgateway models list                     # installed models with sizes
```

The commands talk to the running gateway (admin rules and audit log apply);
add `--local` to run them in-process instead. Engine installs run on the
gateway host and are on by default for a loopback gateway, and for someone at
the gateway machine whatever it listens on
([`allow_engine_install`](docs/configuration.md#allow_engine_install)). See
[docs/console.md](docs/console.md) and [docs/api.md](docs/api.md#models-and-engines).

## Quickstart (HTTP server, bundle mode, explicit configuration)

```bash
pip install abstractgateway

export ABSTRACTGATEWAY_DATA_DIR="$PWD/runtime/gateway"

# Optional: set only for a custom bundle registry. When unset, Gateway uses
# the packaged shipped bundle directory containing basic-agent.
# export ABSTRACTGATEWAY_FLOWS_DIR="/path/to/bundles"

# User auth is the normal browser-console/browser-app path.
export ABSTRACTGATEWAY_USER_AUTH=1
# Browser-origin allowlist (glob patterns). Default allows localhost; customize when exposing remotely.
export ABSTRACTGATEWAY_ALLOWED_ORIGINS="http://localhost:*,http://127.0.0.1:*"

abstractgateway serve --host 127.0.0.1 --port 8080
```

OpenAPI docs (Swagger UI): `http://127.0.0.1:8080/docs`

Smoke checks:

```bash
curl -sS "http://127.0.0.1:8080/api/health"

curl -sS -H "Authorization: Bearer $(cat "$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token")" \
  "http://127.0.0.1:8080/api/gateway/bundles"
```

That last call lists the workflows a fresh install already serves — including a
verify-gated coding agent (`coding-agent`), `deep-research`, and
`co-scientist`, alongside the default `basic-agent`. See
[docs/shipped-workflows.md](docs/shipped-workflows.md).

## Hosted user auth

`ABSTRACTGATEWAY_AUTH_TOKEN` is a legacy Gateway-level bearer token for
server/operator access. Browser apps and `/console` should use file-backed user
principals and one runtime/data plane per user:

```bash
export ABSTRACTGATEWAY_USER_AUTH=1
abstractgateway serve --host 127.0.0.1 --port 8080
```

On first start, Gateway creates `default/admin`, writes the browser-login token
to `<ABSTRACTGATEWAY_DATA_DIR>/auth/bootstrap-admin-token`, and prints the token
when bound to a loopback host. Use user `admin` plus that `agw_...` token in
`/console`, AbstractFlow, AbstractCode Web, or AbstractObserver. Admins manage
users through `/api/gateway/admin/users`; generated user bearer tokens are
returned once and stored only as hashes under
`<ABSTRACTGATEWAY_DATA_DIR>/auth/users.json`. User clients call
`GET /api/gateway/me` after connecting to confirm the resolved principal and
routing mode. Gateway rejects duplicate runtime ids within the same tenant, so
the default hosted model remains `1 user = 1 runtime`. Deleting a user reserves
the retained runtime id; admins can explicitly purge retained runtime data or
transfer it to an existing same-tenant user. See
[docs/security.md](docs/security.md) for the current hosted isolation boundary
and remaining operator-route hardening work.

Gateway also serves a built-in control-plane console at `/console`. The console
uses the same browser-session contract as hosted apps: sign in with a Gateway
user id and its token. Because the console is served by Gateway, it uses the
current origin and does not ask for a Gateway URL. You can then manage the
current account, admin-only user records with optional email metadata, retained
runtime reservations, provider connections, and multimodal capability defaults
selected from available providers without storing the bearer token in browser
storage. Provider endpoint URLs/API keys are configured in the Providers tab;
the Multimodal Capabilities tab only selects provider/model pairs. Direct
providers such as `openai` and `anthropic` appear automatically when their
required keys are already available from scoped Core config or environment.
Reachable default local servers such as LM Studio (`http://localhost:1234/v1`)
and Ollama (`http://localhost:11434`) are also surfaced automatically when
Gateway can discover models from them. The Sandbox tab runs quick smoke tests
against the selected multimodal capability defaults in a chat surface, including
text chat, drag-and-drop attachments, inline image/video previews, and audio
players for voice, sound, and music artifacts. The Resources tab shows what is
loaded in host memory right now — RAM/GPU meters, the resident-model table
with modality chips, lock state, and context facts, and session prompt
caches; admins additionally get warm-up, lock/unlock, unload (with a force
confirmation when a model is locked), and cache-clear controls.
Defaults model pickers use Core route filters for LLM/embedding rows, for
example `capability_route=input.image,output.text` and
`capability_route=embedding.text`. Generated image/video/voice/sound/music rows
continue to use capability plugin catalogs so readiness and download/setup state
stay out of Core's raw model registry.
Input fallback routes are explicit: `input.voice` selects the STT backend for
speech attachments, and `input.video` selects an overrideable video/VLM fallback
when the text route cannot or should not handle frames directly. If those routes
are unconfigured and the primary text model lacks native support, Gateway/Core
return a configuration error instead of silently probing installed packages.

Browser apps should not keep user bearer tokens. They exchange the user token at
`POST /api/gateway/session/login` for an opaque Gateway browser session and use
that session for proxied Gateway calls. The login response body does not return
the session id or CSRF token. Session writes require the Gateway CSRF token, and
`POST /api/gateway/session/logout` revokes the session.

## Docker server

Release images are published to GHCR. The default image is the light,
portable server image:

```bash
docker pull ghcr.io/lpalbou/abstractgateway:0.4.1
```

NVIDIA hosts can try the experimental full GPU image when local
vLLM/HuggingFace/Diffusers engines are wanted. This image is published
best-effort until it has a real CUDA build and smoke gate:

```bash
docker pull ghcr.io/lpalbou/abstractgateway:0.4.1-gpu
```

Legacy `abstractgateway-server` and `abstractgateway-server-nvidia` GHCR aliases
are still published for existing deployments; new deployments should use
`abstractgateway`.

The image installs the base `abstractgateway` package: HTTP server,
`AbstractRuntime`, Runtime-owned provider/tool and
multimodal facades, OpenAI-compatible text/media providers,
provider/session prompt-cache helpers, AbstractMemory/LanceDB KG support,
AbstractAgent, and AbstractFlow compatibility. Local sentence-transformer
embeddings and hardware-local inference engines are explicit extras so the
light server image does not pull PyTorch/CUDA runtime packages. Remote text
embeddings remain part of the light profile through the `embedding.text`
capability route: point it at OpenAI, OpenRouter, Portkey, LM Studio, vLLM,
any OpenAI-compatible embeddings endpoint, or a remote AbstractCore server.

AbstractFlow note:
- You do **not** need the `abstractflow` Python package to run `.flow` bundles (bundle mode). You only need it to author bundles. VisualFlow directory mode was intentionally removed from the gateway to keep the dependency direction clean.

```bash
docker run --rm --name abstractgateway \
  -p 8080:8080 \
  -e ABSTRACTGATEWAY_DATA_DIR=/data \
  -e ABSTRACTGATEWAY_USER_AUTH=1 \
  -e LMSTUDIO_BASE_URL="http://host.docker.internal:1234/v1" \
  -v "$PWD/runtime:/data" \
  ghcr.io/lpalbou/abstractgateway:latest
```

On first start, the container creates `default/admin` and writes the admin user
token to `runtime/auth/bootstrap-admin-token`. Use that token in `/console`,
then rotate it or create named users from the console.

Configure framework model defaults through execution-host capability routes:

```bash
docker exec abstractgateway abstractgateway-config set-default input.text \
  --provider lmstudio \
  --model your-model \
  --base-url http://host.docker.internal:1234/v1
```

In user-auth mode this writes the Gateway baseline Core config at
`/data/config/abstractcore.json`. Per-user runtime overrides use the same Core
schema under `/data/users/<tenant>/<runtime>/runtime/config/abstractcore.json`;
use `abstractgateway-config set-default --scope user --user alice ...` for
operator-side scripting.

`output.text` is a compatibility alias for this same text route. Gateway reports
it as a read-only view of `input.text`, so LLM text input and output do not drift
to different default models.

On Apple Silicon, keep Metal/MLX inference native on macOS and run the
lightweight Gateway container as the transport/control plane. Point
`OPENAI_BASE_URL` at a generic host-native OpenAI-compatible endpoint such as
Docker Model Runner (`http://model-runner.docker.internal/engines/v1`) or
`mlx_lm.server` on a host port. For named providers, use
`LMSTUDIO_BASE_URL=http://host.docker.internal:1234/v1` or
`OLLAMA_BASE_URL=http://host.docker.internal:11434`.
For native non-Docker installs with local engines, use
`pip install "abstractgateway[apple]"` on Apple Silicon, and
`pip install "abstractgateway[gpu]"` on GPU workstations or NVIDIA Docker builds.
For a minimal Apple-local Gateway + Flow setup, see
[docs/apple-local-gateway-flow.md](docs/apple-local-gateway-flow.md).

Compose and deployment details: [docs/deployment.md](docs/deployment.md).

## Current capability scope

Current direct Gateway APIs:
- `GET /api/gateway/runs/{run_id}/input_data`
- `GET /api/gateway/runs/{run_id}/history_bundle`
- `POST /api/gateway/runs/{run_id}/voice/tts`
- `POST /api/gateway/runs/{run_id}/audio/transcribe`
- `POST /api/gateway/runs/{run_id}/images/generate`
- `POST /api/gateway/runs/{run_id}/images/edit`
- `POST /api/gateway/runs/{run_id}/images/upscale`
- `POST /api/gateway/runs/{run_id}/videos/generate`
- `POST /api/gateway/runs/{run_id}/videos/from_image`
- `POST /api/gateway/runs/{run_id}/music/generate`
- `GET /api/gateway/voice/voices`
- `GET /api/gateway/audio/speech/models`
- `GET /api/gateway/audio/transcriptions/models`
- `GET /api/gateway/audio/music/providers`
- `GET /api/gateway/audio/music/models`
- `GET /api/gateway/vision/provider_models`
- `GET /api/gateway/vision/models`
- `GET /api/gateway/vision/adapters`
- `/api/gateway/artifacts/search` cross-run/session/run artifact search with
  canonical `artifact_envelope_v1` rows, exact stats/facets, bounded paging,
  descriptor filters, and UI-friendly `artifact_kind` filtering for
  Voice/Music/Sound/unclassified audio and text/media render kinds
- `/api/gateway/prompt_cache/*` provider/model operator controls
- `/api/gateway/prompt_cache/saved|save|load` Runtime-backed host-local export/import admin aliases
- `/api/gateway/sessions/{session_id}/prompt_cache/*` session lifecycle controls
- `/api/gateway/kg/query` with configurable `lancedb` or in-memory AbstractMemory stores, plus `sqlite` when the installed AbstractMemory build exposes `SQLiteTripleStore`
- `/api/gateway/discovery/capabilities` package, plugin, and thin-client contract discovery

Discovery note:
- the capability contract is versioned and stable for endpoint discovery and
  feature gating
- provider/model/voice catalog routes now add a stable Gateway-owned envelope:
  `catalog.contract = gateway_catalog_v1` plus canonical `items`
- the shared thin-client contract now also exposes `common.readiness` as a
  compact Gateway-owned surface summary derived from endpoint descriptors
- legacy fields such as `models`, `providers`, `provider_models`, `profiles`,
  and `voices` remain in place for compatibility
- richer deployment/readiness truth is still separate from the catalog
  envelope and depends on lower-layer Runtime/Core surfaces

Workflow/Core-backed capabilities:
- Generated images and videos are available to Runtime workflows through
  Runtime's media backend integrations, and the direct Gateway image/video
  routes use the same Runtime/Core output-selector contracts. Image,
  image-edit, text-to-video, and image-to-video direct routes expose child-run
  progress through `abstract.progress` ledger records; image progress is
  best-effort and may be limited to start/complete for backends that do not
  report step progress.
- Direct image/video requests preserve task-specific batch and adapter fields:
  `count` / `n`, `seeds`, ordered `lora_adapters`, and video `flow_shift`.
  Batch responses keep compatibility singular fields (`image_artifact`,
  `video_artifact`) and also return the full `image_artifacts` /
  `video_artifacts` lists.
- Generated music is available through Gateway's direct Runtime-backed child-run
  route, with provider/model discovery exposed through Gateway capability
  contracts and music catalog endpoints for higher apps.
- Catalog routes now return a canonical `items` array and a `catalog` metadata
  block so higher apps can stop parsing route-local payload variants.
- Vision adapter discovery is available through
  `GET /api/gateway/vision/adapters`, routed through Runtime's public discovery
  facade.
- Audio transcription is available through a direct Runtime-backed child-run
  route, and the capability contract also exposes `voice.listen` as a
  host-capture command surface for higher apps that record locally before
  emitting events or uploading audio.
- Prompt-cache support depends on the active provider/model. Session lifecycle
  routes provide Gateway-owned naming and orchestration, not a provider-
  independent local KV cache.
- Prompt-cache export/import admin remains local-only. Local runtimes keep those
  artifacts under the Gateway data dir; remote and hybrid runtimes return a
  structured `prompt_cache_local_only` response.

## Client contract (replay-first)

- Clients **start runs**: `POST /api/gateway/runs/start`
  - optional `thinking` sets the run-scoped `_runtime.thinking` default used by
    Flow LLM/Agent nodes and AbstractAgent adapters when Core/provider support
    reasoning controls
- Clients can **schedule runs** (bundle mode): `POST /api/gateway/runs/schedule`
- Clients **act** by submitting durable commands: `POST /api/gateway/commands`
  - supported types: `pause|resume|cancel|emit_event|update_schedule|compact_memory`
- Clients **render** by replaying/streaming the durable ledger:
  - replay: `GET /api/gateway/runs/{run_id}/ledger?after=...`
  - stream (SSE): `GET /api/gateway/runs/{run_id}/ledger/stream?after=...`

The same configuration surfaces are available in a terminal through the
`abstractgateway-console` Rust app (`cargo install abstractgateway-console`),
and model residency through `abstractgateway models loaded|load|unload`. See
[docs/console.md](docs/console.md).

See [docs/api.md](docs/api.md) for curl examples and the live OpenAPI spec (`/openapi.json`).

## Install

### Base remote-light server

Requires Python `>=3.10` (see `pyproject.toml`).

The base install is the remote-light HTTP/SSE server: Gateway, Runtime,
Agent, Flow compatibility, Runtime-owned provider/tool and multimodal facades,
and LanceDB-backed Memory. It intentionally excludes local sentence-transformer
embeddings and hardware-local inference engines so Linux installs do not pull
PyTorch/CUDA packages. Remote embeddings and remote multimodal input/output
still work in this profile through hosted providers, OpenAI-compatible
endpoints, or a remote AbstractCore server.

```bash
pip install abstractgateway
```

### Optional extras

- `abstractgateway[apple]`: full native macOS Python profile with Apple-local engines and all non-NVIDIA framework capabilities
- `abstractgateway[gpu]`: full local GPU profile with vLLM/HuggingFace, local Diffusers image generation, local voice engines, music, and KG memory; this is also the NVIDIA Docker install profile
- `abstractgateway[embeddings]`: local sentence-transformer embeddings for semantic KG queries
- `abstractgateway[tray]`: a menu bar / system tray icon for `abstractgateway serve` (macOS, Windows, Linux) — open the console, pause/resume workflows, unload models, watch memory and GPU, restart or update; see [docs/tray.md](docs/tray.md)
- `abstractgateway[docs]`: MkDocs site tooling
- `abstractgateway[dev]`: local test/dev deps

KG memory nodes use Gateway's memory resolver. The default durable/vector
backend is LanceDB; `memory` is process-local dev/test storage, and `sqlite` is
structured-only when the installed AbstractMemory build exposes
`SQLiteTripleStore`. A fresh persistent store is still reported as available
when the backend resolves; empty queries return empty results instead of hiding
KG authoring surfaces.

Gateway has a first-class config helper:

```bash
abstractgateway-config status
abstractgateway config init --env-file .env
```

For details on capability route defaults, store backends, and workflow sources, see [docs/configuration.md](docs/configuration.md).

## Creating a `.flow` bundle (authoring)

Use AbstractFlow to pack a bundle:

```bash
abstractflow bundle pack /path/to/root.json --out /path/to/bundles/my.flow --flows-dir /path/to/flows
```

See [docs/getting-started.md](docs/getting-started.md) for running, split API/runner, and file→SQLite migration.

## Docs

Published docs site: https://www.lpalbou.info/AbstractGateway/

### Project docs

- Changelog: [CHANGELOG.md](CHANGELOG.md) (compat: `CHANGELOD.md`)
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
- Security policy (vulnerability reporting): [SECURITY.md](SECURITY.md)
- Acknowledgments: [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md) (compat: `ACKNOWLEDMENTS.md`)

### Package docs

- Docs index: [docs/README.md](docs/README.md)
- Getting started: [docs/getting-started.md](docs/getting-started.md)
- FAQ: [docs/faq.md](docs/faq.md)
- Architecture: [docs/architecture.md](docs/architecture.md)
- Configuration: [docs/configuration.md](docs/configuration.md)
- Deployment: [docs/deployment.md](docs/deployment.md)
- API overview: [docs/api.md](docs/api.md)
- Security: [docs/security.md](docs/security.md)
- Operator tooling (optional): [docs/maintenance.md](docs/maintenance.md)

---

## docs/README.md

# AbstractGateway docs

Start here if you’re new to the project.

## AbstractFramework ecosystem

AbstractGateway is one component in the larger AbstractFramework ecosystem:

- **AbstractRuntime** (required): durable runs + stores
- **AbstractRuntime + transitive capability packages** (required by the default server install): Runtime owns the LLM/tool/media integration boundary; Gateway uses its discovery/run facades for prompt-cache controls, generated and edited image/video plus voice/audio/music capabilities, and KG-backed bundle execution
- Higher-level UIs (optional): AbstractFlow / AbstractObserver / AbstractCode / thin clients

Related repos:
- AbstractFramework: https://github.com/lpalbou/AbstractFramework
- AbstractCore: https://github.com/lpalbou/abstractcore
- AbstractRuntime: https://github.com/lpalbou/abstractruntime

## Docs map

- First run on your own machine (no configuration, one-time sign-in link, start at login): [first-run.md](docs/first-run.md)
- Quickstart + stores (file/SQLite): [getting-started.md](docs/getting-started.md)
- FAQ / troubleshooting: [faq.md](docs/faq.md)
- Architecture (durable contract + components): [architecture.md](docs/architecture.md)
- Configuration (env vars + install extras): [configuration.md](docs/configuration.md)
- Consoles: the web console at `/console` and the `abstractgateway-console` terminal app: [console.md](docs/console.md)
- Desktop tray icon: [tray.md](docs/tray.md)
- Browser apps (install, start and open Flow, Code, Observer, Continuum, Entity from the gateway; Node.js installed for you): [apps.md](docs/apps.md)
- Local engines (install Ollama, LM Studio, MLX, llama.cpp without a terminal; when the Apple tools or an administrator password are needed): [engines.md](docs/engines.md)
- Model downloads (progress, stalls, cancel, "Use recommended defaults" as one job, the event stream): [model-downloads.md](docs/model-downloads.md)
- Summoned entities: [entities.md](docs/entities.md)
- Apple Silicon local Gateway + Flow quickstart: [apple-local-gateway-flow.md](docs/apple-local-gateway-flow.md)
- Deployment (Docker/GHCR/Compose): [deployment.md](docs/deployment.md)
- API overview (client contract + OpenAPI, including host state/model residency, direct image/video, STT, and music generation): [api.md](docs/api.md)
- Shipped workflows served out of the box (coder, deep research, co-scientist): [shipped-workflows.md](docs/shipped-workflows.md)
- Shipped `deep-research` workflow bundle: [deep-research.md](docs/deep-research.md)
- Security guide (auth/origin/limits/audit log): [security.md](docs/security.md)
- Operator tooling (triage/backlog/process manager): [maintenance.md](docs/maintenance.md)

## API docs (generated)

Published static docs site: https://www.lpalbou.info/AbstractGateway/

When the HTTP server is running (`abstractgateway serve`):
- Health: `GET /api/health`
- OpenAPI JSON: `GET /openapi.json`
- Interactive Swagger UI: `GET /docs`

## Project docs

- Package README: [../README.md](README.md)
- Changelog: [../CHANGELOG.md](CHANGELOG.md) (compat: `CHANGELOD.md`)
- Contributing: [../CONTRIBUTING.md](CONTRIBUTING.md)
- Security policy (vulnerability reporting): [../SECURITY.md](SECURITY.md)
- Acknowledgments: [../ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md) (compat: `ACKNOWLEDMENTS.md`)

---

## docs/getting-started.md

# AbstractGateway — Getting started

AbstractGateway is a deployable HTTP/SSE host for **durable AbstractRuntime runs**:
- clients **start runs** and submit **durable commands**
- clients **render** by replaying/streaming the durable ledger (replay-first)

This guide gets a new installation running in **bundle mode** (recommended), then covers **file vs SQLite** durability and a best-effort **file → SQLite** migration.

## AbstractFramework ecosystem (context)

AbstractGateway is one component in the larger **AbstractFramework** ecosystem:
- **AbstractRuntime** (required): durable runs + workflow registry + stores
- **AbstractRuntime + transitive capability packages** (required by the default server install): Runtime owns the LLM/tool/media integration boundary; Gateway uses its discovery/run facades for prompt-cache controls, generated and edited image/video plus voice/audio/music capabilities, and KG-backed bundle execution

Related repos:
- AbstractFramework: https://github.com/lpalbou/AbstractFramework
- AbstractCore: https://github.com/lpalbou/abstractcore
- AbstractRuntime: https://github.com/lpalbou/abstractruntime

## Prerequisites

- Python `>=3.10` (see `pyproject.toml`)
- Workflow source:
  - **Bundle mode** (recommended): one `.flow` file or a directory of `*.flow` bundles
    - You can also upload bundles after startup via `POST /api/gateway/bundles/upload` (see below)
  - **VisualFlow directory mode** (compat): a directory of `*.json` VisualFlow files; the base install includes the compiler dependency

## Install

```bash
# Remote-light server package (HTTP/SSE + runner + stores + KG memory)
pip install abstractgateway

# Native Apple local engines
pip install "abstractgateway[apple]"

# Native/container GPU local engines, also used by the NVIDIA Docker image
pip install "abstractgateway[gpu]"

# Desktop menu bar / system tray icon for `serve` (macOS, Windows, Linux)
pip install "abstractgateway[tray]"
```

With the base install and a configured provider stack, Gateway can surface
run-scoped direct TTS, STT, image generation, image edit, and music generation
for higher apps through one shared capability contract.

## 0) Fastest start on your own machine

```bash
abstractgateway serve
```

With no auth configured, this binds `127.0.0.1:8080`, enables user auth,
creates `default/admin`, keeps data in your OS's per-user data folder, and
prints a one-time `First run: open http://127.0.0.1:8080/console#claim=...`
link that signs you into the console and opens the first-run guide. See
[first-run.md](docs/first-run.md), including `abstractgateway claim` and
`abstractgateway service install`. The rest of this guide uses explicit
configuration.

## 1) Run (bundle mode, file-backed stores)

File-backed stores are the default and easiest for dev.

```bash
export ABSTRACTGATEWAY_WORKFLOW_SOURCE=bundle
export ABSTRACTGATEWAY_DATA_DIR="$PWD/runtime/gateway"

# Optional: set only for a custom bundle registry. When unset, Gateway uses
# the packaged shipped bundle directory containing basic-agent.
# export ABSTRACTGATEWAY_FLOWS_DIR="/path/to/bundles"

# User auth is the normal browser-console/browser-app path.
export ABSTRACTGATEWAY_USER_AUTH=1
export ABSTRACTGATEWAY_ALLOWED_ORIGINS="http://localhost:*,http://127.0.0.1:*"

abstractgateway serve --host 127.0.0.1 --port 8080
```

On first local start, Gateway creates `default/admin`, writes the browser-login
token to `$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token` (mode `0600`),
prints that token, and prints a one-time console sign-in link. Use the token
with user `admin` for browser apps. `abstractgateway serve --no-print-token`
keeps the token out of the startup output (it stays in the file); on a
non-loopback bind it is hidden by default and `--print-token` shows it. `ABSTRACTGATEWAY_AUTH_TOKEN` remains available for legacy server/operator
bearer-token deployments, but it is not a browser sign-in token.

OpenAPI docs (Swagger UI): `http://127.0.0.1:8080/docs` (use **Authorize** with a Gateway user token)

Smoke checks:

```bash
curl -sS "http://127.0.0.1:8080/api/health"

curl -sS -H "Authorization: Bearer $(cat "$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token")" \
  "http://127.0.0.1:8080/api/gateway/bundles"
```

If `bundles.items` is empty, either:
- point `ABSTRACTGATEWAY_FLOWS_DIR` at the shipped bundle directory or another
  directory containing `*.flow` files (or a single `.flow` file), or
- upload a bundle via the API:

```bash
curl -sS -H "Authorization: Bearer $(cat "$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token")" \
  -F "file=@./my-bundle@0.1.0.flow" \
  -F "overwrite=false" \
  -F "reload=true" \
  "http://127.0.0.1:8080/api/gateway/bundles/upload"
```

You can also create a local env file and inspect readiness with:

```bash
abstractgateway-config init --env-file .env
abstractgateway-config status
```

## 2) Start a run (bundle mode)

First, discover entrypoints from `GET /api/gateway/bundles`. Then start a run:

```bash
curl -sS -H "Authorization: Bearer $(cat "$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token")" -H "Content-Type: application/json" \
  -d '{"bundle_id":"my-bundle","input_data":{"prompt":"Hello"}}' \
  "http://127.0.0.1:8080/api/gateway/runs/start"
```

Notes:
- If a bundle has multiple entrypoints and no default, you must pass `flow_id`.
- See [api.md](docs/api.md) for ledger replay/stream and durable commands.

## 2b) (Optional) Schedule a run (bundle mode)

To launch a workflow periodically, start a **scheduled parent run**:

```bash
curl -sS -H "Authorization: Bearer $(cat "$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token")" -H "Content-Type: application/json" \
  -d '{"bundle_id":"my-bundle","flow_id":"ac-echo","input_data":{"prompt":"Ping"},"start_at":"now","interval":"1h","repeat_count":3}' \
  "http://127.0.0.1:8080/api/gateway/runs/schedule"
```

Tip: to stop a schedule, cancel the scheduled parent run (`POST /api/gateway/commands`, type `cancel`).

## 3) Split API vs runner (recommended for upgrades)

By default, `abstractgateway serve` starts the HTTP API **and** the runner loop in the same process.

To restart the HTTP API without pausing durable execution, run two processes sharing the same `ABSTRACTGATEWAY_DATA_DIR`:

```bash
# Process 1 (runner worker, no HTTP deps needed):
abstractgateway runner

# Process 2 (HTTP API only):
abstractgateway serve --no-runner --host 127.0.0.1 --port 8080
```

## 3b) Docker / Compose

For a containerized remote-light deployment with
`AbstractRuntime`, Runtime-owned provider/tool and
multimodal support, KG memory, and provider/session prompt-cache controls
included. Remote embeddings are available in this profile when `embedding.text`
points at a remote provider, an OpenAI-compatible embeddings endpoint, or a
remote AbstractCore server; local HuggingFace/sentence-transformer embeddings
require `abstractgateway[embeddings]`.

```bash
docker run --rm --name abstractgateway \
  -p 8080:8080 \
  -v "$PWD/runtime:/data" \
  -e ABSTRACTGATEWAY_DATA_DIR=/data \
  -e ABSTRACTGATEWAY_USER_AUTH=1 \
  ghcr.io/lpalbou/abstractgateway:latest
```

See [deployment.md](docs/deployment.md) for Compose, provider keys, and image
customization.

On first start, the container creates `default/admin` and writes the token to
`runtime/auth/bootstrap-admin-token`. NVIDIA hosts can try
`ghcr.io/lpalbou/abstractgateway:0.4.1-gpu` with the compose overlay in
`docker/abstractgateway-server/compose.nvidia.yml`.
It is experimental until a real CUDA build/smoke gate is part of release
validation.
Apple MLX inference should run natively on macOS rather than in Docker because
Linux containers do not get access to Apple's Metal/MLX runtime. The container
can still use native macOS inference through an OpenAI-compatible endpoint:
point `OPENAI_BASE_URL` at Docker Model Runner on
`http://model-runner.docker.internal/engines/v1` or `mlx_lm.server`. For named
local providers, set `LMSTUDIO_BASE_URL=http://host.docker.internal:1234/v1` or
`OLLAMA_BASE_URL=http://host.docker.internal:11434` when the native Ollama model
path uses MLX. For native non-Docker installs, use
`pip install "abstractgateway[apple]"` on Apple Silicon, and
`pip install "abstractgateway[gpu]"` on GPU workstations or NVIDIA Docker builds.

## 4) What’s stored in `ABSTRACTGATEWAY_DATA_DIR` (file backend)

When `ABSTRACTGATEWAY_STORE_BACKEND=file` (default), the gateway persists (via `abstractruntime` stores):
- `run_<run_id>.json` (checkpointed run state)
- `ledger_<run_id>.jsonl` (append-only step records)
- `commands.jsonl` and `commands_cursor.json` (durable inbox + runner cursor)
- `artifacts/` (offloaded blobs/attachments)
- `dynamic_flows/` (gateway-generated wrapper flows, e.g. schedules)
- `workspaces/` (per-run workspaces created at run start when `workspace_root` is not provided)

## 5) Enable SQLite-backed stores

SQLite-backed stores eliminate directory scanning and move run/ledger/inbox data into indexed tables.

Artifacts remain file-backed under `ABSTRACTGATEWAY_DATA_DIR/artifacts/`.

```bash
export ABSTRACTGATEWAY_STORE_BACKEND=sqlite

# Optional; when omitted, defaults to: <ABSTRACTGATEWAY_DATA_DIR>/gateway.sqlite3
export ABSTRACTGATEWAY_DB_PATH="$PWD/runtime/gateway/gateway.sqlite3"
#
# Safety invariant: when using sqlite, the DB file must live under ABSTRACTGATEWAY_DATA_DIR.
# The gateway will refuse to start if ABSTRACTGATEWAY_DB_PATH points outside (prevents UAT/prod cross-wiring).

abstractgateway serve --host 127.0.0.1 --port 8080
```

## 6) Migrate an existing file-backed data dir → SQLite

This is a **best-effort** local migration (`abstractgateway migrate`) that reads:
- `run_*.json`
- `ledger_*.jsonl`
- `commands.jsonl`
- `commands_cursor.json`

and writes a single SQLite DB file. It does **not** delete the original files.

```bash
cp -a runtime/gateway "runtime/gateway.file-backup.$(date +%Y%m%d-%H%M%S)"

abstractgateway migrate --from=file --to=sqlite \
  --data-dir runtime/gateway \
  --db-path runtime/gateway/gateway.sqlite3
```

## Related docs

- Docs index: [README.md](docs/README.md)
- FAQ: [faq.md](docs/faq.md)
- Architecture: [architecture.md](docs/architecture.md)
- Configuration (env vars + optional deps): [configuration.md](docs/configuration.md)
- Deployment: [deployment.md](docs/deployment.md)
- API overview: [api.md](docs/api.md)
- Security: [security.md](docs/security.md)
- Operator tooling (optional): [maintenance.md](docs/maintenance.md)

---

## CHANGELOG.md

# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.4.1] - 2026-09-24

This release requires AbstractCore 2.15.0 and AbstractRuntime 0.4.33
(installed automatically). The `v0.4.0` tag exists but 0.4.0 was never
published: its release run stopped at the test job (Linux-only test
failures, fixed below), so 0.4.1 is the first release of this wave. The
terminal console ships as `abstractgateway-console` 0.8.0 (see
`console-tui/CHANGELOG.md`).

### Added
- **A fresh gateway has a working backlog; the backlog folder and the exec
  runner are settings and launch flags (mission II, 2026-09-24).** The
  operator: "fix continuum for a new fresh install ... i don't like
  environment variables and these should be handled with proper settings and
  --param_name." Continuum's Board on a fresh gateway said "Backlog browsing is
  not configured ... set ABSTRACTGATEWAY_TRIAGE_REPO_ROOT". Now:
  - without a setting the gateway uses its own folder `<data dir>/backlog/`
    and creates the standard layout there on first use (`docs/backlog/`
    `overview.md`, `template.md`, `planned/`, `proposed/`, `completed/`,
    shipped as package data; nothing existing is overwritten);
  - ONE resolution (`runtime_config.resolve_backlog_root` /
    `resolve_exec_runner`): `serve --backlog-root PATH` / `--exec-runner
    on|off` > the saved setting (`triage_repo_root`, `backlog_exec_runner`) >
    a legacy environment value (reported as `source: env`, never needed) >
    the default. Every consumer calls it: the backlog, report, triage and
    process routes, the exec runner (at each poll), the skills shelf;
  - `GET /api/gateway/backlog/status` (available, source, reason; paths for
    admins) and the runtime-config rows carry label/help/cli, `available`,
    a path-free `reason`, `default_path`, and `stored_value` under a flag;
  - `abstractgateway config get [KEY]`, `config set KEY VALUE`, `config unset
    KEY`: every runtime setting from a terminal, through the running
    gateway's door when one serves the data dir (applies at once, audited),
    else the settings store;
  - the console's Apps tab gains *Advanced: backlog settings (Continuum)*
    (folder, exec runner, process manager, with source pills and *Use the
    gateway's own folder*);
  - one validation for every door: a folder must exist and contain
    `docs/backlog`, or be the gateway's own folder; switches are on/off (a
    typo refuses instead of storing "off").
- **The Entity card offers "Create your first entity" (mission JJ,
  2026-09-24).** The operator: with no entity yet, Open landed on an empty
  list with nothing to do. The apps payload gains `content_summary` on every
  row: `{"entities_count": n}` for Entity (the entry count of
  `GET /api/gateway/entities`, from the same registry, `null` when unknown or
  when an Entity started outside the gateway talks to another gateway),
  `null` for the other apps. At exactly 0 the card's one primary button reads
  "Create your first entity" and opens the app on its creation form; 1 or
  more, or unknown, keeps "Open". Same card on the guide's Apps step.
- **`POST /api/gateway/apps/{id}/open` accepts `path`** (e.g. `/#new`): where
  the browser lands inside the app after the signed-in handover. The path is
  bound to the one-time code and must be a path inside the app: `//host`, a
  full address, a backslash, whitespace or a control character is refused
  with 400 `invalid_app_path` and no link is made. docs/apps.md.
- **Console: the create-user modal follows the user-accounts mode.** With
  user accounts off (`/me` → `auth.user_auth_enabled: false`) it offers the
  admin role only and says "User accounts are off on this gateway: only admin
  accounts can sign in. Turn user accounts on to add members."; a gateway
  whose `/me` lacks the field is named in the modal; a 409
  (`user_accounts_off_admin_only`) is shown in the server's own words.
- **Reverse proxy settings instead of environment variables (mission Z).**
  Operator: "i explicitly told you i don't like env vars. most should be
  something one can configure from the consoles (wui+tui)". Browser origins
  (`allowed_origins`) and trust proxy (`trust_proxy`) are stored in the
  network setting and changed through the same door as the mode:
  `POST /api/gateway/network {allowed_origins?, trust_proxy?}` (`mode` is now
  optional), `abstractgateway network set --allowed-origins https://a,https://b
  --trust-proxy on|off`, `abstractgateway network show`, the console's Network →
  *Advanced: reverse proxy* (chips, validation, switch, "Saved · applies now")
  and the console TUI's Connection screen (origins edit line, trust checkbox).
  Both apply to the NEXT REQUEST: the security middleware reads them per
  request (one `stat()`, re-parsed on change), no restart. `GET /network`
  carries `reverse_proxy{allowed_origins, trust_proxy}` with `value`,
  `source: setting|env|default`, `overridden_by_env`, `effective`, `applies`;
  a POST answers `changed{field: {from, to, applies: live|restart|overridden_by_env}}`.
  Origins are validated (`scheme://host[:port]`, no path, no trailing slash,
  default port dropped, `*`/patterns flagged); a list with a bad entry is
  refused whole (400 `invalid_origins`, `errors[]`), and every door shows the
  same sentence. `ABSTRACTGATEWAY_ALLOWED_ORIGINS` / `ABSTRACTGATEWAY_TRUST_PROXY`
  still pin the values when set at start, and every surface says so in words.
  The running gateway's environment is recorded in the run record
  (`proxy_env`) so the CLI reports it, not its own shell's.
- **Browser apps settings `apps.*` (mission Z).** The five
  `ABSTRACTGATEWAY_APPS_*` knobs are runtime-config keys (`apps.node`,
  `apps.ports`, `apps.host`, `apps.npm_registry`, `apps.pypi_url`; registry
  `runtime_config.APPS_SETTINGS` with label/help), stored > env > default,
  written through `POST /api/gateway/admin/runtime-config {"apps.host": …}`,
  `abstractgateway apps config get|set`, the Apps page (*Advanced: apps
  settings*) and the TUI (Runtimes → *Runtime knobs* → *Edit apps settings*).
  `AppsManager` reads them at each use (`resolve_apps_setting`; an unknown
  name raises). `env_registry` rows name the replacing setting
  (`superseded_by`).
- **Setting changes on the audit line.** `POST /network` and
  `POST /admin/runtime-config` attach `setting_change` (fields from/to, or the
  refusal) to the security middleware's audit-log line for the request.

- **Apps that also run in a terminal: "Open in Terminal" (mission Y).** Each
  row of `GET /api/gateway/apps` gains `interfaces[]`: the browser app (kind
  `web`, mirroring the row) and, for Code, its terminal version (kind `tui`:
  `installed`, `version`, `install_available`, `install_method`
  `release_binary`|`cargo`, `install_blocked_reason`, `launch_available`,
  `launch_blocked_reason`, `launch_mode` `terminal`|`copy`, `command`,
  `install_command`, `signin_command`). The overview also carries
  `console_tui` (the gateway console's own terminal app, crates.io only).
  Presence only: the binary in `<data>/apps/bin/`, on PATH or in
  `~/.cargo/bin`, accepted when its `--help` names it (PyPI's unrelated
  `abstractcode` script is not it). `POST /apps/{id}/install-tui` (admin)
  installs Code's prebuilt binary from its GitHub release, checked against
  the release's `SHA256SUMS` and GitHub's per-file digest, smoke-tested with
  `--version` before it replaces anything; with no binary for the computer the
  row says "Needs the Rust toolchain" and gives `cargo install abstractcode`
  (409 `toolchain_required` + `command` from the route, never a fake button).
  `POST /apps/{id}/launch-tui` (admin) opens a new terminal window on the
  gateway machine (macOS Terminal, Linux terminal emulators, Windows `cmd`)
  only when the request comes from that machine (loopback socket peer,
  loopback `Host`, no proxy headers); otherwise 409 `not_on_gateway_machine`
  with the command to copy. The window's launcher script holds a one-time
  code (2 minutes, single use, deletes itself) that `tui_signin.py` trades at
  `POST /apps/tui-handover` (outside `/api/gateway`, loopback peers only) for
  a per-launch bearer token acting as the caller, accepted from loopback only,
  kept in the gateway's memory only, and handed to the app through its
  environment (never argv, a file or the browser). The ephemeral loopback
  token registry can now bind a token to a principal
  (`register_ephemeral_loopback_token(..., principal=)`). Console: the Apps
  step and Apps tab cards show "Also runs in your terminal" with "Open in
  Terminal", the command to copy (remote browser), "Install for Terminal", or
  "Needs the Rust toolchain" + the command; commands are one mono line with an
  ellipsis and Copy. The Done step says once that the console also exists as a
  terminal app, with `cargo install abstractgateway-console`. Tray: "Open Code
  in Terminal" when the gateway reports it installed. Terminal parity:
  `abstractgateway apps install-tui <id>` (same job and checksum rules; a
  refusal prints the reason and the cargo command), `abstractgateway apps
  tui-command <id>` (`POST /apps/{id}/tui-command`, admin, gateway machine
  only: the same one-use launcher without a window; prints the one-time
  sign-in line and the plain command), and a `terminal:` line per app in
  `abstractgateway apps list`. docs/apps.md "Terminal versions", docs/tray.md.
- **The guide's "Recommended for this computer" text card follows
  AbstractCore's per-computer pick (mission W1).** On a Mac the recommended
  text model is an MLX build chosen by memory (below 24 GiB Qwen3.5 9B,
  24 GiB to below 128 GiB Qwen3.8 27B, 128 GiB and above Qwen3.8
  Flash-Next); other computers keep LM Studio `qwen/qwen3.5-9b@4bit`. The
  Gateway holds no list of its own: the card, **Download all**, **Use
  recommended defaults** and the tray's "Your defaults" all read AbstractCore
  (`recommended_text_model()` through the runtime facade). The text entry of
  `/models/availability` `recommended` carries `catalog_id`, `basis`, `tier`,
  `fit_verdict`, `fits` and `warning`, and the card shows the warning when
  AbstractCore's estimate says the model may not fit. Catalog artifacts carry
  `quant_class` and `options` (docs/api.md, "Catalog artifacts").
- **Console: the model catalog is one card per model, with a filter bar
  (mission X2).** The Models tab (and the setup guide's Default model step)
  no longer shows one long table: each model is a card (name, organisation,
  parameters, licence, capabilities, a Starter badge) with its builds as
  compact rows (provider, artifact id shortened with a tooltip and copied on
  click, quantization, download size, weights and fit pills, one action:
  Download, then the shared progress bar with bytes, speed, time left and
  Cancel, then Use as default). The recommended build is first and marked.
  The filter bar: search, **4-bit / 8-bit / Other** from AbstractCore's
  `quant_class`, provider, capability, Downloaded / Not downloaded and **Fits
  this computer**, with a live count ("12 of 77 models · 31 artifacts
  shown"); its top row stays under the header while the cards scroll. The
  8-bit builds (MLX `-8bit`, Ollama `-q8_0`, GGUF `Q8_0`; LM Studio `@8bit` ids could not be verified upstream and are not listed)
  are listed next to the 4-bit ones, every build, never capped. The filters
  live in the address (`#catalog?quant=8bit&provider=mlx&fits=1`), so a link
  reproduces the view. A gateway whose AbstractCore does not send
  `quant_class` says so and keeps the quantization filter off (the class is
  never guessed in the browser). The guide's step shows the same cards with
  Fits this computer on and **Open in the Models tab**; an engine's **Browse
  models** opens the tab filtered to that engine. AbstractCore's installed
  list (with Delete) stays below the cards as **On this computer**. A
  **Catalog / Hugging Face** switch next to the search box brings back the
  old table's Hugging Face search: Enter asks the catalog API's Hub search and
  the results are the same cards (quantization "Not stated" when the result
  names none, size from the Hub, fit, Download with the same progress), the
  query lives in the address (`#catalog?hf=smollm`), and an empty answer or
  an unreachable Hub is said in plain words.
- **Console: every backend contract that landed after the redesign is wired
  (mission L2).** Downloads: "Download all" is ONE card for the parent job
  (`grp_…`: overall bar, bytes, speed, ETA, one row per model with its own
  bar and Cancel, plus "Cancel all"); the console follows
  `GET /models/downloads/stream` (SSE) and falls back to polling whenever the
  stream is not open; a stalled download reads "Stalled · no data for 18 s ·
  still trying" in the warning tone. Engines: Ollama / LM Studio offer the two
  real install locations from the gateway's own dry-run plans ("Install" =
  just for you, no password; "Install for all users (administrator)" only
  when that plan needs an administrator); Start/Stop, Cancel and the
  `continue` actions show progress on the button and a result on the card
  ("Re-check", "Install tools", "Continue with administrator password").
  Apps: "Show log" (the tail, how much is shown, "Show more" up to the
  route's 5000 lines, then the log file's path), "Update to <version>", a
  Node.js row with its own Install, and results on the card. A new
  **Network** tab (also on the guide's Done step): the three modes of
  `gateway_network_v1` as a segmented control with a plain explanation each,
  the refusal reason and its fix, the Internet warnings before the
  acknowledgement, "Restart to apply" / "Restart now" only when a restart can
  apply it (else the reason), and every address with its own Copy; the header
  shows the primary address with a copy button. Refusals from the apps and
  network routes (`{message}` / `{refused_reason}`) are shown in words, never
  as "HTTP 409", with the whole response behind "Show details".
- **Console: tray links land where they point.** `#claim=<code>&tab=<tab>`
  keeps the tab after the code is stripped, and a claim no longer re-opens the
  first-run guide once first run is completed (a `tray` link, per
  `claim.created_by`, never opens it); the "Setup guide" button always does.
  The guide is keyboard-complete: each step starts at its title, Tab walks
  its controls, Escape closes it and focus returns to what opened it.
- **`POST /api/gateway/session/claim` says who minted the link.** The
  response gains `claim: {created_by}`, one of `serve` (first run), `cli`
  (`abstractgateway-config claim-url` / `abstractgateway claim`) or `tray`
  (the tray's sign-in). It is `null` for a link minted before the field
  existed. The console can now tell a tray sign-in from a first run. The
  change only adds a field.
- **Downloads keep working after an MLX model is loaded, and installed rows
  carry `kind`/`tasks`** (through AbstractCore). Loading an MLX model no
  longer puts the gateway process into Hugging Face offline mode, which made
  every later download job fail with `OfflineModeIsEnabled`. Download jobs
  never inherit an offline flag written in-process after start, and their log
  says so. `GET /api/gateway/models/installed` rows gain `kind`, `tasks` and
  `tasks_source`, read from local files only. **Restart the gateway to pick
  this up.**
- **Network exposure: localhost only / local network / internet (mission R).**
  One runtime-config setting (`network`: `exposure` + `port`), edited through
  `GET/POST /api/gateway/network` (contract `gateway_network_v1`),
  `abstractgateway network status|set|addresses|restart`, and the console TUI's
  Connection screen (↑/↓ + Enter picks the mode, `c` copies the highlighted
  URL, a restart banner when the running bind differs). `serve` reads it when
  `--host/--port` are not given (explicit flags win and are reported as
  `overridden_by_cli`). `lan`/`internet` bind `0.0.0.0` and are refused (409,
  nothing stored, the exact fix) unless user auth will be on at the next start;
  `internet` also needs `acknowledge_internet: true` and returns the TLS /
  port-forwarding warnings. Changes apply at the next start
  (`restart_required` + `configured` vs `effective`);
  `POST /api/gateway/network/restart` (admin) restarts through the existing
  host-control relaunch and refuses when a restart cannot apply the setting.
  Addresses are discovered per call (psutil, else `ifconfig`/`ip`), labelled
  ("Wi-Fi"), with the Bonjour `<name>.local` when it resolves and the WAN
  address only on an explicit admin request. In a network mode `serve` allows
  the gateway's own LAN origins when `ABSTRACTGATEWAY_ALLOWED_ORIGINS` is
  unset, so the console signs in from another machine.
- **The tray icon is a control centre (mission Q).** The menu is now built as
  data (`tray/menu_model.py`: snapshot + extras → nodes; rendered on pystray,
  unit-tested without a display) and gains:
  - **Start AbstractGateway at login** — a check item showing whether THIS
    gateway would really start at the next login, toggled from the menu. One
    shared module, `abstractgateway.autostart`, used by the tray, the new
    `abstractgateway service enable|disable` (and `status`, which now reports
    `on | off | broken | other` with the reason) and the installers. "On" is
    read back, never assumed: a LaunchAgent / unit / Run value pointing at a
    program that no longer exists, an unreadable file, a unit that is not
    enabled, a `launchctl disable`, a Task Manager "disabled" switch or an XDG
    entry switched off reads **broken** ("— needs repair"; a click repairs it);
    a registration for another data folder reads **other**. Enabling registers
    for the next login without starting a second gateway; disabling only
    unregisters (the running gateway, possibly the tray's parent, keeps
    running).
  - **Apps ▸** — Observer, Flow, Code, Continuum, Entity and Assistant,
    detected by presence only (no import): the gateway-managed install
    (`/api/gateway/apps`), the app's own command on PATH or its package under
    `npm root -g` (the terminal `abstractcode` is not Code), and for the
    Assistant an `AbstractAssistant.app` bundle, the `abstractassistant`
    command or `find_spec` in this Python (a namespace-only match — a folder in
    the working directory — does not count). Each app shows what it can do now:
    **Open X** (one-time signed-in handover), **Start X**, **Install X…**
    (install + start + open signed in, with progress), **Start X (global
    install)**, **Launch Assistant**; launched processes get a token-free
    environment. **Manage Apps in Console…** opens the Apps tab.
  - **Models ▸** replaces "Loaded Models": what is in memory (each loaded model
    a submenu whose action is **Eject — frees N GB**; eject warns when work is
    running), then **Load a Model ▸**: your configured defaults first, then every
    installed model grouped by engine (recognised catalog models first, then
    A–Z, lists over 30 split into ranges, nothing dropped), sizes on disk, a
    "more than free memory" hint with a confirmation, embeddings greyed ("load
    on use"). Loads and ejects show progress and the result, failures the
    gateway's full reason.
  - **Open Console signs you in**: the tray (and the Activity window) mint a
    one-time claim link locally, as `abstractgateway claim` does, so an expired
    8-hour session never ends on a token prompt; plain URL + a notification
    saying why when the gateway runs with a static token. No sign-in endpoint
    was added.
  - **Network ▸** and **Copy Address ▸** (consuming `GET/POST
    /api/gateway/network`): the primary address and mode under the status
    header, radio items Localhost only / Local network / Internet… (Internet
    asks for an explicit acknowledgement showing the gateway's warnings),
    "Restart to apply" while a change is pending, refusals shown with their
    reason and fix; every address the gateway answers on, one click to copy.
  - Explicit degrade: `"flat_menu": true` in `<data>/tray/prefs.json` renders
    the same rows without submenus (path-prefixed) for panels that drop them;
    pystray's menu-less X11 backend gets a start notification naming the CLI
    equivalents.
- **Linux login item without systemd.** Where no systemd user manager answers,
  `service install|enable` writes an XDG autostart entry
  (`~/.config/autostart/abstractgateway.desktop`) instead of failing.
- **The console looks and behaves like a finished product (mission L).**
  The first-run guide is a full-page flow instead of a 760 px modal: a step
  rail on the left (done / current / upcoming), a content area that uses the
  window, and a sticky footer with Skip / Back / Next; Escape closes it
  without marking it done. Local engines are cards, not a table: one card per
  engine with a plain status pill (Ready, Running, Installing, Needs your
  approval, Needs Apple tools, Not installed, Not for this computer), one
  primary action per state from the `gateway_engines_v2` row (Install, Start,
  Stop, Continue with administrator password, Install tools, Try again), an
  inline progress bar, the job's plain sentence first and the full log behind
  "Show details". Downloads (wizard starter models, Multimodal "Weights") show
  a real bar with bytes, percent, speed, time left and per-file rows, a
  distinct "Stalled" state, Cancel, and a "Download all" for the recommended
  set; a reload re-attaches to running downloads. The Apps step and a new
  **Apps** tab install, start, open (signed in) and stop the browser apps
  through the gateway's apps service, with "Node.js will be installed for
  you" instead of terminal commands. Every table fits its box: when a table
  no longer fits, each row becomes a card (label/value grid) instead of a
  horizontal scrollbar; ids, model names, paths and URLs are ellipsized with
  the full value on hover and click-to-copy, never character-wrapped
  (ADR-0026). CLI lines, route ids and commands sit behind a "Technical
  details" switch (sidebar and guide). New module `console_ui.py`.
- **The console header runs the abstractuic kit's real widgets.** The
  top-right cluster is the kit's `AfTopBarActions` (assistant, appearance,
  setup guide, signed-in identity, Connect/Disconnect pill) and Appearance is
  the kit's `AfAppearanceDialog` (its `ThemeSelect`, font and header size),
  mounted as React islands from the kit's own bundle
  (`ui-kit/scripts/build_islands.mjs`), vendored into the generated
  `console_islands.py` with the kit's component CSS by
  `python -m abstractgateway.console_islands_sync`, and drift-pinned by
  `tests/test_gateway_console_islands_sync.py` (kit-source hash, byte compare
  of the built bundle, API probe). The static cluster stays as the fallback.
- **Model downloads show real progress from the first second, and "Use
  recommended defaults" is one job.** A download job now carries `state`
  (queued, resolving, downloading, verifying, installing, done, failed,
  cancelled, stalled), bytes done and total, percent, speed, time left, the
  file arriving now and one row per file, and a plain sentence such as
  "Downloading model.safetensors (2 of 5) · 1.2 GB of 4.8 GB · 38 MB/s ·
  1 min left", for Hugging Face / mlx-gen, Ollama, LM Studio and Supertonic
  alike. A download that receives no bytes for 15 s says it is stalled and
  recovers by itself. `POST /models/download {"recommended": true}` also
  returns a parent job (`grp_...`) whose bytes, percent, speed and time left
  add up the three models. New routes: `POST /models/download/{job}/cancel`
  (admin; stops the transfer within about a second, or every child of a
  parent) and `GET /models/downloads/stream` (Server-Sent Events of the same
  jobs; polling still works). See [docs/model-downloads.md](docs/model-downloads.md).
  Requires the matching AbstractCore. The `message` of a running download is
  now that sentence; the engine tool's own last line moved to `detail`.
- **The gateway installs and runs the browser apps.** Flow Editor, Code,
  Observer, Continuum and Entity can be installed, started, stopped, updated
  and opened from the gateway, with no terminal: when the machine has no
  Node.js 18+, the gateway installs Node.js 24 in its own data folder (no
  admin rights, sha256-checked), then downloads the app from the npm registry
  (sha512-checked), installs its dependencies, starts it on a free port and
  opens it already signed in to this gateway through a one-time link (the
  token never reaches the page or the URL). Every step is a job with percent,
  bytes and a plain message, and the full log when it fails. Started apps are
  children of the gateway: they stop with it, restart after a crash (at most
  3 times a minute), and start again with the gateway until you stop them.
  Routes under `/api/gateway/apps` (list, runtime/install, install, update,
  launch, stop, open, logs, jobs), the `/apps/handover/{code}` link, and
  `abstractgateway apps list|install|launch|stop|update|open|logs|runtime|jobs`.
  See [docs/apps.md](docs/apps.md).
- **Engine installs finish without a terminal: user-level first, tools and
  administrator only through the operating system's own dialogs.** llama.cpp
  installs upstream's prebuilt Metal wheel (0.3.28; CPU 0.3.35 on Linux and
  Windows x64) instead of building the PyPI sdist; a source build happens only
  when no wheel fits, and only after the job has checked for the Apple
  command-line tools (`needs_tools`, one-click `xcode-select --install`, the
  job resumes by itself). Ollama and LM Studio on macOS download the vendor's
  signed app (Ollama: SHA-256 from its GitHub release; both: Developer ID team
  check), place it in `/Applications` when the account can write it or else
  in `~/Applications`, and start the server. A step that genuinely needs an
  administrator stops in `needs_admin` with the reason and the exact command;
  nothing elevated runs until someone presses Continue, and then only through
  macOS's password dialog (`osascript … with administrator privileges`) or
  `pkexec` on a Linux desktop. vLLM on a Mac and MLX off Apple silicon are
  refusals with the reason and no Install button. Jobs report state, percent,
  bytes, a plain-language message (heartbeat every 3 s) and the full log.
  New routes in `routes/engines.py`: `GET /engines` (contract
  `gateway_engines_v2`), `POST /engines/{id}/install {location}`,
  `GET /engines/jobs[/{id}]`, `POST /engines/jobs/{id}/continue|cancel`,
  `POST /engines/{id}/start|stop`; CLI `abstractgateway engines
  continue|cancel|start|stop`. See [docs/engines.md](docs/engines.md).

### Changed
- **Console: calm app cards with one action row (mission GG).** On the Apps
  tab and the setup guide's Apps step each card is now icon + name + status
  pill / one line of description (the whole sentence on hover) / one action
  row. The action rows of a grid row sit at the same height (each card is five
  subgrid rows: head, description, body, actions, technical), and the row
  holds the primary action for the state (**Install and open**, **Open**;
  **Open** also starts a stopped or crashed app, there is no separate Start)
  plus the terminal action next to it (**Open in Terminal**, or a quiet
  **Install for Terminal**). Stop, Start, Show log, Update, the version, the
  address, the npx line and the terminal commands (Rust toolchain case,
  another computer) are rendered only with the **Technical details** switch on,
  as one secondary line of text buttons under the action row; switching it off
  removes them from the page. The "is installed and running. Open it with the
  Open button." box is gone: result boxes ("Code opened in a new Terminal
  window, signed in to this gateway.", "Flow Editor opened in a new tab.")
  close themselves after 6 seconds; errors stay, with **Show details**. The
  terminal version is no longer its own block ("Also runs in your terminal",
  glyph, pill, sentence). The Done step's note about the console's terminal
  app is one quiet line; its install and open commands show with Technical
  details. The Engines cards and the guide's recommended-model tiles use the
  same five rows, so their action rows line up too (a "Learn more"-only row
  sat 16 px lower than a row of buttons); a model download's **Cancel** moved
  into the tile's action row.
- **No user-facing text tells anyone to set an environment variable** (mission
  Z, operator rule). Network-mode warnings point at the controls ("Add your
  public https origin under Reverse proxy below"); auth refusals describe the
  state the gateway was started in ("This gateway was started with accounts
  (user auth) off …"); `serve`'s hardening hints, the missing-auth refusal,
  the loopback auth line, `claim`'s refusal and the apps hints name settings
  and commands. Inventory: `untracked/missionZ/env_instructions.md`.

- **The login service lets the Network setting apply (mission T).** Every
  registration (LaunchAgent, systemd user unit, XDG autostart entry, Windows Run
  entry) now starts plain `serve` — no `--host`, no `--port` — so a mode chosen
  in the tray, the console or `abstractgateway network set` takes effect at the
  next start. Before, the pinned `serve --host 127.0.0.1 --port N` overrode it
  forever (`restart.applies: false`). `service install|enable` store the bind
  in the setting first (nothing stored → `localhost` on the chosen port: the
  same bind as before; a stored mode is kept; `--host 127.0.0.1|0.0.0.0` and
  `--port` are written into the setting, printed; a refused mode registers
  nothing). New `--pin-command-line` keeps the old command line on purpose
  (recorded as `pinned` in `service.json`, reported as an override).
  `service status` / `autostart_status` report a registration that still pins
  `--host/--port` as `broken` + `needs_repair` ("pinned to 127.0.0.1:N by the
  login item — run `abstractgateway service enable` again to let the Network
  setting apply"), which the tray shows as *needs repair*; `service enable` or
  the tray click rewrites it in place. New status fields: `bind_source`,
  `pinned_command_line`, `network_setting`, `repairs`, `needs_repair`. The
  tray's switch no longer passes its own URL's host/port (that would have
  written 127.0.0.1 into the setting). **Installed machines: run
  `abstractgateway service enable` once** (then `service install`, or log out
  and in, to restart the gateway from the new registration).
- **Windows login item is a per-user `HKCU\…\Run` value** (was a Startup-folder
  shortcut). A Run value can be read back and verified (a `.lnk` cannot without
  COM) and Task Manager's switch for it is readable; install/uninstall remove
  an older `AbstractGateway.lnk`, which would start a second gateway. Still
  experimental (not validated on a Windows VM).
- `service uninstall` on Linux only calls `systemctl --user disable` when the
  unit file exists (it used to fail on a machine with nothing installed).

### Fixed
- **A leftover browser app from a gateway that died is stopped again on
  Linux.** The reaper recognises the app by its install path in the process
  command line, read with `ps`; Linux `ps` cut that line at 80 columns when
  not writing to a terminal, so the path never matched, the leftover was not
  stopped and kept its port ("No free port for the app"). It now reads the
  whole line (`ps -ww`).
- **`abstractgateway --version`** prints `abstractgateway <version>` (the
  flag did not exist).
- **No false "PyTorch was imported" GGUF warning.** Every command printed
  "GGUF GPU offload could NOT be reserved ... PyTorch was imported before this
  gateway started" on a light install (no llama-cpp-python) and on any host
  that is not Apple silicon, where Metal offload cannot exist. The reservation
  and its warning now run only on Apple silicon with llama-cpp-python
  installed; `config`, `--version` and `--help` skip it.
- **The saved backlog folder was ignored by the backlog routes (mission II).**
  Every backlog/report/process route read ONLY the environment, so the
  `triage_repo_root` setting saved from Continuum or the console changed
  nothing, and the exec runner did the same. A saved folder that disappears
  now answers `404 Backlog folder not available on this gateway: <reason>`
  (no server path in the sentence) instead of "not configured".
- **`abstractgateway backlog-exec-runner` no longer writes
  `os.environ["ABSTRACTGATEWAY_TRIAGE_REPO_ROOT"]`** in its own process: the
  folder (`--repo-root`, else the setting) is passed to the runner as an
  argument. Its old fallback to the current directory is replaced by the
  setting's default (the gateway's own folder).
- **Process control stays off on the gateway's default backlog folder** (it
  is not a checkout); it needs `triage_repo_root` set to the framework
  checkout it manages, as before.
- **Apps started outside the gateway are listed and open signed in (mission
  HH, 2026-09-24).** The operator's report: with the whole dev stack running
  (`scripts/start-local.sh --build`: all five apps on 127.0.0.1:3001-3005),
  the tray said "Observer — can't install here: …" for every app. The apps
  manager only knew apps it installed itself. It now asks the usual loopback
  ports (3001-3005, then 3000 and 3007) for the start page and recognises
  each app by its title (no app has an identity route that answers without a
  gateway sign-in); the version is read from the `package.json` of the
  listening program. Such an app is `installed`, `running`,
  `source: "external"`, `managed: false`, with `url`, `port`, `version` and
  one action, `open`, which goes through the usual one-time handover (the
  app's server reads the same cookies whoever started it; the gateway URL in
  them is the gateway's own). Stop answers 409 `started_outside_gateway`;
  launch does not start a second copy. Probing is loopback only, 0.5 s per
  port in parallel, cached 5 s, and never probes a port an app of this
  gateway holds.
- **One port table, the stack map.** The gateway's usual app ports now match
  `scripts/start-local.sh`: Observer 3001, Continuum 3002, Code 3003, Entity
  3004, Flow 3005 (`apps_manager.STACK_PORTS`, used by the managed launch, the
  external probe and the tray; it was Code 3002, Flow 3003, Continuum 3004,
  Entity 3007). Apps are listed in that order.
- **The person at the gateway machine may install by default, whatever the
  bind.** With no saved `allow_engine_install`, installs of apps, Node.js,
  terminal apps and engines are allowed for a caller on the gateway machine
  itself: the socket peer is loopback or one of this host's own interface
  addresses, and the request has no proxy header
  (`security/same_machine.py`). A LAN-bound gateway used to refuse the
  operator at their own keyboard. Remote callers still need the setting; a
  saved OFF is off for everyone. `install_policy` gains
  `caller_on_this_machine` and the source `default_same_machine`.
- **Tray Apps submenu: one short line per app, no reasons.** Running
  (managed or external) or installed → "Open X"; installable → "Install X…";
  otherwise the name, greyed. When an install is blocked, one line near the
  bottom: "Installs are off for this gateway · Console → Apps". The console's
  Apps card shows an external app with the Running pill and Open, and under
  Technical details "Started outside the gateway on port N" instead of Stop.
- **Tray base URL.** Confirmed loopback (`http://127.0.0.1:<port>`) for every
  network mode (wildcard and loopback binds); the LAN address in the header
  is the network status, not the tray's connection. Pinned by a test.

- **Engine installer downloads go to the per-OS user cache (mission FF).**
  Without an explicit cache directory, `engines_install` wrote to a
  hard-coded `~/.cache/abstractgateway/engines`, ignoring `XDG_CACHE_HOME`.
  The new `host_paths.user_cache_dir()` (next to `user_data_dir()`) answers
  `~/Library/Caches/AbstractGateway` on macOS, `$XDG_CACHE_HOME/abstractgateway`
  (default `~/.cache/abstractgateway`; a relative value is ignored per the XDG
  spec) on Linux and `%LOCALAPPDATA%\AbstractGateway\Cache` on Windows.
- **The administrator copy of an engine app is chowned to the process owner
  from the password database**, not to an inherited `USER` value (which can
  name someone else, e.g. under `sudo`). The `USER` read is gone.
- **Every environment read is declared.** `env_registry` now carries the
  desktop-session reads (`DISPLAY`, `WAYLAND_DISPLAY`) and the per-OS
  directory reads (`XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`,
  `LOCALAPPDATA`, `APPDATA`) as reads of the operating system, not settings
  (class `deployment`, owner `desktop-session`, silent in the boot scanner).
- **Console: cancelling a model download uses the long-call budget.** The
  download panel's Cancel (`POST /models/download/{id}/cancel`) went through
  the 60 s default; like the download start it now passes `slow: true`.
- **The consoles say why MTP did not run, in words.** The web console's sandbox and
  default-route test lines, and the terminal console's test line, now show the response's
  `speculation.message` (and the discovery capabilities' `message`) instead of only the
  slug. For example: "MTP not used: MTP acceleration off: companion
  mlx-community/Qwen3.5-9B-MTP-4bit … is not downloaded; download it with `abstractcore
  models download mlx …`". Model downloads now fetch an MLX model's MTP companion in
  the same job. That comes from AbstractCore: see its CHANGELOG, mission CC.
- **`lan`/`internet` are refused when read protection is off** (mission AA
  finding). `ABSTRACTGATEWAY_PROTECT_READ=0` makes the middleware answer every
  unauthenticated read as the admin; the network modes now refuse it
  (`reason_code: auth_disabled`) and `serve` falls back to loopback with the
  reason. `auth_mode_summary` reports `read_protected`.
- `abstractgateway network … --data-dir DIR` now uses DIR (it was accepted
  and ignored); `network` no longer reserves the GGUF GPU offload (no model
  is ever loaded by it).

- **Security: with user accounts off, a non-admin account can no longer sign
  in and change the operator's settings (mission BB).** With user accounts
  off the gateway runs one runtime, the operator's; `POST /session/login`
  nevertheless accepted any registry account, so a non-admin (role `user`,
  created by the admin) could sign in and then change the gateway-wide
  capability defaults and add endpoint profiles to the admin's own list (his
  bearer token was already refused). Now only admin accounts can hold a
  browser session in that mode: login answers `401`
  (`reason_code: "user_accounts_off_admin_only"`, a message naming the two
  ways out: the operator turns user accounts on, or sign in with an admin
  account), and a session that already exists for a non-admin account is
  refused and removed at its next use. The rule lives in one place
  (`security/sessions.py::principal_barred_from_shared_runtime`) and every
  session path applies it, including the browser-app sign-in handover. Admin
  accounts sign in in both modes, and the login answer now reports the real
  mode instead of always claiming user accounts are on.
- **Users: no account that could never sign in, and never zero admins.**
  With user accounts off, `POST /admin/users` answers `409` instead of
  creating a non-admin account, and a `PATCH` that removes the `admin` role is
  refused the same way. In both modes, deleting, disabling or demoting the
  last enabled admin account answers `409` (`reason_code: "last_admin"`).
- **Workflows: `POST /bundles/{id}/deprecate` and `/undeprecate` apply the
  shared-registry ownership check** that every other registry write already
  applied (they answered `404` for a missing bundle, i.e. authorization had
  passed, where `/bundles/reload` answered `403`).
- **The route authorization contract test tells the truth.** Its comment
  credited a `_principal_requires_isolation` function that never existed; it
  now describes the real mechanism and pins both modes against the live
  route table: with user accounts off a non-admin session is refused on every
  write route, and with them on it gets `403` on every admin family and never
  changes the admin's gateway-wide view.
- **An engine Install no longer surfaces a raw build failure.** Before, the
  llama.cpp Install ran `uv pip install llama-cpp-python` (a CMake source build
  that fails without Xcode's tools, shown as a 43-line log and "uv exited 1"),
  and the Ollama Install on macOS ran the vendor shell script, which needs
  `sudo` for `/usr/local/bin` and cannot ask for it from a job.
- **The admin token is printed again at first launch.** 0.3.0 hid it behind
  `ABSTRACTGATEWAY_BOOTSTRAP_PRINT_TOKEN=1`, so a first launch showed only the
  one-time console link and no credential for the browser apps. A loopback
  `serve` now prints the token (as 0.2.30 did) together with the link; a
  non-loopback bind hides it. The switch is a launch flag,
  `abstractgateway serve --print-token` / `--no-print-token`; the environment
  variable is kept only as an alias of `--print-token`.
- **A restart from the tray or console no longer carries in-process Hugging
  Face offline flags into the new gateway.** `host_control.relaunch_process`
  used `os.execv`, which hands the new process the current `os.environ`. An
  `HF_HUB_OFFLINE=1` written in-process during the run (the old MLX / Hugging
  Face provider writes, a third-party library) therefore became the new
  process's start-up environment, which AbstractCore records as the
  operator's choice and which makes every explicit download refuse by name.
  The relaunch (POSIX `execve` and the Windows spawn) now resets
  `HF_HUB_OFFLINE`, `TRANSFORMERS_OFFLINE` and `HF_DATASETS_OFFLINE` to the
  values the gateway started with (a value counts only if both the gateway's
  and AbstractCore's start-up snapshots saw it) and logs each variable it
  dropped or restored. Every other variable passes through unchanged.

### Tests
- **The suite passes on a headless Linux runner (CI).** The local engines'
  default addresses (`LMSTUDIO_BASE_URL`, `OLLAMA_BASE_URL`, `OLLAMA_HOST`)
  point at a closed loopback port for every test and the whole session: off
  Apple silicon a gateway with no provider builds an LM Studio client that
  lists models at localhost:1234. The engine-install fakes answer
  `xcode-select -p` with a folder that exists; the apps tests use valid port
  ranges and build the terminal command as on a Mac instead of needing a
  desktop session.
- The conftest's network guard now also refuses 127.0.0.1:3000-3007 (the
  operator's browser apps), and an autouse fixture empties the external-app
  probe's port list: a test names its own scratch ports (mission HH).
- **Subprocess guard (mission FF, 2026-09-24).** A child process has its own
  sockets, so a real engine CLI slipped past the network guard. The conftest
  now refuses launching `lms`, `ollama`, `open` or `xdg-open` (through
  `subprocess`, asyncio subprocesses or `os.system`, including `sh -c` and
  `env …` forms), fails the test and lists the launch under "subprocess
  guard". Opt-outs: the `fake_cli` fixture registers a stand-in script (only
  that file may run); `@pytest.mark.desktop("reason")` (mandatory reason,
  skipped unless `pytest --allow-desktop`) and `network` tests may launch the
  real ones. First catch: the fresh-install catalog test ran the operator's
  real `~/.lmstudio/bin/lms ls --json` through the refusal's weights probe; it
  now uses a fake `lms`. Self-tests in `tests/test_conftest_subprocess_guard.py`.
- **Tests never touch your home or the network (mission DD, 2026-09-24).**
  `tests/conftest.py` now also isolates `HOME`, `HF_HOME`/`HF_HUB_CACHE` and
  the AbstractCore config directory (at import and per test), clears exported
  `ABSTRACT*`/`HF_*` path settings and `XDG_*`, and installs a socket guard that
  refuses non-loopback destinations and the live local services on loopback
  (8080, 1234, 11434, 18850). Findings fixed: the live API-parity checks in
  `test_gateway_offline_parity.py` queried the operator's gateway on :8080 by
  default (now opt-in by URL and marked `network`); the fresh-install catalog
  test queried the live LM Studio on :1234 (now a loopback fake); three tests
  resolved the stand-in `core.test` server over real DNS (now stubbed).
  `@pytest.mark.network("reason")` (skipped unless `pytest --allow-network`)
  and `@pytest.mark.real_home("reason")` are the opt-outs; a bare marker is a
  collection error. See
  [CONTRIBUTING.md](CONTRIBUTING.md#tests-never-touch-your-home-or-the-network).

## [0.3.0] - 2026-09-23

This release requires AbstractRuntime 0.4.33 and AbstractCore 2.14.0
(installed automatically).

### Added
- **Models and Engines tabs in the web console.** Browse models that fit this
  machine, download or delete them, and see and install local engines
  (Ollama, LM Studio, MLX, llama.cpp, Hugging Face). These are AbstractCore's
  own screens, embedded in the gateway, so the gateway and
  `abstractcore serve` show the same data and the same actions. Download,
  delete and install are admin-only; an install first shows the exact command
  it will run on the gateway host. See [docs/console.md](docs/console.md).
- **The first-run guide uses them.** The engines step lists the real engines
  on this machine with an install button. The model step lists models that
  fit, downloads one, and sets an installed model as the default text model.
- **Routes** (same bodies and payloads as AbstractCore's `/acore/*`):
  `GET /api/gateway/host/profile`, `GET /api/gateway/engines`,
  `GET /api/gateway/engines/{id}`, `POST /api/gateway/engines/{id}/install`,
  `GET /api/gateway/models/catalog`, `GET /api/gateway/models/installed`,
  `POST /api/gateway/models/delete`, `GET /api/gateway/jobs`,
  `GET /api/gateway/jobs/{id}` and `POST /api/gateway/jobs/{id}/cancel`.
  Every POST is admin-only and in the audit log. An AbstractCore older than
  2.14.0 answers 501 with the upgrade command instead of failing.
  See [docs/api.md](docs/api.md#models-and-engines).
- **Commands:** `abstractgateway models list|catalog|search|download|delete|jobs|cancel`
  and `abstractgateway engines status|install|open`, with the same arguments
  and exit codes as `abstractcore models|engines` (0 ok, 1 error, 2 refused).
  They call the running gateway; `--local` runs them in-process instead.
  Job cards in the consoles show these commands.
- **`allow_engine_install`** (runtime config): engine installs from the
  console or API run on the gateway host, so they are on by default only for
  a gateway bound to loopback. Dry runs are always allowed. See
  [docs/configuration.md](docs/configuration.md#allow_engine_install).
- `abstractgateway claim` and `abstractgateway-config claim-url` accept
  `--base-url` as another name for `--url` (the bootstrap installers use it).
- **console-tui (crate `abstractgateway-console` 0.7.0, versioned separately):**
  the terminal console gains screens 9 **Models** and 0 **Engines**, which are
  AbstractCore's shared screens from the `abstractcore-console` crate mounted
  over the gateway's `/api/gateway/models/*`, `/engines/*`, `/host/profile`
  and `/jobs/*` routes. See
  [console-tui/CHANGELOG.md](console-tui/CHANGELOG.md).
- **Zero-configuration first run.** With no auth configured, `abstractgateway serve`
  binds `127.0.0.1`, enables user auth, creates `default/admin`, and prints a
  one-time console sign-in link instead of a token. See
  [docs/first-run.md](docs/first-run.md).
- **One-time sign-in links:** `abstractgateway claim [--open]` and
  `abstractgateway-config claim-url [--open]` mint a single-use, 10-minute link
  (`/console#claim=<code>`); `POST /api/gateway/session/claim` redeems it for an
  admin browser session from a loopback peer only.
- **First-run guide in the web console** (host summary, local engines, default
  model with recommended downloads, apps, CLI equivalents), opened once per
  data folder and reachable later from the **Setup** button.
  `GET /api/gateway/host/first-run` and `POST` (admin) hold its state.
- **`abstractgateway service install|uninstall|status`**: start the gateway at
  login as a macOS LaunchAgent, a Linux systemd user unit, or (experimental) a
  Windows Startup shortcut, with `--dry-run`, free-port selection and a
  persisted port.
- `serve --data-dir`.
- `abstractgateway-config status --json` gains `schema`
  (`gateway_config_status_v1`), `data_dir_source`, `data_dir_reason`,
  `auth_mode`, `auth`, `service`, `claim_pending`, `claims`, `first_run` and
  `serve`; `GET /api/gateway/host/state` gains a `gateway` block with the same
  facts.

### Changed
- **Model downloads run in AbstractCore's job registry.** `POST /models/download`
  and `GET /models/download/{job}` keep their `{ok, job}` envelope and
  behaviour (a queued job reads `running`, a duplicate request joins the
  running job), and the job is also readable at `GET /api/gateway/jobs/{id}`.
  The job now carries AbstractCore's fields as well (`schema`, `job_id`,
  `kind`, `log_tail`, `command`, `cli_equivalent`); `started_at` is an
  ISO-8601 time instead of a Unix timestamp. Jobs started by the
  `abstractcore` CLI on the same machine appear in the job list.
- **Default data folder.** When `ABSTRACTGATEWAY_DATA_DIR` is unset, the gateway
  uses `./runtime` only if it already exists in the working directory, and
  otherwise the per-user data folder (macOS
  `~/Library/Application Support/AbstractGateway`, Linux
  `$XDG_DATA_HOME/abstractgateway`, Windows `%LOCALAPPDATA%\AbstractGateway`).
  The `triage-reports`, `triage-apply`, `backlog-exec-runner` and `data list`
  commands use the same default as `serve` (they previously defaulted to
  `./runtime/gateway`). Set `ABSTRACTGATEWAY_DATA_DIR` to keep any other layout.
- **`serve --host` default.** `127.0.0.1` when no auth setting is present;
  `0.0.0.0` (unchanged) when any auth setting is present.
- **The bootstrap admin token is no longer printed** on loopback starts; it
  stays in `<data dir>/auth/bootstrap-admin-token`. Set
  `ABSTRACTGATEWAY_BOOTSTRAP_PRINT_TOKEN=1` to print it.
- **Windows:** the runner's singleton lock uses `msvcrt.locking`, so two
  gateways on one data folder no longer both run workflows.

## [0.2.30] - 2026-09-23

This release requires AbstractRuntime 0.4.32, AbstractAgent 0.3.13 and
AbstractMemory 0.3.0 (installed automatically). It also folds in the
`[0.2.29]` changes below, which were never published separately.

### Added
- **Stop kill switch.** A `cancel` command (the Stop button) cancels the run
  tree and stops the model call that is executing. If a call of the cancelled
  tree is still running after `stop_kill_switch_s` seconds (runtime config key,
  or `ABSTRACTGATEWAY_STOP_KILL_SWITCH_S`; default `10`, `0` disables), the
  gateway kills that inference in process. The gateway process, other runs and
  the HTTP API keep working. Stopped calls are recorded as `cancelled` ledger
  steps with `cancelled_by` / `killed_by`. See
  [docs/configuration.md](docs/configuration.md#stop-and-the-kill-switch).
- **`abstractgateway models loaded|load|unload`.** List, warm and eject models
  on a running gateway from a shell, through the same routes the consoles use
  (`--url`, `--token`, `--provider`, `--model`, `--force` for a locked model).
- **MTP (speculative decoding) controls.** `speculation` is accepted on
  `/runs/start`, `/runs/schedule`, `/sandbox/generate` and as
  `_runtime.speculation` (`false` = Off, a native-MTP object selects a depth).
  The web console and the console TUI edit the Core-owned default
  (`options.speculation` on the text route) with an MTP selector.
- **Desktop tray icon for `abstractgateway serve`** (install the `tray` extra):
  open the console, pause/resume workflows, unload models, and watch memory,
  GPU and recent runs. See [docs/tray.md](docs/tray.md).
- **Pause / resume execution** (`POST /api/gateway/host/pause|resume`, admin;
  `GET /host/runner`). A paused runner still applies commands, so Stop works.
- **Restart and self-update** from the tray or console (`POST /host/restart`,
  `GET /host/update`, `POST /host/update/check|start`), aware of pip, uv, pipx,
  editable and Docker installs.
- **Host views:** `GET /host/metrics/live` (GPU, memory and execution state in
  one call) and `GET /host/runs` (recent runs across every data plane, admin).
- **Workflows tab in both consoles** listing every registered workflow with its
  versions and entrypoints, plus import, export
  (`GET /api/gateway/bundles/{bundle_id}/download`) and delete. Versions that
  cannot be served are listed in `skipped` with the reason.
- **Out-of-the-box workflows.** A fresh install serves `basic-agent`,
  `coding-agent` (`coder` entrypoint), `deep-research`, `co-scientist`,
  `docs-qa`, and the `react-agent` / `codeact-agent` / `memact-agent` native
  loops. See [docs/shipped-workflows.md](docs/shipped-workflows.md).
- **Durable session replay.** `use_session_history` seeds a run's
  `context.messages` from the session's prior turns (with a message cap), and
  `GET` history-bundle / session-bloc endpoints serve replayable transcripts.
- **Summoned entities.** Persistent entities with their own homes, identity,
  memory and lifecycle: `abstractgateway entity create|list|inspect|verify|chat`
  and `/api/gateway/entities/*` (summon with a queue, chat, visits,
  sleep/wake/pause, diary, skills, voice, task inbox, tool policy). See
  [docs/entities.md](docs/entities.md).
- **Run-level skills selection** and skills/MCP inventories for launch surfaces.
- **One seam for AbstractCore-owned configuration** (`core_config.py`); the
  text reasoning effort is editable from the Gateway.
- `inject_guidance` runner command, durable `emit_event` delivery
  (`payload.durable: true`), and a declared environment-variable registry.

### Changed
- **One gateway-owned workspace per session**, not per run (HTTP API and
  Telegram bridge). The system prompt stays byte-stable across turns, so
  prompt caches are reused.
- **Skills selection never widens an explicit tool ceiling.** If a run passes
  `_runtime.allowed_tools`, include `read_skill` yourself when you want the
  skill tool available.
- **Cancellation, turn grounding and agent loops follow AbstractRuntime 0.4.32
  and AbstractAgent 0.3.13:** cancelled ledger steps have status `cancelled`;
  stored user turns may start with a `<runtime_metadata>` grounding envelope;
  tool loops append messages marked `_af_synthetic`. Clients that render
  transcripts should handle all three.
- The fresh-install capability seed belongs to the install (it is not re-applied
  on every boot), and the capability-defaults read reports its provenance.
- `dp-*` workflow ids are renamed `deep-*`.
- The tray menu has a Workflows section; the `desktop_tray` setting was removed
  (the icon is present whenever `serve` runs on a desktop).

### Fixed
- The ledger stream's `event: done` follows the run's terminal save instead of
  an idle timer, and the runner wakes on events instead of polling, so a
  no-tool chat turn finishes as soon as its answer is saved.
- The shipped `basic-agent` bundle (0.0.5) no longer waits 3 s after answering.
- Workflow publish, promote, upload and reload no longer block health checks.
- `POST /prompt_cache/prepare_modules` forwards `thinking`.
- Runs of catalog-published workflows are listed normally.
- An event-entry flow no longer gets a second derived listener (no duplicate
  messages or tool calls).
- A non-object client `context` is kept as sent.
- A configured `ABSTRACTGATEWAY_BACKLOG_CODEX_BIN` counts as an available
  executor.
- Idle file-store deployments no longer burn CPU, and valid credentials are no
  longer caught by the auth lockout.
- Gateway writes of Core-owned configuration keep the fields they did not name.

### Security
- Writes to the shared workflow registry (upload, delete, reload, deprecate,
  publish) require an admin principal; per-user registries are unchanged.
- `POST /models/download` and `POST /config/capability-defaults/apply-recommended`
  require an admin principal.

## [0.2.29] - 2026-08-27

Never published separately; these changes ship in 0.2.30.

### Added
- **`GET /api/gateway/host/state` — one-call host snapshot.** Memory, GPU,
  resident models, and session prompt caches, plus byte totals, in a single
  authenticated read. Every section is independently best-effort: a missing
  facade method or a failed probe nulls that section and names it in
  `degraded` (with a `reasons` map saying why) instead of failing the
  snapshot; the route never returns a 500. `totals.models_resident`
  (additive) counts only rows with `resident: true` so every client can show
  a truthful "N loaded" — `totals.models` counts every known row,
  configured / cached included, and must not be presented as "loaded".
- **`GET /api/gateway/host/metrics/memory`.** Host RAM/process/device memory
  snapshot relayed from the Runtime host facade, with the same
  `supported: false` degraded style as `GET /host/metrics/gpu`. The snapshot
  exposes both `process.rss_bytes` and `device.allocated_bytes`;
  `device.allocated_bytes` is the signal that verifies an in-process unload
  freed device memory, since freed buffers can keep process RSS unchanged.
- **Frozen `model_residency_row_v1` row schema.** `GET /models/loaded` now
  also returns `rows` — normalized records (`runtime_id`, `task`,
  `provider`, `model`, `source`, `resident`, `state`, `pinned`, `default`,
  `size_bytes`, `size_vram_bytes`, `expires_at`, `context_length`,
  `loaded_at`, `last_used_at`, `locked`, `lockable`, `modalities`,
  `calibrated_context_length`, `context_calibrated`, `host_id`, `host_name`,
  `details`) — and `row_schema`, alongside the unchanged raw `models`
  records. Residency truth is provider-first:
  `provider_resident`/`provider_loaded` outrank runtime lease booleans, state
  strings can confirm residency but never deny it, and unknown values stay
  `null`. The schema is additive-tolerant: fields beyond the original 16 are
  optional and `null` when the runtime does not report them. Rows and the
  `GET /host/state` snapshot (its optional top-level `host` block) carry a
  host identity as the aggregation seam for a proposed multi-machine model
  resource pool
  ([backlog 0093](docs/backlog/proposed/0093_multi_machine_model_resource_pool.md)).
- **Model residency locks.** Admin-only `POST /api/gateway/models/lock` and
  `POST /api/gateway/models/unlock` pin a resident model against unload and
  release that pin, selecting the target like unload does (`runtime_id` or
  `provider`+`model`). Lock requires provider-verified residency: a
  configured or merely-warm model refuses with an
  `error: "model_not_resident"` payload (load with `lock: true` instead),
  and unlock always works — even for a since-evicted model — so locks are
  never stranded. `POST /models/unload` answers **HTTP 409** with
  the normalized `model_locked` refusal payload when the target is locked,
  and the unload request gains `"force": true` to unload anyway; every other
  unload outcome stays in-band at 200. Rows report `locked`/`lockable` so
  clients can render lock state and offer the right verb.
- **`GET /api/gateway/models/context_estimate`.** Context/KV memory estimate
  for a `provider`+`model` (optional `context_length` >= 1), relayed from the
  Runtime host facade with in-band `confidence` (`calibrated` | `estimated` |
  `unknown`) and fields such as `predicted_max_context` (the context that
  fits beside the weights), the tri-state `fits_weights` /
  `fits_requested_context` split, and `budget_bytes` (real-ceiling budget;
  basis and reserve stated in `notes`). Advisory only — no load path gates
  on it. Available to any
  authenticated principal; degrades at 200 with
  `code="context_estimate_unavailable"`/`"context_estimate_error"` like the
  other host relays.
- **A Resources surface in both consoles.** The web console gains a
  `Resources` tab and the console-TUI a `Resources` screen (8): memory/GPU
  meters with
  degradation notes, the resident-model table (modality chips/labels from
  the shared `modality_ui` palette, tri-state residency, lock state, context
  facts with calibration), and session prompt caches with per-session clear.
  The web table defaults to provider-verified RESIDENT rows only — the
  section header counts resident rows, and configured / cached rows
  (labeled "configured — not in memory", Estimate only, no Unload/Lock)
  appear behind a "Show configured / cached (N)" toggle; the TUI totals line
  counts resident rows apart from the row total. Default ≠ loaded: a
  configured capability default is never presented as loaded.
  Admins additionally get warm-up (with an optional lock-after-load and a
  live context-estimate hint), lock/unlock, and unload — a locked model's
  409 refusal triggers an explicit force-unload confirmation instead of a
  dead end. Reads render for every authenticated user; mutation controls are
  admin-gated. The web tab polls `/host/state` every 5s while active
  (stale responses are discarded), the TUI every 4s while the screen is
  active.
- **Session prompt-cache enumeration lane.**
  `GET /api/gateway/sessions/prompt_cache?session_id=` lists the prompt
  caches the runtime actually minted, with session/run/workflow/node
  attribution, and admin-only
  `POST /api/gateway/sessions/{session_id}/prompt_cache/clear_all` unloads
  every cache for a session in one call. This lane is recommended over the
  identity-derived per-session lifecycle endpoints, which are unchanged.
- **Discovery contract additions.** `capabilities.contracts.common` gains
  `host_state` and `session_caches` descriptors, and the `model_residency`
  descriptor now names its `row_schema`, lists the `lock`/`unlock`/
  `context_estimate` endpoints, and carries `modality_ui` — the canonical
  modality color map (`{version: 1, colors: {...}}`, one `{color, label}`
  entry per residency task plus an `unknown` fallback) every residency
  client renders with instead of hardcoding its own palette. `modality_ui`
  is a rendering contract and is served even when the runtime facade is
  absent.

### Changed
- **Host and residency reads are user-level.** `GET /models/loaded`,
  `GET /models/context_estimate`, `GET /host/state`, `GET /host/metrics/*`,
  and `GET /sessions/prompt_cache` serve any authenticated principal.
  Mutations — `POST /models/load|unload|lock|unlock|download` and every
  prompt-cache mutation, including the new `clear_all` — remain admin-only,
  and anonymous requests are still rejected.
- Raised the AbstractRuntime dependency floor to `AbstractRuntime>=0.4.31`
  across the base, `apple`, and `gpu` profiles; that release provides the
  host facade methods (memory snapshot, session-cache enumeration) these
  endpoints relay.

## [0.2.28] - 2026-06-14

### Changed
- Raised the Gateway dependency floors to `AbstractRuntime>=0.4.29`, `abstractagent>=0.3.12`, and `abstractcore[embeddings]>=2.13.38` across the base and hardware profiles so published installs consume the released Runtime/Core/Agent contract from this wave.
- Release packaging now ships only the supported Gateway bundles `basic-agent.flow` and `abstractassistant-orchestrator@0.0.0.flow`; local draft bundles under `flows/bundles/` are ignored by default and no longer ride along into sdists, wheels, or Docker source copies.

## [0.2.27] - 2026-06-06

### Added
- Added `POST /api/gateway/runs/{run_id}/images/upscale`, backed by Runtime's durable `AbstractCoreRunFacade.upscale_image(...)` child-run path.
- Added `upscaled_image` media capability/readiness contract entries and `task=image_upscale` Vision provider-model discovery.
- Added `GET /api/gateway/vision/adapters`, backed by Runtime's public discovery facade, so thin clients can query compatible installed adapters for image/video tasks.
- Direct image/video routes now return plural artifact fields (`image_artifacts`, `video_artifacts`) for batch generation while preserving the existing singular compatibility fields.

### Changed
- Raised the Runtime floor to `AbstractRuntime>=0.4.28` across Gateway base, Apple, and GPU profiles so Gateway installs always include the Runtime `read_pdf` / `write_pdf` nodes and their permissive `pypdf` / `reportlab` dependencies.
- Forwarded newer Runtime/Core/Vision request controls such as image/video batch `count` / `n`, `seeds`, ordered `lora_adapters`, video `flow_shift`, and image-upscaler parameters through Gateway direct media routes.
- Raised the `abstractcore[embeddings]` optional profile floor to `>=2.13.37`, matching Runtime's Core floor used by the base, Apple, and GPU Gateway profiles.

### Fixed
- Added Gateway bundle execution coverage for writing a real PDF artifact, reading it back through Runtime's PDF node, and exposing the extracted text through `On Flow End`.
- Bundle-mode VisualFlow execution preserves Runtime structured LLM `data` outputs through data edges and Break Object while leaving `response` as text.
- Bundle-mode structured LLM outputs can now drive `Answer User` and `Switch` nodes through `Break Object` without dropping the parsed data payload.
- Gateway now reuses Runtime's published workspace-path and file-filter helpers, and the published package/HTTP app versions are aligned to `0.2.27` while the base/Apple/GPU dependency floor for `abstractagent` stays on the latest PyPI release line.
- Gateway provider/model resolution now falls back to the service store base directory when embedded hosts expose stores without a full host config object, keeping backlog-assist and other hosted endpoints usable in lightweight service contexts.

## [0.2.26] - 2026-06-03

### Added
- `abstractgateway serve` now auto-ensures the `default/admin` Gateway user and writes the bootstrap browser-login token when user auth is enabled, matching the Docker first-run path for native pip installs.
- Added runtime-scoped Core config storage for Gateway capability defaults:
  Gateway baseline defaults live in `<ABSTRACTGATEWAY_DATA_DIR>/config/abstractcore.json`
  and user runtime overrides live in
  `<ABSTRACTGATEWAY_DATA_DIR>/users/<tenant>/<runtime>/runtime/config/abstractcore.json`.

### Changed
- Gateway Console now presents provider endpoint profiles as provider connections for OpenAI, Anthropic, OpenRouter, Portkey, LM Studio, Ollama, and custom OpenAI-compatible endpoints, with clearer endpoint/key hints and model discovery.
- Gateway configuration docs now distinguish browser user tokens from the legacy server/operator `ABSTRACTGATEWAY_AUTH_TOKEN`.

### Removed
- BREAKING: removed legacy Gateway `config/capability_defaults.json` overlay support. Gateway capability defaults now use only scoped Core config files (`config/abstractcore.json`). Existing overlay files are ignored; recreate those defaults with `abstractgateway-config set-default ...`.

## [0.2.25] - 2026-05-31

### Changed
- Set Gateway container defaults for host-native LM Studio and Ollama endpoints so named provider discovery does not default to `localhost` inside the container.
- Updated Docker deployment docs to use `LMSTUDIO_BASE_URL` for LM Studio and `OPENAI_BASE_URL` for generic OpenAI-compatible endpoints.

### Fixed
- Fixed Gateway Console capability-default model discovery so the Base URL field is forwarded to the provider model catalog before saving.
- Fixed Docker Compose/OpenAI-compatible documentation drift where `OPENAI_COMPATIBLE_BASE_URL` was shown as the primary AbstractCore discovery variable even though AbstractCore uses `OPENAI_BASE_URL`.

## [0.2.24] - 2026-05-31

### Added
- Added `abstractgateway-config bootstrap-admin` to create or recover a file-backed `default/admin` Gateway user for hosted/container user-auth deployments.
- Added a Gateway Docker entrypoint that bootstraps the admin user token into `/data/auth/bootstrap-admin-token` before starting the server.
- Added first-class GHCR tags for `ghcr.io/lpalbou/abstractgateway:<version>`, `latest`, `<version>-gpu`, and `gpu-latest`, while preserving the legacy `abstractgateway-server` tags during transition.

### Changed
- Gateway Docker and Compose defaults now use `/data`, enable hosted user auth, and build release images from the just-published PyPI wheel instead of local source.
- Gateway startup now accepts hosted user-auth deployments without the legacy shared `ABSTRACTGATEWAY_AUTH_TOKEN`.

### Fixed
- Fixed the PyPI/GHCR release path so container images can start cleanly from the published Gateway wheel and still provide an initial admin login token.

## [0.2.23] - 2026-05-31

### Fixed
- Fixed local-source Gateway container builds so the packaged `basic-agent` workflow bundle is present when Hatch builds the wheel inside the release image.

## [0.2.22] - 2026-05-31

### Added
- Added hosted user-principal auth with `GET /api/gateway/me`, admin-only `/api/gateway/admin/users` CRUD, and a file-backed user registry storing bearer-token hashes.
- Added request-scoped Gateway service routing so hosted user-auth mode maps each principal to a separate GatewayService data plane under `<DATA_DIR>/users/<tenant_id>/<runtime_id>/`.
- Added the built-in Gateway Console at `/console` for browser-session sign-in, account/runtime summary, admin user management, token rotation, and per-principal capability default editing.
- Added per-principal capability-default overlays in hosted user-auth mode so users can set provider/model defaults for their own runtime without mutating the global AbstractCore config.
- Added provider endpoint profiles for Gateway-stored OpenAI-compatible or hosted endpoints. Profiles keep API keys server-side, discover endpoint models on demand, and surface as virtual providers in Gateway defaults and Flow node selectors.

### Changed
- Raised dependency floors to `AbstractRuntime>=0.4.26`, `abstractagent>=0.3.10`, and `abstractcore[embeddings]>=2.13.31` so Gateway installs inherit the latest light-profile, media, and provider-profile contracts.

### Fixed
- Fixed the Gateway Console sign-in page so generated inline JavaScript parses correctly, the sign-in form posts to `/api/gateway/session/login`, and signed-out users see only the same-origin Gateway user/token login card.
- Made `abstractgateway.security` export session and middleware helpers lazily so direct `abstractgateway.users` imports are not order-sensitive.
- Kept the base `pip install abstractgateway` remote-light on Linux while relying on the base `AbstractRuntime` install for MCP and remote multimodal routing. Local sentence-transformer embeddings moved behind `abstractgateway[embeddings]`, and Gateway no longer declares direct base `sentence-transformers` or `numpy` dependencies, avoiding PyTorch/NVIDIA CUDA runtime wheels unless an explicit local-engine profile is selected.
- Kept remote/provider-backed embeddings in the base light profile through `embedding.text` routes and remote AbstractCore delegation, while surfacing embedding setup errors instead of reporting a generic missing integration.
- Gateway admin user routes now fail closed when request principal context is absent while Gateway security is enabled.
- Gateway route-family authorization now keeps operator/admin surfaces and server-workspace file helpers admin-only in hosted user-auth mode while regular users remain able to operate within their own runtime data plane.

## [0.2.21] - 2026-05-29

### Added
- Gateway artifact search/import/export endpoints for thin clients, including scoped artifact lookup by run, session, or all stored artifacts with modality, content type, text, and tag filters.
- Capability discovery now advertises artifact search, workspace import, and workspace export descriptors in the shared thin-client contract.

### Changed

- Removed legacy compatibility install extras (`abstractgateway[http]`, `[server]`, `[multimodal]`, `[memory]`, `[voice]`, `[vision]`, `[telegram]`, `[visualflow]`, `[all]`, `[all-apple]`, `[all-gpu]`, `[server-nvidia]`). The supported install surface is now:
  - `pip install abstractgateway`
  - `pip install "abstractgateway[apple]"`
  - `pip install "abstractgateway[gpu]"`
- Raised dependency floors to `AbstractRuntime[multimodal,mcp-worker]>=0.4.25` and `abstractagent>=0.3.9`.
- KG memory readiness now treats a resolvable fresh persistent AbstractMemory store as available, so empty stores return empty query results instead of hiding Flow authoring surfaces.

### Fixed
- Media model-residency discovery now keeps image editing distinct from image generation when Runtime/Core expose task-specific residency state.

## [0.2.20] - 2026-05-26

### Added
- Direct Runtime-backed video generation routes:
  - `POST /api/gateway/runs/{run_id}/videos/generate` for text-to-video
  - `POST /api/gateway/runs/{run_id}/videos/from_image` for image-to-video
- Thin-client capability contracts and readiness metadata now advertise `generated_video` and `image_to_video`, including `provider_models_task` values and `abstract.progress` child-run progress events.
- Model-residency capability reporting now includes video tasks (`text_to_video`, `image_to_video`, and `video_generation`) when Runtime/Core expose them.

### Changed
- Raised the Runtime floor to `AbstractRuntime[multimodal,mcp-worker]>=0.4.24`.
- Gateway documentation now describes direct video routes, video provider/model catalog tasks, and progress-event handling for long-running media jobs.

## [0.2.19] - 2026-05-26

### Added
- Gateway capability-default routing and configuration helpers so downstream thin clients can discover provider/model defaults without hardcoded fallbacks.
- Run-retention cleanup support for draft and ephemeral Flow runs.

### Changed
- Raised dependency floors to `AbstractRuntime[multimodal,mcp-worker]>=0.4.23` and `abstractagent>=0.3.8`.
- Refined Gateway model-residency and catalog proxy responses around Runtime/Core discovery truth, including the latest MLX-Gen vision and OmniVoice catalog surfaces.
- Refreshed Docker and deployment docs for the new release image tags.

### Fixed
- Removed brittle catalog payload assertions by normalizing Gateway-owned catalog envelopes at the route boundary.

## [0.2.18] - 2026-05-23

### Added
- Catalog and provider discovery routes now include a stable Gateway-owned envelope (`catalog.contract=gateway_catalog_v1`, `catalog.version=1`) plus one canonical `items` array, while preserving legacy lower-layer fields for compatibility.
- Capability discovery now also exposes `common.readiness` (`gateway_surface_readiness_v1`): a compact surface-level summary derived from endpoint descriptors, memory readiness, prompt-cache, media gates, and Runtime/Core truth.

### Changed
- Raised the Runtime floor to `AbstractRuntime[multimodal,mcp-worker]>=0.4.22`.
- Removed VisualFlow directory mode and fully removed the `abstractflow` package dependency from Gateway. VisualFlow JSON is stored/published via Gateway endpoints and executed as `.flow` WorkflowBundles (bundle mode).

## [0.2.17] - 2026-05-22

### Added
- Gateway now exposes Runtime-backed image editing for thin clients through `POST /api/gateway/runs/{run_id}/images/edit`.

### Changed
- Raised the Runtime floor to `AbstractRuntime[multimodal,mcp-worker]>=0.4.21`.
- Gateway capability discovery and thin-client contracts now advertise edited-image and generated-music availability, richer voice `tts|stt|listen` contracts, and Runtime-backed model residency truth instead of hard-coded media support flags.
- Direct STT now forwards `prompt`, `response_format`, `temperature`, and source `format` hints through the Runtime transcription surface.
- Release-facing docs now describe the current higher-app surface more precisely, including the stable route/contract layer and the current best-effort catalog payload limitation.

## [0.2.16] - 2026-05-21

### Changed
- Raised the Runtime floor to `AbstractRuntime[multimodal,mcp-worker]>=0.4.20` across the base, Apple, and GPU install profiles.
- Gateway's legacy prompt-cache snapshot aliases, `GET /api/gateway/prompt_cache/saved` and `POST /api/gateway/prompt_cache/save|load`, now delegate to Runtime's public host facade instead of using provider-private prompt-cache state directly.
- Local bundle runtimes now keep host-local prompt-cache exports under `<DATA_DIR>/prompt_cache_exports` through Runtime's export root policy.

### Fixed
- Removed the last Gateway-side prompt-cache boundary bypass (`runtime._abstractcore_llm_client`, direct provider-instance access, and provider-private `_prompt_cache_store` / GGUF cache hooks) from the public route surface.
- Removed the stale internal Core catalog proxy module after discovery routing fully moved to Runtime's public discovery facade.

## [0.2.15] - 2026-05-21

### Added
- Added Runtime-backed durable bloc prompt-cache control-plane routes under `/api/gateway/blocs/*`, including KV manifest/list/ensure/load/delete/prune helpers for exact-reuse workflows.
- Added Gateway-owned workspace file helper support plus focused route and contract coverage for durable blocs, model residency, notifier behavior, and Runtime-backed capability discovery.

### Changed
- Raised the Runtime floor to `AbstractRuntime[multimodal,mcp-worker]>=0.4.19` and moved Gateway's public provider/media/tool boundary behind Runtime facades rather than direct package imports.
- Updated Apple/GPU install profiles to cascade through Runtime's aggregate extras and excluded internal `tests/`, `flows/`, and backlog notes from source distributions.
- Expanded the docs and capability contract to cover durable blocs, media/model residency, Runtime-backed email/Telegram helpers, and the current Docker/runtime dependency shape.

### Fixed
- Gateway no longer reads AbstractCore config for LLM helper defaults; provider/model resolution now follows request values, Gateway env, and flow defaults with a clear config error when unset.
- Gateway's operator email, Telegram, and notification paths now use Runtime's AbstractCore host facades, while local file/workspace helpers stay owned by Gateway.
- Capability discovery and prompt-cache readiness reporting now better reflect the actual state of generated-media, voice/audio, and provider-backed cache controls.

## [0.2.14] - 2026-05-19

### Fixed
- Gateway now carries explicit modern OpenAI/httpx/anyio dependency bounds in its base install metadata, preventing Python 3.10 resolver backtracking while preserving the Apple/GPU profile cascade into `[all-apple]` and `[all-gpu]` framework dependencies.

### Changed
- Raised the Runtime floor to `AbstractRuntime>=0.4.14` so Gateway profiles consume Runtime's resolver bounds for AbstractCore provider/tool extras.

## [0.2.13] - 2026-05-19

### Fixed
- Gateway's base install now avoids mixing Core's narrow base media/embeddings extras with Core `[all-apple]` and `[all-gpu]` profile dependencies, while still installing the media, compression, and embeddings dependency set needed by the remote-capable base package.
- Gateway's base media dependency set now uses a Python-3.10-compatible `unstructured` line and bounds `python-pptx` to supported modern releases so document-capable installs do not backtrack into broken legacy setup packages.
- Gateway's base web dependency set now prefers current compatible FastAPI/Uvicorn/Requests/urllib3 releases to keep CI and user installs out of unnecessary resolver backtracking.
- Gateway now applies a compatible setuptools lower bound so Apple/GPU installs satisfy Torch's `<82` constraint without resolving into ancient broken setuptools releases.

### Changed
- Raised the Runtime floor to `AbstractRuntime>=0.4.13` so Gateway profiles consume Runtime's updated multimodal dependency metadata, and raised the Music floor to `abstractmusic>=0.1.2`.

## [0.2.12] - 2026-05-19

### Fixed
- Gateway Apple install profiles now preserve the entrypoint contract by cascading `[all-apple]` through Runtime, Agent, Core, Vision, Voice, Music, and Memory dependencies; GPU profiles continue to cascade `[all-gpu]`.

### Changed
- Gateway's base remote-capable install now includes Core embeddings dependencies alongside remote providers, media, tools, tokens, compression, voice/audio, and vision while preserving the published Core dependency floor.

## [0.2.11] - 2026-05-19

### Fixed
- Gateway voice, TTS, STT, and vision catalog routes now use the AbstractCore capability abstractions as the source of truth for provider and provider-model discovery.
- Direct Gateway TTS and STT routes now dispatch through the AbstractCore capability registry, preserving explicitly selected media providers and models through execution.
- Gateway LLM provider/model discovery can proxy configured AbstractCore Server catalog routes while keeping Flow's existing response contract.

### Changed
- Raised dependency floors to Runtime `>=0.4.12`, Core `>=2.13.15`, Flow `>=0.3.11`, Vision `>=0.3.6`, and Voice `>=0.10.3`.

## [0.2.10] - 2026-05-13

### Fixed
- Gateway capability discovery now builds its embedded capability registry with Gateway-scoped media configuration, keeping discovery contracts aligned with the concrete voice, TTS, STT, and image catalog routes.
- Gateway media catalog proxy calls now avoid forwarding unset optional query params, preventing stale `None` values from breaking downstream capability discovery.

### Changed
- Raised dependency floors to Runtime `>=0.4.11`, Core `>=2.13.14`, Flow `>=0.3.11`, Vision `>=0.3.5`, and Voice `>=0.9.4`.


## [0.2.9] - 2026-05-12

### Added
- Gateway discovery now advertises `/api/gateway/audio/transcriptions/models` for STT catalog lookup.
- Added local and proxied STT model catalog responses backed by AbstractCore/AbstractVoice.

### Fixed
- Gateway capability catalogs now map Gateway-scoped voice and vision env vars into the embedded capability registry, so local Gateway deployments expose configured voice/TTS/STT/image models without requiring duplicate lower-level env names.
- Catalog proxy calls now omit unset optional query params instead of forwarding `None` values.

### Changed
- Raised dependency floors to Runtime `>=0.4.10`, Core `>=2.13.13`, Flow `>=0.3.10`, and Voice `>=0.9.3`.

## [0.2.8] - 2026-05-10

### Added

- Capability discovery now advertises
  `capabilities.contracts.common.runs.input_data` and
  `capabilities.contracts.common.runs.history_bundle` so thin clients can
  feature-detect the run input and RunHistoryBundle endpoints from the shared
  Gateway contract.

## [0.2.7] - 2026-05-10

### Updated

- Bumped abstractagent floor to >=0.3.6 to match the new abstractagent release that requires abstractruntime>=0.4.9.

## [0.2.6] - 2026-05-09

### Fixed

- Raised the AbstractVision floor to `abstractvision>=0.3.4` across Gateway
  install profiles so `abstractgateway[gpu]` and the NVIDIA image inherit the
  stable-diffusion.cpp binding constraint that avoids the broken
  `stable-diffusion-cpp-python==0.4.6` Linux sdist.
- Updated release-facing Docker examples and package metadata from `0.2.5` to
  `0.2.6`.
- Release/CI installs now bypass the restored pip dependency cache for editable
  dependency resolution, avoiding stale package indexes immediately after
  lower-package releases.

## [0.2.5] - 2026-05-09

### Changed

- Promoted the base `abstractgateway` install to the remote-light HTTP/SSE
  server profile. It now includes Runtime multimodal support, AbstractAgent,
  AbstractCore remote/media/tools/tokens/compression/vision/voice/audio,
  AbstractVision, AbstractVoice, AbstractFlow compatibility,
  AbstractMemory/LanceDB KG support, FastAPI, multipart uploads, and Uvicorn.
- Raised Runtime and Agent floors to `AbstractRuntime>=0.4.9` and
  `abstractagent>=0.3.6`.
- Simplified install guidance around `abstractgateway`, `abstractgateway[apple]`,
  and `abstractgateway[gpu]`. The older `http`, `server`, `multimodal`,
  `memory`, `voice`, `vision`, `all`, and `server-nvidia` extras remain as
  compatibility aliases.
- The NVIDIA Docker image now installs `abstractgateway[gpu]`; `server-nvidia`
  remains only as a compatibility alias.

## [0.2.4] - 2026-05-08

### Added

- Explicit install profiles for the Gateway package: minimal base,
  `http`, `multimodal`, `server`, `memory`, `apple`, `gpu`, `all-apple`,
  `all-gpu`, and `server-nvidia`.
- `abstractgateway-config` plus `abstractgateway config` for operator status and
  private `.env` bootstrap without taking ownership of AbstractCore provider
  configuration.
- Gateway memory store resolver for AbstractMemory-backed LanceDB, SQLite, and
  in-memory stores, including `/kg/query` store metadata.
- Core catalog proxy endpoints for thin clients:
  `GET /api/gateway/voice/voices`,
  `GET /api/gateway/audio/speech/models`, and
  `GET /api/gateway/vision/provider_models`.
- Added a `server-nvidia` extra plus an experimental CUDA/PyTorch-based
  `abstractgateway-server-nvidia` Docker image recipe for full NVIDIA machines.
- Release and manual GHCR image workflows now publish the light default server
  image and attempt an experimental best-effort NVIDIA full image.

### Changed

- Base installs are now intentionally minimal again:
  `AbstractRuntime>=0.4.8` only.
- Server and multimodal profiles now use the aligned Runtime/Core/Voice/Vision
  floors: `AbstractRuntime>=0.4.8`, `abstractcore>=2.13.12`,
  `abstractvision>=0.3.3`, and `abstractvoice>=0.9.2`.
- Server, native Apple, native GPU, and NVIDIA profiles now require
  `abstractagent>=0.3.5`, so Gateway-hosted agent nodes resolve against the
  same Core/Runtime baseline as Gateway itself.
- Release tests now reset Gateway's process-global service between cases and
  pass explicit provider/model overrides for ledger summary/chat generation
  tests.
- Native Python hardware profiles are full deployment aggregates:
  `abstractgateway[apple]` and `abstractgateway[all-apple]` install the
  Apple-local stack and all relevant non-NVIDIA framework capabilities, while
  `abstractgateway[gpu]` and `abstractgateway[all-gpu]` install the matching
  local GPU stack.
- Gateway-owned runtime handoff now seeds `_runtime.prompt_cache`,
  `_runtime.max_attachment_bytes`, and `_runtime.workflow_bundles_dir` from
  Gateway configuration.
- Gateway LLM helper defaults now resolve through the same deployment cascade as
  runtime execution instead of hardcoded local model fallbacks.
- Docker Compose local builds can override `ABSTRACTGATEWAY_EXTRAS`; the
  default examples use port `8080`, and an NVIDIA compose overlay is available
  for GPU hosts.
- The default Docker server image now composes `abstractgateway[server,memory]`
  so KG workflows and `/kg/query` have the AbstractMemory/LanceDB store package
  available without making memory a base-package dependency.
- The `memory` profile now depends on `AbstractMemory[lancedb]>=0.2.6`.

### Fixed

- `memory_kg_*` effects and `/kg/query` no longer assume LanceDB directly;
  in-memory stores work, SQLite structured queries work when the installed
  AbstractMemory build exposes `SQLiteTripleStore`, and semantic queries fail
  clearly when the selected store has no vector/search capability.
- Dynamic voice/audio/vision catalog discovery now delegates to the AbstractCore
  server catalog boundary when configured, with bounded static fallback when it
  is not.
- Observer/chat/backlog/discovery helpers now return a clear provider/model
  configuration error when no request, Gateway env, or AbstractCore default is
  available.

### Notes

- The default Docker image remains the release-grade light, portable image for
  `linux/amd64` and `linux/arm64`. The NVIDIA image is `linux/amd64` only and
  is experimental/best-effort because vLLM/Torch/Diffusers dependency
  resolution is much heavier than the default server profile and still needs a
  CUDA host smoke gate before production positioning.
- There is no practical MLX Docker image target for Apple Silicon today: MLX
  depends on Apple's Metal stack and Docker Desktop runs Linux containers
  without Metal/MPS device access. Apple local inference should stay native on
  macOS, not containerized; the Gateway container can point at Docker Model
  Runner, native LM Studio, `mlx_lm.server`, or Ollama OpenAI-compatible
  endpoints via `model-runner.docker.internal` or `host.docker.internal`.

## [0.2.3] - 2026-05-08

### Added

- Versioned thin-client capability contracts for Gateway common features, AbstractFlow editor/runtime support, AbstractAssistant media/cache controls, and AbstractCode-facing prompt-cache controls.
- AbstractFlow gateway-first editor contract validation, including VisualFlow CRUD/publish/start/observe coverage and a bundled flow input-schema endpoint.
- Gateway-owned session prompt-cache lifecycle routes:
  - `GET /api/gateway/sessions/{session_id}/prompt_cache/status`
  - `POST /api/gateway/sessions/{session_id}/prompt_cache/prepare`
  - `POST /api/gateway/sessions/{session_id}/prompt_cache/rebuild`
  - `POST /api/gateway/sessions/{session_id}/prompt_cache/clear`
- Generated-media contract fields in capability discovery, including direct-vs-workflow generated-image availability.
- Direct generated-image route, `POST /api/gateway/runs/{run_id}/images/generate`, backed by Runtime/Core image output selectors, artifact storage, and `abstract.media.image.generated` ledger events.
- Backlog completion ledger for the capability contract, Flow editor contract, session prompt-cache lifecycle, and generated-media gateway contract.

### Changed

- Capability discovery now truthfully reports provider-level and session-level prompt-cache controls, plus direct Gateway voice/audio/image endpoints where configured.
- API, configuration, deployment, Docker, README, FAQ, and LLM ingestion docs now describe generated images as both workflow-backed and directly available through the Gateway route when a Runtime/Core image backend is installed and configured.
- Docker/Compose release examples now point at the `0.2.3` server image.

### Fixed

- Fixed stale release-facing docs that said Gateway had no direct image-generation endpoint after the direct route landed.
- Fixed an order-dependent test import leak so the full local pytest suite can run cleanly after the AbstractFlow editor contract tests.

### Notes

- Direct image generation still depends on a configured Runtime/Core/AbstractVision-compatible backend; Gateway does not bundle heavy local image engines.
- Session prompt-cache lifecycle is Gateway-owned naming and orchestration over provider/model controls. It is not a provider-independent local KV cache or full CachedSession persistence system.

## [0.2.2] - 2026-05-06

### Added

- MkDocs Material configuration for the documentation site.
- CI docs build job and release docs gate.
- Release workflow deployment to GitHub Pages via `mkdocs gh-deploy`.
- PyPI-backed GHCR server image publishing for `ghcr.io/lpalbou/abstractgateway-server`.
- CI validation build for the local server Docker image recipe.
- Docker server image, Compose profile, and deployment documentation.
- `docs`, `server`, `vision`, and `multimodal` optional dependency extras.
- Discovery metadata for AbstractCore capability plugins (`voice`, `audio`, `vision`, and future `music`).

### Changed

- Version metadata aligned across `pyproject.toml`, package `__version__`, and FastAPI app metadata.
- The server install profile now mirrors the newer AbstractRuntime/Core multimodal stack: `AbstractRuntime[multimodal]>=0.4.6`, `abstractcore[remote,media,tools,tokens,compression,vision,voice,audio]>=2.13.10`, `abstractvision>=0.3.1`, and `abstractvoice>=0.9.0`.
- The server Docker/Compose profile now documents workflow-backed image generation through AbstractVision, direct Gateway TTS/STT through AbstractVoice, and provider-dependent prompt-cache controls.
- Gateway voice/audio endpoints now accept AbstractVoice's newer local/remote backend environment knobs in addition to the existing Gateway-scoped settings.

### Notes

- Release scope is intentionally explicit: TTS and STT have direct Gateway endpoints; generated images are available through Runtime/Core workflows with AbstractVision installed and configured, but Gateway does not yet expose a direct image-generation HTTP endpoint.
- Prompt-cache support is provider-level control-plane support. This release does not add a Gateway-owned CachedSession lifecycle API.
- `flows/bundles/article@dev.flow` was inspected and left untracked. It is a local `dev` bundle generated by the Gateway publisher, not a release artifact.

## [0.2.1] - 2026-02-09

### Changed

- Dependency bumps (see `pyproject.toml`):
  - `AbstractRuntime>=0.4.2` (and `AbstractRuntime[abstractcore]>=0.4.2` for HTTP/voice/telegram/all extras)
  - `abstractagent>=0.3.1`, `abstractvoice>=0.6.3`, `abstractflow>=0.3.7`
  - `abstractcore[media,tools]>=2.11.8` (via `abstractgateway[all]`)
- Documentation refresh for external users:
  - added explicit AbstractFramework ecosystem context
  - updated minimum versions in install snippets to match `pyproject.toml`
  - kept the architecture diagram as the canonical “shape of the system”
- Version metadata alignment:
  - `pyproject.toml`, `src/abstractgateway/__init__.py`, and `src/abstractgateway/app.py` now agree on `0.2.1`

## [0.1.1] - 2026-02-04

### Changed

- Documentation refresh for external users:
  - new FAQ (`docs/faq.md`)
  - clarified quickstart + smoke checks in `README.md`
  - tightened getting started, configuration, security, and API overview docs
  - improved cross-linking in `CONTRIBUTING.md` and `SECURITY.md`
  - refreshed `llms.txt` / `llms-full.txt` for agent ingestion (index + full snapshot)
- Version bump to reflect the documentation release (`0.1.0` → `0.1.1`).

### Notes

- No intentional runtime behavior changes in this release; it is documentation-focused.

## [0.1.0] - 2026-02-03

### Added

- Initial public package for AbstractGateway (`abstractgateway`).

---

## CONTRIBUTING.md

# Contributing

Thanks for your interest in improving AbstractGateway.

This repo is a Python package (`src/` layout) with a FastAPI server, a durable runner worker, and contract tests under `tests/`.

## Quick start (dev)

```bash
python -m venv .venv
source .venv/bin/activate

python -m pip install -U pip
pip install -e ".[dev]"
```

Run the test suite:

```bash
pytest
```

If you only want the fast/unit/contract layer:

```bash
pytest -m basic
```

Notes:
- `integration` and `e2e` tests may require optional dependencies and/or external services (e.g. an LLM provider).
- The CLI entrypoint is `abstractgateway` (see `pyproject.toml`).

### Tests never touch your home or the network

`tests/conftest.py` makes every run hermetic, whatever your shell exports:

- **Home and caches.** `HOME` (and `USERPROFILE` on Windows) points at a
  temporary directory for the whole session and a fresh one for each test, so
  everything the code derives from the home directory lands there: the
  AbstractCore config, models, embeddings and blocs under `~/.abstractcore`,
  the Hugging Face cache (`HF_HOME`, `HF_HUB_CACHE`), the data registry, and the gateway's own data, flows and AbstractCore config store (per-test directories).
  Path settings exported in your shell (the `ABSTRACT*`/`HF_*` directory, file
  and cache variables, `XDG_*`) are cleared for the run. This happens when the
  conftest is imported, before any package or `huggingface_hub` loads; a test
  fails loudly if `huggingface_hub` froze its cache path on your real home.
- **Network guard.** Sockets refuse any non-loopback destination and name
  lookup, and also the live local services on loopback: the gateway (8080), LM
  Studio (1234), Ollama (11434) and 18850. Any other loopback port stays open,
  so `TestClient`, fake servers and scratch-port fixtures work. A refused
  attempt fails the test and is listed under "network guard" at the end of the
  run with the host and port it tried to reach. Point such a test at a fake or
  a scratch port; the `fake_public_dns` fixture answers name lookups for code
  that resolves a host before a faked fetch.
- **Subprocess guard.** A child process has its own sockets, so a real engine
  CLI would slip past the network guard. Launching `lms`, `ollama`, `open` or
  `xdg-open` (through `subprocess`, `asyncio` subprocesses or `os.system`,
  including `sh -c "…"` and `env …` forms) is refused, fails the test, and is
  listed under "subprocess guard" at the end of the run. Fake the CLI instead:
  record the argv in a double, or register a stand-in script with the
  `fake_cli` fixture (`fake_cli("lms", "#!/bin/sh\necho ok\n")` returns its
  path; only that file may run).
- **Opting out, with a reason.** `@pytest.mark.desktop("reason")` marks a test
  that drives the real engine CLIs or the desktop; it is skipped unless you
  run `pytest --allow-desktop` (a `network` test may launch them too). `@pytest.mark.network("reason")` marks a test
  that genuinely needs the network (a Hub lookup, a real download, a live
  provider). Such tests are skipped unless you run `pytest --allow-network`.
  `@pytest.mark.real_home("reason")` marks a test that READS your real home
  (for example installed tokenizers); `HOME` still stays temporary, and the
  test gets the real path as `ABSTRACT_TEST_REAL_HOME`. A marker without its
  reason is a collection error, as is a test module that reads the real-home
  path without the marker. `pytest --markers` lists all three.

## How to contribute

1. **Open an issue** (or a draft PR) describing what you want to change and why.
2. Keep changes **small and reviewable**.
3. Add/adjust tests where it improves confidence.
4. Update docs so they remain truthful and user-facing:
   - README is the entrypoint.
   - `docs/getting-started.md` is the step-by-step guide.
   - Prefer adding FAQ entries for recurring “gotchas”.
   - Regenerate the LLM snapshot: `python scripts/generate-llms-full.py` (updates `llms-full.txt`).

## Project conventions

- Source of truth is the code in `src/`.
- Keep public docs concise, actionable, and aligned with the current behavior.
- Prefer explicit env var names as used in code (see `docs/configuration.md`).

## Release checklist (maintainers)

1. Update `CHANGELOG.md`.
2. Bump version in:
   - `pyproject.toml`
   - `src/abstractgateway/__init__.py`
   - `src/abstractgateway/app.py` (FastAPI version string)
3. Run `pytest`.
4. Build artifacts (optional): `python -m build`

## Related docs

- Package overview + quickstart: [README.md](README.md)
- Docs index: [docs/README.md](docs/README.md)
- Getting started: [docs/getting-started.md](docs/getting-started.md)

---

## SECURITY.md

# Security policy

Thanks for helping keep AbstractGateway and its users safe.

## Reporting a vulnerability

Please **do not** open a public GitHub issue for security vulnerabilities.

Instead, use GitHub’s **private vulnerability reporting** / **Security Advisories** for this repository:
- Go to the repository’s **Security** tab
- Open **Advisories**
- Click **Report a vulnerability** (or create a draft advisory)

If you cannot use GitHub advisories, contact the maintainers privately (e.g. via GitHub profile contact links).

## What to include

To help us triage quickly, include:
- a clear description of the issue and impact
- minimal reproduction steps or a PoC
- affected versions and environments (OS/Python version/config)
- any suggested mitigation or patch

## Coordinated disclosure

We appreciate responsible disclosure and will work with you to:
- confirm the issue
- assess severity and affected versions
- produce a fix and release

Please avoid active exploitation, privacy violations, or destructive testing.

## Related docs

- Security configuration (auth/origin/limits): [docs/security.md](docs/security.md)
- Getting started: [docs/getting-started.md](docs/getting-started.md)

---

## ACKNOWLEDGMENTS.md

# Acknowledgments

AbstractGateway stands on the shoulders of many open-source projects and contributors.

This list is **non-exhaustive**. The canonical dependency list for this package is in `pyproject.toml`.

## Core dependencies

- **AbstractRuntime**: durable run model, workflow registry, file/SQLite stores, and runtime tick loop.

## Optional integrations (feature-dependent)

These are not required for the base gateway, but are used by optional modes/features:

- **FastAPI** (via **Starlette**) + **Pydantic**: HTTP API surface and request/response models (base install).
- **Uvicorn**: ASGI server used by `abstractgateway serve` (base install).
- **python-multipart**: multipart upload support for bundle/attachment endpoints (base install).
- **AbstractFlow**: workflow authoring/bundling workflows (Gateway does not depend on it; it runs `.flow` bundles).
- **AbstractCore** integration (via base `abstractruntime`): LLM/tool execution wiring, embeddings client, Telegram TDLib wrapper.
- **AbstractAgent**: Visual Agent nodes in bundle mode.
- **AbstractMemory** + **LanceDB**: `memory_kg_*` nodes in bundle mode (knowledge graph storage).
- **TDLib**: Telegram Secret Chats support when using the TDLib transport.

## Dev/test tooling

- **pytest** and **httpx**: test suite and HTTP client utilities used under `tests/`.
- **hatchling**: Python packaging/build backend.

## Contributors

Thank you to everyone who reports issues, improves documentation, and contributes code.

---

## docs/security.md

# AbstractGateway — Security guide

AbstractGateway secures the **gateway API surface** (`/api/gateway/*`) using an ASGI middleware:
`GatewaySecurityMiddleware` in `src/abstractgateway/security/gateway_security.py`.

Notes:
- `/api/health` is intentionally not protected.
- `/api/triage/action/*` uses signed action tokens and is not under `/api/gateway` (see `src/abstractgateway/routes/triage.py`).
- Vulnerability reporting policy: see [../SECURITY.md](SECURITY.md).

## Default behavior

By default, `abstractgateway serve` refuses to start if write endpoints are
protected and neither a legacy server/operator token nor Gateway user auth is
configured.
Evidence: startup self-check in `src/abstractgateway/cli.py` (`load_gateway_auth_policy_from_env`).

Recommended browser-console/browser-app setup:

```bash
export ABSTRACTGATEWAY_USER_AUTH=1
export ABSTRACTGATEWAY_DATA_DIR="$PWD/runtime/gateway"
abstractgateway serve --host 127.0.0.1 --port 8080

# Use this with Gateway user admin.
cat "$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token"
```

API clients can send a Gateway user token:

```text
Authorization: Bearer <token>
```

### Tenant and user isolation

In local/single-user mode, `ABSTRACTGATEWAY_AUTH_TOKEN` remains a gateway-level
control-plane token and maps to the `local-admin` principal. Treat that token as
full authority for the Gateway instance.

Hosted user-auth mode is enabled with `ABSTRACTGATEWAY_USER_AUTH=1` or
`ABSTRACTGATEWAY_AUTH_MODE=users`. In that mode, Gateway bearer tokens resolve
to concrete principals with `tenant_id`, `user_id`, roles/scopes, and a token
fingerprint. `GET /api/gateway/me` returns the resolved principal and routing
mode. The presence of an `auth/users.json` registry file is readiness state; it
does not silently enable hosted user auth unless `ABSTRACTGATEWAY_USER_AUTH_AUTO=1`
is set for compatibility. Admin principals can manage users through:

- `GET /api/gateway/admin/users?kind=human|entity|all` (default `all`)
- `POST /api/gateway/admin/users`
- `GET /api/gateway/admin/users/{user_id}?tenant_id=...`
- `PATCH /api/gateway/admin/users/{user_id}?tenant_id=...`
- `DELETE /api/gateway/admin/users/{user_id}?tenant_id=...`

Every user row carries a first-class `principal_kind` field (`"human"` or
`"entity"`); clients must read it (or the `kind` filter) instead of
re-deriving kind from the `roles` convention. Census asymmetry is deliberate:
`GET /api/gateway/entities` is the ENTITY census (homes on disk), while
`?kind=entity` here is the entity-PRINCIPAL census — homes created before
principal minting have no user row, so the two lists can legitimately differ
and neither may be derived from the other.

Entity principals (minted at entity creation) are shaped by the entities
lane, not the users lane: `PATCH` refuses `token`/`rotate_token`/`roles`/
`runtime_id` and `DELETE` refuses outright (HTTP 403 naming the lane). A
rotation would mint a live entity bearer that by design must not exist, and
a delete would remove the name-collision guard protecting the entity's
identity. `enabled` (the door-side disable), `email`, and `scopes` stay
editable. The guard lives in `GatewayUserRegistry` itself, so the config CLI
refuses the same writes.
- `GET /api/gateway/admin/runtime-reservations`
- `POST /api/gateway/admin/runtime-reservations/{runtime_id}/transfer`
- `POST /api/gateway/admin/runtime-reservations/{runtime_id}/purge`

Gateway stores user token hashes in `<ABSTRACTGATEWAY_DATA_DIR>/auth/users.json`
by default. Generated or rotated bearer tokens are returned once from the admin
create/update response and are never stored in plaintext.

Browser apps should exchange user bearer tokens for Gateway browser sessions
instead of storing bearer tokens. `POST /api/gateway/session/login` accepts a
Gateway user id and user token, validates them against the registry, and sets an
opaque signed session id plus a CSRF token as cookies. The JSON response body
does not expose those values. Gateway stores session records in
`<ABSTRACTGATEWAY_DATA_DIR>/auth/sessions.json` by default.
The session cookie is HTTP-only; the CSRF cookie is readable by the hosting app
so it can send the CSRF header. Both cookies use path `/` and `SameSite=Lax`.
Plain HTTP local-dev responses do not set `Secure`; HTTPS responses, including
requests forwarded with `X-Forwarded-Proto: https`, do set `Secure`.
Non-remembered sessions omit `Max-Age`; remembered sessions include one.
Session-authenticated mutating requests must send:

```text
X-AbstractGateway-Session: <session id>
X-AbstractGateway-CSRF: <csrf token>
```

`POST /api/gateway/session/logout` revokes the session. Disabling, deleting, or
rotating the Gateway user invalidates existing browser sessions for that user.

**Who can sign in depends on whether user accounts are on.** With user
accounts on, every registry account (admin or not) can sign in, and each one
works in its own runtime (below). With user accounts off the gateway runs one
runtime, the operator's, so every signed-in person would share the operator's
runtime, capability defaults, endpoint profiles and workflows. In that mode
only accounts with the `admin` role can hold a browser session:

- `POST /api/gateway/session/login` answers `401` for a non-admin account, with
  `reason_code: "user_accounts_off_admin_only"` and a message naming the two
  ways out: the gateway operator turns user accounts on, or the person signs
  in with an admin account.
- A session that already exists for a non-admin account (minted by an older
  gateway, or while user accounts were on) is refused and removed at its next
  use. Every session path applies the same rule (`principal_barred_from_shared_runtime`
  in `security/sessions.py`), including the browser-app sign-in handover.
- `POST /api/gateway/admin/users` answers `409` (same `reason_code`) instead of
  creating a non-admin account that could never sign in, and
  `PATCH /api/gateway/admin/users/{user_id}` refuses to remove the `admin` role
  from an admin account in this mode.

Admin accounts sign in in both modes. The rule reads the same setting the
service routing reads, so the two cannot disagree.

**The last admin account is protected.** `DELETE /api/gateway/admin/users/{user_id}`,
and a `PATCH` that disables it or removes its `admin` role, answer `409` with
`reason_code: "last_admin"` when the target is the only enabled admin account
left (entity principals never count). Create or enable another admin first.

When user auth is active, the Gateway service composition root routes each
principal to an isolated service/data plane under:

```text
<ABSTRACTGATEWAY_DATA_DIR>/users/<tenant_id>/<runtime_id>/runtime
<ABSTRACTGATEWAY_DATA_DIR>/users/<tenant_id>/<runtime_id>/flows
```

Gateway rejects duplicate `runtime_id` values within the same tenant during
user creation and update. This keeps the default multi-user invariant at
`1 user = 1 runtime`. Deleting a user removes the credential but reserves the
retained runtime id for that principal, so another same-tenant user cannot be
assigned to retained data by accident. Reusing the same runtime id in a
different tenant remains valid.

Admins can intentionally resolve retained runtime reservations through
admin-only lifecycle routes. Transfer assigns a retained runtime to an existing
same-tenant user and reserves that user's previous runtime id. Purge requires an
exact `confirm_runtime_id`, deletes the retained runtime root under
`<ABSTRACTGATEWAY_DATA_DIR>/users/<tenant_id>/<runtime_id>/`, then releases the
runtime id for reuse. Regular users cannot list, transfer, or purge retained
runtime reservations.

Clients must not send authoritative `user_id`, `tenant_id`, `runtime_id`, or
workspace-root values. Runtime fields such as `actor_id` and `session_id`, and
references such as `run_id`, `artifact_id`, and memory `owner_id`, remain
correlation and lookup fields; they do not authorize access by themselves.

Hosted multi-user mode is an incremental surface: the core request path now has
principal auth and per-principal services. Gateway also applies a central
route-family authorization table for operator/admin surfaces. Admin-only route
families include user management, audit, process control, backlog/triage/report
operations, email bridge routes, model residency mutations
(`POST /models/load|unload|lock|unlock|download`), session-wide prompt-cache
clearing (`POST /sessions/{session_id}/prompt_cache/clear_all`), server
workspace file helpers, server-workspace artifact import/export, and global
prompt-cache/bloc mutation routes. Host and residency reads —
`GET /models/loaded`, `GET /models/context_estimate`, `GET /host/state`,
`GET /host/metrics/*`, and `GET /sessions/prompt_cache` — are visibility every
authenticated client needs and serve any authenticated principal; anonymous
requests remain rejected. Regular users remain able to use
their own runtime data plane for run, ledger, artifact upload, discovery, and
runtime-scoped Core capability-default routes.

The route table is intentionally conservative around server filesystem access:
browser-local files should use `/api/gateway/attachments/upload`; server
workspace reads/imports/exports require an admin principal until a stronger
per-user workspace grant model exists.

Capability discovery follows the same policy. Regular users can still discover
ordinary run, ledger, artifact, upload, provider/model catalog, KG, and
runtime-scoped defaults surfaces, but admin-only workspace artifact
import/export and provider prompt-cache controls are advertised as unavailable
with machine-readable `admin_required` metadata. Session-level prompt-cache
keys remain available for users; the private hash includes the current
principal scope, so two users using the same session id/provider/model tuple do
not collide in a shared provider control plane.

Hosted provider secrets are supported through Gateway provider connections.
Connections are stored under the relevant Gateway data plane, expose only
non-secret metadata and a virtual provider id such as `endpoint:office-vllm`,
and inject the raw key only into the transient Runtime provider call. Normal
users can manage user-scoped connections; Gateway-scoped connections require an
admin principal. The current capability-default cascade uses execution-host
Core defaults, then the Gateway/root Core config baseline, then the user's
runtime Core config override under that user's Gateway data plane. A stronger encrypted vault, audit model, and
bridge/delegated-tool propagation policy remain future hardening work.

### Workflow registry ownership

Writing a workflow registry requires owning it. Under hosted user auth the
`/api/gateway/bundles` routes resolve to the calling principal's own bundle
directory, which that user may change freely. The gateway's own directory is
the shared set every user can see and run, so changing it requires an admin
principal.

One check covers every route that writes a registry — `POST /bundles/upload`,
`DELETE /bundles/{bundle_id}`, `POST /bundles/reload`,
`POST /bundles/{bundle_id}/deprecate`, `POST /bundles/{bundle_id}/undeprecate`
and `POST /visualflows/{flow_id}/publish` — so a shared workflow cannot be
replaced through one route while another is restricted. The check runs before
the route looks the bundle up, so a non-admin gets `403` for a bundle that
does not exist as well. (Until 2026-09-24 the two deprecation routes skipped
it.) Since a non-admin account cannot be signed in while user accounts are off
(above), this check is the second, independent line of defence. `POST /visualflows/{flow_id}/publish` accepts a caller-supplied
`bundle_id`, `bundle_version` and `overwrite`, and installs into the same
registry as `upload`; it is gated on the same rule. Non-admin requests against
the shared registry return `403`. Read routes are unchanged.

`DELETE /bundles/{bundle_id}` returns `409` for the `basic-agent.flow` the
gateway verifies at startup: removal has no undo and would prevent the next
start. Install a replacement bundle first, then remove the old file.

### Shared workflow catalog

Do not share workflows by pointing multiple users at another user's private
bundle directory. Private `/api/gateway/bundles` routes stay scoped to the
current principal's runtime. Shared/default workflows belong in the Gateway
workflow catalog:

- catalog versions are immutable by `scope + tenant + bundle_id +
  bundle_version + sha256`;
- admins move explicit default pointers instead of overwriting existing
  versions;
- catalog ACLs are checked at run start against the authenticated principal's
  tenant, roles, and user id;
- catalog runs execute in the requesting user's runtime by default;
- catalog run policy is Gateway-issued and HMAC-signed before it is handed to
  Runtime state; client-supplied `_runtime.workflow_policy` values are stripped;
- private bundle inspection routes reject catalog-internal bundle ids, so
  catalog flow/schema inspection remains ACL-aware;
- deprecate/block/tombstone changes block new starts without deleting stored
  bundle bytes.

Catalog mutation routes are admin-only under
`/api/gateway/admin/workflow-catalog/*`. User-visible catalog discovery is
available at `GET /api/gateway/workflow-catalog`.

## Origin allowlist (browser/origin defense)

If the request includes an `Origin` header, the middleware allows it only when
it matches the allowlist (glob-style patterns, fnmatch). The allowlist is
`http://localhost:*` and `http://127.0.0.1:*`, the gateway's own LAN origins in
a network mode, plus the **`allowed_origins` setting** (console: Network →
*Advanced: reverse proxy*; TUI: Connection screen; CLI:
`abstractgateway network set --allowed-origins https://gateway.example.com`).
The setting is read per request: a change applies to the next request, no
restart. Each origin is validated (`scheme://host[:port]`, no path, no trailing
slash); `*` and wildcard patterns are accepted only as typed and are flagged.
See [configuration.md](docs/configuration.md#reverse-proxy-allowed-origins-and-trust-proxy).

A gateway started with `ABSTRACTGATEWAY_ALLOWED_ORIGINS` in its environment
uses that list instead (a deployment pin): every surface says "This gateway was
started with ABSTRACTGATEWAY_ALLOWED_ORIGINS in its environment" and reports
`overridden_by_env: true`; the saved setting applies once it starts without it.

Evidence: `GatewayAuthPolicy.allowed_origins`, `_effective_allowed_origins()` and
`_origin_allowed()` in `src/abstractgateway/security/gateway_security.py`;
`live_reverse_proxy()` in `src/abstractgateway/network_exposure.py`.

Important nuance:
- FastAPI’s CORS middleware in `src/abstractgateway/app.py` is permissive, but **origin enforcement for gateway endpoints is done by this security middleware**.
- In a network exposure mode from the settings store, `serve` adds the
  gateway's own discovered LAN origins (IP literals and `<name>.local`). A
  foreign origin, including a DNS-rebinding name that resolves to your LAN IP,
  is still refused (403) unless it is in `allowed_origins`.

## Network exposure

The network exposure setting ([configuration.md](docs/configuration.md#network-exposure-localhost--local-network--internet))
chooses `localhost`, `lan` or `internet`. What changes for someone else on
your network:

- **`localhost`** (default for a first run): the gateway listens on
  `127.0.0.1` only. Nobody else can open a connection; every local process of
  every local user still can, which is why user auth stays on.
- **`lan`**: the gateway listens on every IPv4 interface. Anyone on the same
  network (and anyone on a VPN such as Tailscale whose address is listed) can
  reach the sign-in page and the API. The gate is authentication: `lan` is
  refused unless user auth will be on at the next start; unauthenticated
  requests answer 401, failed credentials are locked out per client address
  with a growing wait, and a browser page from a foreign origin is refused
  (403). `lan` and `internet` are also refused when the gateway was started
  with read protection off (`ABSTRACTGATEWAY_PROTECT_READ=0`: unauthenticated
  reads would be answered as the admin). What `lan` does NOT give you:
  - **encryption**: it is plain HTTP. Passwords, bearer tokens and the
    session cookie cross the network in clear; the session cookie is
    `HttpOnly; SameSite=Lax` but not `Secure` over HTTP. Anyone who can sniff
    the network (shared Wi-Fi, a compromised router) can capture and replay a
    session. Use `lan` on networks you trust, or use a TLS proxy / VPN.
  - **a smaller attack surface**: every admin route is reachable to whoever
    holds an admin credential. Give each person their own account, keep the
    admin token off other machines, and prefer non-admin accounts for daily use.
  - **exposure of the browser apps**: apps started from the Apps page stay
    bound to `127.0.0.1` (the `apps.host` setting, *Where apps listen*), and the sign-in
    handover (`/apps/handover/{code}`) only works on the host it was minted
    for and refuses a loopback-only app to a browser on another machine.
  - **engine and app installs for remote admins**: `allow_engine_install`
    defaults to off on a non-loopback bind for callers on other computers.
    Someone at the gateway machine itself can still install: the request's
    socket peer is loopback or one of this host's own addresses, and it
    carries no proxy header (`Forwarded`, `X-Forwarded-For`,
    `X-Forwarded-Host`, `X-Real-IP`). A remote computer cannot use this
    host's own address as the source of an established TCP connection (the
    handshake reply never reaches it, and the kernel drops outside packets
    with a local source address); a reverse proxy on this host would make
    every visitor look local, which is why a proxied request never counts.
- **`internet`**: the same bind plus an explicit acknowledgement. The gateway
  does **not** terminate TLS and does not configure your router or firewall.
  Put a TLS reverse proxy (Caddy, nginx, Traefik) or a tunnel (Cloudflare
  Tunnel, Tailscale Funnel, ngrok) in front and expose that; add the public
  `https://` origin under *Reverse proxy* (`allowed_origins`), turn on *Trust
  the proxy's client address* (`trust_proxy`) only when your own proxy is in
  front of every request, and rate-limit at the proxy. Forwarding the raw port
  means plain HTTP on the internet: do not.

The mode is applied at the next start and `serve --host/--port` override it;
`GET /api/gateway/network` always says what is configured, what is running,
and why they differ.

## Workspace filesystem scope (blacklist/whitelist)

AbstractGateway supports “thin clients” (browser UIs, bridges) that can trigger **filesystem-ish tools** (e.g. `list_files`, `read_file`, `write_file`). To avoid a thin client expanding server filesystem access, the gateway enforces a **workspace policy**.

Key point: the **main configuration** for filesystem allowlisting/denylisting is set when you **launch the gateway** (operator-controlled env vars). Thin clients can only request broader scopes when the gateway is started in a permissive mode.

### Default (safe): everything outside the run workspace is blocked

- When a run is started via `POST /api/gateway/runs/start` and `workspace_root` is missing (or rejected), the gateway creates a **per-run workspace** under:
  - `<ABSTRACTGATEWAY_DATA_DIR>/workspaces/<uuid>`
- AbstractRuntime applies workspace scoping to filesystem-ish tool arguments. The default is:
  - `workspace_access_mode=workspace_only`
  - absolute paths must stay under `workspace_root`

This means that by default, **all absolute paths are effectively “blacklisted”** except the run’s `workspace_root`.

Evidence:
- Run default workspace injection: `src/abstractgateway/routes/gateway.py` (`start_run`)
- Client scope clamping: `src/abstractgateway/routes/gateway.py` (`_sanitize_run_workspace_policy`, `_client_workspace_scope_overrides_enabled`)
- Runtime tool scoping: `abstractruntime/integrations/abstractcore/workspace_scoped_tools.py`
- Tests: `tests/test_gateway_workspace_policy_enforcement.py`

### Operator-controlled allowlist roots (recommended)

- `ABSTRACTGATEWAY_WORKSPACE_DIR`: base directory used to resolve relative workspace paths and as the default root for `/files/*` helpers.
- `ABSTRACTGATEWAY_WORKSPACE_MOUNTS`: additional allowed roots (newline-separated `name=/abs/path`).

Thin clients can discover the server policy via:
- `GET /api/gateway/workspace/policy`
  Note: it returns **mount names only** (no absolute paths).

Canonical public server paths use `rel/path` for the main workspace root and
`mount_alias/rel/path` for approved mounts. When two allowed roots share the
same basename, Gateway emits deterministic digest-suffixed mount aliases so
the public path string stays stable across discovery, import/export, and
Runtime execution.

### Permissive mode: allow thin clients to choose scope (trusted machines only)

To honor client-provided workspace knobs (`workspace_root`, `workspace_access_mode`, `workspace_allowed_paths`, `workspace_ignored_paths`) beyond the operator roots, enable one of:

- `ABSTRACTGATEWAY_ALLOW_CLIENT_WORKSPACE_SCOPE=1`
- `ABSTRACTGATEWAY_TRUST_CLIENT_WORKSPACE_SCOPE=1`

In this mode, a client can request:
- `workspace_access_mode=all_except_ignored` (“full access” unless explicitly blocked)

Do **not** enable this when serving untrusted browser origins: a compromised thin client can request access to arbitrary server paths.

### Important limitation (still true in all modes)

`execute_command` is **not** an OS sandbox: even if the runtime sets the default working directory under `workspace_root`, the command itself can reference absolute paths or `cd ..`.

## Common security env vars

All are loaded by `load_gateway_auth_policy_from_env()` (see `src/abstractgateway/security/gateway_security.py`).

### Enable/disable

- `ABSTRACTGATEWAY_SECURITY=1|0` (default: enabled)

### Tokens

- `ABSTRACTGATEWAY_AUTH_TOKEN` (single shared secret)
- `ABSTRACTGATEWAY_AUTH_TOKENS` (comma-separated list)
- `ABSTRACTGATEWAY_USER_AUTH=1` or `ABSTRACTGATEWAY_AUTH_MODE=users`: enable
  file-backed user principals and per-principal service routing
- `ABSTRACTGATEWAY_USER_AUTH_AUTO=1`: compatibility mode that also enables
  user auth when the registry file exists
- `ABSTRACTGATEWAY_USERS_FILE`: optional user registry path; defaults to
  `<ABSTRACTGATEWAY_DATA_DIR>/auth/users.json`
- `ABSTRACTGATEWAY_SESSIONS_FILE`: optional browser session registry path;
  defaults to `<ABSTRACTGATEWAY_DATA_DIR>/auth/sessions.json`
- `ABSTRACTGATEWAY_SESSION_TTL_S`: default browser session lifetime in seconds
  (default: 8 hours; bounded)
- `ABSTRACTGATEWAY_REMEMBER_SESSION_TTL_S`: browser session lifetime when an
  app requests "remember me" (default: 30 days; bounded)

### Protect reads vs writes

- `ABSTRACTGATEWAY_PROTECT_WRITE=1|0` (default: `1`)
- `ABSTRACTGATEWAY_PROTECT_READ=1|0` (default: `1`)
- `ABSTRACTGATEWAY_DEV_READ_NO_AUTH=1|0`
  Dev escape hatch: allow unauthenticated reads **from loopback only**.

### Limits (abuse resistance)

- `ABSTRACTGATEWAY_MAX_BODY_BYTES` (default: `10MB`)
  Applies to every mutating request. Oversized requests are **rejected** with
  `413` naming both sizes — bodies are never truncated. The default is sized for
  authored documents (a VisualFlow save is a whole workflow, not a small API
  payload), not just for abuse resistance.
- `ABSTRACTGATEWAY_MAX_ATTACHMENT_BYTES` (default: `25MB`)
- `ABSTRACTGATEWAY_MAX_BUNDLE_BYTES` (default: `75MB`)
- `ABSTRACTGATEWAY_MAX_CONCURRENCY` (default: `64`)
- `ABSTRACTGATEWAY_MAX_SSE` (default: `32`)

### Auth lockout (brute-force safety net)

- `ABSTRACTGATEWAY_LOCKOUT_AFTER` (default: `5`)
- `ABSTRACTGATEWAY_LOCKOUT_BASE_S` (default: `1.0`)
- `ABSTRACTGATEWAY_LOCKOUT_MAX_S` (default: `60.0`)

### Audit log (write requests)

- `ABSTRACTGATEWAY_AUDIT_LOG=1|0` (default: enabled for writes)
- `ABSTRACTGATEWAY_AUDIT_LOG_MAX_BYTES` (default: `50MB`)
- `ABSTRACTGATEWAY_AUDIT_LOG_ROTATIONS` (default: `10`)
- `ABSTRACTGATEWAY_AUDIT_LOG_HEADERS` (comma-separated allowlist; default: `x-client-id,x-client-version,x-forwarded-for`)

### Reverse proxies

- The `trust_proxy` setting (console: Network → *Advanced: reverse proxy* →
  *Trust the proxy's client address*; TUI: Connection screen checkbox; CLI:
  `abstractgateway network set --trust-proxy on|off`). On: `X-Forwarded-For` is
  used for IP attribution (audit log) and lockout tracking. Read per request:
  it applies to the next request. Only when your own proxy sits in front of
  every request; otherwise any client chooses the address the gateway sees.
  The ephemeral tray token never honours it (raw socket peer only).
- A gateway started with `ABSTRACTGATEWAY_TRUST_PROXY` in its environment uses
  that value instead; the status reports `overridden_by_env: true`.

## Production checklist (minimal)

- Run behind TLS (reverse proxy) and bind `--host 127.0.0.1` (proxy in front) or lock down your network if binding `0.0.0.0`.
- Use a strong random token and list exact origins in `allowed_origins` (avoid public wildcards).
- Keep `ABSTRACTGATEWAY_SECURITY=1`.

## Related docs

- Configuration overview: [configuration.md](docs/configuration.md)
- API overview: [api.md](docs/api.md)
- FAQ: [faq.md](docs/faq.md)

---

## docs/apps.md

# Browser apps

The five browser apps (Observer, Continuum, Code, Entity and Flow Editor) can be
installed, started, stopped and updated from the gateway: from the console's
Apps page, from the HTTP API below, or with `abstractgateway apps`. Nobody has
to open a terminal or install Node.js by hand.

## What happens when you click Install

1. **Node.js.** The apps are small Node.js servers. If the gateway finds
   Node.js 18 or newer on the machine, it uses it. Otherwise it installs
   Node.js 24 for you in its own data folder (`<data dir>/runtime/node/`,
   about 56 MB to download, no administrator rights). This is the Node.js
   build published on PyPI as `nodejs-wheel-binaries`, the same one the
   installer's `--with-apps` option gets with `uv tool install nodejs-wheel`.
   The download is checked against PyPI's sha256 checksum.
2. **The app.** The gateway downloads `@abstractframework/<name>` from the npm
   registry into `<data dir>/apps/<app>/<version>/` and checks it against the
   registry's sha512 integrity hash. Apps that need other npm packages get them
   through npm (with its cache in `<data dir>/apps/npm-cache/`); Code needs
   none and is unpacked directly.
3. **Start.** With "Install and open", the gateway starts the app on a free
   port, waits until it answers, and opens it in a new tab, already signed in.

Every step is a job with a percentage, downloaded bytes and a plain message.
When a step fails, the job says why in one sentence and carries the full log
in its `details` field (`<data dir>/apps/jobs/<job>.log`).

## Running apps

- An app started from the gateway is a child process of the gateway. It stops
  when the gateway stops (even if the gateway is killed: the app watches the
  pipe the gateway holds open and exits when it closes).
- An app you started stays **enabled**: the next time the gateway starts, it
  starts the app again, on the same port when that port is free. Stop the app
  to turn this off (the console's **Stop** is under **Technical details**, or
  `abstractgateway apps stop <app>`). Nothing is registered with launchd, systemd or the login
  items; the gateway itself starts the apps.
- If an app exits unexpectedly, the gateway restarts it (after 1, 2 then 4
  seconds). After more than 3 crashes in a minute it stops trying and shows
  "crash_loop" with the app's log.
- Each app writes its output to `<data dir>/logs/apps/<app>.log`.
- Apps listen on `127.0.0.1` only. Ports: the app's usual port when it is
  free, else the first free port in 3100-3199. The usual ports are the
  framework's stack port map (`scripts/start-local.sh`): Observer 3001,
  Continuum 3002, Code 3003, Entity 3004, Flow 3005.

## Apps started outside the gateway

An app can also run without the gateway having started it: the framework's
development stack (`scripts/start-local.sh`), `npx @abstractframework/observer`,
a global npm install, a service. The gateway finds such an app by asking the
usual ports on this machine (3001-3005, then 3000 and 3007, which older
launch scripts used) for their start page and reading its title
("AbstractObserver", "AbstractContinuum", "AbstractCode", "AbstractEntity",
"AbstractFlow"). None of the apps has an address that says who it is without
a gateway sign-in, and the start page is plain HTML, so this costs one short
local request per port (half a second at most, all ports at once, remembered
for 5 seconds). The version is read from the `package.json` of the program
listening on the port, when this computer shows which program that is.

Such an app is listed as installed and running, with `source: "external"`,
`managed: false`, its `url`, `port` and `version`, and one action: **Open**.
Opening it works exactly like opening an app the gateway started (the
one-time sign-in link below): the app's server reads the same sign-in
cookies whoever started it. The gateway does not stop, update or show the
log of an app it did not start; the console's **Technical details** says
"Started outside the gateway on port 3001" instead, and `POST /apps/{id}/stop`
answers 409 `started_outside_gateway`. Starting the gateway's own copy while
an outside one runs does nothing (the app is already running).

## Who may install

Installing an app (or Node.js, or a terminal version) runs software on the
gateway machine, so it follows the gateway's `allow_engine_install` setting.
With no saved choice, installs are allowed for someone at the gateway machine
itself, whatever address the gateway listens on (a browser or the tray on that
machine, including one that uses its network address), and for everyone when
the gateway listens on this machine only. A browser on another computer needs
an admin to turn the setting on. How "at the gateway machine" is decided is
in [configuration.md](docs/configuration.md#allow_engine_install).

## Opening an app signed in

The console asks the gateway for a one-time link
(`POST /api/gateway/apps/{id}/open`) and opens it in a new tab. The link
(`/apps/handover/<code>`) works once, for two minutes, and only on the address
it was made for. The gateway creates a browser session for the person who
clicked, puts it in the app's own sign-in cookies, and redirects to the app.
Browsers keep cookies per machine name, not per port, so the app's server finds
the session and the app opens connected. The gateway token never appears in
the page, the link or the browser's storage.

A browser on another computer cannot reach an app that listens on
`127.0.0.1`; the gateway says so instead of producing a broken link.

### Landing somewhere inside the app

`POST /api/gateway/apps/{id}/open` accepts an optional `path`: where inside
the app the browser lands after the handover, for example `/#new`, Entity's
creation form. The path is bound to the one-time code when the link is made
(the link itself carries nothing), and it must stay inside the app: it starts
with a single `/`, and a second leading slash (`//host`), a full address, a
backslash, a space or a control character is refused with 400
`invalid_app_path` before any link is made. Without `path` the browser lands
on the app's start page, as before.

### An app with nothing in it yet

Each app row carries `content_summary`: a small fact about what the app holds
on this gateway, or `null` for apps that report nothing. Today only Entity
has one, `{"entities_count": n}`: the number of entries
`GET /api/gateway/entities` lists (counted from the same entity registry,
without reading any entity), `null` when it cannot be known, including for an
Entity started outside the gateway whose page names another gateway. When the
count is exactly 0, the Entity card's button reads **Create your first
entity** and opens the app on `/#new` through the handover above; with one
entity or more, or an unknown count, it reads **Open**. The guide's Apps step
shows the same card.

## Terminal versions

Some apps also run in a terminal. Today that is **Code** (`abstractcode`, a
Rust terminal app from the abstractcode repository); Flow Editor, Observer,
Continuum and Entity are browser apps only. The gateway's own console also
has a terminal twin, `abstractgateway-console`, which the console's Done step
mentions once.

Every app row therefore lists its interfaces: `interfaces[0]` is the browser
app (it mirrors the row's own fields), and apps with a terminal version have a
second entry of kind `"tui"`:

```json
{"kind": "tui", "name": "Code in the terminal", "binary": "abstractcode",
 "installed": true, "version": "0.5.1", "source": "gateway", "path": "<data dir>/apps/bin/abstractcode",
 "latest_version": "0.5.1", "update_available": false,
 "install_available": false, "install_method": "release_binary", "install_blocked_reason": null,
 "install_command": "cargo install abstractcode", "download_page": "https://github.com/lpalbou/abstractcode/releases",
 "launch_available": true, "launch_blocked_reason": null, "launch_mode": "terminal",
 "command": "<data dir>/apps/bin/abstractcode --gateway http://127.0.0.1:8080",
 "signin_command": null, "active_job": null}
```

- **Found by presence.** The gateway never imports or runs an app to detect
  it: it looks for the binary in `<data dir>/apps/bin/`, on `PATH` and in
  `~/.cargo/bin`, and accepts it only when its `--help` names the program
  (PyPI's unrelated Python package `abstractcode` installs a script with the
  same name). `source` says where it was found (`gateway` or `path`).
- **Install (`install_method`).** `release_binary`: Code publishes prebuilt
  binaries for macOS (Apple silicon and Intel), Linux (x86_64 and arm64,
  glibc) and Windows (x86_64) on its GitHub release, with a `SHA256SUMS` file.
  "Install for Terminal" downloads the archive for this computer, checks it
  against `SHA256SUMS` and against the sha256 digest GitHub reports for the
  file (both must agree), unpacks the single binary into `<data dir>/apps/bin/`,
  makes it executable and runs `--version` before it replaces anything.
  About 3 MB, no administrator rights, no terminal. `cargo`: there is no
  prebuilt binary for this computer (musl Linux, Windows on ARM, other CPUs),
  or the program is published as source only (the gateway console, crates.io).
  Then `install_available` is `false`, `install_blocked_reason` starts with
  "Needs the Rust toolchain" and `install_command` is the exact command to
  copy; the console shows no Install button.
- **Open (`launch_mode`).** `terminal`: the caller is an admin on the gateway
  machine itself, and "Open in Terminal" opens a new terminal window there
  (macOS Terminal; on Linux the first of `x-terminal-emulator`,
  `gnome-terminal`, `konsole`, `xfce4-terminal`, `kitty`, `alacritty`, `xterm`
  when a desktop session exists; Windows `cmd`). `copy`: the browser is on
  another computer (or the caller is not an admin); with **Technical
  details** on, the card shows `command` (this gateway's address as the
  browser reaches it) and `signin_command` (`abstractcode login --gateway
  <url> --token <your token>`) to copy. A terminal is never opened for a
  remote browser. How the card presents each case: [console.md](docs/console.md#apps-tab).

### Signed in, without a token anywhere it could leak

The terminal window runs a small launcher script
(`<data dir>/apps/terminal/open-code-<random>.command`, mode 0700) that holds
a **one-time code** (two minutes, single use) and deletes itself as its first
line. It runs `tui_signin.py` from the gateway package (standard library only,
`python -I`), which trades the code at `POST /apps/tui-handover` for a bearer
token and then becomes the terminal app with the token in the app's
**environment** (`ABSTRACTCODE_GATEWAY_TOKEN`, which Code prefers over its
saved login). The token

- is new for each launch and is not the admin token;
- acts as the person who clicked (their identity and role, never more);
- is accepted only from this machine (a loopback socket peer);
- lives in the gateway's memory only, so it stops working when the gateway
  restarts (open the app again from the console);
- is never in a command line, a file, the page, or a URL.

### From a terminal

Everything above works without the console:

```bash
abstractgateway apps list                 # each app; Code also gets a "terminal:" line (installed, where, what to run)
abstractgateway apps install-tui code     # the same job: release binary, SHA256SUMS + GitHub digest, --version check
abstractgateway apps tui-command code     # the one-time sign-in line for THIS machine (2 minutes, works once) + the plain command
```

`install-tui` prints the job's progress; when there is no prebuilt binary for
the computer it prints the reason and `cargo install abstractcode` and exits 2.
`tui-command` makes the same one-use, self-deleting launcher as "Open in
Terminal" (it holds a single-use code, never a token) but opens no window: run
the printed line in a terminal on the gateway machine. From another computer
it prints the command and the `abstractcode login` line instead.

`/apps/tui-handover` sits outside `/api/gateway` like the browser handover:
the code in its body is its only credential, it answers only loopback socket
peers without proxy headers, and a browser handover code does not work there
(nor the reverse).

## Why the gateway runs each app's own server

The gateway could in principle serve the apps' built files itself. It does
not, because each app's server does real work:

- it holds the sign-in (`/api/connection/gateway`, HttpOnly session cookies,
  CSRF) and forwards `/api/*` to the gateway on the same origin, which is how
  live updates (server-sent events) reach the page;
- four of the five apps load their files from absolute `/assets/...` paths,
  and Code and Observer register a service worker at the site root, so they
  cannot live under a sub-path of the gateway;
- Observer reveals local folders, Flow keeps its connection file, and
  Continuum proxies the agora hub.

Serving the files alone would break sign-in and live updates in every app.

## Settings

Settings, changed from the Apps page (*Advanced: apps settings*), the TUI
(Runtimes → *Runtime knobs* → *Edit apps settings*) or the terminal
(`abstractgateway apps config get|set NAME VALUE`). A saved value applies at the
next app start or download. See
[configuration.md](docs/configuration.md#browser-apps-settings-apps).

| Setting (console label) | Default | Effect | CLI | Legacy env (fallback) |
|---|---|---|---|---|
| `apps.node` (*Node.js for apps*) | `auto` | `auto`: Node.js on the machine, else the gateway's own. `managed`: always the gateway's own. `system`: never install Node.js. A path: use that `node`. | `apps config set node auto` | `ABSTRACTGATEWAY_APPS_NODE` |
| `apps.ports` (*Ports for apps*) | (empty) | A port or range, e.g. `3100-3199`. When set, apps only use ports in it. | `apps config set ports 3100-3199` | `ABSTRACTGATEWAY_APPS_PORTS` |
| `apps.host` (*Where apps listen*) | `127.0.0.1` | Where the apps listen. Other values expose them to the network: use your own access control. | `apps config set host 0.0.0.0` | `ABSTRACTGATEWAY_APPS_HOST` |
| `apps.npm_registry` (*npm registry*) | `https://registry.npmjs.org` | npm registry (mirror) for app downloads and their dependencies. | `apps config set npm_registry URL` | `ABSTRACTGATEWAY_APPS_NPM_REGISTRY` |
| `apps.pypi_url` (*Node.js download index*) | `https://pypi.org/pypi` | Where the Node.js build is looked up. | `apps config set pypi_url URL` | `ABSTRACTGATEWAY_APPS_PYPI_URL` |

Console: Apps → *Advanced: apps settings* (one field per setting, with where its
value comes from). TUI: Runtimes → *Runtime knobs* → *Edit apps settings*. CLI:
`abstractgateway apps config get [NAME] [--json]` / `set NAME VALUE` (`""`
clears), on the data dir directly. All three validate the same way and show the
gateway's sentence on a refusal.

The legacy environment variables are only a fallback: a saved value always wins
(stored > env > default). A value that comes from the environment the gateway
was started with is reported as `source: env` ("From the environment" on the
page); an env value a saved one shadows is reported as `env_shadowed`.

Installing Node.js or an app runs software on the gateway host, so it follows
the host's **allow engine install** setting: on by default when the gateway
listens on this machine only (loopback), off otherwise. Install, update,
start and stop need an admin. Any signed-in user can list the apps and open a
running one (as themselves).

## Without internet

- Apps already installed keep working offline.
- Installing needs the npm registry (and PyPI for Node.js). When it cannot be
  reached, the Apps page says so on each app and the Install button is off;
  a job that loses the network fails with "The npm registry
  (registry.npmjs.org) is not reachable…". The gateway does not ship app
  copies: the five packages are about 6 MB, but four of them need npm
  dependencies (Flow alone installs to about 130 MB), which would have to
  ship too.

## Command line

The same actions, through the running gateway:

```bash
abstractgateway apps list                 # Node.js, every app, installed/latest, URL
abstractgateway apps install code --launch
abstractgateway apps launch observer
abstractgateway apps open observer        # prints a one-time signed-in link
abstractgateway apps logs observer --tail 50
abstractgateway apps update flow
abstractgateway apps stop observer
abstractgateway apps runtime              # install Node.js only
abstractgateway apps install-tui code     # Code's terminal version (see "Terminal versions")
abstractgateway apps tui-command code     # one-time signed-in launch line for this machine
abstractgateway apps jobs [JOB_ID]
```

`--url`, `--token` and `--data-dir` work as for `abstractgateway models`: by
default the command finds the gateway running for this data dir and uses its
admin token on a loopback URL.

## HTTP API

All routes are under `/api/gateway/apps` and need a signed-in principal.

| Method and path | Who | What |
|---|---|---|
| `GET /apps?latest=true` | any user | Node.js status, one row per app (with `interfaces[]`, see "Terminal versions") and `console_tui` (the gateway console's terminal app). `latest=false` skips the npm registry and GitHub release lookups (cached 10 minutes). |
| `POST /apps/runtime/install` | admin | Install Node.js (a job), or `job: null` when one is already usable. |
| `POST /apps/{id}/install` `{"version"?, "launch"?}` | admin | A job: Node.js if needed, download, check, dependencies, and with `launch: true` start the app. |
| `POST /apps/{id}/update` `{"version"?}` | admin | A job: install the latest (or given) version; a running app is restarted on it. |
| `POST /apps/{id}/launch` | admin | Start the app (waits until it answers) and mark it enabled. |
| `POST /apps/{id}/stop` | admin | Stop the app and mark it disabled. 409 `started_outside_gateway` for an app the gateway did not start. |
| `POST /apps/{id}/open` `{"remember"?, "path"?}` | any user | A one-time `open_url` (relative to the gateway) that opens the running app signed in, at `path` inside the app when given (e.g. `/#new`). 400 `invalid_app_path` for anything that is not a path inside the app. |
| `GET /apps/{id}/logs?tail=200` | admin | The end of the app's log. |
| `GET /apps/jobs`, `GET /apps/jobs/{job}` | any user | Jobs: `state` (queued, running, succeeded, failed, cancelled), `percent`, `bytes_done`, `bytes_total`, `message`, `steps`, `details` (full log on failure). |
| `POST /apps/jobs/{job}/cancel` | admin | Cancel a job. |
| `POST /apps/{id}/install-tui` | admin | A job: download, check and place the app's prebuilt terminal version (or update the gateway's copy). 409 `toolchain_required` with `command` when only a source build exists. |
| `POST /apps/{id}/launch-tui` | admin, on the gateway machine | Open the terminal version in a new terminal window, signed in as the caller: `{ok, app_id, interface: "tui", terminal, version, message, expires_in_s}`. From another computer (non-loopback peer or `Host`, or any proxy header): 409 `not_on_gateway_machine` with `command` and `signin_command`, and nothing opens. |
| `POST /apps/{id}/tui-command` | admin, on the gateway machine | The same one-use launcher as launch-tui without opening a window: `{ok, app_id, interface, version, signin_command, command, expires_in_s}`. From another computer: 409 `not_on_gateway_machine` with `command` and `signin_command`. |
| `POST /apps/tui-handover` `{"code"}` (outside `/api/gateway`) | the launcher script, loopback only | Trade a launcher's one-time code for `{token, token_env, url_env, gateway_url, gateway_flag, user}`. 403 `loopback_only`, 410 `handover_expired`. |

Errors are `{"ok": false, "reason", "message", "hint"?, "details"?}` with
404 (`unknown_app`, also for a terminal route on a browser-only app), 409
(`not_installed`, `node_missing`, `not_running`, `app_loopback_only`,
`toolchain_required`, `not_on_gateway_machine`, `no_terminal`,
`started_outside_gateway`), 403
(`installs_not_allowed`), 502 (`integrity_mismatch`), 503
(`network_unavailable`, `no_free_port`) or 500 (`launch_failed`, with the
app's output in `details`). The terminal errors also carry `command` (and,
where it helps, `install_command` or `signin_command`) so the console can
offer the command to copy instead.

---

## docs/engines.md

# Local engines: install, start, stop

The gateway can install the local inference engines (Ollama, LM Studio, MLX,
llama.cpp, vLLM, the Hugging Face stack) on the machine it runs on, from the
console or the command line, without a terminal and without a password in the
common case. This page explains what each **Install** does, when it asks for
the Apple command-line tools or an administrator password, and the API a UI
renders.

## What Install does, engine by engine

| Engine | macOS (Apple silicon) | Linux | Admin rights? |
|---|---|---|---|
| **Ollama** | Downloads the signed `Ollama-darwin.zip` from Ollama's official GitHub release, checks its published SHA-256 and the Developer ID signature (team `3MU9H2V9Y9`), places `Ollama.app` in `/Applications` when your account can write it (an admin account can, with no password) or else in `~/Applications`, then starts it and waits until `http://127.0.0.1:11434/api/version` answers | Ollama's official installer (`curl -fsSL https://ollama.com/install.sh \| sh`) | macOS: no. Linux: yes (the installer writes `/usr/local` and a systemd service) |
| **LM Studio** | Downloads the signed disk image from lmstudio.ai, checks the signature (team `D65G88RHWN`), copies `LM Studio.app` to `/Applications` or `~/Applications`, opens it once and starts its local server (`lms server start`) | LM Studio's official installer: the headless daemon and the `lms` CLI under `~/.lmstudio` | no |
| **MLX** | Prebuilt `mlx` and `mlx-lm` wheels into the gateway's own Python | not supported | no |
| **llama.cpp** | Upstream's prebuilt Metal wheel (`llama-cpp-python` 0.3.28) into the gateway's own Python; no compiler | prebuilt CPU wheel (0.3.35) | no |
| **vLLM** | not supported (the row says why; no Install button) | Linux with an NVIDIA GPU: the vLLM wheels into the gateway's Python | no |
| **Hugging Face** | `abstractcore[huggingface]` at the installed AbstractCore version (several GB) | same | no |

Windows keeps the vendor commands AbstractCore plans (winget / the vendor
PowerShell installers / pip); the flow on this page is not yet exercised there.

Everything the gateway's own Python receives is installed with the gateway's
`abstract*` packages pinned to the versions it runs, so an engine install can
never change the gateway itself.

### When the Apple command-line tools are needed

Only for llama.cpp when no prebuilt wheel fits (an Intel Mac, a Python under
Rosetta, or a broken wheel): it must then be built from source, which needs a
C compiler. The job stops **before** the build, in the `needs_tools` state,
with the reason in plain words, for example:

> The prebuilt llama.cpp wheel did not install (no matching package for this
> Python and machine); building from source needs the Apple command-line tools.

**Install tools** runs `xcode-select --install`, which opens Apple's own
installer dialog on the gateway machine's screen (no administrator password
involved). The job waits and continues by itself once the tools are there.

### When an administrator password is needed

Only when a step genuinely needs it, and never silently:

- you asked for `/Applications` (`"location": "system"`) and your account
  cannot write it (a standard, non-admin account);
- Ollama on Linux (its installer writes `/usr/local` and creates a service).

The job stops in the `needs_admin` state and shows the exact reason and the
exact command. Nothing runs with administrator rights until someone presses
**Continue with administrator password**; the gateway then asks the operating
system: on macOS the standard password dialog (`osascript … with
administrator privileges`), on a Linux desktop `pkexec`. On a machine that
cannot show a dialog (a headless server), the job shows the command to run in
a terminal and a **re-check** button. There is no hidden `sudo` anywhere.

### What you see when something fails

The job's `message` is one plain sentence (what failed and why); the whole
log (every line, never a tail) is behind it in `details` and in a log file in
the gateway's data folder (`engines/jobs/<job id>.log`). A failed llama.cpp
build, for example, reads:

> The prebuilt llama.cpp wheel did not install (the wheel file is corrupted),
> and building it from source failed: no C compiler was found. The full build
> log is in the details.

## Command line

```bash
abstractgateway engines status --probe
abstractgateway engines install ollama --yes            # [--location auto|user|system] [--force] [--dry-run]
abstractgateway engines continue <job-id>               # after needs_admin (password dialog) or needs_tools
abstractgateway engines continue <job-id> --action install_tools
abstractgateway engines cancel <job-id>
abstractgateway engines start ollama                     # stop | start: ollama, lmstudio
```

A job that stops for tools or an administrator exits with code 2 and prints
what it needs and the `continue` command.

## API (contract `gateway_engines_v2`)

All routes are under `/api/gateway`. Reads are user-level; every POST is
admin-only; `install` and `continue` also need `allow_engine_install`
([configuration.md](docs/configuration.md#allow_engine_install)); a dry run never does.

| Method and path | Returns |
|---|---|
| `GET /engines?probe=1` | `{schema: "gateway_engines_v2", engines: [row…], install_allowed, install_policy, host, active_job}` |
| `GET /engines/{id}?probe=1` | one row; 404 for an unknown id |
| `POST /engines/{id}/install` `{dry_run?, force?, location?: auto\|user\|system}` | a job (below); `dry_run` returns the plan; 409 `busy` while another engine installs (the same engine joins its job); 409 `unsupported_on_this_machine` |
| `GET /engines/jobs` | `{jobs: [job…]}`, newest first |
| `GET /engines/jobs/{job_id}` | one job; 404 |
| `POST /engines/jobs/{job_id}/continue` `{action?: approve_admin\|install_tools\|recheck}` | the job, resumed; 409 when it is not waiting |
| `POST /engines/jobs/{job_id}/cancel` | the job |
| `POST /engines/{id}/start`, `/stop` | `{ok, engine, action, running, base_url, …}`; 409 for an engine that is not a server |

**Row:** `id, name, description, supported, support_reason, installed,
version, install_location, running, reachable, base_url, models_count,
install {available, method: wheel|app|script|unsupported, target, needs_admin,
admin_reason, needs_tools, tools_action, notes, steps, command_preview, url,
allowed, fallback {kind: open_page, url, recheck}}, actions [{id, label,
enabled, reason?, method?, path?, url?}], active_job`. Action ids: `install`,
`open_page`, `recheck`, `start`, `stop`, `docs`. `running` is known only on a
probed read, so `start`/`stop` appear only with `probe=1`.

**Job (`engine_install_job_v1`):** `job_id, engine, state, percent,
bytes_done, bytes_total, message, details, log_path, admin_prompt {key,
reason, command, method: osascript|pkexec|manual, button, prompt_text,
where}, tools_prompt {key, reason, tools, action {kind, command, available,
button}, started}, continue_actions, can_cancel, events [{at, state, message,
percent}], result, error {code, message}, started_at, updated_at,
finished_at`. `state` is one of `queued, downloading, installing, needs_admin,
needs_tools, done, failed, cancelled`. While a step runs quietly, `message`
says so at least every 3 seconds (`… (still working, 45 s)`). The job also
carries `status` (`host_job_v1` words) so older clients polling
`GET /jobs/{id}` keep working.

Jobs live in the gateway process: a restart forgets them (the log files stay).

---

## docs/model-downloads.md

# Model downloads: progress you can see

Every model download the Gateway starts is a job that reports real progress
(bytes, total, percent, speed, time left, per file) from the first second to
the last, for every source: Hugging Face (and MLX / mlx-gen), Ollama,
LM Studio and Supertonic. A download that stops receiving bytes says it is
stalled; a cancelled download stops within about a second and never leaves
files that later read as installed.

The job machinery is AbstractCore's (`abstractcore.config.host_jobs`,
`host_job_v1`); the Gateway serves it unchanged and adds the parent job for
"Use recommended defaults", the cancel route and the event stream.

## Routes

All under `/api/gateway`.

| Method and path | Access | Returns |
|---|---|---|
| `POST /models/download` `{"provider", "artifact", "dry_run"?, "expected_bytes"?}` | admin | `{"ok": true, "job": {...}}` at once; the bytes move in the background |
| `POST /models/download` `{"recommended": true}` | admin | `{"ok": true, "recommended": true, "jobs": [one per model], "group": {parent}}` |
| `GET /models/download/{job_id}` | user | `{"ok": true, "job": {...}}`; a `grp_...` id returns the parent; 404 when unknown (jobs are per Gateway process) |
| `GET /models/downloads` | user | `{"ok": true, "jobs": [...]}`, newest first; parents are listed, and each child names its parent in `parent_job` |
| `POST /models/download/{job_id}/cancel` | admin | `{"ok": true, "job": {...}}` with `cancel_requested: true`; the job turns `cancelled` when the tool has stopped (normally < 1 s); a parent cancels every running child; 404 when unknown |
| `GET /models/downloads/stream` | user | Server-Sent Events, below |

The request bodies are unchanged from before this contract.

### The event stream

`GET /models/downloads/stream` sends `event: downloads` with
`data: {"jobs": [...]}` (the same list as `GET /models/downloads`) each time
something changed, at most every 0.5 s, and a `: keepalive` comment every
15 s. `?job_id=<id>` streams one job as `event: job` / `data: {"job": {...}}`.
`?until_idle=1` ends the stream once nothing is running (after sending the
final state). Polling keeps working; the stream is optional.

## The job

| Field | Meaning |
|---|---|
| `job_id` (also `job`) | `dl_...` for one download, `grp_...` for a parent |
| `kind` | `download`, or `download_group` for a parent |
| `status` | the coarse lifecycle older pollers read: `running` (queued included), `completed`, `failed`, `cancelled`; `host_status` keeps AbstractCore's own word |
| `state` | `queued`, `resolving`, `downloading`, `verifying`, `installing`, `done`, `failed`, `cancelled`, `stalled` |
| `bytes_done`, `bytes_total` | bytes so far and the total; same values as `downloaded_bytes`, `total_bytes` |
| `size_unknown`, `size_note` | `true` only when the source cannot say how big the download is; `size_note` says why |
| `percent` | `bytes_done / bytes_total × 100`; 100 when done; `null` while the size is unknown |
| `bytes_per_second` | speed over the last 5 s; falls to 0 when bytes stop, never frozen at its last value |
| `eta_s` | seconds left at that speed; `null` when unknown or stalled |
| `started_at`, `updated_at`, `finished_at` | ISO-8601 UTC; `updated_at` moves at least every 0.5 s while the job runs |
| `message` | one plain sentence to show as is |
| `detail` | the engine tool's own last line, unchanged |
| `files` | `[{name, bytes_done, bytes_total, state}]`, file state `pending`, `downloading`, `done`, `failed`, `cancelled`; Ollama layers are named `layer <digest>` |
| `current_file` | the file arriving now |
| `error` | the full reason when `failed` |
| `stall_after_s`, `stalled_for_s` | the stall threshold (default 15 s) and how long the current stall has lasted |
| `transitions` | `[{at, state, why}]`, every state change |
| `cancel_requested` | `true` from the cancel request until the job ends |
| `parent_job` | on a child of a parent job |

A parent (`download_group`) adds `children` (the full child jobs),
`child_job_ids` and `label`, and uses `files` for one row per model. Its
bytes, percent and speed add up its children; models already installed count
as done with nothing to fetch. It is `stalled` only when every running child
is stalled, `failed` when every child ended and one failed (`error` names
which, with its reason), `cancelled` when one was cancelled, and `done` when
all are.

### States

- `queued`: accepted, not started (milliseconds for an in-process job).
- `resolving`: finding what to fetch and how big it is (hub file list, `lms get` search, Ollama manifest).
- `downloading`: bytes are moving.
- `stalled`: no bytes for `stall_after_s` seconds (15 by default,
  `ABSTRACTCORE_DOWNLOAD_STALL_S` on the Gateway host). The job keeps trying
  and turns back to `downloading` by itself when bytes arrive again. The
  stall and the recovery are logged (`abstractcore.host_jobs`) and listed in
  `transitions`.
- `verifying`: checking what arrived (Ollama's sha256, every Hugging Face file whole).
- `installing`: moving into the library (Ollama "writing manifest", LM Studio "Finalizing download...").
- `done`, `failed`, `cancelled`: finished.

`verifying` and `installing` never count as stalls.

### Real examples

Captured on a hermetic Gateway (port 18822, scratch caches) with the real
recommended artifact ids; the voice and image files came from a local stand-in
for huggingface.co and LM Studio from a stand-in `lms` that prints exactly what
the real one prints. Key fields only.

`queued`:

```json
{"job_id": "dl_2b0e98639928", "provider": "ollama", "artifact": "all-minilm", "status": "running", "state": "queued", "bytes_done": null, "bytes_total": null, "size_unknown": false, "percent": null, "bytes_per_second": null, "eta_s": null, "updated_at": "2026-09-24T03:28:38.435Z", "current_file": null, "message": "queued (detached)", "error": null, "stalled_for_s": null}
```

`resolving`:

```json
{"job_id": "dl_be2ff93db7d6", "provider": "mlx-gen", "artifact": "AbstractFramework/flux.2-klein-4b-8bit", "status": "running", "state": "resolving", "bytes_done": null, "bytes_total": null, "size_unknown": false, "percent": null, "bytes_per_second": null, "eta_s": null, "updated_at": "2026-09-24T03:27:55.745Z", "current_file": null, "message": "Preparing · reading the file list of AbstractFramework/flux.2-klein-4b-8bit", "error": null, "stalled_for_s": null}
```

`downloading`:

```json
{"job_id": "dl_f46f63c01098", "provider": "mlx-gen", "artifact": "AbstractFramework/flux.2-klein-4b-8bit", "status": "running", "state": "downloading", "bytes_done": 25976720, "bytes_total": 65005200, "size_unknown": false, "percent": 39.96, "bytes_per_second": 7699301.9, "eta_s": 6, "updated_at": "2026-09-24T03:25:15.363Z", "current_file": "transformer/diffusion_pytorch_model.safetensors", "message": "Downloading transformer/diffusion_pytorch_model.safetensors (2 of 5) · 26 MB of 65 MB · 7.7 MB/s · 6 s left", "error": null, "stalled_for_s": null, "files": [{"bytes_done": 1200, "bytes_total": 1200, "name": "model_index.json", "state": "done"}, {"bytes_done": 10485760, "bytes_total": 40000000, "name": "transformer/diffusion_pytorch_model.safetensors", "state": "downloading"}, {"bytes_done": 10485760, "bytes_total": 20000000, "name": "text_encoder/model.safetensors", "state": "downloading"}, {"bytes_done": 5000000, "bytes_total": 5000000, "name": "vae/diffusion_pytorch_model.safetensors", "state": "done"}, {"bytes_done": 4000, "bytes_total": 4000, "name": "README.md", "state": "done"}]}
```

`stalled`:

```json
{"job_id": "dl_f8684d929f51", "provider": "supertonic", "artifact": "supertonic-3", "status": "running", "state": "stalled", "bytes_done": 25942208, "bytes_total": 56185929, "size_unknown": false, "percent": 46.17, "bytes_per_second": 0.0, "eta_s": null, "updated_at": "2026-09-24T03:27:38.444Z", "current_file": "onnx/vector_estimator.onnx", "message": "Stalled: no data for 16 s · 26 MB of 56 MB · still trying, it resumes by itself when data flows again", "error": null, "stalled_for_s": 15.3}
```

`verifying`:

```json
{"job_id": "dl_3d015e06ebaa", "provider": "mlx-gen", "artifact": "AbstractFramework/flux.2-klein-4b-8bit", "status": "running", "state": "verifying", "bytes_done": 65005200, "bytes_total": 65005200, "size_unknown": false, "percent": 100.0, "bytes_per_second": 3742244.1, "eta_s": 0, "updated_at": "2026-09-24T03:28:21.002Z", "current_file": "transformer/diffusion_pytorch_model.safetensors", "message": "Verifying · checking 5 file(s) are whole · 65 MB of 65 MB", "error": null, "stalled_for_s": null}
```

`installing`:

```json
{"job_id": "dl_faa442ea7875", "provider": "lmstudio", "artifact": "qwen/qwen3.5-9b@4bit", "status": "running", "state": "installing", "bytes_done": 6000000000, "bytes_total": 6000000000, "size_unknown": false, "percent": 100.0, "bytes_per_second": 466469568.4, "eta_s": 0, "updated_at": "2026-09-24T03:26:40.607Z", "current_file": null, "message": "Installing · Finalizing download... · 6.0 GB of 6.0 GB", "error": null, "stalled_for_s": null}
```

`done`:

```json
{"job_id": "dl_e36dca05d05c", "provider": "supertonic", "artifact": "supertonic-3", "status": "completed", "state": "done", "bytes_done": 56185929, "bytes_total": 56185929, "size_unknown": false, "percent": 100.0, "bytes_per_second": null, "eta_s": 0, "updated_at": "2026-09-24T03:26:41.409Z", "current_file": null, "message": "Downloaded 56 MB in 15 s", "error": null, "stalled_for_s": null}
```

`failed`:

```json
{"job_id": "dl_842ede4aff62", "provider": "ollama", "artifact": "all-minilm", "status": "failed", "state": "failed", "bytes_done": null, "bytes_total": null, "size_unknown": false, "percent": null, "bytes_per_second": null, "eta_s": null, "updated_at": "2026-09-24T03:27:14.156Z", "current_file": null, "message": "cannot reach the Ollama server at http://127.0.0.1:11499: [Errno 61] Connection refused", "error": "cannot reach the Ollama server at http://127.0.0.1:11499: [Errno 61] Connection refused", "stalled_for_s": null}
```

`cancelled`:

```json
{"job_id": "dl_be2ff93db7d6", "provider": "mlx-gen", "artifact": "AbstractFramework/flux.2-klein-4b-8bit", "status": "cancelled", "state": "cancelled", "bytes_done": 25976720, "bytes_total": 65005200, "size_unknown": false, "percent": 39.96, "bytes_per_second": null, "eta_s": null, "updated_at": "2026-09-24T03:27:59.076Z", "current_file": "transformer/diffusion_pytorch_model.safetensors", "message": "cancelled", "error": null, "stalled_for_s": null}
```

`download_group (downloading)`:

```json
{"job_id": "grp_080bae3c6d3e", "status": "running", "state": "downloading", "bytes_done": 0, "bytes_total": null, "size_unknown": true, "percent": null, "bytes_per_second": null, "eta_s": null, "updated_at": "2026-09-24T03:26:26.753Z", "message": "Downloading 3 models · 0 of 3 ready · 3 of 3 sources cannot report their size yet", "error": null, "kind": "download_group", "files": [{"bytes_done": null, "bytes_total": null, "job_id": "dl_faa442ea7875", "name": "lmstudio qwen/qwen3.5-9b@4bit", "state": "downloading"}, {"bytes_done": null, "bytes_total": null, "job_id": "dl_e36dca05d05c", "name": "supertonic supertonic-3", "state": "resolving"}, {"bytes_done": null, "bytes_total": null, "job_id": "dl_8dde3c3f4834", "name": "mlx-gen AbstractFramework/flux.2-klein-4b-8bit", "state": "resolving"}], "children": "[3 child jobs]"}
```

`download_group (stalled)`:

```json
{"job_id": "grp_a1e3f58d4cd0", "status": "running", "state": "stalled", "bytes_done": 6090947408, "bytes_total": 6121191129, "size_unknown": false, "percent": 99.51, "bytes_per_second": 0.0, "eta_s": null, "updated_at": "2026-09-24T03:25:34.055Z", "message": "Stalled: no data for 16 s from supertonic supertonic-3 · 2 of 3 ready · 6.1 GB of 6.1 GB · 0 B/s", "error": null, "kind": "download_group", "files": [{"bytes_done": 6000000000, "bytes_total": 6000000000, "job_id": "dl_ec66382e80a1", "name": "lmstudio qwen/qwen3.5-9b@4bit", "state": "done"}, {"bytes_done": 25942208, "bytes_total": 56185929, "job_id": "dl_2b7360ab34fe", "name": "supertonic supertonic-3", "state": "stalled"}, {"bytes_done": 65005200, "bytes_total": 65005200, "job_id": "dl_f46f63c01098", "name": "mlx-gen AbstractFramework/flux.2-klein-4b-8bit", "state": "done"}], "children": "[3 child jobs]"}
```

## What each source reports

| Source | What the source exposes | What the job reports | Cancel |
|---|---|---|---|
| Hugging Face, MLX, mlx-gen | the hub's file list with sizes and blob names; the files being written in the cache (`blobs/<etag>…incomplete`) | the total and every file before the first byte, then per-file bytes read from disk every 0.25 s; files already complete count as done (the download resumes) | the transfer runs in a child process, stopped at once; its temporary files are removed; a marker in the repo folder keeps an unfinished download from reading as installed |
| Ollama | `/api/pull` lines with `digest`, `total`, `completed` per layer | layers added up into one total that never goes back, one `files` row per layer; "pulling manifest" is `resolving`, "verifying sha256 digest" `verifying`, "writing manifest" `installing` | the connection is closed at once; Ollama keeps the layers it has |
| LM Studio (`lms get`) | a progress bar with bytes, total, speed and time left | those numbers; "Finalizing download..." is `installing`. When no bar is printed: the bytes landing in the LM Studio models folder, "LM Studio reports no progress; N MB on disk so far", with `size_unknown: true` unless the catalog size was sent as `expected_bytes` | answers `lms get`'s "continue in the background?" with No, so LM Studio stops too, then stops the CLI |
| Supertonic (voice) | the size of each file (HEAD), then the file bodies | per-file bytes with the total known before the first byte | stops at once; the partial file is removed, finished files are kept |

Hugging Face downloads started from a job use plain HTTP rather than Xet:
Xet writes a file only once it is complete, so the bar would sit still and
then jump. `ABSTRACTCORE_HF_XET=1` turns Xet back on (progress then moves one
whole file at a time). With huggingface_hub 1.x a cancelled file restarts
from zero on the next download; files that were complete are kept.

## Limits

- Parent jobs live in the Gateway process: after a restart a `grp_...` id
  answers 404, while its children (AbstractCore jobs, persisted) can still be
  read with `GET /models/download/{dl_id}` or `GET /jobs`.
- `lms get` is LM Studio's own CLI. Its progress bar and its cancel question
  are what this relies on; if a future `lms` prints neither, the job falls
  back to bytes on disk and a plain stop of the CLI.

---

## docs/deployment.md

# AbstractGateway deployment

AbstractGateway can run as a Python process or as a containerized server. The
container path is the recommended baseline for a single self-contained Gateway
deployment because it packages the HTTP API, durable runner, AbstractRuntime,
and the Runtime-owned provider/tool stack together.

## Published image

Release images are published to GHCR. The default image is the light,
portable server image:

```bash
docker pull ghcr.io/lpalbou/abstractgateway:0.4.1
```

NVIDIA hosts can try the experimental full GPU image when local
vLLM/HuggingFace/Diffusers engines are wanted. This image is published
best-effort until it has a real CUDA build and smoke gate:

```bash
docker pull ghcr.io/lpalbou/abstractgateway:0.4.1-gpu
```

Legacy aliases `ghcr.io/lpalbou/abstractgateway-server:*` and
`ghcr.io/lpalbou/abstractgateway-server-nvidia:*` are still published for a
transition period. New deployments should use `abstractgateway`.

The default image installs the base `abstractgateway` package, which includes:

- `AbstractRuntime`
- `AbstractMemory[lancedb]>=0.2.6`
- `abstractagent`
- FastAPI/Uvicorn

This profile supports hosted/commercial providers, OpenAI-compatible text
and multimodal provider routing, Runtime-owned tool execution, KG memory, and
provider/session prompt-cache controls. Remote embeddings are included through
the `embedding.text` capability route for hosted providers, LM Studio, vLLM,
other OpenAI-compatible endpoints, or a remote AbstractCore server. Local
sentence-transformer embeddings and hardware-local model runtimes remain
explicit opt-ins, so the base Linux image does not pull PyTorch/CUDA runtime
packages. MLX, vLLM, HuggingFace
Transformers, local Diffusers/sdcpp, AbstractVoice local engines, and local
AbstractMusic engines belong in native `abstractgateway[apple]` or
`abstractgateway[gpu]` installs.

The NVIDIA image installs `abstractgateway[gpu]` and uses a CUDA/PyTorch base.
It is experimental and release automation publishes it as
best-effort for `linux/amd64`; the default image remains the release-grade
portable `linux/amd64` and `linux/arm64` image. Treat the NVIDIA image as
production-ready only after a CUDA host build/smoke gate is added and passes.

### Apple Silicon / MLX

There is no Apple/MLX Gateway Docker image target. MLX uses Apple's Metal
stack, while Docker Desktop runs Linux containers without Metal/MPS device
access. The supported Docker shape is a lightweight Gateway container calling a
host-native OpenAI-compatible inference endpoint:

```bash
docker run --rm --name abstractgateway \
  -p 8080:8080 \
  -e ABSTRACTGATEWAY_DATA_DIR=/data \
  -e ABSTRACTGATEWAY_USER_AUTH=1 \
  -e OPENAI_BASE_URL="http://model-runner.docker.internal/engines/v1" \
  -v "$PWD/runtime:/data" \
  ghcr.io/lpalbou/abstractgateway:latest
```

Set the execution-host text route separately:

```bash
docker exec abstractgateway abstractgateway-config set-default input.text \
  --provider openai-compatible \
  --model your-model \
  --base-url http://model-runner.docker.internal/engines/v1
```

Other host-native endpoints are also valid: LM Studio at
`http://host.docker.internal:1234/v1` with `LMSTUDIO_BASE_URL`, Ollama at
`http://host.docker.internal:11434` with `OLLAMA_BASE_URL`, or `mlx_lm.server`
exposed on a host port. For fully native non-Docker installs with local engines, use
`pip install "abstractgateway[apple]"` on Apple Silicon, and
`pip install "abstractgateway[gpu]"` on GPU workstations or NVIDIA Docker builds.

## Compose quickstart

Create an env file from the template, adjust provider keys/defaults, then start
the server. The default env keeps user auth enabled and bootstraps
`default/admin` if missing:

```bash
cp docker/abstractgateway-server/.env.example docker/abstractgateway-server/.env
docker compose --env-file docker/abstractgateway-server/.env \
  -f docker/abstractgateway-server/compose.yml up -d
```

For the experimental NVIDIA image on a GPU host with the NVIDIA Container
Toolkit:

```bash
docker compose --env-file docker/abstractgateway-server/.env \
  -f docker/abstractgateway-server/compose.yml \
  -f docker/abstractgateway-server/compose.nvidia.yml up -d
```

The default compose profile binds to `127.0.0.1:8080`, mounts a durable Gateway
data volume at `/data`, and exposes a container workspace at `/workspace`. It
serves the workflows the image ships with — `basic-agent`, `coding-agent`,
`deep-research`, `co-scientist`, and more
([shipped-workflows.md](docs/shipped-workflows.md)).

To serve your own bundles instead, point `ABSTRACTGATEWAY_HOST_FLOWS_DIR` at
your bundle directory (mounted read-only at `/data/flows`) and set
`ABSTRACTGATEWAY_FLOWS_DIR=/data/flows`:

```bash
ABSTRACTGATEWAY_HOST_FLOWS_DIR=/path/to/bundles \
ABSTRACTGATEWAY_FLOWS_DIR=/data/flows \
  docker compose -f docker/abstractgateway-server/compose.yml up -d
```

Smoke checks:

```bash
curl http://127.0.0.1:8080/api/health

ADMIN_TOKEN="$(docker compose -f docker/abstractgateway-server/compose.yml exec -T abstractgateway cat /data/auth/bootstrap-admin-token)"
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://127.0.0.1:8080/api/gateway/me
```

## Core configuration

Required for hosted/container user-auth mode:

- `ABSTRACTGATEWAY_USER_AUTH=1`: enables Gateway user tokens and per-user routing
- `ABSTRACTGATEWAY_BOOTSTRAP_ADMIN=1`: creates `default/admin` if missing

Optional:

- `ABSTRACTGATEWAY_AUTH_TOKEN`: legacy shared admin bearer token for
  compatibility/bootstrap APIs; browser apps should use Gateway user tokens

Common:

- Browser origins and trust proxy are settings, not variables: the console's
  Network → *Advanced: reverse proxy*, the TUI's Connection screen, or
  `abstractgateway network set --allowed-origins https://gateway.example.com --trust-proxy on`
  (inside a container: `docker exec <container> abstractgateway network set …`).
  They apply to the next request. `ABSTRACTGATEWAY_ALLOWED_ORIGINS` /
  `ABSTRACTGATEWAY_TRUST_PROXY` in the container environment still pin them
  (reported as `overridden_by_env`); see
  [configuration.md](docs/configuration.md#reverse-proxy-allowed-origins-and-trust-proxy).
- `input.text` capability route: default for LLM/agent nodes
- `ABSTRACTGATEWAY_TOOL_MODE`: `approval`, `passthrough`, `delegated`, or local dev modes
- `ABSTRACTGATEWAY_STORE_BACKEND`: `file` or `sqlite`
- `ABSTRACTGATEWAY_DB_PATH`: SQLite file, when using `sqlite`
- `ABSTRACTGATEWAY_RUNNER`: `1` for combined API+runner, `0` for API-only
- `ABSTRACTGATEWAY_MEMORY_STORE_BACKEND`: `lancedb` or `memory` for KG workflows and `/kg/query`; `sqlite` works when the installed AbstractMemory build exposes `SQLiteTripleStore`

Provider keys and endpoints:

- `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY`
- `OPENROUTER_API_KEY`
- `PORTKEY_API_KEY` / `PORTKEY_CONFIG`
- `OPENAI_BASE_URL` / `OPENAI_API_KEY` for generic OpenAI-compatible endpoints
- `OPENAI_COMPATIBLE_BASE_URL` / `OPENAI_COMPATIBLE_API_KEY` as legacy operator aliases; prefer `OPENAI_BASE_URL` for AbstractCore discovery
- `LMSTUDIO_BASE_URL`
- `OLLAMA_BASE_URL`
- `VLLM_BASE_URL`

Image/voice plugin endpoints:

- `ABSTRACTVISION_BACKEND`: `openai`, `openai-compatible`, `diffusers`, or `sdcpp`
- `ABSTRACTGATEWAY_VISION_BACKEND` / `ABSTRACTGATEWAY_VISION_BASE_URL` / `ABSTRACTGATEWAY_VISION_API_KEY` / `ABSTRACTGATEWAY_VISION_MODEL_ID` (legacy `ABSTRACTVISION_*` names still work)
- `ABSTRACTGATEWAY_VOICE_TTS_ENGINE` / `ABSTRACTGATEWAY_VOICE_STT_ENGINE` (`openai` by default in the server image; legacy `ABSTRACTVOICE_*` names still work)
- `ABSTRACTGATEWAY_VOICE_REMOTE_BASE_URL` / `ABSTRACTGATEWAY_VOICE_REMOTE_API_KEY`
- `ABSTRACTGATEWAY_VOICE_TTS_MODEL` / `ABSTRACTGATEWAY_VOICE_STT_MODEL`

Core catalog proxying:

- `ABSTRACTCORE_SERVER_BASE_URL`: explicit standalone Core server URL for voice, TTS/STT, and vision catalog routes
- `ABSTRACTGATEWAY_ABSTRACTCORE_SERVER_AUTH_TOKEN`: Core server auth token, separate from Gateway auth
- `ABSTRACTGATEWAY_CORE_CATALOG_TIMEOUT_S`: timeout for catalog routes

Filesystem/media controls from AbstractCore remain available:

- `ABSTRACTCORE_SERVER_BASE_URL_ALLOWLIST`
- `ABSTRACTCORE_SERVER_URL_FETCH_ALLOWLIST`
- `ABSTRACTCORE_SERVER_MEDIA_ROOT`
- `ABSTRACTCORE_SERVER_ALLOW_LOCAL_FILES`

## Single machine without Docker

On a desktop or laptop, `abstractgateway service install` registers the
gateway as a per-user login service (macOS LaunchAgent, Linux systemd user
unit or XDG autostart entry, Windows Run entry) that runs plain `serve`, so the
[network exposure](docs/configuration.md#network-exposure-localhost--local-network--internet)
setting decides the bind (seeded to `localhost`, i.e. `127.0.0.1`, on install),
with data in the per-user data folder. See [first-run.md](docs/first-run.md). Containers and
servers keep the explicit configuration shown on this page: the image sets
`--host 0.0.0.0` and needs explicit auth, exactly as before.

## Cache and auth notes

Gateway auth is controlled by `ABSTRACTGATEWAY_*` variables and protects
`/api/gateway/*`. AbstractCore provider/server auth variables control upstream
provider access inside AbstractCore integrations. Keep those two layers
separate: clients receive only the Gateway token, while provider keys stay in
the server environment.

Prompt-cache control endpoints are exposed under `/api/gateway/prompt_cache/*`
where supported by the active provider/model. Session lifecycle routes under
`/api/gateway/sessions/{session_id}/prompt_cache/*` provide Gateway-owned
naming/status/prepare/clear/rebuild orchestration on top of those provider
controls. They are not a provider-independent local KV cache or full
CachedSession persistence system.

## Local-source image

Before a version is published to PyPI, build from the checkout:

```bash
ABSTRACTGATEWAY_INSTALL_MODE=local \
ABSTRACTGATEWAY_IMAGE_TAG=0.4.1-local \
docker compose -f docker/abstractgateway-server/compose.yml up -d --build
```

Release automation builds the published image from the PyPI package after the
PyPI release is available, matching the AbstractCore server image pattern.

---

## docs/architecture.md

# AbstractGateway — Architecture

> Status: implemented (main branch)
> Last reviewed: 2026-08-27

AbstractGateway is a **durable run gateway** for AbstractRuntime:
- **Start runs** (and optionally schedule them)
- Accept **durable commands** (`pause`, `resume`, `cancel`, `emit_event`, …)
- Let clients **replay** the durable ledger and optionally **stream** updates (SSE)

This document describes the code in this repository (see **Evidence** links).

## Ecosystem placement (AbstractFramework)

AbstractGateway is designed to sit between **thin clients / UIs** and **AbstractRuntime**:
- AbstractGateway: HTTP/SSE API + durability glue + baseline security (`src/abstractgateway/app.py`, `src/abstractgateway/routes/gateway.py`)
- AbstractRuntime (required): run model + tick loop + stores (`pyproject.toml`, `src/abstractgateway/runner.py`)
- AbstractRuntime + transitive capability packages (required by the default server install): Runtime owns the LLM/tool/media integration boundary; Gateway uses its discovery/run facades for prompt-cache controls, generated image/video/voice/audio/music capabilities, and KG-backed bundle execution (`src/abstractgateway/hosts/bundle_host.py`)

## High-level shape

```mermaid
flowchart LR
  subgraph Clients["Clients (thin/stateless UIs)"]
    UI["Web/PWA / TUI / 3rd-party"]
  end

  subgraph GW["AbstractGateway (this package)"]
    Sec["GatewaySecurityMiddleware\n(auth + origin + limits)"]
    API["FastAPI routes\n/api/gateway/*"]
    Runner["GatewayRunner\npoll commands + tick runs"]
    Host["Workflow host\n(bundle mode)"]
    Stores["Durable stores\nruns + ledger + commands + artifacts"]
  end

  subgraph RT["AbstractRuntime"]
    Runtime["Runtime.tick(...)"]
    Registry["WorkflowRegistry / WorkflowSpec"]
  end

  UI -->|HTTP| Sec --> API
  API -->|append commands / upload bundles| Stores
  API -->|ledger replay / SSE stream| Stores
  Runner -->|poll inbox| Stores
  Runner -->|load runtime+workflow| Host
  Host --> Registry
  Runner --> Runtime
  Runtime -->|append StepRecords| Stores
```

## Core components (code-mapped)

- **HTTP API**: `src/abstractgateway/app.py` mounts routers under `/api` (`/api/gateway/*` is the main surface).
- **Security layer** (ASGI middleware):
  - Protects `/api/gateway/*` with bearer token auth + origin allowlist + request limits.
  - Implemented in `src/abstractgateway/security/gateway_security.py`.
- **Durable stores** (file or SQLite):
  - Built by `src/abstractgateway/stores.py` (`build_file_stores`, `build_sqlite_stores`).
  - Store types come from `abstractruntime` (RunStore, LedgerStore, CommandStore, ArtifactStore).
- **Workflow host** (what “workflows” mean in this gateway):
  - `bundle` (default): load `.flow` WorkflowBundles and compile VisualFlow JSON via `abstractruntime.visualflow_compiler` (`src/abstractgateway/hosts/bundle_host.py`).
  - Wired in `src/abstractgateway/service.py` (`create_default_gateway_service`).
- **Workflow catalog**:
  - Gateway-owned metadata and immutable bundle bytes for shared/default
    workflows.
  - Private runtime bundles remain per-principal; catalog bundles are loaded
    into each user's host under internal bundle ids so private ids cannot
    shadow catalog ids.
  - Run start and schedule routes resolve catalog ACL/default policy before
    execution; catalog runs still execute in the caller's runtime.
  - Gateway signs catalog workflow-policy snapshots before Runtime receives
    them, and direct private-bundle routes reject catalog-internal ids.
- **Runner worker**:
  - Polls the durable command inbox and applies commands; ticks RUNNING runs forward (`src/abstractgateway/runner.py`).
  - A filesystem lock (`gateway_runner.lock`) prevents double-ticking in split-process deployments. Refused acquisition is retried continuously (never one-shot), a live wrong holder is asked to yield via a one-shot takeover request (`gateway_runner.takeover`, newest process wins), the holder heartbeats the lock file mtime, and lock state is surfaced on `GET /api/health` plus `StartRunResponse.runner_warning` when nobody provably ticks the data dir.

## Durable contract (replay-first)

The gateway is intentionally **replay-first**:
- The **durable ledger** is the source of truth.
- SSE (`/ledger/stream`) is an optimization; clients should reconnect by replaying from a cursor.

This contract is stated and implemented in `src/abstractgateway/routes/gateway.py` (ledger endpoints + SSE) and `src/abstractgateway/runner.py` (StepRecord append semantics).

## Thin-client control plane

Gateway also acts as the control plane for higher-level apps such as
AbstractFlow, AbstractAssistant, and AbstractObserver:

- `GET /api/gateway/discovery/capabilities` exposes a versioned shared contract
  for run input/history access, media endpoints, voice contracts, prompt-cache
  surfaces, host state, session-cache enumeration, and model residency truth
  (`common.model_residency` with `row_schema` and the canonical `modality_ui`
  color map, `common.host_state`, `common.session_caches`).
- Provider/model catalogs are intentionally routed through Gateway. The legacy
  lower-layer payload fields are preserved, but Gateway now adds a stable
  `gateway_catalog_v1` envelope plus canonical `items` so higher apps can stop
  carrying route-local parsing logic.
- Gateway also exposes `common.readiness` as a compact surface-level summary
  for thin clients and operator UIs. That summary is deliberately limited to
  Gateway-owned contract truth; deeper backend/provider diagnostics still
  belong below Gateway.
- Direct run-scoped media routes currently include TTS, STT, image generation,
  image edit, and music generation.
- Voice listen is intentionally a host-capture contract, not a server-side
  microphone transport. Gateway tells clients how to emit or upload captured
  audio; clients keep ownership of live capture UX.
- Model residency, host state/metrics, session prompt-cache enumeration,
  prompt-cache lifecycle, durable blocs, and discovery catalogs are
  server-owned control-plane surfaces so higher apps do not have to import
  Runtime/Core packages directly.
- Model residency truth is Runtime/provider-owned and Gateway-normalized:
  `GET /models/loaded` and `GET /host/state` relay Runtime's host facade
  records and add frozen `model_residency_row_v1` rows in which provider
  residency booleans outrank runtime lease booleans, state strings can only
  confirm residency (never deny it), and unknown values stay `null`. Gateway
  never fabricates residency, memory, or GPU truth; unavailable sections
  degrade in-band instead of failing the snapshot.
- Host and residency surfaces follow a read/mutation authorization split:
  reads (`/models/loaded`, `/models/context_estimate`, `/host/state`,
  `/host/metrics/*`, `GET /sessions/prompt_cache`) serve any authenticated
  principal, while mutations (`/models/load|unload|lock|unlock|download`,
  prompt-cache mutations including `clear_all`) require an admin principal.
- The shared contract is principal-aware for high-trust actions. Regular users
  see admin-only workspace artifact import/export and provider prompt-cache
  controls as unavailable in discovery, while ordinary run/ledger/artifact
  upload, KG query, provider/model discovery, and per-principal defaults remain
  available in their routed runtime.

## Deployment shape: one process vs split API/runner

Supported patterns:
- **Single process**: `abstractgateway serve` starts both the HTTP API and the background runner (FastAPI lifespan + service composition).
- **Split**: run `abstractgateway runner` (worker) and `abstractgateway serve --no-runner` (API) against the same `ABSTRACTGATEWAY_DATA_DIR`.

Evidence:
- CLI flags and runner env toggles: `src/abstractgateway/cli.py`
- Runner lock file: `src/abstractgateway/runner.py`

## Workflow sources (bundle)

### Bundle mode (recommended)

- Input: `*.flow` files (WorkflowBundles) under `ABSTRACTGATEWAY_FLOWS_DIR` (file or directory).
- Internals:
  - Bundles are opened with `abstractruntime.workflow_bundle.open_workflow_bundle`.
  - VisualFlow JSON is namespaced (`bundle@version:flow`) and compiled via `compile_visualflow`.
  - “Dynamic flows” (e.g. schedules) are persisted under `<data_dir>/dynamic_flows/` and reloaded on startup.

Evidence: `src/abstractgateway/hosts/bundle_host.py` (`WorkflowBundleGatewayHost.load_from_dir`).

### Workflow catalog

The workflow catalog is a control-plane registry for shared workflows. It is
separate from the caller's private bundle directory:

- user-visible discovery: `GET /api/gateway/workflow-catalog`;
- admin mutation: `/api/gateway/admin/workflow-catalog/*`;
- immutable bundle bytes: existing `bundle_id@version` content cannot be
  replaced with different bytes;
- default pointers: omitted catalog versions resolve through the admin-managed
  pointer, not semver order;
- status: deprecated, blocked, and tombstoned versions cannot start new runs.

Gateway stores catalog metadata under the root Gateway data dir and loads
tenant catalog bundle bytes into each per-principal runtime host. Runtime still
executes workflows; Gateway owns the authorization decision.

The implemented catalog scope is `tenant_catalog`. `framework_catalog` is
reserved for a later cross-tenant distribution model and is rejected by the API
until host loading and policy semantics exist for it.

### VisualFlow directory mode

VisualFlow directory mode was intentionally removed. Store VisualFlows through
`/api/gateway/visualflows/*`, publish a `.flow` WorkflowBundle via
`POST /api/gateway/visualflows/{flow_id}/publish`, and run in bundle mode.

## Security model (gateway endpoints)

`GatewaySecurityMiddleware` applies only to paths starting with `/api/gateway`:
- **Bearer token auth** (`ABSTRACTGATEWAY_AUTH_TOKEN` / `ABSTRACTGATEWAY_AUTH_TOKENS`)
- **Origin allowlist** (`ABSTRACTGATEWAY_ALLOWED_ORIGINS`, glob patterns supported)
- **Abuse resistance** (body size caps, concurrency caps, auth lockouts, optional audit log)

Evidence: `src/abstractgateway/security/gateway_security.py` (`GatewayAuthPolicy`, `load_gateway_auth_policy_from_env`, middleware `__call__`).

Bearer tokens are gateway-level control-plane credentials, not tenant or
browser-session identities. `session_id`, `run_id`, `artifact_id`, and memory
owner ids are references/correlation fields, not authorization proofs. Deploy a
separate Gateway/runtime/data plane per independent user or tenant unless all
users are a trusted cohort that may share runs, artifacts, workflows, memory,
workspaces, tools, provider credentials, and audit scope. Hosted user-auth mode
routes each principal to a separate GatewayService data plane; browser apps
exchange user tokens for opaque session cookies plus CSRF rather than storing
raw bearer tokens. Session prompt-cache names include a private principal scope
in their hash to avoid cross-user key collisions. See [security.md](docs/security.md).

## Evidence (jump-to-code)

- Composition root: `src/abstractgateway/service.py` (`create_default_gateway_service`, `start_gateway_runner`)
- API surface: `src/abstractgateway/routes/gateway.py` (everything under `/api/gateway/*`)
- Runner semantics: `src/abstractgateway/runner.py` (`GatewayRunner`)
- Store backends: `src/abstractgateway/stores.py`
- Security policy + middleware: `src/abstractgateway/security/gateway_security.py`
- CLI + split runner: `src/abstractgateway/cli.py`

## Related docs

- Getting started (run + stores): [getting-started.md](docs/getting-started.md)
- FAQ: [faq.md](docs/faq.md)
- Configuration (env vars): [configuration.md](docs/configuration.md)
- API overview (client contract): [api.md](docs/api.md)
- Security guide: [security.md](docs/security.md)
- Operator tooling (triage/backlog/process manager): [maintenance.md](docs/maintenance.md)

---

## docs/faq.md

# AbstractGateway — FAQ

This FAQ is written for first-time users integrating or operating `abstractgateway`.
For the full API surface, rely on the live OpenAPI spec (`/openapi.json`, `/docs`) which is generated from code.

## Getting started

### What is AbstractGateway?

AbstractGateway is a **durable run gateway** for AbstractRuntime:
- starts runs from workflows (bundle mode or visualflow directory mode)
- accepts a **durable command inbox** (commands are appended, then applied asynchronously by the runner)
- exposes a **replay-first ledger** API (SSE is optional)

Evidence: `src/abstractgateway/routes/gateway.py`, `src/abstractgateway/runner.py`, `src/abstractgateway/service.py`.

### How does this fit in the AbstractFramework ecosystem?

- **AbstractRuntime** (required): the durable run model + tick loop + stores (declared in `pyproject.toml`).
- **AbstractGateway** (this repo): a deployable HTTP/SSE facade around AbstractRuntime runs (API in `src/abstractgateway/routes/gateway.py`).
- **AbstractRuntime + transitive capability packages** (required by the default server install): Runtime owns the LLM/tool/media integration boundary; Gateway uses its discovery/run facades for prompt-cache controls, generated and edited image plus voice/audio/music capabilities, and KG-backed bundle execution (`src/abstractgateway/hosts/bundle_host.py`).
- Higher-level UIs (optional): AbstractFlow (authoring/bundling), AbstractObserver / AbstractCode / thin clients (operations + rendering).

Related repos:
- AbstractFramework: https://github.com/lpalbou/AbstractFramework
- AbstractCore: https://github.com/lpalbou/abstractcore
- AbstractRuntime: https://github.com/lpalbou/abstractruntime

### Do I need AbstractFlow to run workflows?

Not for **bundle mode** (the default).

- Bundle mode loads `.flow` bundles and compiles VisualFlow JSON via `abstractruntime.visualflow_compiler` (no `abstractflow` import).
- You only need `abstractflow` to **author** bundles.

Evidence: `src/abstractgateway/hosts/bundle_host.py` (bundle compilation).

### What’s the difference between bundle mode and visualflow directory mode?

- **Bundle mode** (`ABSTRACTGATEWAY_WORKFLOW_SOURCE=bundle`, default):
  - input: one `.flow` file or a directory of `*.flow`
  - versioning: bundles are addressed as `bundle_id@bundle_version`
- **VisualFlow directory mode**: removed. Use VisualFlow CRUD + publish to `.flow` bundles, then run in bundle mode.

Evidence: `src/abstractgateway/service.py` (workflow source switch), `src/abstractgateway/hosts/bundle_host.py`.

## Security

### Why does `abstractgateway serve` refuse to start?

By default, the server requires either Gateway user auth or a legacy
server/operator bearer token for `/api/gateway/*` and will fail fast if neither
is configured.

Recommended browser-app fix:

```bash
export ABSTRACTGATEWAY_USER_AUTH=1
export ABSTRACTGATEWAY_DATA_DIR="$PWD/runtime/gateway"
abstractgateway serve --host 127.0.0.1 --port 8080

# Use user admin plus this token for first login.
cat "$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token"
```

Use `ABSTRACTGATEWAY_AUTH_TOKEN` only for legacy server/operator bearer-token
deployments; it maps to `local-admin` and is not a browser sign-in token.

Evidence: startup self-check in `src/abstractgateway/cli.py`, policy loading in `src/abstractgateway/security/gateway_security.py`.

### What’s the difference between `--host` and `ABSTRACTGATEWAY_ALLOWED_ORIGINS`?

- `abstractgateway serve --host ...` controls the **bind address** (network interfaces the server listens on).
- `ABSTRACTGATEWAY_ALLOWED_ORIGINS` controls an **Origin allowlist** for requests that include an `Origin` header (browser/origin defense) on `/api/gateway/*`.

Evidence: CLI flags in `src/abstractgateway/cli.py`, origin checks in `src/abstractgateway/security/gateway_security.py`.

### Why do I get `401` / `403` / `429` / `413` from `/api/gateway/*`?

Common causes:
- `401 Unauthorized`: missing/invalid `Authorization: Bearer <token>`
- `403 Forbidden (origin not allowed)`: browser `Origin` not matched by `ABSTRACTGATEWAY_ALLOWED_ORIGINS`
- `429 Too Many Requests (auth lockout)`: repeated auth failures from the same client IP (lockout backoff)
- `413 Payload Too Large`: request exceeds configured body/upload limits

Evidence: `GatewaySecurityMiddleware.__call__` in `src/abstractgateway/security/gateway_security.py`.

### Can I disable security (dev only)?

Prefer keeping security enabled, even in dev.

If you must relax it:
- disable the gateway security layer entirely: `ABSTRACTGATEWAY_SECURITY=0`
- or (safer) allow unauthenticated reads on loopback only: `ABSTRACTGATEWAY_DEV_READ_NO_AUTH=1`
- or fine-tune: `ABSTRACTGATEWAY_PROTECT_READ=0`, `ABSTRACTGATEWAY_PROTECT_WRITE=0`

Evidence: env policy loader in `src/abstractgateway/security/gateway_security.py`.

## Storage

### Where is data stored?

Everything is rooted at `ABSTRACTGATEWAY_DATA_DIR`:

- File backend (default): `run_*.json`, `ledger_*.jsonl`, `commands.jsonl`, `commands_cursor.json`, plus `artifacts/`
- SQLite backend: a single DB file (default `<DATA_DIR>/gateway.sqlite3`) plus `artifacts/`
- Gateway-generated workflows (e.g. schedules): `dynamic_flows/`
- Per-run workspaces (when `workspace_root` is not provided at start): `workspaces/`

Evidence: `src/abstractgateway/stores.py`, `src/abstractgateway/routes/gateway.py` (`start_run` workspace default), `src/abstractgateway/hosts/bundle_host.py` (dynamic flows).

### How do I switch to SQLite? Can I migrate?

- Switch by setting `ABSTRACTGATEWAY_STORE_BACKEND=sqlite` (and optionally `ABSTRACTGATEWAY_DB_PATH`).
- Migrate file → SQLite with `abstractgateway migrate --from=file --to=sqlite ...` (best-effort local migration).

Evidence: `src/abstractgateway/stores.py`, `src/abstractgateway/migrate.py`, CLI wiring in `src/abstractgateway/cli.py`.

## Runs, ledger, commands

### What is the ledger, and what does `after` mean?

- The ledger is an **append-only** list of step records.
- `after` is a cursor meaning “number of records already consumed”; responses return `next_after`.
- SSE streams ledger updates, but clients should always reconnect by replaying from the last cursor.

Evidence: `GET /runs/{run_id}/ledger` and `/ledger/stream` in `src/abstractgateway/routes/gateway.py`.

### How do durable commands work? When do they take effect?

`POST /api/gateway/commands` appends a command record to a durable inbox.
The background runner polls the inbox and applies commands asynchronously.

Supported command types:
`pause|resume|cancel|emit_event|update_schedule|compact_memory`

Evidence: `submit_command` in `src/abstractgateway/routes/gateway.py`, command application in `src/abstractgateway/runner.py`.

### Can I schedule a workflow to run periodically?

Yes (bundle mode).

Use `POST /api/gateway/runs/schedule` to start a scheduled parent run that launches the target workflow as child runs over time.

Notes:
- `interval` supports compact durations like `15m`, `1h`, `2d`.
- If `interval` is set and `repeat_count` is omitted, the schedule repeats forever (until you cancel it).
- To stop the schedule, cancel the scheduled parent run via `POST /api/gateway/commands` with type `cancel`.

Evidence: `ScheduleRunRequest` + `start_scheduled_run` in `src/abstractgateway/routes/gateway.py`.

## Bundles and workflow execution

### How do I run a specific bundle version?

When starting runs in bundle mode you can select versions in two ways:
- pass `bundle_id` + `bundle_version`
- or pass a namespaced `flow_id` like `bundle@version:flow` (this also works for selecting “latest” via `bundle:flow`)

Evidence: bundle selection in `src/abstractgateway/hosts/bundle_host.py` (`start_run`).

### My bundle fails with “LLM/tool execution requires AbstractCore integration”

AbstractRuntime’s AbstractCore integration is included by the base
`abstractgateway` install. If this error appears, verify the installed package set with
`pip show AbstractRuntime abstractcore`.

Evidence: `src/abstractgateway/hosts/bundle_host.py` (imports under `needs_llm/needs_tools`).

### My bundle fails with “LLM nodes but no default provider/model is configured”

Configure the execution-host `input.text` route:

```bash
abstractgateway-config set-default input.text \
  --provider lmstudio \
  --model qwen/qwen3.6-35b-a3b \
  --base-url http://127.0.0.1:1234/v1
```

Alternatives:
- Pin provider/model on at least one `llm_call` or `agent` node; the gateway scans the flow JSON for defaults.
- Keep provider secrets in `abstractcore-config`; use `--base-url` on the capability route when the
  selected provider endpoint is not the provider default.

Evidence: `_scan_flows_for_llm_defaults` + provider/model selection in `src/abstractgateway/hosts/bundle_host.py`.

### Why do tool calls not execute?

In bundle mode, tool execution is controlled by:

- `ABSTRACTGATEWAY_TOOL_MODE=approval` (default): safe tools execute immediately; dangerous/unknown tools pause for explicit approval.
- `ABSTRACTGATEWAY_TOOL_MODE=passthrough`: approval required for *all* tools (including safe ones); after approval, the runtime executes the tool batch in-process.
- `ABSTRACTGATEWAY_TOOL_MODE=delegated`: tools are not executed locally; workflows enter a durable `JOB` wait for external executors.
- `ABSTRACTGATEWAY_TOOL_MODE=local` (or `local_all`): tools execute inside the gateway process without approval (dev only; unsafe).

Evidence: tool executor selection in `src/abstractgateway/hosts/bundle_host.py`.

### Why do `/voice/tts` or `/audio/transcribe` fail with “capability unavailable”?

Those endpoints are surfaced through Runtime's voice/audio integration path and
the required capability packages are included by the base `abstractgateway`
install. Verify the installed package set with:

```bash
pip show abstractgateway AbstractRuntime abstractcore
```

By default, the gateway allows the configured voice backend to download models on first use. If you disabled downloads (or want to enable them explicitly), set:

```bash
export ABSTRACTGATEWAY_VOICE_ALLOW_DOWNLOADS=1
```

For remote/OpenAI-compatible voice backends, configure the Gateway-scoped voice
environment variables in the gateway process, for example
`ABSTRACTGATEWAY_VOICE_TTS_ENGINE`, `ABSTRACTGATEWAY_VOICE_STT_ENGINE`,
`ABSTRACTGATEWAY_VOICE_REMOTE_BASE_URL`, and
`ABSTRACTGATEWAY_VOICE_REMOTE_API_KEY` (legacy `ABSTRACTVOICE_*` names still
work).

### How do I enable generated images, edited images, generated music, or other Runtime-managed multimodal outputs?

Use the base install for the Gateway control plane and remote/provider-backed
routes:

```bash
pip install abstractgateway
```

The base install includes Runtime-owned tool and multimodal integration and can
proxy to configured remote/provider routes. Remote embeddings are supported
through the `embedding.text` capability route when it points at OpenAI,
OpenRouter, Portkey, LM Studio, vLLM, another OpenAI-compatible endpoint, or a
remote AbstractCore server. Local sentence-transformer embeddings and
hardware-local image, audio, voice, and music engines are explicit opt-ins so a
light Linux install does not pull PyTorch/CUDA packages. Use
`abstractgateway[apple]` or `abstractgateway[gpu]` only when this Gateway host
should execute those local engines itself.

Generated images are available both inside Runtime workflows and through
Gateway's direct run-scoped endpoint:

```text
POST /api/gateway/runs/{run_id}/images/generate
POST /api/gateway/runs/{run_id}/images/edit
POST /api/gateway/runs/{run_id}/images/upscale
POST /api/gateway/runs/{run_id}/videos/generate
POST /api/gateway/runs/{run_id}/videos/from_image
```

The direct image and video endpoints use Runtime/Core output selectors and
store the result as a run artifact, so they still require a configured
Runtime-compatible vision/video backend. Image dimensions are optional
passthrough overrides; clients should not inject a default `512x512` request
because supported sizes depend on the selected provider/model. Image/video
routes also accept optional batch `count` / `n`, `seeds`, and ordered
`lora_adapters`; video routes additionally accept `flow_shift`, and batch
responses return `image_artifacts` / `video_artifacts` alongside the
compatibility singular artifact fields. Use
`GET /api/gateway/vision/adapters` when a thin client needs the compatible
installed adapter catalog for a selected provider/model/task. For long media
runs, stream the returned `child_run_id` ledger and watch `abstract.progress`
records. Image progress is best-effort and may only show start/complete when the
backend does not expose step progress.

Generated music is exposed through Gateway's direct Runtime child-run route and
its thin-client discovery/catalog contract:

```text
POST /api/gateway/runs/{run_id}/music/generate
GET /api/gateway/audio/music/providers
GET /api/gateway/audio/music/models
```

Higher apps should feature-detect music from
`capabilities.contracts.flow_editor.media.generated_music` or
`capabilities.contracts.assistant.media.generated_music`.

### What is `voice.listen` in the capabilities contract?

`voice.listen` is not a live server-side microphone transport. It is a
higher-app contract that tells clients how to handle local capture:

- capture audio on the client or host device
- either upload it to `POST /api/gateway/runs/{run_id}/audio/transcribe`
- or emit the configured event/command into the run contract

This keeps live capture UX owned by higher apps such as Assistant or Observer
while Gateway stays responsible for durable runs, artifacts, and transcription.

### Are catalog responses fully normalized by Gateway?

Not yet.

Gateway already owns the route family and the thin-client contract pointers for
provider/model discovery, but some catalog response bodies still preserve
lower-layer shape differences. Higher apps like Flow currently normalize a few
legacy variants when reading model/provider catalogs.

What is stable today:

- which discovery routes exist
- which contract fields point at those routes
- which media tasks and direct endpoints are available

What is not yet versioned as a strict Gateway contract:

- one canonical provider/model catalog response envelope across text, vision,
  voice, STT, and music
- a dedicated deployment/readiness block for operator dashboards

### What does Gateway session prompt-cache orchestration include?

The `/api/gateway/prompt_cache/*` routes expose provider/model prompt-cache
controls when the active AbstractCore integration supports them. Gateway also
provides session lifecycle routes under
`/api/gateway/sessions/{session_id}/prompt_cache/*` for status, prepare,
rebuild, and clear using deterministic session keys.

This is Gateway-owned naming and orchestration over provider controls, not a
provider-independent local KV cache or full CachedSession persistence system.

### My bundle fails with “Visual Agent nodes require AbstractAgent”

AbstractAgent is included by the base `abstractgateway` install. Verify the
installed package set with:

```bash
pip show abstractgateway abstractagent
```

Evidence: agent workflow registration in `src/abstractgateway/hosts/bundle_host.py`.

### My bundle fails with “memory_kg_* nodes … install abstractmemory”

`memory_kg_*` nodes use Gateway's AbstractMemory TripleStore integration,
included by the base `abstractgateway` install.

Keep the default `lancedb` backend for durable vector-capable memory, use
`memory` for process-local dev/test memory, or set
`ABSTRACTGATEWAY_MEMORY_STORE_BACKEND=sqlite` only when your installed
AbstractMemory build exposes `SQLiteTripleStore`.

A fresh persistent store does not make KG memory unavailable. Capability
discovery treats the surface as available once AbstractMemory is installed and
the configured backend resolves; empty structured queries return empty results
until a flow asserts triples.

Evidence: memory KG wiring in `src/abstractgateway/memory_store.py` and
`src/abstractgateway/hosts/bundle_host.py`.

## Desktop tray

### How do I get the menu bar / tray icon, and why is there none?

Install the extra — `pip install "abstractgateway[tray]"` — and start the
gateway with `abstractgateway serve` on a desktop session. The boot log says
what happened: `Desktop tray: started (pid …)`, or `not started` with the
reason (`missing_dependency`, `disabled_by_setting`, `dev_reload`). Headless
hosts (SSH, containers, services) never start it and stay silent. On Linux the
GTK/AppIndicator bindings are needed (`python3-gi` +
`gir1.2-ayatanaappindicator3-0.1`); GNOME also needs the AppIndicator
extension. The switch lives in Console → Resources → Gateway. Details:
[tray.md](docs/tray.md).

### What does "Pause Workflows" actually stop?

New workflow steps. Runs, schedules and messages from connected apps are
still accepted and wait; a step already inside an LLM or tool call finishes
first; the console and the API keep answering; summoned entities' own loops
are separate processes and keep their schedule. Pause persists across
restarts until you resume (tray, console banner, or
`POST /api/gateway/host/resume`).

## Deployment

### How do I run API and runner as separate processes?

Run:

```bash
abstractgateway runner
abstractgateway serve --no-runner --host 127.0.0.1 --port 8080
```

The runner uses a lock file (`gateway_runner.lock`) to prevent double-ticking on the same data dir. A locked-out runner keeps retrying acquisition in the background; a newly started process asks a live holder to yield (newest process wins), and the holder heartbeats the lock file so `GET /api/health` can report whether anyone is actually ticking the data dir (`runner.runners[].status`).

Evidence: CLI flag `--no-runner` in `src/abstractgateway/cli.py`, lock lifecycle (`_run`/`_acquire_singleton_lock`/`runner_status`) in `src/abstractgateway/runner.py`.

## Related docs

- Docs index: [README.md](docs/README.md)
- Getting started: [getting-started.md](docs/getting-started.md)
- API overview: [api.md](docs/api.md)
- Security: [security.md](docs/security.md)
- Configuration: [configuration.md](docs/configuration.md)
- Architecture: [architecture.md](docs/architecture.md)
- Operator tooling (optional): [maintenance.md](docs/maintenance.md)

---

## docs/maintenance.md

# AbstractGateway — Operator tooling (optional)

`/api/gateway/*` includes “operator tooling” endpoints used by higher-level UIs and workflows (reports inbox, triage queue, backlog helpers, process manager, file/attachment helpers, …). These features are **not required** to use AbstractGateway as a durable run gateway.

This document groups the main non-core features and how to enable them safely.

## Safety model (read this first)

Some endpoints can:
- write files under `ABSTRACTGATEWAY_DATA_DIR`
- read files from configured workspace mounts
- start/stop local processes (process manager)
- execute queued backlog tasks (backlog exec runner)

Only enable these features on **trusted machines** and keep gateway auth enabled.
Security enforcement for `/api/gateway/*` is in `src/abstractgateway/security/gateway_security.py`.

## Reports inbox + triage queue

Implemented in `src/abstractgateway/routes/gateway.py` and `src/abstractgateway/maintenance/*`.

Key endpoints:
- `POST /api/gateway/bugs/report`
- `POST /api/gateway/features/report`
- `GET /api/gateway/reports/bugs` / `GET /api/gateway/reports/features`
- `POST /api/gateway/triage/run`
- `GET /api/gateway/triage/decisions`

CLI helpers:
- `abstractgateway triage-reports` (scan inbox → decision queue; optional draft writing)
- `abstractgateway triage-apply <decision_id> approve|reject|defer`

Notification helpers used by `triage-reports --notify`:
- Telegram: `ABSTRACT_BACKLOG_TELEGRAM_CHAT_ID` or `ABSTRACT_TRIAGE_TELEGRAM_CHAT_ID`
- Email recipients: `ABSTRACT_BACKLOG_EMAIL_TO` or `ABSTRACT_TRIAGE_EMAIL_TO`
- Optional email account override: `ABSTRACT_BACKLOG_EMAIL_ACCOUNT` or `ABSTRACT_TRIAGE_EMAIL_ACCOUNT`

Evidence: CLI wiring in `src/abstractgateway/cli.py`.

## Backlog browsing/editing

The gateway also exposes endpoints that read/write backlog Markdown files in a folder that contains `docs/backlog/*`.

They work out of the box: without a setting the gateway uses its own folder, `<data dir>/backlog/`, created with a starter overview and item template on first use. To point it at a project checkout:

```bash
abstractgateway config set triage_repo_root /path/to/your/repo   # saved; or Continuum Settings, or the console
abstractgateway serve --backlog-root /path/to/your/repo          # this run only
```

See [configuration.md](docs/configuration.md#backlog-folder-exec-runner-and-process-manager-continuum) for the resolution order and the three doors.

Evidence: `resolve_backlog_root` in `src/abstractgateway/runtime_config.py`, used by `src/abstractgateway/routes/gateway.py` (process manager + backlog endpoints) and `src/abstractgateway/maintenance/backlog_exec_runner.py`.

## Backlog execution runner (high risk; disabled by default)

The backlog exec runner consumes queued execution requests under `<DATA_DIR>/backlog_exec_queue/` and executes them (optionally using the `codex` CLI).

Enable (applies at once on a running gateway):

```bash
abstractgateway config set backlog_exec_runner on
abstractgateway config set executor codex        # codex | claude | cursor-agent | abstractcode
```

or `abstractgateway serve --exec-runner on` for one run, or Continuum → Settings → Gateway administration.

Additional knobs (see `BacklogExecRunnerConfig.from_env()`):
- `ABSTRACTGATEWAY_BACKLOG_EXEC_POLL_S`
- `ABSTRACTGATEWAY_BACKLOG_EXEC_WORKERS`
- `ABSTRACTGATEWAY_BACKLOG_CODEX_BIN`
- `ABSTRACTGATEWAY_BACKLOG_CODEX_MODEL`
- `ABSTRACTGATEWAY_BACKLOG_CODEX_REASONING_EFFORT` (`low|medium|high|xhigh`)
- `ABSTRACTGATEWAY_BACKLOG_CODEX_SANDBOX`
- `ABSTRACTGATEWAY_BACKLOG_CODEX_APPROVALS`

Evidence: `src/abstractgateway/service.py` (runner startup), `src/abstractgateway/maintenance/backlog_exec_runner.py`.

## Process manager (dev-only; disabled by default)

The process manager can start/stop a small allowlisted set of local processes and tail logs. It is intended for **trusted dev machines**.

Notes:
- **Process control** (`/api/gateway/processes`, start/stop, log tail) is **repo-root scoped** for safety and assumes a monorepo-style checkout (scripts like `./build.sh`, `./agw-uat.sh`, …).
- **Env-var management** (`/api/gateway/processes/env`) is **repo-root independent** and works in packaged installs (it persists under `ABSTRACTGATEWAY_DATA_DIR`).

Enable:

```bash
abstractgateway config set process_manager on

# Process control only: the AbstractFramework checkout it manages
abstractgateway config set triage_repo_root "$PWD"
```

Process control stays off while the backlog folder is the gateway's own default folder (it is not a checkout).

Optional config path:

```bash
export ABSTRACTGATEWAY_PROCESS_MANAGER_CONFIG="$PWD/runtime/gateway/processes.json"
```

Endpoints:
- `GET /api/gateway/processes` (requires the backlog folder set to a checkout: `triage_repo_root`)
- `POST /api/gateway/processes/{id}/start|stop|restart|redeploy`
- `GET /api/gateway/processes/{id}/logs/tail`
- `GET /api/gateway/processes/env` (metadata only; never returns values; does not require repo root)
- `POST /api/gateway/processes/env` (write-only set/unset for allowlisted keys; does not require repo root)

Evidence: `src/abstractgateway/routes/gateway.py` (endpoint guards) and `src/abstractgateway/maintenance/process_manager.py`.

### Env var allowlist (write-only)

Env var editing is allowlist-only and values are write-only (they are never returned to the client). Overrides are persisted on the gateway host under:
- `<ABSTRACTGATEWAY_DATA_DIR>/process_manager/env_overrides.json`

When the gateway starts with the process manager on (`process_manager` setting), it loads and applies persisted overrides to its own `os.environ` (best-effort).

To extend the allowlist, update:
- `src/abstractgateway/maintenance/process_manager.py` → `managed_env_var_allowlist()`

## File + attachment helpers (thin-client support)

The gateway exposes helpers used by thin clients and workflows:
- Workspace policy: `GET /api/gateway/workspace/policy`
- File access: `GET /api/gateway/files/search|read|skim`
- Attachments: `POST /api/gateway/attachments/ingest` and `POST /api/gateway/attachments/upload`

Workspace scope is **operator-controlled at gateway launch**:

- Default (safe): thin clients cannot expand filesystem scope. If a run is started without `workspace_root`, the gateway creates a per-run workspace under `<ABSTRACTGATEWAY_DATA_DIR>/workspaces/<uuid>`, and filesystem-ish tool calls are scoped to that workspace (`workspace_access_mode=workspace_only`).
- Allowlist additional roots for file helpers via `ABSTRACTGATEWAY_WORKSPACE_DIR` + `ABSTRACTGATEWAY_WORKSPACE_MOUNTS`.
- Permissive mode (trusted machines only): enable client-provided `workspace_*` overrides (including `all_except_ignored`) via `ABSTRACTGATEWAY_ALLOW_CLIENT_WORKSPACE_SCOPE=1` (or `ABSTRACTGATEWAY_TRUST_CLIENT_WORKSPACE_SCOPE=1`).

Note: `/api/gateway/files/*` + `/api/gateway/attachments/ingest` ignore client-provided scope overrides unless client overrides are enabled.

Server-side workspace mounts (operator-controlled):

```bash
# newline-separated: name=/absolute/path
export ABSTRACTGATEWAY_WORKSPACE_MOUNTS=$'repo=/abs/path/to/repo\\ndata=/abs/path/to/data'
```

Evidence: `_workspace_mounts()` and related policy helpers in `src/abstractgateway/routes/gateway.py`, tests in `tests/test_gateway_workspace_policy_enforcement.py`.

## Bridges (Telegram, email)

Background bridges can ingest external messages and start durable runs (thin-client semantics), and may also emit events for specialized workflows.

Enable (Telegram):
- `ABSTRACT_TELEGRAM_BRIDGE=1`
- transport + credentials depend on configuration (see `src/abstractgateway/integrations/telegram_bridge.py`):
  - Bot API (default when token is present): `ABSTRACT_TELEGRAM_BOT_TOKEN=...`
  - TDLib (E2EE): `ABSTRACT_TELEGRAM_TRANSPORT=tdlib` + TDLib setup
- access control (fail-closed defaults):
  - DMs default to allowlist: set `ABSTRACT_TELEGRAM_ALLOWED_USERS=...` (numeric Telegram user_id; discover via `/whoami`)
  - Groups default to disabled (opt-in via `ABSTRACT_TELEGRAM_GROUP_POLICY=allowlist|open`)
- Optional: override which workflow to run per message:
  - `ABSTRACT_TELEGRAM_BUNDLE_ID=...`
  - `ABSTRACT_TELEGRAM_FLOW_ID=...`
  - Default (when unset): shipped `basic-agent` bundle entrypoint.
- Tool approvals:
  - `ABSTRACTGATEWAY_TOOL_MODE=approval` (default): safe tools run in-process; dangerous/unknown tools require `/approve` or `/deny`.
  - `ABSTRACTGATEWAY_TOOL_MODE=passthrough`: approval required for *all* tools (including safe ones); after approval, the runtime executes the tool batch in-process.
  - `ABSTRACTGATEWAY_TOOL_MODE=delegated`: tools are not executed locally; workflows enter a durable `JOB` wait for external executors.
- optional knobs:
  - Telegram-only routing override: `ABSTRACT_TELEGRAM_MODEL` (and optionally `ABSTRACT_TELEGRAM_PROVIDER`)
  - Durable history limit: `ABSTRACT_TELEGRAM_MAX_HISTORY_MESSAGES`
  - `/reset` controls: `ABSTRACT_TELEGRAM_RESET_DELETE_MESSAGES`, `ABSTRACT_TELEGRAM_RESET_DELETE_MAX`, `ABSTRACT_TELEGRAM_RESET_MESSAGE`

Enable (Email):
- `ABSTRACT_EMAIL_BRIDGE=1`
- IMAP credentials + polling config (see `src/abstractgateway/integrations/email_bridge.py`)

Evidence: bridge startup in `src/abstractgateway/service.py` (`start_gateway_runner`).

## Email inbox endpoints (AbstractObserver Inbox → Email)

If email accounts are configured on the gateway host, the gateway exposes account-scoped endpoints used by AbstractObserver to list/read/send emails:
- `GET /api/gateway/email/accounts`
- `GET /api/gateway/email/messages`
- `GET /api/gateway/email/messages/{uid}`
- `POST /api/gateway/email/send`

These endpoints proxy through Gateway's Runtime comms facade and never accept arbitrary IMAP/SMTP host/user secrets from the browser.

Configuration notes:
- Multi-account: set `ABSTRACT_EMAIL_ACCOUNTS_CONFIG=/path/to/emails.yaml` (recommended).
- Single-account env fallback: `ABSTRACT_EMAIL_IMAP_*` / `ABSTRACT_EMAIL_SMTP_*`.

Evidence: `/api/gateway/email/*` routes in `src/abstractgateway/routes/gateway.py` which call the Runtime AbstractCore comms facade.

## Related docs

- API overview (core client contract): [api.md](docs/api.md)
- Security: [security.md](docs/security.md)
- FAQ: [faq.md](docs/faq.md)
