# Mosaic Stack — Admin Guide

## Table of Contents

1. [User Management](#user-management)
2. [System Health Monitoring](#system-health-monitoring)
3. [Provider Configuration](#provider-configuration)
4. [MCP Server Configuration](#mcp-server-configuration)
5. [Environment Variables Reference](#environment-variables-reference)
6. [Pi Goal Loop Operations](#pi-goal-loop-operations)
7. [Local Fleet Canary](./fleet-local-canary.md)

---

## User Management

Admins access user management at `/admin` in the web dashboard. All admin
endpoints require a session with `role = admin`.

### Creating a User

**Via the web admin panel:**

1. Navigate to `/admin`.
2. Click **Create User**.
3. Enter name, email, password, and role (`admin` or `member`).
4. Submit.

**Via the API:**

```http
POST /api/admin/users
Content-Type: application/json

{
  "name": "Jane Doe",
  "email": "jane@example.com",
  "password": "securepassword",
  "role": "member"
}
```

Passwords are hashed by BetterAuth before storage. Passwords are never stored in
plaintext.

### Roles

| Role     | Permissions                                                           |
| -------- | --------------------------------------------------------------------- |
| `admin`  | Full access: user management, health, all agent tools                 |
| `member` | Standard user access; agent tool set restricted by `AGENT_USER_TOOLS` |

### Updating a User's Role

```http
PATCH /api/admin/users/:id/role
Content-Type: application/json

{ "role": "admin" }
```

### Banning and Unbanning

Banned users cannot sign in. Provide an optional reason:

```http
POST /api/admin/users/:id/ban
Content-Type: application/json

{ "reason": "Violated terms of service" }
```

To lift a ban:

```http
POST /api/admin/users/:id/unban
```

### Deleting a User

```http
DELETE /api/admin/users/:id
```

This permanently deletes the user. Related data (sessions, accounts) is
cascade-deleted. Conversations and tasks reference the user via `owner_id`
which is set to `NULL` on delete (`set null`).

---

## System Health Monitoring

The health endpoint is available to admin users only.

```http
GET /api/admin/health
```

Sample response:

```json
{
  "status": "ok",
  "database": { "status": "ok", "latencyMs": 2 },
  "cache": { "status": "ok", "latencyMs": 1 },
  "agentPool": { "activeSessions": 3 },
  "providers": [{ "id": "ollama", "name": "ollama", "available": true, "modelCount": 3 }],
  "checkedAt": "2026-03-15T12:00:00.000Z"
}
```

`status` is `ok` when both database and cache pass. It is `degraded` when either
service fails.

The web admin panel at `/admin` polls this endpoint and renders the results in a
status dashboard.

---

## Provider Configuration

Providers are configured via environment variables and loaded at gateway startup.
No restart-free hot reload is supported; the gateway must be restarted after
changing provider env vars.

### Ollama

Set `OLLAMA_BASE_URL` (or the legacy `OLLAMA_HOST`) to the base URL of your
Ollama instance:

```env
OLLAMA_BASE_URL=http://localhost:11434
```

Specify which models to expose (comma-separated):

```env
OLLAMA_MODELS=llama3.2,codellama,mistral
```

Default when unset: `llama3.2,codellama,mistral`.

The gateway registers Ollama models using the OpenAI-compatible completions API
(`/v1/chat/completions`).

### Custom Providers (OpenAI-compatible APIs)

Any OpenAI-compatible API (LM Studio, llama.cpp HTTP server, etc.) can be
registered via `MOSAIC_CUSTOM_PROVIDERS`. The value is a JSON array:

```env
MOSAIC_CUSTOM_PROVIDERS='[
  {
    "id": "lmstudio",
    "name": "LM Studio",
    "baseUrl": "http://localhost:1234",
    "models": ["mistral-7b-instruct"]
  }
]'
```

Each entry must include:

| Field     | Required | Description                         |
| --------- | -------- | ----------------------------------- |
| `id`      | Yes      | Unique provider identifier          |
| `name`    | Yes      | Display name                        |
| `baseUrl` | Yes      | API base URL (no trailing slash)    |
| `models`  | Yes      | Array of model ID strings to expose |
| `apiKey`  | No       | API key if required by the endpoint |

### Testing Provider Connectivity

From the web admin panel or settings page, click **Test** next to a provider.
This calls:

```http
POST /api/agent/providers/:id/test
```

The response includes `reachable`, `latencyMs`, and optionally
`discoveredModels`.

---

## MCP Server Configuration

The gateway can connect to external MCP (Model Context Protocol) servers and
expose their tools to agent sessions.

Set `MCP_SERVERS` to a JSON array of server configurations:

```env
MCP_SERVERS='[
  {
    "name": "my-tools",
    "url": "http://localhost:3001/mcp",
    "headers": {
      "Authorization": "Bearer my-token"
    }
  }
]'
```

Each entry:

| Field     | Required | Description                         |
| --------- | -------- | ----------------------------------- |
| `name`    | Yes      | Unique server name                  |
| `url`     | Yes      | MCP server URL (`/mcp` endpoint)    |
| `headers` | No       | Additional HTTP headers (e.g. auth) |

On gateway startup, each configured server is connected and its tools are
discovered. Tools are bridged into the Pi SDK tool format and become available
in agent sessions.

The gateway itself also exposes an MCP server endpoint at `POST /mcp` for
external clients. Authentication requires a valid BetterAuth session (cookie or
`Authorization` header).

---

## Environment Variables Reference

### Required

| Variable             | Description                                                                                                 |
| -------------------- | ----------------------------------------------------------------------------------------------------------- |
| `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start.                           |
| `DATABASE_URL`       | Runtime-only PostgreSQL connection injected from the dedicated deployment secret; no default or inline DSN. |

### Gateway

| Variable              | Default                  | Description                                    |
| --------------------- | ------------------------ | ---------------------------------------------- |
| `GATEWAY_PORT`        | `14242`                  | Port the gateway listens on                    |
| `GATEWAY_CORS_ORIGIN` | `http://localhost:3000`  | Allowed CORS origin for browser clients        |
| `BETTER_AUTH_URL`     | `http://localhost:14242` | Public URL of the gateway (used by BetterAuth) |

### SSO (Optional)

| Variable                    | Description                                                                  |
| --------------------------- | ---------------------------------------------------------------------------- |
| `AUTHENTIK_CLIENT_ID`       | Authentik OAuth2 client ID                                                   |
| `AUTHENTIK_CLIENT_SECRET`   | Authentik OAuth2 client secret                                               |
| `AUTHENTIK_ISSUER`          | Authentik OIDC issuer URL                                                    |
| `AUTHENTIK_TEAM_SYNC_CLAIM` | Optional claim used to derive team sync data (defaults to `groups`)          |
| `WORKOS_CLIENT_ID`          | WorkOS OAuth client ID                                                       |
| `WORKOS_CLIENT_SECRET`      | WorkOS OAuth client secret                                                   |
| `WORKOS_ISSUER`             | WorkOS OIDC issuer URL                                                       |
| `WORKOS_TEAM_SYNC_CLAIM`    | Optional claim used to derive team sync data (defaults to `organization_id`) |
| `KEYCLOAK_CLIENT_ID`        | Keycloak OAuth client ID                                                     |
| `KEYCLOAK_CLIENT_SECRET`    | Keycloak OAuth client secret                                                 |
| `KEYCLOAK_ISSUER`           | Keycloak realm issuer URL                                                    |
| `KEYCLOAK_TEAM_SYNC_CLAIM`  | Optional claim used to derive team sync data (defaults to `groups`)          |
| `KEYCLOAK_SAML_LOGIN_URL`   | Optional SAML login URL used when OIDC is unavailable                        |

Each OIDC provider requires its client ID, client secret, and issuer URL together. If only part of a provider configuration is set, gateway startup logs a warning and that provider is skipped. Keycloak can fall back to SAML when `KEYCLOAK_SAML_LOGIN_URL` is configured.

### Agent

| Variable                 | Default         | Description                                             |
| ------------------------ | --------------- | ------------------------------------------------------- |
| `AGENT_FILE_SANDBOX_DIR` | `process.cwd()` | Root directory for file/git/shell tool access           |
| `AGENT_SYSTEM_PROMPT`    | —               | Platform-level system prompt injected into all sessions |
| `AGENT_USER_TOOLS`       | all tools       | Comma-separated allowlist of tools for non-admin users  |

### Mosaic Pi goal loop

| Variable                      | Default | Description                                                          |
| ----------------------------- | ------- | -------------------------------------------------------------------- |
| `MOSAIC_GOAL_MAX_TURNS`       | `40`    | Per-goal autonomous turn limit; accepted range `1..500`              |
| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6`     | Consecutive identical progress-report limit; accepted range `1..100` |

These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or
out-of-range values fall back to the defaults; they do not disable the bounds.

### Providers

| Variable                  | Default                      | Description                                      |
| ------------------------- | ---------------------------- | ------------------------------------------------ |
| `OLLAMA_BASE_URL`         | —                            | Ollama API base URL                              |
| `OLLAMA_HOST`             | —                            | Alias for `OLLAMA_BASE_URL` (legacy)             |
| `OLLAMA_MODELS`           | `llama3.2,codellama,mistral` | Comma-separated Ollama model IDs                 |
| `MOSAIC_CUSTOM_PROVIDERS` | —                            | JSON array of custom OpenAI-compatible providers |

### Memory and Embeddings

| Variable                | Default                     | Description                                          |
| ----------------------- | --------------------------- | ---------------------------------------------------- |
| `OPENAI_API_KEY`        | —                           | API key for OpenAI embedding and summarization calls |
| `EMBEDDING_API_URL`     | `https://api.openai.com/v1` | Base URL for embedding API                           |
| `EMBEDDING_MODEL`       | `text-embedding-3-small`    | Embedding model ID                                   |
| `SUMMARIZATION_API_URL` | `https://api.openai.com/v1` | Base URL for log summarization API                   |
| `SUMMARIZATION_MODEL`   | `gpt-4o-mini`               | Model used for log summarization                     |
| `SUMMARIZATION_CRON`    | `0 */6 * * *`               | Cron schedule for log summarization (every 6 hours)  |
| `TIER_MANAGEMENT_CRON`  | `0 3 * * *`                 | Cron schedule for log tier management (daily at 3am) |

### MCP

| Variable      | Description                                      |
| ------------- | ------------------------------------------------ |
| `MCP_SERVERS` | JSON array of external MCP server configurations |

### Plugins

| Variable                                | Description                                                                                                                               |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN`                     | Discord bot token (enables Discord plugin)                                                                                                |
| `DISCORD_SERVICE_TOKEN`                 | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only |
| `DISCORD_SERVICE_USER_ID`               | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata  |
| `DISCORD_GUILD_ID`                      | Discord guild/server ID                                                                                                                   |
| `DISCORD_GATEWAY_URL`                   | Gateway URL for Discord plugin to call (default: `http://localhost:14242`)                                                                |
| `DISCORD_ALLOWED_GUILD_IDS`             | Required comma-separated Discord guild snowflake allowlist; default-deny                                                                  |
| `DISCORD_ALLOWED_CHANNEL_IDS`           | Required comma-separated Discord channel snowflake allowlist; default-deny                                                                |
| `DISCORD_ALLOWED_USER_IDS`              | Required comma-separated Discord user snowflake allowlist; default-deny                                                                   |
| `DISCORD_INTERACTION_BINDINGS`          | Required JSON bindings from guild/channel to logical agent and paired Discord users with `viewer`, `operator`, or `admin` roles           |
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; authorized turns per guild/channel/user each minute (default: `30`)                                            |
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE`  | Optional positive integer; mention-thread routes per guild/channel/user each minute (default: `5`)                                        |
| `TELEGRAM_BOT_TOKEN`                    | Telegram bot token (enables Telegram plugin)                                                                                              |
| `TELEGRAM_GATEWAY_URL`                  | Gateway URL for Telegram plugin to call                                                                                                   |

### Discord ingress security

When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged.

Inbound Discord messages must originate from an allowed guild and configured parent channel, come from an allowed and paired user whose role permits sending, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. Attachment references are limited in count, metadata size, field length, and declared size; only query-free HTTPS URLs without credentials or fragments are accepted, so bearer or presigned URLs never reach persistence or an agent prompt. The gateway validates the service identity, envelope signature, allowlists, pairing, and role again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state.

Configured channels are dedicated agent interaction surfaces. An authorized untagged message routes to the bound logical agent and the response returns in that channel. Mentioning the bot on a normal channel message creates a public Discord thread, or reuses the thread already attached to that same message; the response and later thread messages stay in that thread without repeated mentions. A normal channel's category is not an authorization parent—only a Discord thread inherits authorization from its configured parent channel. Runtime control commands such as `/approve` and `/stop <approval>` remain on the current channel/thread because they target that durable session rather than opening a new topic.

Authorization and per-user/channel rate limits are evaluated before thread creation, so an unlisted guild/channel/user, unpaired user, `viewer`, or rate-limited sender cannot create bot threads or dispatch gateway work. The bot needs Discord permissions to view/send in configured channels and create/send in public threads. If thread creation fails, the turn is not dispatched because the requested response destination cannot be honored.

Conversation handles use the configured logical agent plus Discord channel/thread identity. They do not contain a Claude, Codex, Pi, OpenCode, model, process, or runtime-provider identifier; changing the runtime behind the logical session therefore does not require reconnecting the Discord bot.

#### Interaction binding format

`DISCORD_INTERACTION_BINDINGS` must be a non-empty JSON array. Each item requires `instanceId`, trusted `agentConfigId`, `guildId`, `channelId`, and a non-empty `pairedUsers` object. `instanceId` is the configured logical-agent name; its trusted database `agentConfigId` must resolve to an agent configuration with exactly that name, preserving provider/model/prompt/tool selection per binding. The guild/channel must also appear in their corresponding allowlists. IDs below are placeholders:

```json
[
  {
    "instanceId": "interaction-agent",
    "agentConfigId": "agent-config-id",
    "guildId": "guild-id",
    "channelId": "channel-id",
    "pairedUsers": {
      "discord-user-id": {
        "role": "operator",
        "mosaicUserId": "mosaic-user-id"
      }
    }
  }
]
```

| Pairing role | Send message | Create/continue thread | Approve | Stop |
| ------------ | ------------ | ---------------------- | ------- | ---- |
| `viewer`     | No           | No                     | No      | No   |
| `operator`   | Yes          | Yes                    | No      | No   |
| `admin`      | Yes          | Yes                    | Yes     | Yes  |

A legacy role-only value such as `"discord-user-id": "operator"` remains valid for non-privileged ingress. Approval and stop require the object form with a provisioned `mosaicUserId`; gateway policy checks that Mosaic identity and consumes one exact-action approval once. Do not make the Discord service principal an approving administrator.

After changing bindings or allowlists, restart the gateway/plugin through the normal service manager and verify both Discord and gateway connectivity. The adapter reports `connected` only when both links are ready, `degraded` when one is ready, and `disconnected` when neither is ready. Test one authorized untagged channel turn, one mention-created thread, one thread follow-up, and one unauthorized user denial without using production credential values in logs or evidence.

### Session retention and garbage collection

Session cleanup is scoped to one session identifier and only removes that session's Valkey keys and demotes that session's hot logs. Gateway startup and scheduled jobs do not perform global session cleanup; startup removes legacy repeatable `session-gc` schedules created by older deployments. The `/gc` command is intentionally disabled until a distinct global-retention job supplies explicit authorization and audit evidence. This prevents one tenant or session's cleanup from changing another's retained data.

### Observability

| Variable                      | Default                 | Description                      |
| ----------------------------- | ----------------------- | -------------------------------- |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318` | OpenTelemetry collector endpoint |
| `OTEL_SERVICE_NAME`           | `mosaic-gateway`        | Service name in traces           |

### Web App

| Variable                  | Default                  | Description                            |
| ------------------------- | ------------------------ | -------------------------------------- |
| `NEXT_PUBLIC_GATEWAY_URL` | `http://localhost:14242` | Gateway URL used by the Next.js client |

### Coordination

| Variable                | Default                       | Description                                |
| ----------------------- | ----------------------------- | ------------------------------------------ |
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |

---

## Pi Goal Loop Operations

The reviewed runtime asset is deployed at
`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another
copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two
competing lifecycle controllers.

Operational checks:

1. Run `mosaic pi` and verify `/goal help` is available.
2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence.
   Reports persist in Pi session data; controller-owned state redacts common credential shapes, but
   Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output
   in goals, pause reasons, or evidence.
3. Use `/goal pause <reason>` before planned maintenance or manual investigation. Pause and cancel
   abort the current goal-driven run when Pi is busy.
4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds.
5. Use `/goal cancel` before replacing an unfinished goal.

A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its
limits or restart the process. Framework sync owns file deployment, while Pi's native session file
owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet
supervisor.
