# PI WEB configuration reference

PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, desired plugin enablement/settings, server-plugin recovery, file-explorer path access, manual upload defaults, upload limits, the Pi agent state directory, and session-daemon tools.

## Config files

PI WEB uses two config files:

- **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`.
- **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings.

Each PI WEB machine has its own config. When you use [several machines](machines.md), Settings uses the selected machine for config that affects work running there: session daemon options, desired PI WEB plugin enablement/settings, external path access, upload defaults, logs, and the update command. Settings of the PI WEB you open (the gateway) stay local to it: keyboard shortcuts, the machine list and tokens, and the gateway's host/port/allowed-hosts.

Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi Packages**. In a federated setup, **Settings → Pi Packages** targets the currently selected machine. The PI WEB `plugins` config key controls desired enablement/settings for discovered browser-only, server-only, and dual-entry PI WEB plugins on that machine; it does not install, remove, or update Pi packages.

If you installed services with a custom config path, `pi-web start`, `pi-web restart`, and `pi-web doctor` automatically use the `PI_WEB_CONFIG` saved in those service definitions for their readiness checks. A nonempty `PI_WEB_CONFIG` supplied when invoking one of those commands overrides the installed path for that command. On systemd, these commands fail rather than guess if active drop-ins, `EnvironmentFile=` inputs, stale manager state, a different loaded fragment, or an effective environment mismatch make the loaded definition untrustworthy. On launchd, `start` and `doctor` likewise fail if an already-loaded label came from another plist or retains a different config path; `restart` reloads the installed plists and can repair that stale state. Rerun `pi-web install --config /path/to/config.json` after changing the managed path or after upgrading from a version that only applied it to the web service; this regenerates service files so the web/API and session daemon use the same config.

## Reverse-proxy deployment paths

The deployment path is not a PI WEB config-file key or environment setting. The published client is portable: one build works at `/` and at canonical trailing-slash prefixes such as `/ai/` or `/test/ai/`.

For a nested deployment, redirect the slashless prefix to the trailing-slash URL, strip the prefix before forwarding to PI WEB, and proxy authenticated HTTP and WebSocket traffic through the same location. Relative browser and PWA URLs then stay within that prefix. See [Reverse proxy at the root or under a path prefix](install.md#reverse-proxy-at-the-root-or-under-a-path-prefix) for a complete Nginx example.

## Precedence and reloads

Machine-global runtime values are resolved as:

```text
defaults → global config file → environment overrides
```

Supported project-local settings are then applied for that project's workspaces. For upload defaults, `<project>/.pi-web/config.json` overrides the global value.

Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_TLS_CERT` / `PI_WEB_TLS_KEY`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and `PI_WEB_ENVIRONMENT_FACTS`.

Process restarts depend on the key:

- `host` / `port`: restart the gateway web/API service or process.
- `tls`: turning HTTPS on or off, or pointing it at other files, needs a web/API restart. A renewed certificate written to the same files is picked up within 10 minutes without a restart.
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
- `extensionDialogsTimeoutMs` / `environmentFacts` / `piBuiltInExtensions`: restart the session daemon on that machine.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
- `logging`: the web/API process and the session daemon re-read it within a minute; no restart.
- `updateCommand`: the Updates page uses it from its next status check; no restart.
- `plugins`: a toggle saved in **Settings → PI WEB Plugins** applies at once, without a restart or reload, except for the cases [Turn plugins on and off](plugins.md#turn-plugins-on-and-off) lists (a workspace-provider or machine-registry plugin, a changed `settings` object, `serverSettings` block or `models` list of a plugin with a server half, a new package revision, and hand edits of this file, which wait for the next page load or session-daemon start).
- `serverPlugins.safeStart`: persistent offline recovery state applied before server-plugin discovery/import on the next sessiond start; use the `pi-web plugins safe-start ...` CLI rather than hand-editing it.
- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. For a PI WEB package with `serverModule`, manually [restart the session daemon](install.md#restart-the-session-daemon), then reload the browser. If a global Pi extension adds or removes a model provider, or changes a provider's connection settings, the same manual sessiond restart is required; `/reload` cannot change either startup snapshot. A known Pi model provider refreshing only its own model list is applied without a restart. See [Pi extension provider baseline](#pi-extension-provider-baseline).
- `shortcuts`: saved settings apply in the browser after config refresh/save.
- `listTiles`: a choice saved in **Settings → Appearance → Lists** applies at once; a change made in the config file by hand applies after a browser reload.

## Global config example

```json
{
  "host": "127.0.0.1",
  "port": 8504,
  "pathAccess": {
    "allowedPaths": ["~/SDKs", "/opt/reference"]
  },
  "uploads": {
    "defaultFolder": ".pi-web/uploads"
  },
  "maxUploadBytes": 67108864,
  "extensionDialogsTimeoutMs": 0,
  "plugins": {
    "updates": { "enabled": true },
    "info": { "enabled": false }
  },
  "shortcuts": {
    "core:view.chat": "mod+1",
    "core:session.stop": null
  }
}
```

## Project-local config

Project-local config lives at `<project>/.pi-web/config.json`. Use it for settings that should follow a repository.

```json
{
  "version": 1,
  "pathAccess": {
    "allowedPaths": ["~/SDKs", "/opt/reference"]
  },
  "uploads": {
    "defaultFolder": "manual/uploads"
  }
}
```

Project-local `pathAccess.allowedPaths` entries are merged after the global list and deduplicated. Paths must still be host-absolute or `~`-prefixed; relative roots are not supported.

Project-local `uploads.defaultFolder` overrides the global upload destination for workspaces in that project. PI WEB servers always include this workspace-effective value on the workspace responses used locally and through machine federation.

Plugins may own separate project files under `.pi-web/`.

PI WEB also honors one optional project hook; see [Worktree pre-remove hook](#worktree-pre-remove-hook).

## Worktree pre-remove hook

Before PI WEB removes a workspace — for Git projects, a secondary worktree — it gives the repository one chance to tear down project-owned infrastructure tied to that workspace. To use the hook, provide an executable script at:

```text
.pi-web/hooks/worktree-pre-remove
```

relative to the workspace where the deletion command runs. PI WEB runs the deletion command from the project's main workspace when it exists, so commit the hook there and it follows the repository.

When the hook is present and executable, PI WEB dispatches the hook and the removal as one composed terminal command:

```sh
'<hook path>' '<workspace path>' && <workspace removal command>
```

For Git projects the removal command is `git worktree remove '<worktree path>'`.

Contract:

- **Arguments:** exactly one — the absolute path of the workspace being removed.
- **Working directory:** the workspace the removal command runs in, not the workspace being removed.
- **Exit codes:** `0` lets the removal proceed; any non-zero exit blocks it. The `&&` chain is the fail-closed guarantee — a failing hook keeps the worktree on disk.
- **Absent hook:** a missing file, or a file without the executable bit (for example after a checkout that lost it), is treated as no hook; PI WEB then runs the removal command on its own.

The composed command is dispatched like any other workspace deletion — same `Delete workspace: <branch>` terminal title — so hook output and failures are visible in the terminal run. If PI WEB cannot probe the hook path because of an unexpected filesystem error, the deletion request fails before any workspace terminals are closed.

Example: a hook that stops and removes local dev containers that bind-mount the worktree, so deletion does not leave stale containers behind. The hook is an opaque extension point — the contract does not assume any specific tooling, so use whatever the repository standardizes on:

```sh
#!/bin/sh
# .pi-web/hooks/worktree-pre-remove
set -eu

worktree_path="$1"

# Stop/remove local dev containers bind-mounting "$worktree_path",
# release other per-worktree resources, etc.
# Exit non-zero to block the worktree removal.
```

## Configuration matrix

Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `logging`, `updateCommand`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.

| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
| --- | --- | --- | --- | --- | --- |
| **Config-file keys** |  |  |  |  |  |
| Web/API bind host | `host` | `PI_WEB_HOST` | Global | Not supported locally | Restart web/API |
| Web/API port | `port` | `PI_WEB_PORT`, `PORT` | Global | Not supported locally | Restart web/API |
| HTTPS certificate chain and private key, PEM files at absolute or `~/` paths; both or neither ([Serve HTTPS directly](install.md#serve-https-directly)) | `tls.certFile`, `tls.keyFile` | `PI_WEB_TLS_CERT`, `PI_WEB_TLS_KEY` | Global | Not supported locally | Restart web/API; a renewed certificate in the same files within 10 minutes |
| Dev-server allowed hosts: `true` or an array of hostnames (env: `true` or a comma-separated list); used only by the Vite dev server (`pnpm run dev:client`), not by `pi-web-server` | `allowedHosts` | `PI_WEB_ALLOWED_HOSTS` | Global | Not supported locally | Restart dev web/UI |
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh |
| Log level and retention | `logging.level`, `logging.maxFileMb`, `logging.keepFiles` | — | Global (web/API and session daemon) | Not supported locally | Within a minute, no restart |
| Update command for the Updates page | `updateCommand` | `PI_WEB_UPDATE_COMMAND` (the saved value wins) | Global | Not supported locally | Next status check, no restart |
| Tiles per row in the Navigate lists | `listTiles.phone`, `listTiles.desktop` | — | Global (the gateway's browser UI) | Not supported locally | At once from Settings; browser reload after a hand edit |
| One theme for every browser | `theme.themeId`, `theme.auto` | — | Global (the gateway's browser UI) | Not supported locally | At once in the browser that changes it; other browsers when they next load the page or come back to the foreground |
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine |
| Extension dialog auto-cancel timeout | `extensionDialogsTimeoutMs` | — | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Session environment facts | `environmentFacts` | `PI_WEB_ENVIRONMENT_FACTS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Load pi's built-in extensions (codemode, tool search, MCP), on by default; their tools stay off until pi's `defaultTools` turns them on | `piBuiltInExtensions` | — | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| PI WEB plugin desired enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings`, `plugins.<id>.serverSettings` ([private](#private-plugin-settings)), `plugins.<id>.models` ([granted models](#models-a-plugin-may-call)) | — | Global + sessiond startup snapshot for server entries | Not core local config; plugins may read their own project files | A Settings toggle applies at once, with the exceptions in [Turn plugins on and off](plugins.md#turn-plugins-on-and-off) |
| Server-plugin safe start | `serverPlugins.safeStart` | — | Global/offline recovery | Not supported locally; manage with `pi-web plugins safe-start ...` | Applied before discovery/import on next sessiond start |
| Keyboard shortcuts | `shortcuts.<actionId>` | — | Global | Not supported locally | Applies after settings save/config refresh |
| Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
| **Runtime-only environment variables** |  |  |  |  |  |
| Global config file path | — | `PI_WEB_CONFIG` (`XDG_CONFIG_HOME` affects the default path) | Process/env | Selects the global config file; not a project config | Restart services/processes after changing env |
| Managed data directory | — | `PI_WEB_DATA_DIR` | Process/env | Not supported locally | Restart web/API and session daemon |
| Session daemon socket | — | `PI_WEB_SESSIOND_SOCKET` | Web/API + session daemon env | Not supported locally | Restart daemon and web/API; both must match |
| Session daemon TCP port | — | `PI_WEB_SESSIOND_PORT` | Session daemon env | Not supported locally | Restart session daemon; set `PI_WEB_SESSIOND_URL` for web/API too |
| Session daemon TCP host | — | `PI_WEB_SESSIOND_HOST` | Session daemon env | Not supported locally | Restart session daemon |
| Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API |
| Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override |
| Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override |
| Pinned sessions storage file | — | `PI_WEB_SESSION_PINS_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override |
| Log directory | — | `PI_WEB_LOG_DIR` | Web/API + session daemon env | Not supported locally | Restart both; `pi-web install` sets it to `~/.pi-web/logs`, otherwise `$PI_WEB_DATA_DIR/logs`. See [Logs](#logs) |
| Session daemon shutdown drain | — | `PI_WEB_SHUTDOWN_DRAIN_MS` | Session daemon env | Not supported locally | Milliseconds the daemon waits on stop for in-flight runs (default `60000`; `0` exits at once). Applies to the next stop |
| Service command overrides | — | `PI_WEB_SERVER_EXEC`, `PI_WEB_SESSIOND_EXEC` | `pi-web install` env | Not supported locally | Read when you run `pi-web install`; the command is written into the service instead of `pi-web-server` / `pi-web-sessiond` |
| Agent state directory | — | `PI_CODING_AGENT_DIR` (deprecated alias `PI_WEB_AGENT_DIR`) | Session daemon env | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugins. See [Agent state directory](#agent-state-directory) |
| Agent session storage directory | — | `PI_CODING_AGENT_SESSION_DIR` (deprecated alias `PI_WEB_AGENT_SESSION_DIR`) | Session daemon env | Not supported locally | Restart session daemon on that machine |
| Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes. The session daemon always sets `PI_SKIP_VERSION_CHECK` for its own process (not for its terminals), so pi extensions that prompt for pi updates stay quiet in PI WEB sessions |
| Offline mode | — | `PI_WEB_OFFLINE`, `PI_OFFLINE` | Web/API + session daemon env | Not supported locally | Restart session daemon and web/API after env changes; also disables the [background model catalog refresh](#background-model-catalog-refresh) |

## Key details

### Managed data directory

`PI_WEB_DATA_DIR` sets the root for PI WEB-managed runtime state and defaults to `~/.pi-web`. Unless a more specific path override is configured, PI WEB stores beneath this root its project registry (`projects.json`), the machine list (`machines.json`), the pages approved to use this machine from a browser (`machine-access.json`, see [Machines](machines.md#which-pages-may-use-a-machine)), pinned sessions (`session-pins.json`), locally discovered plugins (`plugins/`), the key that fingerprints private plugin settings (`plugin-settings-revision.key`), the default session-daemon socket (`sessiond.sock`; on Windows the default is a named pipe, `\\.\pipe\pi-web-sessiond-<hash of the data directory>`, so each data directory gets its own), session archives, and the logs of a process started without `PI_WEB_LOG_DIR`. These files are PI WEB's own state, not a configuration interface.

Each data directory is independent: after pointing PI WEB at a new root, it starts there with empty registries and no session archives. To carry session archives over, stop PI WEB, then copy `archived-sessions.json` and the `archived-sessions/` directory from the old data directory into the new one before starting it again.

One live session daemon owns each data directory. At startup the daemon records its ownership in `sessiond-owner.json` inside the data directory; a second session daemon pointed at the same directory while the first is still running fails loudly at startup with an error naming the owning process and the distinct `PI_WEB_DATA_DIR`, `PI_WEB_SESSIOND_SOCKET` (or `PI_WEB_SESSIOND_PORT` / `PI_WEB_SESSIOND_HOST`), and `PI_WEB_PORT` values a second instance needs. The web/API process of the same instance shares the data directory without claiming it, and a short startup grace covers ordinary service restarts. A marker left behind by a daemon that is no longer running is taken over automatically; if startup still refuses because of a marker whose owner is gone, delete the stale `sessiond-owner.json` as the error message suggests.

This setting does not change the PI WEB config file selected by `PI_WEB_CONFIG` or Pi-owned state such as the active session files selected by `PI_CODING_AGENT_SESSION_DIR`.

### Agent process environment

Agent shells, terminals, and spawned sessions inherit the session daemon's environment almost as-is. When the daemon starts, it removes only `NODE_ENV` and `PORT` from the environment agent processes see, so development commands behave normally inside sessions — for example, `npm install` is not affected by a production `NODE_ENV` meant for the daemon. Ordinary variables (`PATH`, `HOME`, proxy settings, and the like) stay visible, and so do the daemon's `PI_WEB_*` configuration keys and the resolved `PI_CODING_AGENT_DIR` / `PI_CODING_AGENT_SESSION_DIR` values, so a `pi` CLI started from inside a session uses the same agent state — auth, models, and session storage — as the daemon. The daemon itself keeps using the values it captured at startup.

Every process spawned from a session also inherits `PI_WEB_SESSION=1`, marking it as nested inside the running PI WEB instance, and a PI WEB terminal also sets `PI_WEB_TERMINAL=1`, so your shell and tools can tell they run inside PI WEB. The inherited `PI_WEB_*` values point at that live instance, so starting another PI WEB instance from inside a session fails loudly at startup because the live instance owns the state (see [Managed data directory](#managed-data-directory)); running one deliberately requires a distinct `PI_WEB_DATA_DIR`, `PI_WEB_SESSIOND_SOCKET` (or `PI_WEB_SESSIOND_PORT` / `PI_WEB_SESSIOND_HOST`), and `PI_WEB_PORT`.

Session system prompts state these nesting facts — and, in a Docker deployment, the container layout facts — so agents learn the rules before discovering them by breaking their own session, including the precautions never to restart the hosting session daemon and to restart the web/API process before the session daemon. Set the `environmentFacts` config key to `false`, or `PI_WEB_ENVIRONMENT_FACTS=false` in the session daemon's environment, to leave environment facts out of session system prompts; they default to on.

### External path access

`pathAccess.allowedPaths` grants PI WEB's file explorer and absolute `@` path completions access to specific filesystem roots outside the current workspace.

By default, workspace-relative file reads stay inside the workspace and absolute paths are denied. Add only roots you trust PI WEB to list and read through the browser UI.

Accepted root forms:

- Unix absolute paths: `/opt/reference`
- Home-relative paths: `~/SDKs`
- Windows absolute paths on Windows hosts: `C:\Users\dev\SDKs`

When an absolute request is served, PI WEB expands `~`, canonicalizes the configured roots with `realpath`, requires roots to be existing directories, and rejects symlink escapes outside the allowed roots.

In **Settings → General**, external filesystem roots are saved on the selected machine. Gateway host, port, and allowed-hosts fields stay on the gateway config.

This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API file exposure outside a workspace.

### Manual upload defaults

The Files panel can upload one or more files in two ways:

- Drop files onto the Files panel to upload immediately to the workspace-effective default folder.
- Use the toolbar **Upload** button to open the review dialog, edit the destination, and opt into upload options.

`uploads.defaultFolder` sets the workspace-effective default destination. The built-in default is `.pi-web/uploads`; a global config value applies to every project unless `<project>/.pi-web/config.json` sets a project-local override.

```json
{
  "uploads": {
    "defaultFolder": "manual/uploads"
  }
}
```

The value must be a non-empty workspace-relative folder. PI WEB normalizes repeated separators and backslashes to `/`, and rejects absolute paths or `..` traversal. In the upload dialog only, clearing the destination field uploads that batch to the workspace root.

Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is enabled by default, and overwrite is disabled by default. Direct drag/drop always keeps `overwrite` off; the review dialog lets you explicitly enable overwrite when needed. Browser-owned XHR progress is shown per batch/file, conflicts and errors stay visible in the upload progress UI, and the final file-write response is the source of truth.

For machine federation, Settings saves the global upload default on the selected machine. Remote PI WEB servers always return `workspace.effectiveConfig.uploads.defaultFolder` on the workspace-list response, and the Files panel uses it as the default upload destination.

The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload.

### Logs

`logging` decides what the web/API and session daemon logs record and how much of them stays on disk. Edit it in **Settings → General → Logs** for the selected machine, or in the global config:

```json
{
  "logging": {
    "level": "errors",
    "maxFileMb": 50,
    "keepFiles": 3
  }
}
```

- `level`: `errors` (default) records requests that failed with a server error or took a second or more; `requests` records every request; `debug` records every request and the processes' debug messages. Startup, shutdown, and error messages are recorded at every level.
- `maxFileMb` (default 50) and `keepFiles` (default 3): once `web.log` or `sessiond.log` in the log directory grows past `maxFileMb`, its last `maxFileMb` is copied to `web.log.1` (older copies move to `.2`, `.3`, …, and the oldest beyond `keepFiles` is removed), then the file is emptied in place. Only the tail is copied, so trimming a very large log does not need that much free disk.

Both processes check the setting and the file sizes once a minute. The log directory is the one `pi-web install` points the service manager at, `~/.pi-web/logs`, which it records in the service as `PI_WEB_LOG_DIR`; a process started without it uses `$PI_WEB_DATA_DIR/logs`. A service installed before this setting existed keeps watching `$PI_WEB_DATA_DIR/logs` until it is installed again. Retention applies to the log files the installed launchd services write; on systemd, output that goes to the journal is kept by journald's own settings instead.

### Updates

`updateCommand` is the command the Updates page's **Update** button, and the fleet **Update** in **Settings → Machines**, run on that machine. Save it in **Settings → General → Updates** for the selected machine (the Updates page links there for a nix install without one), or in the global config:

```json
{
  "updateCommand": "/usr/local/bin/update-pi-web"
}
```

The command should update PI WEB and restart it; it runs in a new terminal on the Terminal page, so its output stays visible while the web process and the session daemon restart. It wins over the services' `PI_WEB_UPDATE_COMMAND` (which `pi-web install` records from the installing shell), which wins over the update PI WEB works out from the install method, such as `npm install -g` for a global npm install or `nix profile upgrade` for a `nix profile install`. An install from a Nix configuration (Home Manager, nix-darwin, NixOS) has no update of its own: give it one here or in the services. [Update](install.md#update) lists how each install is updated by hand. An empty value saves none. The status reads it on its next check; no restart.

### Lists

`listTiles` sets how many tiles each row of the Navigate lists shows (Machines, Projects and Sessions, together): `1` or `2`, one choice for PI WEB's phone layout and one for its desktop layout. Edit it in **Settings → Appearance → Lists**, or in the gateway's global config:

```json
{
  "listTiles": {
    "phone": 1,
    "desktop": 2
  }
}
```

A layout without a value fits the width, as before: two per row on a phone, one in the desktop sidebar, and as many as fit on the desktop's full-width Navigate page. The choice lives in the config rather than the browser, so clearing a browser's data keeps it; a phone and a desktop still differ because the choice is kept per layout, not per device. PI WEB uses its phone layout in a window under 760 px wide or on a touch screen under 1080 px wide, so a phone keeps the phone layout on its side and an iPad held landscape gets the desktop layout.

### Shared theme

By default each browser keeps its own theme. Turn on **Settings → Appearance → Same theme on every device** to keep one theme in the gateway's global config instead; every browser that opens this PI WEB then uses it, and a theme picked on any of them changes it for all:

```json
{
  "theme": {
    "themeId": "core:material-dark",
    "auto": true
  }
}
```

`themeId` is a theme's qualified id (`core:material-dark`, `core:material-light`, or `<plugin>:<theme>`), and `auto` follows the system's light/dark preference within the theme's pair. Turning the switch off removes the key; each browser then keeps the theme it last showed. A general Settings save never replaces the key, so it changes only through that switch, a theme choice while it is on, or a hand edit. Other browsers apply a change when they next load the page or come back to the foreground; one that stays open in front keeps its theme until then.

### Model for new sessions

Switching a session's model in PI WEB's model picker also makes that model pi's default on that machine, as pi's own "set as default" does: `defaultProvider` and `defaultModel` in `settings.json` in the machine's Pi agent state directory (`~/.pi/agent` unless `PI_CODING_AGENT_DIR` moves it; see [Agent state directory](#agent-state-directory)). The next session you start on that machine starts on it. A session that already has a conversation keeps the model its history records; one that was never sent a message has no history yet, so it follows the default when it is opened again. Forks and clones keep the model of the conversation they copy, and a session an agent spawns starts on the model it is given or on its parent's. Because it is pi's setting, the `pi` terminal on that machine opens on the same model, and a trusted project's own `.pi/settings.json` default still wins inside that project. The thinking level is not saved as a default: changing it affects only that session, as choosing a level does in pi's own selector. When a session switches models, and when a new one starts, pi takes the level from that model's entry in `modelThinkingLevels`, else from `defaultThinkingLevel`, clamped to what the model supports; a switch with neither keeps the session's level. If the default cannot be saved (a settings file pi cannot read or write), the session still switches, and its conversation says that the default was not saved. Pi sends a model only the thinking that model produced itself (pi 1.3, pi #10995), while PI WEB keeps showing thinking an earlier model wrote: it stays in the transcript but is not part of what the new model reads.

### Agent state directory

PI WEB runs every session on its bundled Pi SDK. `pi-web doctor` and the status/update flow probe the `pi` command on the machine's `PATH`.

`PI_CODING_AGENT_DIR` selects the Pi agent state directory used for auth providers, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugin discovery. It defaults to Pi's own default, `~/.pi/agent`. `PI_CODING_AGENT_SESSION_DIR` overrides session storage separately from the state directory. Set both in the session daemon's environment.

Deprecated inputs are still honored, and PI WEB shows a deprecation banner while any of them is set:

- `PI_WEB_AGENT_DIR` wins over `PI_CODING_AGENT_DIR`.
- `PI_WEB_AGENT_SESSION_DIR` wins over `PI_CODING_AGENT_SESSION_DIR`.
- The config key `agent.dir` is used only when neither `PI_WEB_AGENT_DIR` nor `PI_CODING_AGENT_DIR` is set.
- `agent.command` and `PI_WEB_AGENT_COMMAND` are ignored: sessions always run on the bundled Pi SDK.

```sh
# Session daemon environment
PI_CODING_AGENT_DIR=/opt/pi-profiles/lab
PI_CODING_AGENT_SESSION_DIR=/opt/pi-profiles/lab-sessions
```

The directory must use the data layout supported by the bundled Pi SDK; PI WEB does not load or convert incompatible formats, migrate profile data, or repartition PI WEB-managed archives when the directory changes.

The session daemon resolves the directory once at startup and exports the resolved values to everything it starts, so sessions, terminals, the bash tool, and subsessions all observe the same `PI_CODING_AGENT_DIR` / `PI_CODING_AGENT_SESSION_DIR`. That resolved active directory stays fixed for the daemon lifetime: changing the environment takes effect on the next session-daemon restart on that machine, and until then sessions, Pi package operations, Pi-package-backed PI WEB plugin discovery, status/install detection, and update planning continue to use the daemon-owned active directory; a web/API restart recovers that same active directory instead of applying the new value.

If the session daemon cannot report a valid active directory, profile-dependent Pi package and PI WEB plugin operations report unavailable instead of falling back to independently resolved values. The Updates page offers an update for a Pi-package install only when the daemon reports a valid active directory and the `pi` command is on `PATH`, and the update pins that directory. Restart the session daemon on the selected machine to establish the next active directory.

### Pi extension provider baseline

This policy applies to **Pi runtime extensions that register model providers**, not PI WEB workspace-provider plugins. Pi extensions can call `pi.registerProvider(...)` and follow Pi's extension API. A PI WEB plugin may have a browser `module` and/or a sessiond `serverModule`, but its server entry follows the separate `@gang-of-beads/pi-web/server-plugin-api` lifecycle and cannot register Pi model providers or arbitrary hooks. See [Plugins](plugins.md).

PI WEB shares one model runtime across all sessions. When the session daemon starts, before any project resources load, it initializes global Pi extensions from the active agent directory, including extensions supplied by globally configured Pi packages. Provider registrations made by synchronous or awaited asynchronous extension factories during this bootstrap join the shared baseline. PI WEB captures both config-form registrations (`pi.registerProvider("id", config)`) and native-provider registrations (`pi.registerProvider(provider)`), alongside Pi built-ins, environment credentials, and providers from the active agent directory's `models.json`.

After startup capture, a provider's connection settings are fixed for the daemon lifetime. Later attempts to add a provider, replace an existing provider's configuration, register a native provider, or unregister a provider are no-ops, regardless of source or provider ID. This includes project extensions attempting to add or replace a provider, lifecycle callbacks such as `session_start`, and `/reload`. Non-provider Pi extension features continue to load and reload normally.

#### Model list refresh for a known provider

One narrow update is applied after startup: a provider captured in the baseline may refresh **its own model list**. Extensions that fetch an updated catalog typically re-send their complete provider configuration, so PI WEB compares the incoming registration against the recorded baseline and applies it only when both hold:

- the provider ID is already in the startup baseline, and
- every field except the model list is unchanged — `name`, `baseUrl`, `apiKey`, `api`, `streamSimple`, `headers`, `authHeader`, `oauth`, and `refreshModels`.

Anything else stays a no-op, including a provider that was not in the baseline and a known provider whose credentials, base URL, or API surface differ from startup. Function-valued fields cannot be compared by value, so a registration that supplies a new `streamSimple`, `refreshModels`, or `oauth` implementation is treated as a change and ignored.

An applied refresh becomes the new comparison point, so a provider can refresh repeatedly. Re-sending an unchanged model list is a replay rather than an update and is ignored. Refreshed models are visible to sessions immediately; no restart and no network request is involved, because the extension has already produced the catalog.

Model lists are shared daemon-wide state. If extensions in two workspaces register different model lists for the same provider ID, the last registration wins. A model entry may also carry its own `baseUrl` and `headers`, which take precedence over the provider-level values for that model, so an accepted refresh can change where requests for those models are sent. Both are accepted trade-offs: a catalog is treated as a property of the provider rather than of the project, and Pi extensions are trusted daemon code.

#### Provider decisions in the daemon log

Ignored mutations are written to the session-daemon log once per operation and provider ID, so a replaying extension cannot flood the log. Applied model list refreshes are logged every time, with the resulting model count, because each one changes shared runtime state. Neither entry contains provider configuration or credentials, and PI WEB does not show a session warning or notification.

This prevents accidental provider, configuration, or credential contamination between projects; it is not a security boundary because Pi extensions remain trusted daemon code.

Configure providers before the daemon starts: use the active agent directory's `models.json`, or install the Pi extension globally in that agent directory. Project Pi extensions and project-level `models.json` files cannot add providers to PI WEB's shared baseline. After updating PI WEB—or after installing, removing, or updating a global Pi extension that registers providers—manually [restart the session daemon](install.md#restart-the-session-daemon). Restarting only the web/API service and running `/reload` do not rebuild the baseline.

### Background model catalog refresh

PI WEB shares one model runtime across all sessions, and provider model catalogs are refreshed over the network only on the session daemon's own background schedule. Requests never start a catalog fetch of their own, so a slow or unreachable provider cannot stall opening the model selector, starting a session, or the auth dialogs on its own account.

A refresh that is *already* in flight can still briefly delay starting or opening a session, because the shared runtime is read while that refresh is running. PI WEB says so while you wait: the session's activity line names the startup step it is on and adds `provider model lists are refreshing` when a background refresh is running at the same time. That note reports what is happening concurrently, not a proven cause.

The session daemon runs the refresh:

- **15 seconds after the daemon starts**, then **hourly**. Pi treats stored catalogs as fresh for four hours, so most hourly ticks make no network request at all; the shorter tick only makes sure a due refresh is not delayed to the next tick.
- **Immediately after a provider login or logout**, bypassing that freshness window, because the cached catalog is known to be wrong.

Each run is bounded: it is aborted after **60 seconds**, and a run that times out or cannot reach a provider earns **one retry after five minutes**; a provider that answers with an error status is retried on the next scheduled refresh instead. Failures never clear the stored catalogs — the last successfully fetched models stay in use and the daemon log records what failed. A refresh in flight is also aborted when the daemon shuts down.

Models fetched by a background refresh appear the next time a client asks for the model list, so a model selector left open across a refresh may need to be reopened.

To turn the background refresh off entirely, set `PI_WEB_OFFLINE` or `PI_OFFLINE` in the session daemon's environment and restart it. In offline mode PI WEB performs no provider catalog network requests, including after logins, and sessions use the catalogs already stored in the agent directory. The `PI_WEB_SKIP_VERSION_CHECK` and `PI_SKIP_VERSION_CHECK` keys do **not** affect this refresh; they only suppress PI WEB release checks.

### Project trust for project-local resources

PI WEB always honors Pi's project-trust settings before loading a workspace's project-local `.pi/` resources — `.pi/extensions/*`, the `packages` declared in `.pi/settings.json`, and the other `.pi/` settings and resources. There is no opt-out config key: trust applies at every session start, the way `pi` itself applies it.

A project-local `.pi/extension` is arbitrary code that runs inside the agent process on every session for that workspace, so an untrusted workspace must not load one.

Trust is resolved the way `pi` resolves it with no trust prompt to show:

- A workspace with no trust-requiring `.pi/` resources is always loaded (there is nothing to gate).
- User/global extensions (loaded before the decision, exactly as `pi` does) may decide trust through the `project_trust` extension event, and may request `remember` to persist their decision to the agent directory's `trust.json`. When the event decides, it wins — the same order `pi` uses.
- Otherwise a saved decision in the agent directory's `trust.json` (from the Pi CLI's trust prompt, the workspace trust toggle, or a `remember`-ing extension) wins.
- Otherwise the agent's `defaultProjectTrust` setting decides: `always` loads the project resources, and `never` skips them. `ask` skips them too, because PI WEB has no browser trust prompt yet and a non-interactive `pi` also treats `ask` as untrusted.

This mirrors the Pi CLI: with `defaultProjectTrust: "never"`, an opened workspace's `.pi/` extensions and packages are ignored rather than loaded silently.

The `respectProjectTrust` config key is retired: trust always applies. A config file that still sets it loads; the key has no effect, and the next save from Settings drops it.

### Delegation routes

PI WEB gives agents no tools of its own. Starting other sessions is a session-daemon interface that a plugin or extension can build tools on:

| Route | What it does |
|---|---|
| `POST /api/sessions/:id/spawn` | Start an independent session. Body: `cwd` (this session's workspace), `prompt`, and optionally `targetCwd` (any workspace of the same project) and `model`. |
| `POST /api/sessions/:id/subsessions` | Start a tracked child in this session's own workspace. Body: `cwd`, `prompt`, and optionally `model`. |
| `GET /api/sessions/:id/subsessions?cwd=…` | List this session's tracked children. Runs started with pi's subagent tool belong to the [pi-web-subagents](https://github.com/Gang-of-Beads/pi-web-subagents) plugin, when installed: `POST /api/plugins/subagents/runs.list` with `{ "sessionFile": … }`. |
| `GET /api/sessions/:id/subsessions/:child?cwd=…` | A child's status and last reply. |
| `GET /api/sessions/:id/subsessions/:child/transcript?cwd=…` | A child's transcript. Optional filters: `roles`, `include` (comma-separated), `search`, `maxChars`, `includeToolArgs=true`, `before`, `limit`. |

- The new session inherits this session's model and thinking level. `model`, an exact `provider/model-id` such as `anthropic/claude-sonnet-4-5`, overrides the inherited model, and an unknown value is rejected.
- A tracked child always runs in this session's working directory, and a request for any other one is rejected.
- When a child stops working, its parent receives a completion notice that carries the child's final output when it fits.

The `spawnSessions` and `subsessions` config keys, and `PI_WEB_SPAWN_SESSIONS` and `PI_WEB_SUBSESSIONS`, are retired. They controlled the `spawn_session` and tracked-subsession tools, which PI WEB no longer registers. A config file that still sets them loads, and they have no effect.

#### `ask_user`

PI WEB has no `ask_user` tool: it gives agents no tools of its own. A question tool comes from a pi extension you install, such as [pi-ask-user](https://github.com/Gang-of-Beads/pi-ask-user), and its questions reach the browser as the [extension dialogs](#extension-dialogs) below.

The `askUser` config key and `PI_WEB_ASK_USER` are retired. A config file that still sets `askUser` loads; the key has no effect, and the next save from Settings drops it.

### Extension dialogs

Pi extensions can ask the user questions from `ctx.ui.confirm()`, `ctx.ui.select()`, `ctx.ui.input()`, and `ctx.ui.editor()` — including from `session_start` hooks and in-flight `tool_call` hooks. PI WEB renders these dialogs inline in the session transcript and answers them through a dedicated session-daemon channel, never the prompt queue, so a dialog parked inside a `tool_call` hook cannot deadlock the run. Dialog support is always on; there is no enable flag. See [Pi extensions in PI WEB](pi-extensions.md) for behavior details and author guidance.

`extensionDialogsTimeoutMs` is the unattended-dialog safety valve: how long the session daemon waits for an answer before settling the dialog with its kind's cancel value (`false` for confirm, `undefined` for select, input and editor). It defaults to `0`, which waits for an answer indefinitely; set a positive number of milliseconds to auto-cancel instead. A dialog that expires is settled with its cancel value, which the extension cannot tell apart from a deliberate dismissal, so a timeout short enough to fire while someone is still reading turns their reading time into a silent answer. An extension's own `timeout` option still applies, and the effective deadline is the sooner of the two.

The key is edited directly in the global config file. [Restart the session daemon](install.md#restart-the-session-daemon) after changing it.

### PI WEB plugin config and recovery

The `plugins` key controls desired enablement and JSON settings for PI WEB browser-only, server-only, and dual-entry plugins on the machine whose config you are editing. It does not install, remove, or update Pi packages; use **Settings → Pi Packages** or Pi's package manager for package operations.

```json
{
  "plugins": {
    "git": { "enabled": true, "settings": {} },
    "mermaid": { "enabled": true },
    "updates": { "enabled": false }
  }
}
```

Plugins are enabled by default. `plugins.<id>.enabled: false` turns one off; a toggle saved in Settings applies at once, with the exceptions in [Turn plugins on and off](plugins.md#turn-plugins-on-and-off). A plugin's `settings` are copied into sessiond's startup snapshot, and diagnostics expose only a fingerprint, never the values.

#### Private plugin settings

`plugins.<id>.settings` is public: every browser that opens PI WEB can read it. A value no browser may read, such as a service key, goes in `plugins.<id>.serverSettings`, a JSON object kept on the server:

```json
{
  "plugins": {
    "example": {
      "settings": { "language": "en" },
      "serverSettings": { "apiKey": "replace-with-your-key" }
    }
  }
}
```

- **Reads.** No config answer carries `serverSettings`: not `config` or `effectiveConfig` of `GET`/`PUT /api/config`, `PUT /api/config/theme`, the selected-machine config routes, nor a remote machine's workspace listing relayed by the gateway. Instead, `serverSettingsConfigured` says which plugins have a block (`{}` counts as set), and `serverSettingsProtocol: 1` says the machine supports this field. Being set says nothing about whether the value is valid.
- **Ordinary saves keep them.** A config save never carries private values and does not remove them: on every save PI WEB re-reads the file and keeps each stored `serverSettings` block. When a save leaves out a plugin's entry, the entry is kept with its block and its stored `enabled` value, so keeping a key neither turns a plugin on nor off. A save whose `config` contains `serverSettings` is refused with 400.
- **Changes are explicit.** A config write may add `serverSettingsUpdates`, keyed by plugin id, with `{ "operation": "replace", "value": { ... } }` or `{ "operation": "clear" }`. Nothing else changes a block: there is no mask value to send back, and an empty string is not a delete. `clear` removes only the block; on a plugin without one it does nothing. `PUT /api/config` replaces the public config as always, so send the full public config with it; to change only private settings, use the selected-machine route, which merges: `PUT /api/machines/<id>/config` with `{ "config": {}, "serverSettingsUpdates": { ... } }` (`<id>` is `local` for this machine). Settings has no control for private settings in this release: change them in the config file, or with these operations from your own API client.
- **Other machines.** These checks belong to the PI WEB you opened, so they cover the config writes it forwards: those for machines reached with **This server** ([This browser or this server](machines.md#this-browser-or-this-server)). It sends private operations only to a machine that reports `serverSettingsProtocol: 1`. A machine running an older PI WEB gets no private operation, and no write at all while its own config holds a `serverSettings` block, because its saver would drop it; both answer 409 with code `private-settings-protocol-incompatible`. If a machine accepts a write with private operations but its answer no longer reports the protocol (it was downgraded in between), the answer is 502 with code `private-settings-outcome-unconfirmed`: the change may or may not have been saved, so read the config again before retrying. A machine reporting a protocol other than `1` is refused rather than guessed at. Error answers from another machine's config and workspace routes are rebuilt with the gateway's own words, because an older PI WEB could quote its config file in them.
- **Machines reached with This browser.** The browser writes to such a machine itself, so none of these checks stand in between. Update such a machine before you add a `serverSettings` block to it, and before you change its plugins from a newer browser. Until then, an older machine's config answer still carries its blocks, as it always did, and Settings sends them back unchanged when it saves, so a plugin toggle keeps them; if the machine was updated after Settings read its config, it refuses that save with 400: reload the plugins in Settings and save again.
- **On disk.** The file stays plaintext, readable by whoever administers the host and by trusted plugin code; this keeps secrets from browsers, not from the machine. PI WEB creates a new config file readable by its owner only (mode `0600` on Linux and macOS). It does not change the mode or ACLs of an existing file: restrict those yourself, and do not commit a config that holds keys.
- **Plugins.** A plugin's server half receives its own block when it is activated; its browser half never does. See [Plugin API](plugin-api.md#plugin-settings). The [Voice plugin](https://github.com/Gang-of-Beads/pi-web-voice#readme), for example, reads its Azure Speech key from `plugins.voice.serverSettings.azureSpeech.key`. A changed block, like a changed `settings` object, takes effect at the next start of the processes that run the plugin, and the plugin shows **Restart required** until then.
- **Fingerprint key.** To tell whether a running plugin has the current private settings without keeping a plain hash of them, PI WEB fingerprints them with a random key it creates in the data directory, `plugin-settings-revision.key` (mode `0600`). The key never leaves the machine. If you delete it, a new one is created, and plugins with private settings show **Restart required** until the web/API process and the session daemon have both restarted.

#### Models a plugin may call

`plugins.<id>.models` lists, by exact `provider/model-id`, the models a plugin's server half may call through this machine's model registry and logins ([`ports.models`](plugin-api.md#models)):

```json
{ "plugins": { "example": { "models": ["example-provider/small-fast-model"] } } }
```

A plugin without the list can call no model. Each entry must be a `provider/model-id` name; anything else makes the config invalid. The list is read when the session daemon starts the plugin: a changed list shows **Restart required** until the next session-daemon start. The plugin's card in **Settings → PI WEB Plugins** shows each granted model's calls and tokens since the session daemon started, and the cost the provider reported ("cost not reported" when it reported none). The list is the owner's consent, not a security boundary: a server plugin is trusted code inside PI WEB.

#### Desired versus active plugin state

Sessiond is the single workspace authority. A Settings toggle activates or stops a plugin's server half in the running daemon and tells open pages, so most changes apply live. What cannot change in a running daemon waits for its next start and shows as **Restart required**: a plugin that supplies a workspace provider or the machine registry, a changed `settings` object, `serverSettings` block or `models` list of a plugin with a server half, a new package revision, and hand edits of the config file. A paired browser entry is withheld while its desired source, scope, settings fingerprint or revision differs from the active one, or while the active plugin is unhealthy or incompatible.

**Settings → PI WEB Plugins** shows desired and active state separately, including active, failed, incompatible, disabled, not-active/missing, unknown, conflict, stale-revision, health, safe-mode, and restart-required state. Desired config remains editable when sessiond is unavailable as long as the selected machine's config endpoint works, but PI WEB reports active state as unavailable rather than constructing a second workspace authority.

With [several machines](machines.md), the panel targets the selected machine. Its desired state is saved in that machine's config and its active state comes from that machine's session daemon. If the versioned plugin lifecycle, the machine's plugin manifest, or provider backend routes are unavailable or incompatible, PI WEB reports an explicit unsupported or compatibility error and does not silently use the gateway's config or code.

Mixed-version plugin and provider operation is not supported in either upgrade order. Upgrade the machines together, restart their web/API processes and session daemons, then reload the browser. Other selected-machine settings and features report their own explicit errors.

Apply changes in this order:

1. Install or update the package on the target machine.
2. Save desired enablement/settings.
3. Most toggles apply at once. If the plugin's card says **Restart required**, restart the target session daemon, wait for it, then reload the browser tab. After editing the config file by hand, reload the tab, and restart the daemon for a plugin with a server half.

> **Manual restart warning:** see [Restart the session daemon](install.md#restart-the-session-daemon) for the command on each install. Restarting sessiond may interrupt active sessions and runtime ownership. A browser reload, web/UI autoreload, restarting only web/API, and Pi's `/reload` do not activate server-plugin state.

#### Offline disable and safe start

The recovery CLI edits global config offline. It does not contact sessiond, discover packages, import plugin modules, or include machine credentials. Run it directly on the affected machine; for a custom service config, add `--config /path/to/config.json`.

```bash
pi-web plugins disable <plugin-id> --restart
pi-web plugins safe-start show
pi-web plugins safe-start set bundled-only --restart
pi-web plugins safe-start set none --restart
pi-web plugins safe-start clear --restart
```

`disable` persists `plugins.<id>.enabled: false`. Safe-start state is stored under `serverPlugins.safeStart`: `bundled-only` filters external server packages before discovery/import, while `none` imports no server plugins and retains the kernel project-folder workspace. `clear` restores ordinary configured discovery on the next start. An unsupported `serverPlugins.safeStart` shape or value in otherwise valid JSON fails closed as effective `none`; use `safe-start show`, then `set` or `clear`, to repair it offline.

`--restart` performs a restart only for a recognized safe installed-service plan; otherwise it prints manual instructions. The config mutation is durable before PI WEB attempts the restart. If the service-manager command itself fails, restart sessiond manually.

Ordinary import/activation/start/health failures are quarantined when possible, but server plugins are trusted in-process code, share sessiond's event loop, and are not crash-isolated. `bundled-only` bypasses external plugin failures; `none` is the emergency level that also bypasses bundled server plugins. Setting, clearing, or disabling takes effect for server code only after sessiond restarts, and that restart may interrupt active sessions/runtime ownership.

### Shortcut config

Shortcut values are keyed by action id. Values are shortcut strings such as `mod+k` or `mod+g p`; `null` disables that action's shortcut.

```json
{
  "shortcuts": {
    "core:view.chat": "mod+1",
    "core:session.stop": null
  }
}
```

Prefer Settings → Keyboard for editing shortcuts interactively.

## Prompt completions

The chat composer opens completion menus on three trigger characters:

- `/` at the very start of the draft completes session commands.
- `@` completes file paths: `@` for tracked files, `@ ` (at, then space) or `!@` for all files. Picking one inserts an `@path` reference into the draft, quoted automatically when the path contains spaces.
- `#` completes the models available to the session, filtered case-insensitively as you type (at most 12 entries). Picking one inserts a `#provider/model-id` reference into the draft, which tells agents the request should run on that model — for example as the `model` of a spawn route (see [Delegation routes](#delegation-routes)).
