# Technical Documentation: yc-software/qm

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/yc-software/qm)

### Overview (`README.md`)

<p align="center">
  <img src="docs/images/qm-logo.png" alt="QM" width="120">
</p>

<div align="center">

**A multiplayer agent harness for work. In Slack and on the web.**<br>
Run it in your own cloud, with your own models and keys.

[](https://github.com/yc-software/qm/actions/workflows/cicd.yml)
[](./LICENSE)
[](./package.json)
[](https://www.npmjs.com/package/@yc-software/qm)

[Deploy](#deploy-it-for-your-org) · [How it works](#how-it-works) · [Security model](#security-and-secrets) · [Customize](#customize-your-instance) · [Contribute](#contributing) · [Documentation](#going-deeper)

</div>

## Overview

Most agents are designed like personal assistants. You can make one work for a whole
company, but it quickly gets complex. QM is designed for startups. Employees each get
their own isolated workspace, and can also collaborate with the agent in channels, group messages, and projects.

Each person and each room has its own scoped memory, files, keychain view, permissions,
crons, web apps, and durable sandbox.

It's built with open source in mind. Pick your own harness and model and switch between
them. Pi and Claude Code both drive the same core, and Pi runs models from many
providers, so a deployment isn't tied to any single vendor.

## Setup

Tell your coding agent of choice `Let's deploy https://github.com/yc-software/qm`. From here, it should follow the deployment guide in this repo.

You can also try out a 3rd-party hosted version of QM [here](https://www.agent37.com/qm).

If you're an infra provider interested in offering a hosted version of QM, feel free to reach out.

## Features

- **Personal and shared scopes.** People customize the agent to be _theirs_, and still
  work with it collaboratively in Slack channels and projects.
- **Slack and web.** The same identity and configuration carries between Slack and the
  web app.
- **Admin control.** Set org-level configuration, security and sharing postures. Choose which
  harnesses and models are available.
- **Web apps.** Spin up custom internal apps and publish them to the right people.
- **Shared skills.** Skills are scope-owned and shareable by grant, with admin-gated
  promotion to the whole org and skill packs imported from git repositories.
- **Background work.** Crons, watches, and inbound webhooks work while you're away.

## What you can do with it

- Search internal notes, email, documents, databases, and the web together
- Build internal apps, publish them to the right people, and keep their data current
- Learn your writing voice from past sends, then triage your inbox on a schedule —
  labels and reply drafts included
- Work in an existing repository: run tests, open PRs, monitor CI, check system logs
- Track a project in a shared channel and post updates and follow-ups

## Architecture

```mermaid
flowchart LR
  DB[("Postgres<br/>sessions · memory · queue")]

  subgraph CORE["Headless core"]
    API["API · identity · policy · scheduler"]
    LOOP["Agent loop<br/>(Pi, Claude Code)"]
    API <--> LOOP
  end

  SBX["Per-scope sandbox<br/>files · tools · logged-in services"]

  DB <--> API
  LOOP <--> SBX
```

For durability, set `DATABASE_URL` and `SESSION_STORE=postgres` — without it, sessions
live in process memory and vanish on restart. To exercise a branch against a real model and real Postgres, run
`npm run dev-instance:web` for web/admin or `npm run dev-instance:slack` for Slack.
Use `npm run dev-instance:both` when testing both surfaces together. Bare
`npm run dev-instance` defaults to web for new instances and preserves the surface
on reload. Switch an existing instance with an explicit surface command.

## How it works

Every turn runs through a central core, which can use a variety of models and harnesses
to generate the response. A Postgres persistence layer holds user data, session history,
and other durable state. The agent has a small, fixed tool surface; one of those tools is
`execute`, which runs commands in the scope's own isolated sandbox — its durable computer,
where installed tools stay installed. The web UI and admin panel share one service; the portal and optional built-in
auth broker share another. These modules communicate with core over its HTTP API.
See [combined services](docs/combined-services.md) for configuration and migration;
Slack is an optional in-process plugin that core starts
and supervises through a direct service client.

The core runs TypeScript directly on Node and uses Fastify for HTTP. The Slack plugin
uses Bolt; the web UI builds with Vite and renders with Lit.

The core itself is generic. Everything specific to one company — org config, custom tools
and skills, sandbox image, infrastructure — lives in a **deployment directory** that the
[`qm` CLI](./cli/README.md) validates and deploys. Every substrate (harness, session
store, sandbox, memory) sits behind an interface. Memory can also be routed by scope to
[external providers](./docs/memory-providers.md) while retaining the built-in notebook.

## Security and secrets

QM's approach follows local coding agents like OpenCode, Codex, and Claude Code: the
agent acts as the person it's working for, with their credentials and permissions, and
everything it does is audited. An org picks one security posture, which narrower scopes
can only tighten:

- **Strict** — every harness tool call pauses for human approval, except the two
  no-effect turn enders.
- **Auto** (default) — blocks private-network access.
- **Dangerous** — no tool approval gates, and content screening only observes.

Content screening is a deployment setting, `securityScreen.mode`: `off` (default),
`observe`, or `enforce`, with `securityScreen.classifier` choosing the built-in
`model` or an external `proxy`. When on, it classifies external content under every
posture. `observe` only records verdicts in the audit log. `enforce` quarantines
flagged content pending release approval, except under Dangerous, which caps
screening at `observe`.

The predeclared command policy — approval rules and hard denials for things like
recursive deletes or destructive SQL — applies in every posture, Dangerous included.

Sharing posture is independent:

- **Isolated** (default) — resources stay in their scope unless explicitly shared.
- **Open** — on a live authenticated internal human turn, the speaker's opted-in personal
  files, artifacts, skills, and memory may be read in an opted-in shared room.

[`SECURITY.md`](./SECURITY.md) has the threat model, the operator assumptions, and the
known limitations.

## Deploy it for your org

Create an organization-owned deployment repository that depends on `@yc-software/qm`:

```bash
npm exec --yes --package=@yc-software/qm@latest -- \
  qm init . --org <slug> --target <fly-or-aws>
npm install
```

Initialization materializes a deployment skill for an agent and walks through
infrastructure, web sign-in, connector credentials, optional Slack access, deployment,
and live verification — no source checkout required. Each deployment runs in the
operator's own cloud account; initialization does not generate or enable deployment CI,
and this repository has no production deployment workflow. See
[`deployment.md`](./deployment.md) for the details.

## Contributing

We take contributions as _human-written_ text, not code — see
[`CONTRIBUTING.md`](./CONTRIBUTING.md). Describe the change you'd like informally in a
`.txt` or `.md` file in [`adrs/`](./adrs/), and if we're aligned we'll handle the
implementation. Report vulnerabilities privately — see [`SECURITY.md`](./SECURITY.md),
not a public issue.

## Customize your instance

Choose how you want to customize QM:

- **Config, tools, skills, and services:** use the deployment repository above. It
  pins `@yc-software/qm` and uses that release's runtime images; no source copy is needed.
- **Changes to QM itself:** keep your own source fork, public or private. You may
  modify any part of core, including the runtime, plugins, CLI, docs, and CI.
  Contributing those changes upstream is optional.

### Create a source fork

For a private source fork, create a standalone private repository, outside GitHub's
fork network. Seed only `main` and explicitly set it as the default branch:

```bash
gh repo create <org>/qm-private --private
git clone --single-branch --branch main --no-tags git@github.com:yc-software/qm qm-private
git -C qm-private remote rename origin upstream
git -C qm-private remote add origin git@github.com:<org>/qm-private
git -C qm-private push -u origin main
gh repo edit <org>/qm-private --default-branch main
```

Do not seed with `git push --mirror`: it copies unrelated upstream branches and tags,
leaves default-branch selection implicit, and can delete destination-only refs on later
pushes. The `upstream` remote supplies source updates without copying those refs to your
repository. For a public source fork, GitHub's Fork button is also an option. A GitHub
fork of a public repository cannot be private; keep private work outside that network.

Review inherited workflows before enabling Actions or adding credentials. Choose the CI
checks you want, and disable or adapt upstream release and publishing workflows for your
own package and image registries. Copying the source does not configure production
deployment CI.

### Customize and run your source

Keep deployment configuration, tools, skills, plugin images, and infrastructure in
`deploy/layers/<org>/` in a private source fork, or in a separate private deployment
repository when your source is public. Never commit secrets. See
[`deploy/layers/README.md`](./deploy/layers/README.md) for initialization and layout.
Keep deployment data separate from core code, but change core wherever your desired
behavior requires it.

From the source checkout, install dependencies with `npm ci` and use the in-tree CLI.
After completing the provider setup in [`deployment.md`](./deployment.md), build and
deploy your modified services explicitly:

```bash
node cli/bin/qm.ts check --config <deployment-dir>/qm.config.jsonc
node cli/bin/qm.ts plan --config <deployment-dir>/qm.config.jsonc --build-from .
node cli/bin/qm.ts up --config <deployment-dir>/qm.config.jsonc --build-from .
node cli/bin/qm.ts check --config <deployment-dir>/qm.config.jsonc --live
```

Use this checkout's CLI when changing the CLI itself. Without `--build-from`, the
normal deployment path selects published images, so editing source alone does not
change the deployed runtime. If you publish custom images instead, configure their
immutable references through `imageOverrides`. Follow the provider guide for sandbox
image builds; service builds do not replace that step.

### Keep it current

For a source fork, `update-qm` merges upstream changes while preserving intentional
local behavior. Land sync PRs with their merge ancestry intact, never squash or rebase
them. Conflicts are expected maintenance work, not a requirement to discard
customizations. Use `upstream-pr` only when you want to contribute a generic change;
it prepares a clean upstream branch without private deployment data or history.

For a package deployment, upgrade the exact `@yc-software/qm` dependency and lockfile,
review contract changes and generated assets, then validate and deploy. There is no
upstream source history to merge.

## Going deeper

- [`docs/getting-started.md`](./docs/getting-started.md) — first run, end to end
- [`cli/README.md`](./cli/README.md) — the `qm` CLI and the deployment directory contract
- [`docs/deploy-directory.md`](./docs/deploy-directory.md) — the deployment directory in full
- [`docs/principal-links.md`](./docs/principal-links.md) — one person, several sign-ins: linking principals
- [`docs/porter.md`](./docs/porter.md) — running qm on Porter
- [`docs/superserve.md`](./docs/superserve.md) — using Superserve for agent sandboxes
- [`.env.example`](./.env.example) — every knob, documented in place
- [`docs/swarms.md`](./docs/swarms.md) — durable agent pools, scoped messages, and blank Modal workers
- [`docs/model-gateway.md`](./docs/model-gateway.md) — discover and route models through a gateway
- [`plugins/`](./plugins) — the surfaces (Slack, web UI, admin, portal)

## License

Except where otherwise noted, QM is available under the [MIT License](./LICENSE).

---

### Overview (`cli/README.md`)

# `qm`

The standalone deployment CLI for QM. The normative directory schema,
security guarantees, target behavior, and lifecycle are in
[`docs/deploy-directory.md`](../docs/deploy-directory.md). `qm init` materializes
the agent-consumable package runbook into the deployment repository.

```bash
npm exec --yes --package=@yc-software/qm@latest -- \
  qm init . --org acme --target aws
npm install
npm exec qm -- check
npm exec qm -- infra render
npm exec qm -- doctor
npm exec qm -- infra build-image
npm exec qm -- plan
npm exec qm -- up --yes
npm exec qm -- check --live
```

This package is published to npm as `@yc-software/qm`, with npm provenance attesting the
building workflow. A release is one dispatch of `.github/workflows/release.yml` from
`main`: it signs and pushes the first-party images, publishes the package pinning their
digests, and then tags `v<version>` and creates the GitHub release with the resolved
digests attached. Each release picks its own version: a patch bump past the latest released version, or
`cli/package.json`'s version when a PR raised it higher (for a minor or major bump); a
tag that already exists stops the release rather than moving. The checked-in image manifest is a sentinel that
a deployment overrides with real digests. The packed-artifact test exercises the consumer
path locally.

The CLI deploys long-running QM services; it is not the runtime. Docker runs
them locally, Fly runs them as Fly apps with Fly Machines for agent computers, and AWS
runs digest-pinned tasks on ECS Fargate (amd64 by default for first-party services) with Lambda MicroVM agent computers.

When upgrading an AWS source deployment that previously relied on the ARM64 default,
set each existing workload's `architecture: "arm64"` explicitly until rebuilding
its image for another platform. Secret rotation refuses a task-platform change
before uploading secrets. New release candidates record their workload architectures;
legacy source-built first-party candidates without that record require an explicit
architecture matching the image, or regeneration with the current CLI. Candidate
deployment and migration reject platform mismatches before accessing AWS.

## Deployment directory

```text
qm.config.jsonc
package.json
package-lock.json
deployment.md
.codex/skills/deploy-qm/
.env.example
.env
slack-app-manifest.yml
slack-sso-manifest.yml
sandbox/
  tools/<id>/tool.json
  tools/<id>/<binary>
  skills/<id>/SKILL.md
  Dockerfile
plugins/<name>/Dockerfile
infra/
```

`qm.config.jsonc` is committed and contains no secret values. `.env` is ignored.
`package.json` pins the CLI package at the exact version that scaffolded the
directory — `contract: 1` is only the compatibility floor — so every checkout
resolves the same interpreter; upgrade the pin deliberately.
`cd` into it and the DEPLOY commands act on it; `--config` / `--env-file` / `--sandbox-dir` relocate
a piece (e.g. several deployments sharing one `sandbox/`). `check` validates the config,
computed secret names, tools, skills, and plugins without network access; `up`, `plan`, and
`sandbox build` run the same checks first. `doctor` verifies external prerequisites read-only.
`plan` renders the deployment; AWS mutation requires `up --yes`.

For a single-host Docker deployment, `sandbox.backend: "local"` runs each agent
computer in its own container. `qm up` builds the local runtime from the CLI's
pinned sandbox base, mounts the host Docker socket into trusted core, and connects
core to each sandbox's private network. An explicit `sandbox.image` uses that
runnable local image instead.

On AWS, `up` verifies under the deploy lease that RDS point-in-time recovery
is current (its `LatestRestorableTime` must lag by at most
`QM_AWS_DB_MAX_RESTORE_LAG_MS`, default 10 minutes) and records the pre-deploy
timestamp in the deployment manifest it precedes. `rollback` restores code and
configuration only, so it prints that timestamp as the matching data restore
point (`aws rds restore-db-instance-to-point-in-time`);
`aws.predeployDbSnapshot: false` opts out.

On AWS, `up` waits for every rolling service to reach `COMPLETED` with a single
deployment, polling until `QM_AWS_ROLLOUT_DEADLINE_MS` (default 20 minutes); a
timeout rolls the changed services back. ECS stops the old tasks once they exit
or their `stopTimeout` elapses (core drains for `SHUTDOWN_DRAIN_MS`, 10 s by
default), and a turn cut off there resumes on the new tasks. Core images older
than this release protect their task while a turn runs, so the first `qm up`
that replaces them cannot finish before their longest in-flight turn ends
(protection expires after 60 minutes at most); run that one deploy with a
longer deadline or when no long turn is in flight. The candidate migration task
and the private live-session canary also wait up to `QM_AWS_ROLLOUT_DEADLINE_MS`
(default 30 minutes for them); a task still running then is left to finish, and a
migration that outlasts it fails `up` before any service changes.

AWS deployments can opt into durable background ownership with
`aws.backgroundWorkControl: true`. The CLI allocates a unique
`BACKGROUND_DEPLOYMENT_ID` for each replacement core deployment and records it in
the deployment manifest. A no-op deployment and automatic ECS task replacement keep
that identity; an explicit core restart allocates a new one. Pending preparation
is persisted before ECS changes, and an ambiguous previous deployment must be
reconciled before another identity can be allocated.

Control requests require both `CORE_SIGNING_SECRET` and a distinct
`DEPLOYMENT_CONTROL_SECRET` of at least 32 characters. The control secret is
restricted to core. One shared record names the deployment that owns background
work; a core process with a configured identity admits work only while that
record names its deployment, and `BACKGROUND_WORK_ENABLED` is ignored there.

The exported `awsBackgroundWorkBootState` reads the exact manifest core task to
return its recorded boot flag and optional deployment identity. It returns
`undefined` only without a recorded core task, and rejects missing or ambiguous
boot flags or identities.

The exported `awsBackgroundWorkStatus(config, configDir, candidatePath?)` proves
the stack's exact healthy core task set and reads the owner record through the
stack's own API, returning the responder's `ownerDeploymentId`, `setAt`, `setBy`,
and whether the responding process is currently active. The exported
`awsSetBackgroundWork(config, configDir, enabled, candidatePath?, expected?)` sets
the owner record to the stack's identity (or clears it when `enabled` is false)
without restarting ECS tasks, then polls until every core task of the stack has
reported matching activity. Passing `expected: { ownerDeploymentId }` refuses the change
if the owner differs from what the caller last observed. A demotion refuses to
clear a different deployment's ownership.

The exported `awsBackgroundWorkCapacity(config, configDir, candidatePath?)` proves
that an inactive controlled stack is currently reusable. It requires another
owner, stable native deployments and exact task inventories for every workload,
and resolved deployment preparation. It never changes deployment state. The
result binds the manifest and deployment identities, the owner, workload task
definitions, native deployment IDs, and task ARNs. A release coordinator can
combine both snapshots with immutable candidate provenance and compare them
again under its production lock before any mutation. This is a point-in-time
check, not a reservation: intervening maintenance or task replacement
invalidates the proof and must block promotion.

Live checks use the owning deployment's authenticated canary endpoint to verify a
real model reply, session persistence, generated title, error records, session
cleanup, and database catalog health. The CLI confirms the stack owns background
work and is active before and after the check, and requires a final success bound
to the request and responding process; heartbeats alone do not count. An uncertain
result never triggers an automatic replay or fallback. Legacy deployments retain
the Fargate canary, and a controlled stack that does not own background work uses
that same path.

Replacing or rolling back a controlled stack's core tasks requires that the stack
is not the current owner: hand ownership to the other stack first. Processes that
lose ownership stop claiming within their poll interval (at most their ten-second
validity window) and finish admitted work under their leases; ECS task
replacement then drains them through `SIGTERM`. `qm down` is not guarded: if the
record still names a stack that has no tasks, set the live stack active to
recover.

Core secret uploads defer activation to a subsequent staged `up --restart core`.
The generic upload path refuses changes to either control credential while a
controlled deployment is recorded, because replacing credentials before coordinating
all running processes would break ownership control. Legacy AWS deployments keep
the task replacement path when ownership control is not configured.

Batch operators can set `QM_DEPLOY_PROGRESS_FILE` to a new absolute file path and
`QM_DEPLOY_PROGRESS_TOKEN` to a unique attempt identifier for candidate `up --yes`.
After all forward service updates have been submitted, the CLI atomically creates
a private JSON receipt with `phase: "monitoring"`, `token`, `orgId`, and `targets`
(the selected workload-to-task-definition mapping). It then continues health
checks, manifest recording, and rollback under the deployment lease. This receipt
only permits the batch runner to release a submission slot; the CLI exit status
still determines success. Use a fresh path and token for every attempt. Missing
receipts must keep the submission slot occupied until the CLI exits.

`sandbox build` is a local validation build of the sandbox layer image. At runtime
sandboxes boot their platform's stock image; tools and skills arrive through the
deployment-layer sync, which every ordinary `up` performs.

Content screening is off by default. Set `securityScreen: { "mode": "observe" }`
to record classifier verdicts without acting on them, or `"enforce"` to quarantine
flagged content. The classifier is the built-in model unless `"classifier": "proxy"`
names an external screener with a provider label and HTTPS endpoint; route its token
through `secretEnv.core.SECURITY_SCREEN_PROXY_TOKEN`.

## Commands

```text
init [dir] [--org id] [--target docker|fly|aws]
check [--json] [--live]
doctor
infra render|build-image|delete-image|delete-task-definitions
conformance [dir] [--static]
plan
up [--yes] [--build-from[=repo]] [--image-label label]
slack render
outputs [--json]
admin-login [--email admin@example.com]
proof scope-key <scope-id>
secrets push [--from file]
status
logs [service] [-f] [--tail n]
down [--purge]
rollback [--to revision-or-sha]
sandbox build [--from image] [--tag tag] [--dry-run]
```

## Administrator login without email

After deployment, run `qm admin-login` to print a single-use login URL valid for
five minutes. Open it and confirm the displayed administrator email. The command
uses the deployment's existing `PORTAL_SESSION_SECRET` and the email in
`ADMIN_GRANTS`; if several admins are configured, select one with `--email`.
QM checks that the selected account still has `org_admin` access when the link
is redeemed. The command creates no account or role grant.

Run it from the deployment directory with its `.env`, or use `--config` and
`--env-file`. Inside a running deployment without a config file, supply
`PORTAL_PUBLIC_URL`, `PORTAL_SESSION_SECRET`, and `ADMIN_GRANTS` through its
environment. The CLI prints only the URL, which is a temporary login credential;
do not publish it or put it in shared logs.

`qm setup` offers email setup separately. Skip it to use administrator login
without Resend or SMTP. For ordinary users before email is ready, the broker can
accept passwords: hash one with `node plugins/auth/src/hash-password.ts
user@example.com` and store the output with `qm secrets set AUTH_PASSWORD_USERS`.
This is meant for onboarding; switch to email links or an identity provider
afterwards. To enable ordinary email login later, rerun `qm setup`
and configure the selected transport's complete credential set and sender,
then push secrets and redeploy. Missing email credentials disable email sign-in;
QM and `qm admin-login` remain available, even if a sender is still configured.

Deployment commands accept `--config`, `--env-file`, and `--sandbox-dir`;
`admin-login` uses only `--config` and `--env-file`. `dev` remains
the contributor worktree loop and is separate from the portable deployment contract.

## Package contract

The `@yc-software/qm/contract` export is the supported programmatic surface for
conformance tests. It exposes the contract version, parsing/rendering
functions, and provider ids without registering arbitrary runtime plugins.
Incompatible directory
changes increment the contract major; optional fields may be added within a
major.

Its one runtime dependency is Nodemailer, which `qm check` uses to verify SMTP credentials. It shells out to Docker with Buildx, Flyctl,
the AWS CLI, and Git. Terraform is operator-run against the module generated by `init`.

---

### Overview (`deploy/README.md`)

# Deployment templates

The files under `deploy/` are service-image and Fly configuration templates used
by the `qm` CLI. They are not a production deployment and contain no cloud
account, workspace, or organization credentials.

The one exception is [`layers/`](./layers/), which is empty in qm itself. A private
fork of this repository keeps its organization's deployment there, and that material
never travels back upstream.

For a new hosted instance, follow the repository-root
[`deployment.md`](../deployment.md) workflow. It:

- asks whether to deploy into the operator's Fly.io organization or AWS account;
- creates the organization's deployment directory under [`layers/`](./layers/) with its
  `qm.config.jsonc`;
- configures verified-email web onboarding before optional Slack;
- returns the exact web, admin connector, user connection, and Slack app URLs;
- deploys package-selected immutable images into the operator's provider account; and
- requires live health, browser chat, connector visibility, and optional Slack
  verification before completion.

The supported command sequence is generated by `qm init` and the selected
provider reference under [`.codex/skills/deploy-qm/references/`](../.codex/skills/deploy-qm/references/).
Do not deploy the example names in these templates directly.

## Topology

The public portal is the only Internet-facing service. It authenticates users
over OIDC and proxies the private Web UI and admin surfaces. Core, Postgres, and
agent computers stay private. The optional Slack surface runs inside core over
outbound Socket Mode.

By default the OIDC provider is the `auth` service qm deploys for itself: a
sign-in broker that emails a one-time link. It is private like every other
non-portal service — the portal republishes only its two browser-facing routes
under `/idp/`, so the issuer and the sign-in pages live on the portal's own
origin while the token, userinfo, and JWKS calls stay on the private network.
The self-hoster supplies an admin address, a verified sender, and a Resend key
or SMTP credentials; the CLI generates the broker's signing key and the portal's
client credentials and wires both sides. Core receives the same `RESEND_API_KEY`
and `AUTH_EMAIL_FROM`, which let admins email invitations to external users from
the admin Users tab or by chatting with QM; both are optional on core, and
without them the invitation is still created and the sign-in link is shared by
hand. Dropping `"auth"` from `services` hands
sign-in back to an external identity provider, which then has to register the
exact `<publicUrl>/auth/callback` redirect.

Connector OAuth clients and the optional Slack bot token pair are entered at
the authenticated admin connector URL returned by `qm outputs`. Secrets are
encrypted in durable storage and are never committed to a deployment directory.
The agent advertises only connectors whose admin configuration is enabled.

`cli/templates/fly/auth.toml` declares an `[http_service]` like every other surface, but the
CLI always deploys it `--flycast --no-public-ips`; on AWS the ALB assertions refuse to
attach it to a load balancer. Never `fly launch` that file by hand — a broker with a
public IP exposes its token endpoint and lets a caller forge the rate-limit client
address.

## Fixtures

[`stacks/`](./stacks/) contains account-neutral contract fixtures. The checked-in
`cli/templates/fly/<service>.toml` files are templates from which the CLI derives
deployment-specific copies under the ignored `deploy/stacks/.generated/`
directory.

---

### Overview (`desktop/README.md`)

# QM desktop

An Electron client for an existing QM web deployment. The welcome screen connects to your workspace; the app then runs the same web UI with its own persistent session.

```sh
cd desktop
npm ci
npm start
```

Enter your QM web URL on first launch. HTTPS is required except for localhost development. Change instances with **QM → Change Instance…** (`Cmd+,` on macOS). You can also set `QM_DESKTOP_URL` when starting the app.

```sh
QM_DESKTOP_URL=http://localhost:3000 npm start
npm run package
```

Packaging writes a native app for the current platform into `desktop/dist/`, registering the `qm-desktop` URL scheme. Use the packaged app for browser sign-in. The macOS build is an unsigned local prototype, without automatic updates in development. It connects to a running server; it does not bundle the QM backend or provide offline agent execution.

## Browser sign-in

When sign-in is needed, the app opens `/auth/desktop` in your default browser. Sign in there as usual, confirm your account, and choose **Open QM Desktop**. The browser returns a two-minute, single-use code bound to the app's proof key and random state. The app redeems it over the instance connection and stores the session in its isolated persistent cookie partition. Closing the app or starting another connection cancels unfinished sign-in requests; it does not sign out sessions whose cookie exchange already completed. The portal must include the desktop sign-in routes; older deployments need an update.

The portal preserves the browser session's identity, original authentication time, and expiration. Redemption uses the existing durable core claim store to prevent replay across portal instances and deployments. The desktop proof key exists only for the disposable, in-progress sign-in attempt and is never sent to the browser. Passwords and identity-provider cookies stay in your browser.

On macOS, the window controls sit inside the sidebar header. With a single session open, drag its title bar to move the window; tab dragging remains available in multi-pane layouts. Hold Command to reveal shortcuts for the first nine sessions in expanded sidebar groups, then press 1–9 to switch. Windows and Linux use Control. Collapsed groups and a hidden sidebar are excluded.

Remote content is sandboxed with no Node.js access. An isolated desktop preload adds window styling and session shortcuts with one sender-validated operation for opening a same-instance setup page in the browser. Off-origin links open in your browser. Microphone, camera, and notification permissions are disabled in this prototype.

The welcome screen uses _Becalmed off Halfway Rock_ (Fitz Henry Lane, 1860), sourced from [Ève Bouffard’s QM brand board](https://www.evebouffard.com/qm-brand). [National Gallery of Art collection record](https://www.nga.gov/artworks/76213-becalmed-halfway-rock).

## Connection setup and previews

Slack installation and personal app authorization start in your browser before QM creates the connection attempt. Finish setup there, then return to the desktop app; it refreshes connection status on focus. Settings contains both personal Slack linking and the app picker. This keeps provider callbacks, installation POSTs, cookies, and tab-local authorization state in one browser.

Same-instance links opened in a new tab use a separate sandboxed window sharing the instance session. External links open in the browser. View → Back and Forward navigate the focused window. Closing the main window closes its previews.

## Computer use

On macOS the app can see and drive running Mac apps through [`desk`](desk/README.md), a Rust helper bundled at `QM.app/Contents/Resources/desk`. `npm run package` builds it with Cargo first, so install Rust (`rustup`) on the packaging Mac. Because QM launches it, grant **QM** Accessibility (and Screen Recording for screenshots) in System Settings > Privacy & Security, then relaunch QM. In development, `npm start` uses `desk/target/release/desk`; override with `QM_DESK_BIN`.

The workspace preload exposes `window.qmDesktop.computer.call`. The `desktop` tool accepts JavaScript with top-level await and preserves bindings within the initiating turn. A hidden, sandboxed Electron renderer evaluates code without Node.js or network access and forwards computer API requests to `desk serve` one at a time (see `desk/PROTOCOL.md`). Each request uses the originating turn's owner and absolute call deadline. The same sender check as browser handoff applies. All installed apps are available subject to macOS permissions.

The first call returns API documentation. Start with `var app = await computer.getApp("Calculator")`, then use `app.getAXState()`, `app.click(id)`, or `app.getScreenshot()` in later calls. Accessibility observations default to changes since the previous state; `{disableDiffing: true}` returns the full tree. Await every operation; background callbacks persist within the turn and should not schedule computer actions. Reset, cancellation, and timeout destroy JavaScript state. Results contain at most four resized JPEG screenshots within an 800 KB base64 budget.

Before each click, scroll or drag, a transparent click-through overlay flies an agent cursor to the target point and desk waits for it to land. Set `QM_DESK_CURSOR=0` to skip the overlay.

## Mac release

`npm run package` builds the unsigned local app. For a signed and notarized build, install a Developer ID Application certificate in the Mac keychain and sign in to that team in Xcode Settings → Accounts. Then run:

```sh
QM_MAC_SIGN_IDENTITY="Developer ID Application: Your Organization (TEAMID)" npm run package:release
```

The release command signs the app, submits an archive using the signed-in Xcode account, waits for notarization, and verifies the stapled ticket and Gatekeeper assessment. It produces the notarized ZIP, SHA256SUMS.txt, and RELEASES.json in desktop/dist. Credentials and certificates stay outside this repository. Builds target the current machine's architecture.

Packaged Apple Silicon builds check the desktop-only feed on startup and every five minutes. Updates download in the background and restart QM automatically without a confirmation dialog. Restart waits while a composer has a pending submission, file processing, staged attachments, or an open paste editor. Older servers without the update-readiness handshake defer automatic restarts until their web UI is upgraded. Open non-web previews, such as PDFs, also defer restart until closed. Interaction pauses briefly while the app saves restart state. Before restarting, QM flushes saved text drafts and pane layout, saves the current instance page, and flushes its persistent browser session. Server-side agent work continues during restart. QM → Check for Updates triggers an immediate check. Development, Intel, Windows, and Linux builds do not use this Mac feed. Versions 0.1.0 and 0.1.1 require one manual upgrade to receive the updater.

Publish the verified ZIP and checksum as desktop-v<VERSION>, then upload RELEASES.json to the dedicated desktop-updates GitHub release using gh release upload desktop-updates desktop/dist/RELEASES.json --clobber --repo yc-software/qm. Update the feed only after the versioned download is published and verified. The feed uses Squirrel.Mac's static JSON format and points to the immutable versioned ZIP; backend and dependency releases never enter it.

`npm run test:electron` exercises actual Electron preview windows, POST popups, session sharing, sandboxing, and browser handoff against a temporary local server.

New chats started in desktop have a stable `desktop` tool. Send a request such as “Use Calculator to compute 17 × 23.” Only human-initiated turns submitted from the desktop app can execute it. Automatic openers, background automation, delegated turns, and retries cannot use computer access. The app must stay open and connected for that turn; switching chats is fine. Continuing the chat in a browser retains the tool definition but returns an unavailable result if it is called. Existing browser-created chats do not gain computer access: start a new desktop chat. Chats from the earlier computer-use trial retain their old tool schema and must also be replaced with a new chat.

Computer requests are bound to the submitting desktop turn. Only one turn can control the computer at a time; other turns receive a busy result until it finishes. A closed window, disconnected stream, stopped run, or helper deadline cancels native execution; reconnecting does not replay actions. After an interrupted action, observe the app before retrying because it may have partially completed. Screenshots return as image content, without base64 duplicated in model-visible text.

### Guided computer access

On macOS, missing computer permissions open a local setup window before the workspace.
It checks Accessibility and Screen Recording automatically, opens their System Settings
panes, and provides the installed app as a draggable icon with a Finder fallback.
Choose **Enable computer access** to request the native prompts, then enable this app
in both settings lists. **Continue to workspace** becomes available when both checks
pass and restarts the native helper to pick up the grants. **Not now** skips setup;
**QM → Computer Access…** reopens it. A permission failure during a task reopens setup
and reports the failure without replaying the action.

For local macOS builds, set `QM_MAC_SIGN_IDENTITY` to a consistent signing certificate
to keep the application identity stable across updates. Ad-hoc builds can leave stale
macOS permission entries: remove the old entry, add the current app, and enable it again.

---

### Overview (`fly/README.md`)

# Sandbox base image

`fly/Dockerfile` builds the shared sandbox **base image**: a minimal generic toolset
(the coding-agent CLIs, AWS CLI v2, the baked Python venv at `/opt/agent-venv`, and the
optional agentic browser engine behind the `INSTALL_BROWSER_ENGINE` build arg). The
directory name is historical — it predates the retirement of the Fly Machines sandbox
backend — and stays because the Dockerfile is consumed by paths that pin it by name.

Where the image is used:

- **Signed releases.** `release-package.yml` builds and signs it as
  `ghcr.io/<org>/qm-sandbox-base` alongside the service images.
- **Local docker sandboxes.** `scripts/local-sandbox-build.sh` builds it as
  `qm-sandbox-base:dev`, then stacks `local/Dockerfile` on top to produce
  `qm-sandbox-local:latest` for `SANDBOX_BACKEND=local`.
- **Deployment layer builds.** A deployment's `sandbox/Dockerfile` may build FROM a
  published base via `qm sandbox build` (a local validation build).

Runtime sandbox backends (sprites, smolmachines, e2b, modal) do **not** boot this
image. Sprites and smolmachines boot their platform's stock image. E2B boots the
template named by `E2B_TEMPLATE_ID` (build and publish it from
`deploy/e2b/e2b.Dockerfile`) or, when unset, E2B's stock `base` template. Modal boots
`MODAL_IMAGE` or, when unset, a default assembled at runtime from a digest-pinned
`node:24-slim` with the sandbox toolset apt-installed on top
(`src/sandbox/modal-client.ts`). The deployment layer's tool descriptors and skills
arrive through the deployment-layer sync.

Deployment-specific tools are NOT baked here — a deployment stacks them via its
sandbox layer (`qm sandbox build` over `<deploy dir>/sandbox/`). `fly/tools/x-api` is
copied into the image by the Dockerfile and its file list feeds the local sandbox
image fingerprint (`src/sandbox/local-sandbox.ts`).

---

### Overview (`deploy/helm/README.md`)

# QM Helm chart

This chart runs core, web-ui (including `/admin`), portal (including `/idp` auth),
and the optional egress proxy. `services.auth` and `services.admin` configure
embedded modules, not separate Deployments or Services.

## Images

There is deliberately no default image tag. Build all enabled services from the
same checked-out QM revision that supplies this chart, or independently verify a
release's images include the combined topology and core `/readyz` endpoint. Old
standalone auth/admin images are not compatible. The chart version is **not** an
application image version.

For example, from the repository root, with a registry you own and are signed into:

```bash
export REGISTRY=registry.example.com/your-project/qm
export TAG=$(git rev-parse HEAD)
for service in core web-ui portal egress-proxy; do
  docker buildx build --platform linux/amd64 --push \
    --build-arg GIT_SHA="$TAG" \
    -f "deploy/$service/Dockerfile" -t "$REGISTRY/$service:$TAG" .
done
cat > images.yaml <<EOF_IMAGES
image:
  repository: $REGISTRY
  tag: "$TAG"
EOF_IMAGES
```

Use the architecture of your cluster. A commit tag identifies the build only if
that checkout is clean; use a unique tag when testing uncommitted changes. For
immutable deployments, set each enabled service's `imageRef` to its full registry
reference, such as `registry.example.com/your-project/qm/core@sha256:<digest>`.
`imageRef` takes precedence over repository/tag composition. Otherwise
`services.<name>.tag` overrides `image.tag`. Configure `imagePullSecrets` for a
private registry. Neither this chart nor these examples publish an official release.

## Cluster setup

Bring these before installing:

- Kubernetes, Helm 3, a reachable durable Postgres database, and backups.
- A supported sandbox backend and its credentials. Kubernetes hosting does not
  configure sandbox provisioning or app hosting automatically.
- DNS and an ingress controller. For TLS, provision the referenced certificate or
  install cert-manager with the configured ClusterIssuer.
- The signing/encryption secrets, portal session secret, first administrator grant,
  sign-in allowlist, auth client/signing configuration and email transport described
  in [the deployment reference](../../docs/porter.md) and
  [`src/deployment/secret-schema.ts`](../../src/deployment/secret-schema.ts).

Store credentials in a Kubernetes Secret and use `envFrom`, or supply `secretEnv`
through a protected values file. Helm stores `secretEnv` values in its release
history. Never commit these values. With `envFrom`, explicitly provide both
`AUTH_ALLOWED_EMAILS` and `OIDC_ALLOWED_EMAILS` (or both corresponding domain
variables); Helm cannot inspect externally supplied Secret values. When `envFrom`
is present, the chart does not derive OIDC allowlist environment entries from
`env` or `services.auth.env`, since those entries would override a potentially
narrower policy in an external Secret. Set OIDC explicitly in values or the external
Secret, including when mixing auth values with `envFrom`. Without `envFrom`, auth
allowlists in `env` or `services.auth.env` propagate unless explicit OIDC values
in `env` or `secretEnv` override them. Auth allowlists in `secretEnv` are aliased
inside the chart-managed Secret; later `envFrom` sources retain their precedence.

Only portal should normally have public ingress. `ingress.service` accepts an
enabled `portal`, `core` or `web-ui`, not embedded modules or the TCP egress proxy.
Exposing core or web-ui is an operator choice, not required for the portal setup.

A minimal **Kubernetes configuration fragment**, not a complete installation:

```yaml
publicUrl: https://qm.example.com
imagePullSecrets:
  - name: registry-credentials
envFrom:
  - secretRef:
      name: qm-runtime
ingress:
  enabled: true
  service: portal
  hosts: [qm.example.com]
  className: nginx
  clusterIssuer: letsencrypt-prod
services:
  core:
    replicas: 2
    env:
      SESSION_STORE: postgres
      SNAPSHOT_STORE: s3
      TRANSFER_STORE: s3
      S3_BUCKET: qm-example-data
      S3_REGION: us-west-2
```

This fragment expects `DATABASE_URL`, a remote `SANDBOX_BACKEND` and S3 credentials
in `qm-runtime`; see
[Core data and rollout behavior](#core-data-and-rollout-behavior). After supplying
runtime config and `images.yaml`, render and inspect before applying:

```bash
helm lint deploy/helm -f images.yaml -f cluster.yaml
helm template qm deploy/helm -f images.yaml -f cluster.yaml
helm upgrade --install qm deploy/helm --namespace qm --create-namespace \
  -f images.yaml -f cluster.yaml --wait --timeout 10m
```

Validate the complete installation: test your database, ingress/TLS,
sign-in, real turns, sandbox provider and backup/restore in your target cluster.

## Core data and rollout behavior

Core keeps durable state in two places: Postgres (`DATABASE_URL`) and an object
store for file bytes. Choose one of two layouts.

**Shared storage (recommended, supports multiple replicas).** Set `DATABASE_URL`
with `SESSION_STORE=postgres`, `SNAPSHOT_STORE=s3`, `TRANSFER_STORE=s3`, `S3_BUCKET`
and `S3_REGION` (optionally `S3_PREFIX`) on core, use a remote `SANDBOX_BACKEND`,
and leave `services.core.persistence` disabled. Sessions and runs then live in
Postgres, and uploaded artifacts, session shares, published-app source archives and
sandbox file transfers go to the bucket, so replicas share that state and pods can
be replaced with rolling updates. Production core refuses to start without
`DATABASE_URL` and `SESSION_STORE=postgres`, or with `RUN_STORE=memory`. S3
credentials come from the standard AWS chain, such as a pod IAM role bound through
`serviceAccount.create=true` with `serviceAccount.annotations`, or
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` in the runtime Secret. Any S3-compatible
store works outside AWS: set `AWS_ENDPOINT_URL_S3` to its endpoint (for example
Cloudflare R2, Tigris or Google Cloud Storage with HMAC keys) and `S3_REGION` to
what it expects, such as `auto` for R2. `/data` is then
pod-local, and a few things still live only on the replica that created them:
agent workspace files outside `artifacts/` (including files shared from there) and
pending ChatGPT device logins. The chart sets no session affinity, so these can be
unavailable from another replica and are lost on pod replacement. Switching an
existing volume-backed install to S3 does not migrate bytes already under `/data`;
copy `/data/docstore/` to the bucket under `S3_PREFIX` and `/data/session-shares/`
under `<S3_PREFIX>session-shares/` first, or existing uploads and shares become
unreadable.

**Local volume (single replica).** Without an object store, core writes file bytes
under `/data`, and they are lost when the pod is replaced even with Postgres
configured. `services.core.persistence.enabled=true` mounts a ReadWriteOnce PVC at
`/data`, sets `DATA_DIR=/data` and uses group 1000 for the Node image's volume
permissions. Storage drivers must support `fsGroup`, or an existing volume must
already be writable by UID/GID 1000.

Persistent core supports zero or one replica, with `Recreate` deployment strategy
so upgrades do not mount the same data concurrently. Expect downtime on upgrades;
shared-storage multi-replica operation over a volume is not supported. Use the
object-store layout for high availability. `replicas: 0` scales down without
discarding the claim.

The chart-created `<fullname>-core-data` claim has `helm.sh/resource-policy: keep`;
it is retained on uninstall or when persistence is removed. It is not a backup.
Record the actual claim name before uninstalling. Reinstall with
`services.core.persistence.existingClaim=<name>` and persistence enabled to reuse
it, rather than trying to create the retained claim again. Existing claims are
never created or deleted by this chart. Delete retained data manually only when
you intend to discard it. A null `storageClass` selects the cluster default; `""`
requests no storage class. Existing-claim size and class are managed externally.

`services.<name>.port` controls the process, Service and probes; conflicting
`PORT` values are rejected. The egress-proxy image's Envoy listener is fixed at
48080, so other proxy ports are rejected rather than rendered as broken Services.

Core liveness uses `healthPath: /healthz`; readiness uses
`readinessPath: /readyz`. Its startup probe allows five minutes for initialization
before liveness begins. Override `startupProbe` fields as needed, or set it to null
to disable. Helm merges probe maps, so set `startupProbe.httpGet: null` when
switching to an `exec` or `tcpSocket` handler. When overriding endpoint paths,
update the startup probe too. Database unavailability should remove core from
endpoints, not restart it through liveness.

## Regression tests

Install Helm 3 on PATH (or set `HELM_BIN`) and run:

```bash
node --test test/helm-chart.test.ts
```

These tests render real manifests and parse them with Helm's YAML parser. Missing
Helm is a local skip and a CI failure. They do not replace live Kubernetes tests.

---

### Overview (`deploy/layers/README.md`)

# Organization layers

A private source fork can keep its deployment material under `deploy/layers/<org>/`.
Core code may change independently to implement the organization's desired behavior.
Public source checkouts keep private deployment material in a separate private
repository. Package deployments need only that deployment repository, with no source
fork. See [the README](../../README.md#customize-your-instance) for both paths.

Upstream qm keeps only this shared README here. Organization layers never travel
upstream; `upstream-pr` checks that boundary when a contribution is requested.

## Creating a layer

```bash
node cli/bin/qm.ts init deploy/layers/<org> --org <slug> --target <fly-or-aws>
```

`qm init` writes the deployment config, the secret-name example, the sandbox and provider
scaffolding, an operator runbook, and a per-directory `.gitignore` that keeps `.env` values
and Terraform state out of Git. Generate the layer rather than hand-building it so that
`.gitignore` comes with it; the root `.gitignore` covers the same files as a backstop.

The result, described in full in [`docs/deploy-directory.md`](../../docs/deploy-directory.md):

```text
deploy/layers/<org>/
  qm.config.jsonc          the deployment config; committed, no secret values
  .gitignore               scaffolded; keeps .env and tfstate out of Git
  .env.example             computed secret names, never values
  .env                     local secret values; never committed
  sandbox/                 org tools and skills for agent computers
  plugins/<name>/          org-specific service images
  infra/                   provider infrastructure and tfvars, on AWS targets
  slack-app-manifest.yml   generated bot manifest
  deployment.md            operator runbook
```

Point the CLI at a layer with `--config`:

```bash
node cli/bin/qm.ts check --config deploy/layers/<org>/qm.config.jsonc
```

For a separate deployment directory, substitute its path in both commands.
Deploy modified services with the explicit `--build-from` workflow in the README.
Run the CLI from the tree as shown. `npm exec qm` does not work in a source checkout
because the workspace symlink points at `cli/`, which is unbuilt.

## Nearby directories

`deploy/stacks/` holds account-neutral contract fixtures used to test the Fly backend, and
`deploy/<service>/` holds the service image and Fly templates the CLI renders from. Neither
is a place for organization material.

## The rule

Nothing under `deploy/layers/` may reach upstream qm: not the config, not the sandbox
tools, not the infrastructure coordinates, and not the names of systems or people that
appear inside them. Secrets never enter Git at all, in this directory or any other. They
belong in the provider's encrypted secret store, with local values only in the gitignored
`.env`.

---

### Overview (`deploy/stacks/README.md`)

# Fly deployment fixtures

The directories here are account-neutral contract fixtures for the `qm`
Fly backend. They are not production deployments and CI does not deploy them.

To self-host qm, start from the repository-root
[`deployment.md`](../../deployment.md). It creates the organization's deployment directory
under [`../layers/`](../layers/), asks for the operator's Fly organization or AWS
account before mutation, and generates the optional bot manifest. It generates a
separate Slack SSO manifest only when Slack is selected as the OIDC provider.

The packaged `cli/templates/fly/<service>.toml` files are the service templates.
The CLI derives per-deployment copies under the ignored
`deploy/stacks/.generated/` directory. Secrets belong in the provider's encrypted
secret store and never in these fixtures.

---

### Overview (`desktop/desk/README.md`)

# desk

A small Rust program that lets an agent see and drive Mac apps: list running apps, read a window's accessibility tree as numbered elements (plus an optional screenshot), and run batches of clicks, typing, keys, scrolls and drags. It speaks JSON lines on stdio; see `PROTOCOL.md`. It copies the _shape_ of Codex's Computer Use: AX tree first, indexed targets, events posted to the target pid. No Codex code or prompt text is used.

The QM desktop app bundles the release binary at `QM.app/Contents/Resources/desk` and spawns `desk serve` on first use, so macOS attributes Accessibility and Screen Recording to QM. `npm run package` in `desktop/` builds it with Cargo first.

**Status:** exercised in the signed macOS desktop trial for permission setup, app observation, and Calculator interaction.

## Build and try it alone

```sh
cargo build --release
./target/release/desk permissions --prompt
open -a Calculator
./target/release/desk apps
./target/release/desk observe Calculator --png /tmp/calc.png
./target/release/desk act Calculator '[{"type":"click","target":{"index":12}}]'
```

Run alone, macOS grants permissions to the terminal that launches desk, so switch your terminal on under System Settings > Privacy & Security > Accessibility and Screen Recording, then restart it.

App listing shows regular Mac apps. Explicit names, bundle IDs, and full application paths can also target accessory apps with an accessible, visible window. Background-only processes are excluded; observation and control remain subject to macOS permissions and app support.

## Platform considerations

- **Background input is experimental.** Desk sends target-only synthetic activation and input without raising the app, but some apps may ignore it. `{"type":"raise"}` is an explicit foreground action. Live Mac verification is still required.
- **Screenshots.** On macOS 14+ they use ScreenCaptureKit's `SCScreenshotManager`. The app's own menus, popovers and sheets in front of the target window are composited in, and the image grows to include any part outside the window, across displays if needed; `frame` says which screen area it covers. macOS 12.3–13 uses `CGWindowListCreateImage` for the main window only; menu/panel composition requires macOS 14+. desk links ScreenCaptureKit, so it needs macOS 12.3 or later. `pixels_per_point` still needs checking on a Retina screen.
- **`_AXUIElementGetWindow`** (private, widely used) maps the AX window to a window id for capture and event targeting.
- **Clipboard.** `paste` replaces the general pasteboard, presses Cmd+V and restores the previous contents only once the focused field visibly contains the pasted text. When it cannot confirm that (canvas or terminal views without readable text, password fields, slow apps), it leaves the pasted text on the clipboard and says so, rather than risk the app pasting the user's clipboard. Clipboard data is available before Cmd+V is sent, so the receiving app does not need a callback from desk to read it.
- **Keyboard layout.** `type_text` reads the current layout with `TISCopyCurrentKeyboardLayoutInputSource` and `UCKeyTranslate` on desk's main thread (TIS calls are main-thread only on recent macOS).
- **Accessibility enablement.** On first observation, desk requests `AXManualAccessibility` and uses `AXEnhancedUserInterface` only if an empty web area still needs it. Prior settings are restored on normal exit.
- **Speed.** Element attributes are read in batches. Actions and settability are cached until that app posts an accessibility notification; frames, values and children are always read fresh. Offscreen pruning and a 6,000-element read cap bound each walk.
- **Universal binary:** `cargo build --release --target aarch64-apple-darwin --target x86_64-apple-darwin`, then `lipo`.

---

### Overview (`plugins/admin/README.md`)

# Admin plugin

A minimal **admin surface** for the qm — the operator/admin plane from
spec §14, delivered as an _added_ plugin (like the Slack plugin). It is a separate
process that talks to the core **only** over the admin governance API; the core has
zero dependency on it. Don't run it and nothing about the core changes.

You reach it through the **portal** (real SSO); the surface trusts the portal-synthesized
`admin=<sub>` cookie as identity and asks the **core** whether that principal is an admin
(`GET /api/whoami` → core `GET /v1/admin/whoami` → `canAdminister`). It holds **no admin id list**
of its own. Pick a scope, then either **edit governance** (command policy, SOUL, egress),
**manage users** (the org-wide **Users** tab), or read the **observability** views —
History, Files, Live, Errors, Audit, Skills, Crons, Deployments, Volumes.
The **Users** tab (org-wide, org_admin-only) lists everyone who has
used the agent (from session metadata — no content) with admin status joined, plus the
authoritative grant list, and lets an org_admin **promote** a principal to org_admin
or **revoke** — every mutation attributed and audited, the last org_admin protected.
**Invite teammate** adds someone by email with Member or Org admin access. The invite form grants access without an
expiration date. Teammates appear in Users before their
first session. Addresses already admitted through the domain or allow-list may receive a
sign-in invitation too. Re-inviting an admin never removes their admin role.
Invitations are durable and listed with their access status. Revoke ends access immediately.
Invitation email uses Resend when configured. Teammate invitations contain a single-use sign-in link valid for 24 hours. If delivery is unavailable or fails, Admin shows the same link with a copy control after the invitation is created. Redemption checks current membership and claims the token through durable storage; revocation or a new invitation invalidates earlier links. Company access displays the web email domain and the local Slack allow-list; these are deployment settings, not editable Admin policy.
The existing external-user API still requires an expiry and rejects org members.
Teammate invitations and revocations are restricted to the Admin surface.
(`org_admin` is the only supported role for now; `team_admin` was removed — team-scoped admin
observability is future work. See `src/admin/admin-service.ts`.) History (newest sessions, org-wide or narrowed
to one scope, by origin, drilling into transcripts with per-turn model-context breakdowns), Files
(workspace contents), and Live (ongoing/recent runs) are **top-down content** views: an
org-scope query spans the whole org; a narrower scope is limited to that scope. Every
action is authorized in the core and audited.

## Run

```bash

PORT=8080 ORG_ID=acme npm start


cd plugins/admin
CORE_API_URL=http://localhost:8080 CORE_ORG_ID=acme PORT=8090 npm start

```

No separate build command is needed. The Node 24+ server bundles the admin Lit modules once at startup and embeds them in the existing CSP-hashed script. The backend uses `node:http` and native TypeScript, with optional Sentry error reporting.

The admin tabs render through the Lit modules in `ui/`, with `ui/admin.ts` as their shared entry point. They use the existing light-DOM elements, classes, and styles without layout wrappers. Settings drafts, validation, dirty state, and save feedback render from state; list views own filtering, pagination, and editor state. Stable row keys preserve focus. Shared table, card, and list templates live in `ui/shared.ts`. Specialized safe Markdown, XML, and tool-output formatters remain shared adapters in the shell.

The shared admin controller retains routing, API calls, and change-review dialogs. Successful saves commit the submitted snapshot, preserving newer edits made while a request was in flight. Scope and render generations prevent stale requests from replacing a newer page. Related settings refresh independently so changing one section cannot discard neighboring drafts. Run `npm test` and `npm run typecheck` from this directory after changing the UI.

Env: `CORE_API_URL` (default `http://localhost:8080`), `CORE_ORG_ID` (default `acme`),
`PORT` (default `8090`), `CORE_SIGNING_SECRET` (required outside isolated development), and
`INBOX_USERS` (the same comma-separated principal allowlist used by Inbox and Calendar). The
portal also supplies a short-lived `x-portal-identity` token, which this surface forwards to core.
There is **no** `ADMIN_PRINCIPALS` — admin identity + role + scope live solely in the core's
durable, mutable `admin_grants` store, and this surface derives admin status from it via
`/api/whoami`. `ADMIN_GRANTS` (env) is now only the **one-time seed** for an empty store; after
that, admins are promoted/revoked at runtime through the Users tab (a redeploy never clobbers
runtime grants).

## How it stays safe

- **The browser never holds an admin credential.** The portal supplies the verified identity in a
  short-lived signed header; the compatibility cookie alone is not accepted when auth is configured.
  Core verifies the token and decides admin-ness on every action.
- **All authority is enforced in the core**, not here (spec §14): the core authorizes
  every read and write against `admin_grants` (`canAdminister`) and refuses scopes you don't
  administer (403). `GET /api/whoami` reports that same grant state (it grants no new power).
- **Content reads are scope-authorized and audited.** Transcript, model-request, file, memory,
  keychain, and other sensitive bodies are served only to admins of the owning scope, and every
  read lands in the audit log.

## Endpoints it serves

`GET /` (UI) · `GET /healthz` · `GET /api/me` + `GET /api/whoami` (identity + derived admin
status) · `POST /api/logout` ·
`GET /api/scopes/:scopeId` + `PUT /api/scopes/:scopeId/:resource`
(`command-policy|soul|egress`) ·
`GET /api/{errors|audit|crons|deployments|skills|sessions|runs|files}?scope=` ·
`GET /api/sessions/:id?scope=` (transcript) · `GET /api/sessions/:id/llm?scope=`
(captured model requests) · `GET /api/files/read?id=` + `GET /api/files/download?id=` (document content) ·
`GET /api/retention` (org-wide) · `GET /api/users` (org-wide; roster + grants) ·
`POST /api/grants` (promote) · `DELETE /api/grants/:principalId?scope=&role=` (revoke) ·
`POST /api/external-users` (invite) · `DELETE /api/external-users/:email` (revoke) — all
proxied to the core's `/v1/admin/…` with the admin actor injected. Grant and external-user
mutation is **org_admin-only** (enforced in the core, not the surface).

## Slack setup

Hosted Add to Slack is shown only when core reports `installAvailable`, enabled by
`QM_SLACK_SERVICE_URL` and its matching `QM_SLACK_SERVICE_TOKEN`. Public deployments
without that service lead with the preconfigured Slack app manifest and token form.
Connected hosted apps offer Re-add to Slack; custom apps must be disconnected before
switching to the hosted app. Both paths retain the custom app setup instructions.

---

### Overview (`plugins/auth/README.md`)

# auth — the built-in sign-in broker

An OIDC authorization server that speaks exactly the subset
[`plugins/portal`](../portal/src/oidc.ts) consumes. Instead of an
external identity provider, people prove who they are by opening a one-time link
emailed to an allowed address.

## Endpoints

| Route                                   | Reached by                                  | Notes                                                                                          |
| --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `GET /authorize`                        | browser, via the portal at `/idp/authorize` | validates the request and renders the email form                                               |
| `POST /authorize`                       | browser, via the portal                     | with email configured, answers with the same confirmation page, then emails a link out of band |
| `GET /verify`                           | browser, via the portal at `/idp/verify`    | consumes the link and redirects to the portal's `/auth/callback` with a code                   |
| `POST /token`                           | portal, over the private network            | HTTP Basic client auth, authorization-code grant, PKCE S256                                    |
| `GET /userinfo`                         | portal, over the private network            | Bearer access token, verified statelessly                                                      |
| `GET /.well-known/jwks.json`            | portal, over the private network            | the ES256 public key                                                                           |
| `GET /.well-known/openid-configuration` | operators                                   | discovery, for debugging                                                                       |
| `GET /healthz`                          | the platform                                | liveness                                                                                       |

The broker is never published directly. The portal republishes only the three
browser-facing routes under `AUTH_BROKER_PREFIX` (`/idp` by default), which is
why the issuer is `https://<portal>/idp` and the sign-in pages share the portal's
origin, cookies, and CSP.

## Durability

Nothing about a sign-in lives in this process. The sign-in link, the
authorization code, and the access token are self-contained JWTs sealed with
purpose-separated keys derived from `AUTH_TOKEN_SECRET`; the id_token is signed
with the P-256 key in `AUTH_SIGNING_JWK`. Single use — of both the link and the
code — and the send rate limits are claimed through core's Postgres-backed
`ReplayDedupe` over the chassis signed core client, so a restart, a blue-green
deploy, or a second instance cannot resurrect a spent link. If core cannot record
a claim the broker fails closed and refuses the sign-in.

## Configuration

Values below are set by `qm` from the deployment config and the secret store.
Authentication keys and an email allowlist or domain remain required. Email
delivery is optional; missing or incomplete email configuration disables delivery.
Fully supplied email configuration is validated at boot.

| Variable                                                                        | Source                                                                                                                      |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_ISSUER`, `AUTH_CLIENT_ID`, `AUTH_REDIRECT_URI`                            | derived from `publicUrl`                                                                                                    |
| `AUTH_CLIENT_SECRET`, `AUTH_TOKEN_SECRET`, `AUTH_SIGNING_JWK`                   | generated by `qm setup`                                                                                                     |
| `AUTH_ALLOWED_EMAILS`, `AUTH_ALLOWED_EMAIL_DOMAIN`                              | the operator's admin address or domain                                                                                      |
| `AUTH_PASSWORD_USERS`                                                           | optional `<email>:<hash>` entries for password sign-in (below)                                                              |
| `AUTH_PASSWORD_LIMIT_PER_EMAIL`, `AUTH_PASSWORD_LIMIT_PER_IP`                   | optional, attempts per `AUTH_SEND_WINDOW_S` (defaults 10 and 30)                                                            |
| `AUTH_EMAIL_FROM`                                                               | the operator's verified sender                                                                                              |
| `AUTH_BRAND_NAME`                                                               | `botName` in the deployment config; the Admin page's live branding, when set, takes precedence on rendered pages and emails |
| `AUTH_EMAIL_TRANSPORT` and the chosen transport's variables (below)             | the operator's email provider                                                                                               |
| `AUTH_LINK_TTL_S`, `AUTH_CODE_TTL_S`, `AUTH_ACCESS_TTL_S`, `AUTH_REQUEST_TTL_S` | optional, capped                                                                                                            |
| `AUTH_SEND_WINDOW_S`, `AUTH_SEND_LIMIT_PER_EMAIL`, `AUTH_SEND_LIMIT_PER_IP`     | optional                                                                                                                    |
| `CORE_API_URL`, `CORE_ORG_ID`, `CORE_SIGNING_SECRET`                            | the chassis core block                                                                                                      |

The signing key is single, not a set: rotating it means redeploying, and links
minted by the previous key stop verifying at that moment.

## Password sign-in for getting started

A fresh deployment can reach its first sign-in without Resend, SMTP, or an
identity provider. Set `AUTH_PASSWORD_USERS` to comma-separated `<email>:<hash>`
entries and the sign-in page offers an email-and-password form beside (or
instead of) the email-link form. A successful password sign-in creates the same
remembered browser session and the same authorization code as an email link, so
admin bootstrap, allowlists, and everything downstream behave identically. Each
address must still be permitted by `AUTH_ALLOWED_EMAILS`,
`AUTH_ALLOWED_EMAIL_DOMAIN`, or an invitation.

Generate a hash from a checkout (the password is read from a hidden prompt, or
from stdin when piped, and must be at least 12 characters):

```sh
node plugins/auth/src/hash-password.ts admin@example.com
```

Then, from the deployment directory:

```sh
qm secrets set AUTH_PASSWORD_USERS 'admin@example.com:scrypt$15$8$1$...'
```

Only scrypt hashes are stored; a plaintext password in the list refuses boot.
Passwords are compared in constant time, and attempts are rate limited per
account and per client address through the same durable claims as link sends,
so a core outage fails closed. Password sign-in is meant for onboarding. Once
the deployment is running, configure an email transport or an external identity
provider and unset `AUTH_PASSWORD_USERS`; the page says as much.

## Invited external users

An address an org admin has invited as an external user (Admin → Users, or by
asking the agent) may sign in until its expiry even though it is on neither
`AUTH_ALLOWED_EMAILS` nor `AUTH_ALLOWED_EMAIL_DOMAIN`. The env list is checked
first and settles the answer on its own; only an address it does not cover is
looked up in core over the signed core client (`GET
/v1/auth/broker/email-allowed`), at every step — when the link is requested,
when it is opened, and when the code is exchanged — so a revoked or expired
invitation stops working at once. A lookup that fails or times out counts as not
allowed. One of the two env variables is still required at boot.

## Email transport

`AUTH_EMAIL_TRANSPORT` selects Resend (the default) or SMTP. When
`AUTH_EMAIL_FROM` or any of the selected transport's credentials are absent, the
broker starts without email delivery. The sign-in page and submissions return
503 with “Email delivery isn't configured”; no form is offered and no email is
claimed to have been sent. Previously issued links and codes keep their normal
expiration and single-use checks.

To enable email sign-in, configure the selected transport's credentials and
`AUTH_EMAIL_FROM`, then restart. The sender must be verified and may be written
as `Name <sender@example.com>`. Supplying only part of the configuration leaves
email delivery disabled. Fully supplied but invalid configuration is refused at boot.

| Transport | Variables                                                                             | Notes                                                                                                                                                                                                                                                   |
| --------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resend`  | `RESEND_API_KEY`                                                                      | A key with send access from <https://resend.com/api-keys>. The sending domain must be verified under Domains, which needs DNS records; an unverified domain fails at delivery, not at boot.                                                             |
| `smtp`    | `SMTP_HOST`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and optionally `SMTP_PORT`, `SMTP_TLS` | Any relay. `SMTP_PORT` defaults to `587`. `SMTP_TLS` defaults to `implicit` on port `465` and `starttls` otherwise; `none` is refused in production, and a relay that does not advertise STARTTLS is refused rather than sent credentials in cleartext. |

`qm doctor` proves the Resend key is accepted, or that the SMTP relay is
reachable and answers. Neither proves deliverability — the first real sign-in
link does that.

## Known trade-offs

The sign-in link carries its token in the URL **fragment**, which browsers never
put on the wire, so it reaches no access log, no proxy, and no `Referer`. The
confirmation page moves it from `location.hash` into the form and calls
`history.replaceState`, so it does not linger in the address bar or the history
entry either; the value is held in `sessionStorage` for the life of the tab so a
reload still works. That last step needs JavaScript — the page says so, and the
link can be re-requested if a mail gateway strips the fragment.

The per-mailbox send budget is keyed on the mailbox _and_ the requesting client
address, so a stranger cannot exhaust a known user's budget and lock them out;
the per-address budget is what bounds a single source. Both are durable claims,
so they survive restarts, and both are keyed by an HMAC under
`AUTH_TOKEN_SECRET` so another plugin holding the shared core signing secret
cannot compute — and pre-claim — a chosen mailbox's slots.

## Remembered browsers

Verifying an email link creates a durable broker session in core's Postgres store.
The browser receives an HttpOnly, Secure, SameSite=Lax cookie scoped to the issuer
path (`/idp` behind the portal). Core stores only a hash of the random token; the
cookie also carries a broker-only MAC, so another plugin's core signing credential
cannot manufacture an email sign-in. Rotating `AUTH_TOKEN_SECRET` invalidates all
remembered cookies.

`AUTH_SESSION_IDLE_S` defaults to 30 days and `AUTH_SESSION_ABSOLUTE_S` defaults
to 90 days. Both are whole seconds; the idle limit must not exceed the absolute
limit, and the absolute limit cannot exceed 90 days. Reauthorization slides the
idle expiry without moving the original authentication time or absolute expiry.
The broker rechecks email eligibility before issuing and exchanging a code.

`prompt=login` and `max_age=0` always require a fresh email. A positive `max_age`
compares against the original email authentication time, also returned as
`auth_time` in the ID token. `prompt=none` returns `login_required` when silent
reauthentication is unavailable. Invalid prompt and max-age values fail closed.

The portal forwards only the broker cookie to `/idp` and preserves all broker
Set-Cookie headers. Ordinary `POST /auth/logout` clears both local cookies.
`POST /auth/logout?everywhere=1` additionally revokes all remembered browsers for
the authenticated email principal. The signed core endpoint
`POST /v1/auth/broker/sessions/revoke` accepts `{ "email": "user@example.com" }`
from that user's portal identity or an authorized administrator. These operations
prevent future silent reauthentication; already-issued stateless portal sessions
remain valid until their normal expiry.

When hosted by a portal with trusted OIDC and an explicit provider label configured,
the embedded broker receives
its trusted provider label and shows an alternate sign-in link throughout the
email flow. The link starts a fresh trusted login through the portal, without
submitting an email or linking the two identities. Standalone brokers omit it.

---

### Overview (`plugins/onboarding/README.md`)

# Onboarding plugin

Adds a memory-first onboarding skill: it connects the user's accounts, reads a light
snapshot of their real work, and proposes concrete automations — then persists the
profile in memory.

The core discovers plugin skills from `plugins/*/skills` by default and installs them
into the org skill catalog at startup. The onboarding skill is therefore visible in a
user's DM as `/onboarding`, and the core injects a pending-onboarding prompt when the
personal memory notebook does not contain a completion or dismissal marker for the
current version.

## Source Of Truth

Onboarding state is readable memory, not a separate database table:

```md
## Onboarding

- Onboarding: completed v2 on 2026-06-09.
- I write in direct, straightforward, concise sentences.
```

`completed v2` suppresses the pending prompt. `dismissed v2` also suppresses it until
the onboarding version changes — bumping the version re-triggers onboarding so an
improved flow reaches users who finished an earlier one.

SOUL updates are optional and should only hold compact first-person operating
instructions.

---

### Overview (`plugins/portal/README.md`)

# Portal — the public SSO front door

The portal is the **one** publicly-reachable app in the stack. It replaces the per-surface
`fly proxy` tunnels (one per app, each on its own local port) with a single signed-in URL that
fronts every surface. It does provider-neutral **OIDC** sign-in (Google Workspace
is the email-first deployment default) and reverse-proxies — over Fly's private 6PN — to the
surfaces, which all stay **private** (no public `[http_service]` of their own):

| Path        | → upstream        | Notes                                                                         |
| ----------- | ----------------- | ----------------------------------------------------------------------------- |
| `/*` (root) | `<prefix>-web-ui` | Pi web UI SPA, root-mounted (`/web-ui/*` 308-redirects to root for old links) |
| `/admin/*`  | `<prefix>-admin`  | governance — admin access derived from the core (`canAdminister`)             |

User deployments are never served on this authenticated origin; they use the dedicated apps domain.

Set `PORTAL_APPS_DOMAIN` to a child domain of the company portal, with matching
core `DEPLOY_APPS_DOMAIN`, gateway/session secrets, wildcard DNS and TLS. App
hostnames can point at the same portal ingress: portal forwards them to core's
deployment gateway before its own routes. Core authorizes each app request and
removes gateway cookies before forwarding to the app. Each app has a separate
browser origin; cross-origin browser writes are rejected. Keep the session cookie
domain within the company, never a parent shared with other companies.
For companies using the additional trusted OIDC entry, set core
`DEPLOY_APPS_LOGIN_PATH=/auth/trusted/login` so app sign-in uses that identity
provider. The default remains `/auth/login`.

It is a thin `node:http` server (native TS type-stripping), like the other
surfaces, and it does **not** import the core.

## How a request flows

1. **Sign in** — `GET /auth/login` starts an OIDC Authorization-Code flow with PKCE(S256) +
   `state` + `nonce`, all sealed into a short-lived signed `portal_oidc_tmp` cookie.
2. **Callback** — `GET /auth/callback` exchanges the code over the TLS back-channel
   (confidential client, `client_secret_basic` + the PKCE verifier), then binds the id_token
   signature and payload (`nonce`/`aud`/`iss`/`sub`/timestamps and, for Slack, the `team_id`
   workspace pin) against the configured HTTPS JWKS, then reads
   the subject from userinfo. The verified `sub` **is** the core principal id. It mints a
   signed `portal_session` cookie (`{sub, org, auth, exp}`, HMAC, 7-day sliding lifetime with a
   30-day absolute maximum by default).
3. **Proxy** — every other path requires a valid session. The portal picks the upstream by the
   **exact first path segment**, strips the prefix, and proxies to the private upstream,
   synthesizing the surface cookie for compatibility and attaching a short-lived signed portal
   identity. Surfaces pass that identity to core, which verifies it before any user-scoped action.
   Before proxying, the portal asks core for the session subject's canonical principal
   (`GET /v1/principals/:id/canonical`, cached for a minute). A sign-in that an org admin has
   linked to another identity therefore acts as that person's canonical principal; see
   [`docs/principal-links.md`](../../docs/principal-links.md).

## Additional trusted entry (PoC)

An optional OIDC entry route at `/auth/trusted/login` operates alongside the
existing `/auth/login` provider. Configure `PORTAL_TRUSTED_OIDC` as JSON with
`issuer`, `authEndpoint`, `tokenEndpoint`, `userinfoEndpoint`, `jwksUri`, and
`clientId`; supply its distinct `PORTAL_TRUSTED_OIDC_CLIENT_SECRET` separately.
Register the exact `${PORTAL_PUBLIC_URL}/auth/trusted/callback` URL at that provider.
Endpoints must use HTTPS in production. This is an operator-configured trust
relationship: the additional provider must enforce the destination company's
admission policy before issuing tokens. Primary-provider email and workspace
restrictions do not apply to this separate route.

The route verifies OIDC signature, issuer, audience, nonce, PKCE and subject binding,
and consumes login state through core's durable replay store. Principals are scoped
to the issuer and subject, without automatic linking to email or Slack identities.
No administrator role is assigned by default. To provision administrators on this
entry path, set `PORTAL_TRUSTED_OIDC_ADMIN=1` on portal and set core's
`TRUSTED_OIDC_ADMIN_ISSUER` to the exact trusted issuer. This requires a distinct
`PORTAL_IDENTITY_SECRET` and durable replay storage. After verified provider login,
portal submits a short-lived, single-use, purpose-bound assertion; core creates a
normal durable organization-admin grant before portal issues the session. Primary
login and impersonation cannot request this promotion. Deactivated users stay blocked.
Existing sessions need a fresh trusted sign-in. Revoking a grant removes admin access
until the next successful trusted sign-in; disabling provisioning prevents future
grants but does not remove existing ones. Company login and Slack integration retain their
existing configuration. Deployment CLI secret wiring, account linking, and live
qualification are pending; this is not a released deployment feature.

When `PORTAL_TRUSTED_OIDC_LABEL` is explicitly set, successful trusted sign-in
makes the portal remember the configured issuer in a
host-only, HttpOnly browser preference for one year. Ordinary
`/auth/login` requests then return to the trusted route, including after logout
or session expiry. Session renewal and logout also remember existing trusted
sessions. A successful primary or operator sign-in clears the preference. The
preference only selects a provider; it grants no identity or administrator access.
A new browser or cleared cookies still uses the primary provider by default.
Use `/auth/login?provider=primary` to explicitly select the primary provider; the
trusted sign-in error page offers this alternative.

## Operator admin login without email

Run `qm admin-login` with the deployment's configuration and secrets to generate
a private admin login URL. Open it and confirm the displayed email. The URL
expires after five minutes and works once. With multiple email-based
`ADMIN_GRANTS` administrators, use `qm admin-login --email admin@example.com`.
The command can also run with `PORTAL_PUBLIC_URL`, `PORTAL_SESSION_SECRET`, and
`ADMIN_GRANTS` in its environment, without a deployment config file.

`GET /auth/admin-login` only renders the confirmation page. Its script moves the
token out of the URL fragment into the form and clears the fragment. Nothing
signs in until the user submits that same-origin form. `POST /auth/admin-login`
verifies the token's purpose, portal origin, signature and lifetime, checks the
account's current admin status, and consumes its unique ID through core's
durable Postgres replay store. It then issues the ordinary portal session and
clears any old impersonation cookie. No account or role is created.

This is an operator capability: keep deployment secrets and generated URLs
private. It does not prove an email inbox, replace another user's login, or
depend on a hosting provider. Leave the built-in broker's sender and selected
email credentials unset to run without email; configure them later and restart
to enable ordinary email sign-in. Existing external OIDC login is unchanged.

Live integration checks require a running core, Postgres, admin, portal and
built-in auth broker with email unset. Set `QM_ADMIN_LOGIN_ENV_FILE` to a private
JSON file containing that deployment's environment and run
`node --test test/integration/admin-login.test.ts` from the repository root. Use a
disposable test deployment: the test redeems real links for its configured admin.

## Security model (the parts that must be right)

- **Identity comes from verified OIDC or an operator-issued admin link.** The browser cannot assert an unsigned identity. The
  upstream request is built **from scratch** — an _allowlist_ of safe headers
  (`content-type`/`accept`/`user-agent`/…) plus the one synthesized cookie. The client's
  headers and cookies are **never** forwarded, so a browser can't smuggle a forged
  `admin=`/`x-as-principal` into a 6PN-trusting upstream, and there's no CL/TE desync surface
  (`node:http` frames the piped body itself).
- **Routing decides once.** The router rejects pathologically-encoded targets (`%2f`, `%5c`,
  `%2e%2e`, `\`, `//`, NUL → 400), selects the upstream by exact segment, and derives the
  `/admin` gate from that _selected_ key — never a second `startsWith` that could disagree
  (tier-escape guard).
- **Admin is derived from ONE source — the core's `admin_grants`.** The portal asks the admin
  surface (`GET /api/whoami` over 6PN, **no signing secret**), which asks the core
  (`GET /v1/admin/whoami` → `canAdminister`). The portal caches the boolean ~60s and **fails
  closed** (admin surface down ⇒ no Admin link on the landing, `/admin` 403). `canAdminister` is
  re-checked **per action** at the core — the live boundary. There is **no portal/admin id list**
  anymore; the only place an admin is named is the core's `ADMIN_GRANTS`.
- **Non-admin users need no per-id config.** `PORTAL_EXPECTED_TEAM_ID` pins the workspace, so any
  verified member is a valid user (`WEB_UI_PRINCIPALS` empty = any verified principal).
- **CSRF / open-redirect.** Every non-GET requires a same-origin `Origin`; `returnTo` is reduced
  to a same-origin path (rejects `//evil`, `/\evil`, `https:/evil`, `%2f%2f`/`%5c`). The one
  exception is an origin listed in `PORTAL_API_ALLOWED_ORIGINS`, and only on the chat API (below).
- **Embedding the chat from another app.** `PORTAL_API_ALLOWED_ORIGINS` lists the exact
  origins (comma-separated, bare `https://host[:port]`, no wildcards) that may call the web
  surface's chat API from their own pages with the user's portal session: `/me`, sending a turn,
  following, cancelling and withdrawing runs, listing and reading sessions and their approvals,
  deciding an approval, uploading attachment blobs, and downloading files. Those routes answer CORS preflights
  for listed origins and grant them credentialed reads and writes. Every other route keeps the
  same-origin rule, and unlisted origins get nothing. The session cookie is `SameSite=Lax`, so a
  browser only sends it on these calls when the embedding app is on the same site as the portal
  (for example `internal.example.com` beside `qm.example.com`). Listing an origin trusts every
  page on it to act as any signed-in user who visits it, so list only first-party apps.
- **Secret hygiene.** In production the portal refuses to boot unless `PORTAL_SESSION_SECRET`,
  `PORTAL_IDENTITY_SECRET`, and `OIDC_CLIENT_SECRET` are set and distinct from core ingress auth.
  Public and OIDC endpoints must use HTTPS. Session and temporary cookies use domain-separated keys.

### Documented trade-offs & residual risks (v1)

- **Surface isolation.** The private surface hop carries a signed portal identity; core verifies
  it independently, so a synthesized cookie alone confers no user authority. User deployments
  stay on a dedicated apps hostname and are never proxied through the portal or admin origin.
- **Local logout.** `POST /auth/logout` clears the portal cookies and returns
  `redirectTo`; browsers follow it to `/auth/signed-out`, a terminal page that
  never redirects to the provider. Sign-out is local to this portal: it does not
  contact the OIDC provider or end the provider session, so choosing **Sign in**
  may complete silently through an existing provider session. Sign out of the
  provider directly to end it everywhere. Built-in broker and anonymous logout
  retain their existing flows. Local-development logout also stops on the
  signed-out page until the user chooses **Sign in**.
- **Stateless session limits.** Clearing browser cookies does not revoke a copied
  portal session before `exp`; the core's `canAdminister` (re-read per request)
  remains the live admin revocation path.

## Playground mode

`PORTAL_PLAYGROUND=1` turns the deployment into a public try-it instance: an
unauthenticated browser navigation (a `GET` that accepts HTML) mints an anonymous
principal (`playground-<random>`), seals it into the ordinary `portal_session`
cookie, and continues — so each visitor's sessions, files, memory, and sandbox are
pinned to their browser through the same scoping that isolates real teammates.
Non-HTML requests without a session still get `401`, so the SPA's API calls ride
the cookie from the first page load and bare `curl` never mints.

What playground mode does **not** change: `/auth/login` still runs the full OIDC
flow (that's how the one admin signs in — production still demands the usual OIDC
config), `/admin` refuses anonymous sessions outright, and admin identity remains
the core's `ADMIN_GRANTS`. Signing out of an anonymous session just clears the
cookie; the next visit starts a fresh playground identity.

Minting is rate-limited per client address through the core's Postgres-backed
single-use claim store (the same one the sign-in broker uses), so restarts and
blue-green deploys can't reset it; if the core can't record the claim the portal
fails closed and answers 429. `PORTAL_PLAYGROUND_MINTS_PER_IP` (default 30, at
most 64 — the core grants at most 64 claim slots per request) per
`PORTAL_PLAYGROUND_MINT_WINDOW_S` (default 3600, at most 86400 — the core's
claim horizon); the portal refuses to boot outside those ranges rather than
silently serving 429 to everyone. IPv6 clients are bucketed per /64, not per
address, so a visitor with a routed prefix can't rotate through fresh budgets.
The client address comes from `clientIpOf` — on Fly that's `fly-client-ip`;
elsewhere set `PORTAL_XFF_TRUSTED_HOPS` when a reverse proxy fronts the portal,
or every visitor (and every crawler that accepts HTML) shares the socket
address's one bucket.

Because playground authority must never leave this origin, the portal refuses
to boot with `PORTAL_PLAYGROUND` alongside `PORTAL_COOKIE_DOMAIN`, an apps
domain (`PORTAL_APPS_DOMAIN` / `DEPLOY_APPS_DOMAIN`), or an explicit
`PORTAL_DEPLOYMENTS_ENABLED=1` — a domain-wide cookie or the deployment proxy
would hand anonymous sessions to surfaces that never see the `anon` flag. The
deployment proxy (`/d/<app>/`), on by default for signed-in portals, turns
itself off under the playground, and anonymous sessions are refused it at
request time too. Outside the playground, `PORTAL_APPS_DOMAIN` defaults to
`DEPLOY_APPS_DOMAIN`, and `PORTAL_COOKIE_DOMAIN` to the portal host itself when
the apps domain sits directly under it (`apps.<portal host>`), so one core-side
variable configures both processes. Any other layout needs an explicit
`PORTAL_COOKIE_DOMAIN` — deriving a shared parent by guesswork risks landing on
a public suffix browsers refuse. Anonymous sessions are also refused the `/connect/*` and
`/drop/*` flows, so a visitor can't attach real OAuth tokens or dropped secrets
to a throwaway principal that a cleared cookie orphans.

The `anon` flag lives only in the portal's session cookie — it does not cross
the portal identity boundary. To the core, a playground visitor is an ordinary
**internal** principal of the deployment's org: they can run turns, use their
sandbox, create crons, and reach anything granted or published at `org:` scope,
including org-granted credentials. That is the design — visitors are members of
the playground org — so a playground must be its own deployment with nothing
sensitive at org scope: no org-wide credential grants, no real connector
credentials, no company data. A cleared cookie mints a fresh principal, so pair
this with the core's real brakes: `BUDGET_USD_PER_WINDOW`,
`ORG_BUDGET_USD_PER_WINDOW`, `RATE_LIMIT_PER_WINDOW`, and a single pinned model
via the admin `base-model` / `webui-models` resources. Nothing
garbage-collects an abandoned visitor's scope yet.

## Env

Non-secret (`[env]`): `PORT` (8097 local / 8080 image), `PORTAL_PUBLIC_URL`, `CORE_API_URL`,
`CORE_ORG_ID`, `WEB_UI_UPSTREAM`, `ADMIN_UPSTREAM`,
`OIDC_AUTH_ENDPOINT` / `OIDC_TOKEN_ENDPOINT` / `OIDC_USERINFO_ENDPOINT` / `OIDC_ISSUER` /
`OIDC_JWKS_URI` / `OIDC_SCOPES` / `OIDC_CLIENT_ID`, `PORTAL_EXPECTED_TEAM_ID`,
`PORTAL_SESSION_TTL_S`, `PORTAL_SESSION_MAX_TTL_S`. `PORTAL_SESSION_MAX_TTL_S` caps a session's total life from authentication; it defaults to the larger of 30 days and `PORTAL_SESSION_TTL_S`, and boot fails if it is set below the TTL.
There is no `PORTAL_ADMIN_PRINCIPALS` — admin
access is derived from the core (see the security model above).
`PORTAL_API_ALLOWED_ORIGINS` (default empty) opens the chat API to other first-party origins;
see the security model. Boot fails on an entry that is not a bare https origin (plain http only for localhost).
For local development only, `PORTAL_LOCAL_AUTH_BYPASS=1` mints a local session as
`PORTAL_DEV_PRINCIPAL` without contacting OIDC. The portal refuses this in production
and only accepts it when `PORTAL_PUBLIC_URL` is loopback.

Identity: `OIDC_PRINCIPAL_CLAIM` — `email` (default; the org-canonical id: the
verified work email, lowercased; sign-in fails unless the IdP marks the email verified) or `sub`
(the IdP's opaque subject, e.g. the Slack U… id — only for deployments still keyed on Slack ids).
`OIDC_ALLOWED_EMAIL_DOMAIN` — with `email`, additionally reject any account outside this domain
(checked against the email suffix and Google's `hd` claim).
An address those rules reject still signs in when an org admin has invited it as an
external user: the callback asks core over the signed core client
(`GET /v1/auth/broker/email-allowed`) and accepts an active invitation, `hd` notwithstanding.
An address the env rules permit never triggers the lookup; core unreachable means not allowed.
Deployments keyed on `sub` never consult it, so external users cannot sign in there.

### Google Workspace SSO with the email principal

The OIDC client is generic, so Google is pure config. One-time setup:

1. In a Google Cloud project under the org's Workspace: **APIs & Services → Credentials →
   Create OAuth client ID** (type "Web application"), authorized redirect URI
   `https://<portal-host>/auth/callback`. On the consent screen choose **Internal** (members
   of the Workspace only).
2. Point the portal at Google:
   ```
   OIDC_AUTH_ENDPOINT=https://accounts.google.com/o/oauth2/v2/auth
   OIDC_TOKEN_ENDPOINT=https://oauth2.googleapis.com/token
   OIDC_USERINFO_ENDPOINT=https://openidconnect.googleapis.com/v1/userinfo
   OIDC_ISSUER=https://accounts.google.com
   OIDC_JWKS_URI=https://www.googleapis.com/oauth2/v3/certs
   OIDC_SCOPES="openid email profile"
   OIDC_CLIENT_ID=<client id>
   OIDC_ALLOWED_EMAIL_DOMAIN=<org domain, e.g. example.com>
   ```
   (unset `PORTAL_EXPECTED_TEAM_ID` — it's a Slack-OIDC concept).
3. The Slack plugin keys on emails by default too (bot scope `users:read.email`, already in the
   repo manifest — reinstall the app if it predates the scope), so Slack turns and web logins
   resolve to the same principal.
4. A deployment whose state predates email keying (Slack U… ids in the DB) must be re-keyed
   **once**: `scripts/migrate-principals-to-email.mjs` (dry-run by default; see its header).
   Update the core's `ADMIN_GRANTS` to emails in the same change.

Secrets: `OIDC_CLIENT_SECRET`, `PORTAL_SESSION_SECRET`, and `PORTAL_IDENTITY_SECRET`; each must be
distinct from `CORE_SIGNING_SECRET`.

## Run / test

```bash
npm start
npm run typecheck
npm test
```

See **`deploy/README.md` → Portal** for the public bring-up (IPs + cert, DNS, the Slack OIDC
app, and secrets) and `cli/templates/fly/portal.toml`.

With embedded auth, trusted OIDC, and `PORTAL_TRUSTED_OIDC_LABEL` explicitly set,
all email sign-in pages offer a link
to the trusted provider, including the email-unavailable and link-sent pages.
The label is the provider display name. Without it, the email page and primary
login routing retain their existing behavior. Switching keeps the destination
from the signed email login transaction.
The link is available without a remembered browser preference.

The proxy also signs the original authenticated subject as `authenticatedAs`. Core verifies that it still belongs to the canonical person, rejecting stale claims after unlinking. Trusted-entry failures offer retry of that provider without an alternate email sign-in link; invitation authentication remains unchanged.

The portal always issues `portal_session_x`, a `SameSite=None` twin of the
session cookie that lets opted-in apps be framed by named origins. The cores
serving the portal must include `f75cfd1c` (#1503), which strips
`portal_session_x` before forwarding requests to deployed apps. Once this portal
is deployed, do not roll core back below `f75cfd1c` on its own: an older core
forwards that cookie's session bearer to app code, and cookies already issued
stay in browsers until they expire (`PORTAL_SESSION_TTL_S`, 7 days by default).

---

### Overview (`plugins/web-ui/README.md`)

# Web UI plugin

An end-user web surface with a custom chat shell, connected to the
platform core. It still uses Pi's `Agent` state machine and selected Pi web utilities
for markdown, attachment loading, and model metadata, but the visible conversation UI is
owned by this plugin. Two processes:

- **A zero-dep `node:http` server** (`server/index.ts`) — holds the signed-in principal
  in an `HttpOnly` cookie, injects it as the turn's actor, and proxies a small set of
  `/api/*` routes to the core (chat turns, unified history, and **webhook management** —
  see below). It never imports the core and never sends a model/API key to the browser.
- **A Vite-bundled front-end** (`src/`) — a custom Lit shell + transcript + composer.
  Pi's `Agent` LLM-call boundary (`streamFn`) is swapped for a bridge to the core.
  It `POST`s the turn (`POST /v1/turns?async=1`), then watches the in-flight reply over
  **SSE** (`GET /api/runs/:id/events`, relayed from the core's `partial`) so tokens are
  pushed as they arrive instead of discovered on the next poll tick. It falls back to
  polling `GET /api/runs/:id` where SSE can't be established (old/proxy-hostile
  environments). The real agent loop (model + the three primitives + memory + audit) runs
  server-side in the core's sandbox; the browser is a thin chat client.

**Behind the portal.** When fronted by `plugins/portal` (the public SSO front door), this SPA
is served at the portal root — the default `WEB_UI_BASE=/` build is the one the portal fronts
(the old `/web-ui/` prefix is gone; the portal 308-redirects `/web-ui/*` to root for stale
links). The front-end joins its base via `import.meta.env.BASE_URL` (the `withBase()` helper in
`core-bridge.ts`), so direct/standalone access and `npm run dev` behave identically.
Core binds run reads and signals to the portal-verified actor;
no run bearer is exposed to browser code or placed in a URL. The active-run resume index
(`/api/runs/active`) is per-process best-effort with a durable core fallback for personal threads.

```
npm install --prefix ../admin
npm install
npm run build
npm run serve

npm start
```

Dev (HMR): run `npm run serve` in one terminal and `npm run dev` in another — Vite serves
the front-end on :5173 and proxies `/signin`, `/me`, `/api/*` to the node server.

Env (see `.env.example`): `CORE_API_URL` (default `http://localhost:8080`),
`CORE_ORG_ID` (default `acme`), `PORT` (default 8096), `WEB_UI_PUBLIC_URL`,
`WEB_UI_PRINCIPALS` (csv allowlist; empty = any id, **dev only**),
and `CORE_SIGNING_SECRET` (same value as the core when source-auth is enabled).

## Suggested activities

Suggested activity generation is **on by default** when the configured harness supports
it. Set `SUGGESTED_ACTIVITIES_ENABLED=false` on core to disable generation. Optionally
set `WEB_UI_SUGGESTED_ACTIVITIES` on web to a JSON array for fixed fallback starters;
unset it as well to hide suggestions entirely. No deployment-specific activity content
is bundled into the public application.

```json
[
  {
    "id": "weekly-brief",
    "title": "Wake up to a fresh briefing",
    "prompt": "Let's set up a recurring briefing on the topics I follow.",
    "icon": "schedule"
  },
  {
    "id": "project-app",
    "title": "Build a home for my projects",
    "prompt": "Let's build a private app to keep track of my projects.",
    "icon": "app"
  }
]
```

The first three entries appear above the empty personal-chat composer with colored
icons, without a heading or expansion link. Selecting an entry fills and focuses
an editable draft; it never submits a turn. Suggestions fade and collapse while a draft
or attachment is present and do not appear in existing chats, shared contexts,
or compact pane views. Collapsed suggestions are inert and hidden from assistive
technology; reduced-motion preferences disable the transition. Dark mode uses
subdued blue-gray suggestion text. Drafts use the normal persistence path.

Each entry requires a unique lowercase alphanumeric/hyphen `id` (up to 64
characters), `title` (up to 65 characters), `prompt` (up to 1,200 characters), and
`icon` (one emoji or `yc` for the orange YC mark; legacy `schedule`, `app`, `deck`,
`people`, `calendar`, and `book` values also render as emoji). Configuration
accepts up to 12 entries and 20,000 characters. Invalid configuration fails startup
without printing its contents. Restart the web service after changing it.

The authenticated `/me` response supplies the configured fallback and whether generation is enabled.
Fixed starters are organization-wide; keep them free of personal activity or credentials.

When enabled, opening a new personal chat enrolls the user in an ordinary personal
cron named **Refresh my suggested activities**. Its first run starts immediately;
subsequent runs happen around 2am in the browser's timezone, with the minute
staggered by user. The cron runner uses the normal owner-scoped session, runtime
selection, memory, history, tools, and authorized data access. It has no delivery
destination. The standing task in `src/suggestions/activities.ts` asks it to research
relevant context, avoid mutations or notifications, and return five validated
activity objects: three are shown and two wait in reserve. Draft prompts use a natural, collaborative voice, such as "Let's...",
with relevant facts and uncertainties as neutral context, without attributing knowledge,
feelings, or beliefs to the user. They preserve explicit user
preferences and scope while leaving the approach to the responding agent. It does not
create a separate reduced-context model call.

The UI reads the latest valid result from the cron's completed personal session,
including responses larger than the truncated fire-log preview. It displays the
previous result while a refresh runs and briefly polls for the first/new result;
opening another chat does not normally invoke a model. Failed initial generations
can retry after five minutes. Existing cron queueing, run persistence, authorization,
fire history, and failure handling apply.

When generation is enabled, each suggestion has a low-contrast dismiss button that
strengthens on row hover or keyboard focus. Dismissing collapses that row while the next
reserve suggestion expands into the list with the same height-and-fade motion the list
uses when it collapses and reappears around a draft. The core stores dismissals on the
user's suggestion profile (the 30 most recent IDs and titles), filters them out of every
later response on every device, and adds their titles to the managed cron task so the
next run avoids them; user-edited cron tasks are left alone. Three seconds after the last
dismissal in a burst, the browser asks for a refresh; opening a new chat does the same if that
request never arrived. The core starts one immediate run
of the user's suggestions cron only when a dismissal is newer than the latest run and no
run is in flight. Until it lands, fewer rows show; new rows then expand in.

Cadence is reevaluated hourly. Ten or more user messages in sampled recent private
conversations within 24 hours increases refreshes to every four hours; otherwise it
returns to nightly. Accounts with no observed conversation activity or suggestion
visits for 30 days are paused until activity returns. The owner can pause, delete,
or edit the cron; custom task text and schedules are preserved. The global disable
flag pauses managed jobs. Background work must also be enabled.

Set `SUGGESTED_ACTIVITIES_CONTEXT` on core for rollout guidance (up to 8,000
characters). Describe the organization’s workflows and available tools there;
optionally provide fallback starters on web. Guidance updates propagate to
unmodified managed tasks. Public QM has no organization-specific suggestion context
by default. Suggestions are private to their owner; generated sessions use the
same scoped access controls as other personal work.

## On a phone

Below 860px the same build behaves like an app rather than a shrunken desktop:

- **Drawer, not rail.** The sidebar slides over the content from a floating menu button (or an
  edge swipe); a leftward swipe or a tap on the scrim closes it. Every top bar reserves the
  button's column so nothing renders under it.
- **Bottom sheets.** Popover menus — composer settings, a session's ⋯, the user menu, the
  per-session tools — render as sheets with a backdrop; tap outside or swipe down to dismiss.
- **Compact composer.** Attach · input · settings · send on one row; model, harness, effort, and
  Fast live in the settings sheet. Inputs are 16px so iOS never zooms on focus, and the layout
  tracks the visual viewport so the composer stays above the on-screen keyboard.
- **Touch targets.** Message actions, file chips, selects, drawer rows, approvals, and back
  links are ≥44px; hover tooltips are suppressed on hoverless devices.
- **Split canvas stays on the desk.** A phone never mounts the split layout and leaves the
  persisted desktop layout untouched.
- **Installable.** `manifest.webmanifest` (named after the org's brand label), home-screen
  icons, Apple meta, and theme colors — "Add to Home Screen" opens standalone at `/`.

The phone-class breakpoint is one constant (`src/viewport.ts`, `PHONE_MAX_WIDTH`), shared by
the CSS media queries, the composer, and the split canvas.

## What you get

- **Custom chat UI** — a first-party conversation surface with a left history rail, centered
  transcript, bottom composer, inline **model selector** (the models core reports as
  serviceable for the approved harnesses),
  explicit **effort selector** (`low|medium|high|xhigh|max|ultracode|auto`), **Fast mode**
  toggle, attachments, streaming partials, and a theme picker. Settings → Theme takes
  light, dark, or system, or an imported palette: drop in an iTerm2 `.itermcolors` preset or
  a VS Code color theme `.json` and the app repaints from it (background, text, sidebar,
  buttons, links, text selection, status badges, and code highlighting, mapped from the
  terminal's ANSI colours or the theme's token scopes). The palette is kept per browser
  alongside the light/dark choice.
  The UI drives Pi's `Agent` with a custom `streamFn` (`src/core-bridge.ts`) instead of
  mounting Pi's stock `AgentInterface`.
- **Slash-command skill picker** — type `/` at the start of the composer to browse the
  **skills** available to you (B6): icon · name · description · scope, with the
  typed letters emboldened. Arrow/Tab/Enter to choose (it inserts `/<name> `), Esc/click-out to
  dismiss. The list is the signed-in principal's _visible_ skills — the same set the agent gets
  materialized into a DM turn — fetched once per session via the server's `/api/skills` proxy
  (→ core `GET /v1/skills?principalId=`, resolved by `app.listVisibleSkills`). The skill _body_
  never reaches the browser; only name/description/scope do.
- **Functional selectors** — the chosen model + effort + Fast mode ride along on each turn
  (`POST /v1/turns` `model`/`thinkingLevel`/`fastMode`); the Pi harness applies them
  server-side, bypassing Pi's stale thinking-level clamp for newer effort values and falling
  back to its default on any unknown value. Attachments are forwarded as `IncomingAttachment`s
  and materialized into the agent's sandbox inbox (same path as Slack file shares).
- **Unified history** — every session you participated in (DMs, channels, web), via
  `GET /v1/sessions?principalId=`. The same person sees the same history here and in Slack
  (surface-independence, spec B6).
- **Continue web DMs** — threads originated here (`threadRef` = `web:…`) are continuable.
- **Contexts** (the **Contexts** sidebar view) — one card per scope the signed-in person can
  talk to the agent in: their `personal:` scope plus every channel/group context (each context
  is a separate workspace — own files, own memory). `GET /api/contexts` → core
  `GET /v1/contexts?principalId=` (personal + channels the pre-pushed directory membership
  places the principal in + any shared scope their own sessions place them in — assembled
  entirely from core-local data, never a live Slack call). Opening a context shows its conversations and
  a **New chat** that starts a _web_ conversation inside that shared workspace: the turn carries
  `scopeId` (+ `channelName` for the session label), the server maps it onto
  `conversation {kind: channel|group, channelRef}`, and **core re-authorizes membership**
  (pre-pushed channel membership, or prior participation in the scope's sessions — deliberately
  stricter than the cron-create "any internal may post to a public channel" rule: a post is
  visible to the channel, mounting its workspace from the web is not). The web-ui never vouches for
  membership — it only shapes the claim. Such chats are continuable like any web thread and show
  a floating pill naming the shared context; replies stay on the web (nothing is posted to
  Slack).
- **Deep links** — the address bar always identifies the open conversation (`?session=<id>`)
  or view (`?view=…`), kept in sync via `replaceState`; each conversation's ⋯ menu has a
  **Copy link**. Opening a link while signed out keeps the query string through sign-in, so
  shared links land in the right conversation (subject to the recipient's own access).
- **Read Slack & group/channel sessions** — they render **read-only** (transcript only, no
  composer). One-way projection: a web reply would be invisible to Slack participants, so
  contributing to those Slack threads from the web isn't allowed yet (spec B6) — start a fresh
  web chat in the same context from the **Contexts** page instead. In the sidebar these read-only rows sit
  visibly recessed (a shade darker/dimmer) and carry the Slack mark plus the channel they live in
  (for example, `#engineering`) — the channel name is captured from the surface onto the session record
  (`Session.channelName`, plumbed `conversation.channelName` → `getOrCreateByThread`) and surfaced
  via `GET /v1/sessions`, so you recognize _where_ a conversation happened at a glance.
- **Webhook management** (the **Webhooks** sidebar view) — register, list, and disable your
  own incoming webhooks (spec §7). Each registration is created with `owner = createdBy = you`
  in your `personal:<you>` scope (identity comes from the cookie, **never** the request body —
  the same trust model as `/api/turn`). The server proxies three routes:
  - `POST /api/webhooks` → core `POST /v1/webhooks`; relays core's response — the webhook, the
    **absolute** public ingress URL (core builds it from its public base — the portal in prod),
    and the signing secret **once** (auto-generated if you leave it blank; never shown again).
  - `GET /api/webhooks` → core `GET /v1/webhooks`, then **filtered to `owner === you`** (core's
    source-auth list is operator-wide; secrets are already elided by the core).
  - `POST /api/webhooks/:id/disable` → ownership is **verified here first** (core's operator
    disable has no ownership check), mirroring the run-ownership gate, then proxied.
    The inbound ingress (`POST /v1/webhooks/incoming/:id`) is served by the **core** receiver,
    reached in prod through the **portal**'s one unauthenticated passthrough (the core is not
    publicly exposed); senders sign with their own per-webhook secret, which is the auth on that
    path. Dev single-host posts to the core directly.
- **Cron management** (the **Crons** sidebar view) — create, list, run-now, enable/disable, and
  delete your own scheduled tasks (spec §7), same trust model as webhooks: created with
  `owner = createdBy = you` in your `personal:<you>` scope (identity from the cookie, never the
  body), list filtered to `owner === you`, and every per-cron route ownership-gated here first
  (core's source-auth routes are operator-wide). A cron is either a **task** (a prompt the agent
  re-runs at each fire) or a **message** (literal text relayed as-is — requires a destination,
  since a relay with nowhere to deliver is a no-op), on an `everyMs` interval and/or a one-time
  `firstFireAt` (which may not be in the past). The server enforces a 1-minute interval floor; the
  scheduler itself runs in the core.
- **Files / Connectors / Deploys** (sidebar views — management lives here in one place):
  - **Files** — the doc store (spec §19 `artifacts(kind=file)`; §3 "files & sharing =
    Google Docs"): one `GET /api/files` call (→ core `GET /v1/files?viewer=`) lists files you
    **created/uploaded** (owned) + files **shared with you**, recency-sorted, with Open/Download
    (`GET /api/files/:id/content`, streamed binary). Image files (`image/*`) show an inline
    thumbnail rendered straight from that same `/content` stream. Backed by a durable, owner-scoped registry —
    NOT a transcript scan. Delivered/uploaded files are owned at the initiator's personal scope so
    they surface here, and auto-shared with the conversation (ADR-0003 D2/D4): a public-channel file
    gets an `org:` read grant so every member sees it under "Shared with you"; private-channel
    per-member grants are sequencing-gated (off until enabled), so those stay owner-only until then.
  - **Connectors** — per-provider OAuth status with Connect / Reconnect / Disconnect. The server
    proxies `GET /api/connectors` → core `/v1/connectors/oauth/status`, `POST /api/connectors/:p/start`
    → core `/v1/connectors/oauth/:p/start` (redirect URI = this surface's
    `/connectors/oauth/:p/callback`), and `POST /api/connectors/revoke` → core
    `/v1/connectors/oauth/revoke`. The callback exchanges the code server-side (tokens never reach
    the browser) and bounces back into the SPA via a base-relative redirect.
  - **Deploys** — `GET /api/deployments` → core `/v1/deployments`, grouped into manageable,
    shared, and archived views. Detail and restore routes expose authorized metadata and bring an
    archived version back online; running apps open through the surface's signed deployment proxy.

## Notes

- **No browser-side model keys.** The real agent loop (model +
  the three primitives + memory + audit) runs server-side in the core's sandbox. The model
  picker only expresses a _preference_ the core honors within its policy floor — keys, egress,
  and tools stay server-side.
- Pi's client-side artifacts / JavaScript REPL are not enabled (tools run server-side).
- The custom transcript renders from Pi `Agent` lifecycle events and `waitForIdle()`, so
  in-place streaming mutations are reflected without depending on Pi's stock chat renderer.

This is a **surface plugin**: it carries its own front-end deps (Vite, lit, pi-web-ui) and
runs as a separate process. The zero-runtime-dep core is untouched.

## Chat connection chips

Chat authorization links share the connector service logos. Composio links use recognized
service names in their Markdown labels; unknown or ambiguous names keep a generic icon.
The destination URL and authorization behavior do not depend on the inferred logo.
Gmail, Google Calendar, Google Drive, and Google Sheets artwork comes from
[Simple Icons v16.0.0](https://github.com/simple-icons/simple-icons/tree/16.0.0)
(CC0) and is bundled locally with the existing connector SVG artwork.

## Cohort welcome and app picker

Set `WEB_UI_WELCOME_COHORT=F26` on the web surface to show the cohort welcome in a new user's empty chat. It replaces the automatic first agent turn for that deployment; ordinary chat starts when the user sends a message. The greeting uses the signed-in display name. The welcome remains above the messages in the earliest personal web conversation, including when reopened. It is selected from persisted session creation times. The champagne and soft flutter sequence replays on refresh only before the first message and respects reduced motion.

The picker reads Composio's live catalog in usage order, omits apps that need no authorization, and searches the complete paginated catalog. Known services use local logos; remaining catalog logos use Composio's logo host. Selecting an app submits to the authenticated web surface and opens the provider's authorization link directly. Consent remains on the provider page. Slack is excluded from this picker. A dedicated Connect Slack card in onboarding and Settings authorizes the signed-in person’s Slack tools through Composio and links their verified Slack workspace identity to their existing web account. Installing the company bot remains a separate administrator action.

The core bridge accepts a verified portal identity and uses either that person's own `COMPOSIO_API_KEY` keychain entry or an enabled org service credential granted to them. Secrets never enter the browser. The agent skill reads `/v1/composio/identity` to use the same organization/person identity as the picker. A company project key retains Composio's existing project-wide access boundary; the identity selects accounts and does not isolate them from other holders of that key.

### Local connection-return preview

Open `http://localhost:8138/?connectionDemo=1` on the local dev instance. This loopback-only UI mode replaces app authorization with a provider simulation offering approval, cancellation, and failure. It makes a full navigation round trip with a callback URL, attempt nonce, status, and connected-account ID. The simulated verifier checks its own record rather than trusting `status=success` in the URL.

Session storage retains the account-scoped attempt for twenty minutes, picker query, expanded state, scroll position, and simulated connections. Returning skips the welcome animation, verifies the simulated result, clears callback parameters, and restores the picker. Reset clears the preview's simulated connections. No provider authorization, tokens, or actual connected accounts are changed by this mode.

Real authorization supplies a callback URL on the configured public origin and returns to the same conversation. A twenty-minute, user-bound session-storage attempt retains the account ID, originating widget, search, expanded state, and scroll position. The server lists only the authenticated actor’s active connected accounts; the browser verifies the expected account before showing success and removes callback parameters. Connected apps are refreshed on page load and window focus. Returning skips the welcome animation. Reply widgets become available after the reply is persisted. The loopback preview remains a separate simulation and does not connect real accounts.

The welcome uses the organization's configured branding `orgName`, falling back to “your company” when it is unavailable.

### Setup widgets in agent replies

In web chat, an assistant reply can include `::connect-apps{}` as a standalone paragraph to render the reusable app picker. The separate `::add-to-slack{}` directive renders the Slack setup action for administrators; include both to show both. It omits the welcome and animation and uses the signed-in viewer’s authorization routes. The Composio skill teaches this response for requests to connect apps or reopen setup. Code blocks, quotations, and inline examples remain ordinary text. The directive persists in the transcript and renders again when reopened. Connected-account status retains the same limitations as the onboarding picker and local return-flow preview.

## Optional product analytics

Set `POSTHOG_API_KEY` to a PostHog project ingestion token to enable browser
analytics. `POSTHOG_HOST` defaults to `https://us.i.posthog.com` and must be an
HTTPS origin. The authenticated `/me` response supplies this public configuration;
the portal serves the same web application, so it needs no separate SDK.

Events are explicit pageviews by navigation view, accepted `message_sent` events,
and `session_started` for the first user message in a chat. Company grouping uses
`CORE_ORG_ID`; user identities combine company and authenticated principal. Browser
analytics is disabled during impersonation. Autocapture, replay, exception capture,
performance capture and feature flags are disabled. Event properties exclude chat
content, URLs, query strings, titles and referrers. Delivery is best effort.

Set the same variables on core to capture `app_published` after a successful new
application or version deployment. Publications use the application's creator and
company, matching browser identity. No key means no analytics requests.

Core also captures `response_completed` and `response_failed` when a human turn
reaches its final run state, including when a separate worker executes it.
`completion_boundary=run` means processing finished, not confirmed delivery to the
user. Successful silent or reaction-only results count as completed processing.
`result_status` distinguishes those results; `surface` identifies web or Slack.
Automation, automatic openers, impersonated turns, stopped turns, refusals, queued
results and pending approvals do not emit outcomes. Retries emit only at the final
run state. Stable insert IDs support deduplication. These best-effort events are
not a complete reliability ledger and contain no response text or raw errors.

## Optional browser error reporting

Set `SENTRY_BROWSER_DSN` on the web server to enable browser error reporting.
Use a public HTTPS DSN without a secret key, such as
`https://public@sentry.example.com/1`. Backend `SENTRY_DSN` is never exposed or
used as a browser fallback. The authenticated `/me` response supplies the public
DSN and an optional `SENTRY_RELEASE` (or `GIT_SHA`). The server adds only the DSN
origin to the web application's connection policy.

Reporting starts after authentication and stops on sign-out or an authentication
failure. It is disabled during impersonation and when the browser DSN is unset.
It collects uncaught errors, unhandled promise rejections, and errors the client
catches and handles (tagged with an `error_code` naming the catch site and grouped
by it). Events carry the full error message, cause chain, stack, release, org and
signed-in user. Delivery is best effort.

### Browser performance timing

Set `SENTRY_BROWSER_TRACES_SAMPLE_RATE` (0 to 1, default 0) alongside `SENTRY_BROWSER_DSN` to sample
browser timings; `0.1` is a reasonable start. Each timing is sampled independently at that rate
and carries its own random trace id. A page reports one `pageload` transaction (time to first
byte, DOM content loaded, load, first and largest contentful paint, and a `page` attribute for
the application view) and one `http.client` transaction per same-origin request
made through the web client's shared fetch helper, measured to response headers and named by a
fixed `/api/<resource>` allowlist (`GET /api/sessions/*`) with the HTTP status. At most 200
timings are sent per page. The full page/request URL, including query strings, is retained as a
span attribute. Transactions, child spans, descriptions, attributes, and any attached request or
user context are sent without scrubbing. Timings stop with error reporting on sign-out,
authentication failure, and impersonation.

## Personal AI accounts

Open **Settings → AI access**, or use the account label beside the model picker.
Choose Company, Claude, or ChatGPT / Codex. Sign in with your subscription
or use the secondary API-key option; connecting automatically selects that account. **Company access** switches back without disconnecting
personal credentials. The choice is durable per person and applies to their human
chat turns on the web and in Slack; background tasks retain company access.

A submitted turn keeps its account choice, so switching affects new turns. Personal
access failures do not retry on company credentials. Messages using a different
account queue separately instead of steering an existing run; an explicit steer
across accounts is refused. Organizations that already require individual accounts
continue to require them.

The standalone `::link-slack-account{}` directive offers personal Slack account linking in a web reply. It shows the account card or linked status, requires the company bot to be installed first, and does not include the app picker. `::add-to-slack{}` remains the company installation trigger.

---

### Overview (`src/slack/README.md`)

# Slack surface (Socket Mode)

The Slack surface, run **in-process with the agent core**: core boots it when
Slack tokens are present in its env and hands it a direct client into core's
services. **Socket Mode** means no public URL, ingress, domain, or TLS — it opens
an outbound WebSocket to Slack, so you can run it from a laptop or any box with
internet.

```
Slack  ⇄ (WebSocket)  slack surface (in core)  ── direct calls ──▶  core services (Pi)
```

## 1. Create the Slack app (one paste)

1. Go to <https://api.slack.com/apps> → **Create New App** → **From a manifest**.
2. Pick your workspace, paste [`manifest.json`](./manifest.json), create.
3. **Install to Workspace** (OAuth & Permissions → Install). Copy the
   **Bot User OAuth Token** → `xoxb-…` → `SLACK_BOT_TOKEN`.
4. **Basic Information → App-Level Tokens → Generate Token and Scopes**: add the
   `connections:write` scope, generate. Copy `xapp-…` → `SLACK_APP_TOKEN`.

(Socket Mode is already enabled by the manifest. No request URLs to configure.)

### Slack Agents (top-bar pin)

The manifest enables Slack's [Agents & AI Apps](https://docs.slack.dev/ai/developing-agents)
feature (`agent_view`) so users can pin the app to the Slack top bar and open it anywhere.
Conversations in the resulting split pane are ordinary DM thread messages and flow through
the normal DM machinery unchanged. Thread titles, context passing and suggested prompts
are not implemented. Activity cards are experimental and off by default.

Two caveats:

- **Paid plan required.** Slack only serves the AI-apps experience on paid plans (the
  Developer Program sandbox also works).
- **Existing installs must update the manifest and reinstall** to pick up the
  `assistant:write` scope and the `assistant_thread_started` and
  `assistant_thread_context_changed` events (Slack requires both to enable the feature;
  the plugin acknowledges them as no-ops). Installs that don't update keep working
  exactly as before.

### Optional activity cards

In Admin → Customize → Feature flags, enable **Slack loading indicator (experimental)**
for selected people or conversation scopes. It is off by default. The Slack app must
support [`task_card`](https://docs.slack.dev/reference/block-kit/blocks/task-card-block)
blocks and have `chat:write`.

After QM commits to answering, one card is posted in the existing reply thread and
updated in place: **Working**, **Waiting to resume**, **Background work**, **Monitoring**,
or **No active work**. This describes recorded activity, not success, failure or delivery.
Quiet ambient turns and unthreaded DMs do not create cards. Cards are visible to everyone
in their thread; own status cards and edits are excluded from conversation history.

Waiting cards survive turns and restarts. Only this conversation's registered background
jobs and enabled, unexpired process monitors count, not recurring crons or agent prose.
Unwatched exits become visible when checked, reconciled during sandbox setup, or expired.
Commands and monitor instructions are never rendered. After more than five minutes,
including waits, a subtle **Follow via QM Web** link opens the exact web conversation
with normal authorization. Without a configured web URL, the link is omitted.

A minute sweep refreshes activity and retries transient failures. Durable receipts and
per-thread leases preserve card identity across workers; expired run leases are shown as
waiting, not working. Slack has no conditional writes: late updates are repaired, and an
ambiguous initial post can duplicate if Slack does not deduplicate `client_msg_id`.
An outage can leave a stale card until recovery. Status calls have a separate five-second
timeout and never delay reply delivery.

Once no activity remains, the card settles without a spinner and is no longer tracked.
Later work creates a fresh card. Disabling the flag settles existing cards without
stopping work or deleting messages. Native session status and Stop events are not used.

> **Running locally alongside another developer? Each dev needs their OWN app.**
> See [Local dev with multiple developers](#local-dev-with-multiple-developers)
> below — set the manifest `name`/`display_name` to `agent-<yourname>` before
> creating, so you don't share a bot identity.

## 2. Run

One process — core boots the Slack surface itself when the tokens are in its env:

```bash
cd ~/Programming/qm
nvm use
HARNESS=pi ORG_ID=acme ANTHROPIC_API_KEY=… \
SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… \
npm start
```

(`OPENAI_API_KEY` or `OPENROUTER_API_KEY` works in place of `ANTHROPIC_API_KEY`; the base
model follows whichever provider you configure.)

(Or put the tokens in the repo-root `.env` — `npm start` loads it via
`node --env-file-if-exists`.) It logs `connected as @agent …` when live; without
the tokens, core simply runs without Slack.

## 3. Use it

- **DM the bot** anything → it replies in the DM (one continuous session per DM). While it's
  working on your reply you'll see a **⚙ Working…** status message; once the agent starts
  producing text, the reply streams in place by editing that same message — so the wait is
  never blank and there's never a duplicate post.
- **@mention it in a channel** → it replies in a thread (one session per thread). After
  a real Slack mention in that thread, **just keep talking in the thread — no need to
  re-@mention.** The bot then _listens_
  to every reply and decides whether to chime in **like a thoughtful coworker** (a "turn
  detection" step in the core): it answers a question or a request aimed at it, but stays out
  of a back-and-forth between other people. When it decides to stay quiet it posts nothing at
  all (no spinner, no "I'll stay out of it"). An explicit @mention always gets a reply, and a
  _new_ top-level message still needs an @mention to start a fresh thread.
- **Share a file** with it (DM or @mention with an attachment) → it downloads the file
  and the agent can read/process it; **ask it to send a file** and it uploads the produced
  file back into the thread. Bytes travel **out-of-band**: the plugin streams them to the
  core's blob endpoint (`POST /v1/blobs`) and the turn carries only a lightweight `blobId`,
  so files up to **~1 GB** (Slack's own per-file limit), up to 10 per message, ride the same
  path — no more squeezing base64 into the 1 MB turn body. Outbound files are fetched back
  from the core (`GET /v1/blobs/:id`) and uploaded via `files.uploadV2`. **Only Slack-hosted
  files are ingested** — externally-hosted ("remote") files are refused, since fetching an
  off-Slack URL from the plugin host would be an SSRF / token-leak vector.
- **Replies render natively.** The agent emits standard Markdown; the plugin translates it
  to Slack "mrkdwn" before posting (`toSlackMrkdwn` in [`lib.ts`](./lib.ts)) so `**bold**`,
  `[links](url)`, `# headers`, and `- bullets` show as Slack styling instead of raw markup.
- **The agent can react with emoji — a Slack-only feature with two paths.**
  For explicit reaction requests inside a normal assistant turn, the plugin teaches the agent the
  reply convention via a surface instruction in the turn's `gatewayContext` (`REACTION_INSTRUCTION`):
  include a directive like `[[react: eyes white_check_mark]]` anywhere in the reply. The reply can be
  **just the directive with no other text** — the agent then reacts silently, the way a human drops a 👍
  without typing anything (no "(no response)" placeholder, and the streamed status is torn down
  cleanly rather than flashing an empty bubble). The plugin parses the directive out of the reply
  (`extractReactions` in [`lib.ts`](./lib.ts)), removes it from the text, and applies the emoji
  with `reactions.add` to the message that triggered the turn — or, when the directive carries an
  `@ <id>` target (`[[react: tada @ ~abc123]]`), to that _specific_ earlier message instead. So the
  agent can resolve a phrase like "react to Alice's last message," each channel turn
  reconstructs the conversation as structured roled turns (`renderConversationView`, fed by a
  best-effort `conversations.history`/`replies` fetch that reuses the `channels:history` /
  `groups:history` scopes); every reaction-eligible message carries a **short, stable id** in
  brackets like `[~abc123]` — the message's Slack `ts` packed into base36 (`encodeTs`), written
  **once** on that message's turn. The agent copies that `~id` into the directive and the plugin
  **decodes it straight back to the `ts`** (`decodeTs` — no lookup map), validated against the set of
  message ts it actually showed this turn so a stale/forged id is dropped rather than reacting to the
  wrong message. The id is short (~10 chars vs a 17-char raw timestamp) and STABLE (it always points
  at the same message, so persisting it never drifts,
  unlike the old positional `[N]` handle). A raw `@ <ts>` still works for back-compat. In-thread
  replies are tagged `(reply)` so the agent sees thread structure, and the parent of any listed reply
  is pulled in alongside it (`recentWindow`/`arrangeForDisplay`).
  The **bot token never leaves the plugin**. Names are normalized against the
  **complete Slack emoji set** — `:eyes:`, `eyes`, or a literal char all work, including the ~1900
  standard emoji, ZWJ sequences (`🧑‍💻` → `technologist`), the `❤️`/`❤` variation-selector forms,
  and skin tones (`👍🏽` → `+1::skin-tone-4`). That table (`src/emoji-map.ts`) is built at module load
  from the [`emoji-datasource`](https://www.npmjs.com/package/emoji-datasource) package
  (iamcal/emoji-data, the dataset Slack itself uses) — bump the dependency to refresh it. Names are
  deduped and capped; a per-emoji failure (or a missing
  scope) is logged, never breaking the reply. The directive is also stripped from streamed partials
  so it never flashes while the reply types out.
  Separately, unprompted channel-thread messages can be acknowledged before the assistant turn runs:
  Slack passes `gatewayContext.reactionGuidance`, the core detector may return `status: "react"` with
  emoji names, and the plugin applies those to the triggering message without posting a reply.
- **The agent also HEARS reactions — if someone reacts to a message, it can see it.** With the
  `reactions:read` scope and the `reaction_added` / `reaction_removed` events, a reaction the bot has
  a **stake** in is surfaced to the agent: a reaction on its **own** message, a reaction in a **DM**
  with it, or one on a **thread root it's following** (`shouldSurfaceReaction` in
  [`lib.ts`](./lib.ts)). Everything else — a stray reaction in a channel it merely sits in —
  is ignored, so a busy channel never floods the agent. A relevant reaction is delivered as an
  **unprompted turn** (exactly like a thread-follow message): the core runs **turn detection**, so a
  passing 👍 stays silent while a ✅ on the bot's "shall I deploy?" gets acted on. The reacted message
  is read once via `reactions.get` so the agent sees its text **and its full current emoji set** —
  e.g. _"Alice reacted with :white_check_mark: on your message: …(it now has :white_check_mark: :eyes:)"_
  (`buildReactionTurnText`). The bot's own reactions never loop back, and the same emoji the agent can
  read on recent messages are appended to the reaction-targeting list (`buildRecentMessages`), so it
  can read the slackmojis there too. The reactor is classified and the channel audience is computed
  exactly as for a message — so a reaction in a Slack-Connect / guest-containing context never makes
  the bot respond — and that full audience is resolved (stake → dedup → coalesce → membership) **before**
  any message content is read, so a non-internal channel's content is never fetched. A burst of reactions on
  one message is **collapsed** to a single turn while one is in flight (`reactionsInFlight` guard), so an
  emoji-pile can't wake the agent once per emoji. _(Bounded by design: a reaction on someone else's non-root reply
  inside a followed thread isn't surfaced — the message thread-follow path owns that conversation.)_
- **Dangerous commands ask for approval in Slack.** When a tool call hits a
  `require_approval` command-policy rule, the bot posts buttons for **Allow once**,
  **Allow session**, **Allow always**, and **Deny**. Session/always approvals are exact-command
  grants; deny rules still cannot be bypassed. This works whether the turn _paused_ awaiting
  approval (`pending_approval`) or _finished with a reply but skipped_ the gated command
  (collect-mode `ok` + `pendingApprovals`, what the real Pi harness does) — in the latter case
  the buttons are posted alongside the reply. Clicking re-runs the turn with the command approved.
- **Hand-offs are plain DMs.** When channel work needs another member's own logins, files or
  memory, the agent follows the `hand-off` skill: in Open conversations it uses the requester's own
  access; otherwise it sends that person a DM through `POST /v1/reach` with the full request, and
  says so only after reach confirms who it matched.
- In an **externally-shared (Slack Connect) channel**, or any channel/mpim whose audience
  includes a **guest or external member**, it stays silent in-channel and replies privately
  (ephemeral) — and uploads no file there — internal-only (spec §9). Group DMs (mpim) aren't
  handled yet (it says so out-of-band rather than mis-scoping them).

> **Re-install after upgrading to file sharing.** The `files:read` / `files:write` scopes
> were added to `manifest.json`; update the app's scopes (or re-create from the manifest)
> and **reinstall to the workspace** so Slack grants them, then restart the plugin.

> **Re-install after upgrading to emoji reactions.** The `reactions:write` scope was added to
> `manifest.json`. Update the app from the manifest and **reinstall to the workspace** so Slack
> grants it, then restart the plugin — otherwise the agent's reactions are silently dropped
> (logged as a missing-scope hint).

> **Re-install after upgrading to inbound reactions.** The `reactions:read` scope and the
> `reaction_added` / `reaction_removed` events were added to `manifest.json`. Update the app from
> the manifest and **reinstall to the workspace** (re-subscribing the events), then restart the
> plugin — otherwise the bot won't receive reactions others add, and `reactions.get` reads fail.

> **Re-install after upgrading to workspace emoji.** The `emoji:read` scope lets the
> acknowledgment picker include custom workspace emoji. Update the app from the manifest and
> **reinstall to the workspace**, then restart the plugin.

> **Re-install after upgrading to thread-follow.** The `channels:history` / `groups:history`
> scopes and the `message.channels` / `message.groups` events were added to `manifest.json`.
> Update the app from the manifest and **reinstall to the workspace** (and re-subscribe the
> events), then restart the plugin — otherwise the bot won't see non-mention thread replies.

> **Re-install after upgrading to approval buttons.** The manifest now enables Slack
> interactivity so Block Kit button clicks are delivered as Socket Mode `block_actions`
> payloads. No public request URL is needed, but the app must be updated from the manifest
> and reinstalled.

> **Re-install after upgrading to group-DM thread-follow.** The `message.mpim` event and the
> `mpim:write` / `mpim:history` scopes were added to `manifest.json`. Update the app from the
> manifest and **reinstall to the workspace** (re-subscribing the events), then restart the
> plugin — otherwise the bot won't see non-mention messages in **group DMs (mpims)**, so it only
> ever responds there when explicitly @-mentioned.

## Local dev with multiple developers

Slack **Socket Mode load-balances events across every client connected to the same
app**. If two developers both run this plugin against one shared app, Slack delivers
each incoming message to only _one_ of them (round-robin) — so you each lose ~half
your messages, and a message can land on a teammate whose core is down. More config
won't fix it; the collision is the shared _app identity_.

**Fix: one Slack app per developer.** Separate apps = separate bot identities, and
Slack delivers each app's events only to that app's own connection — zero contention.

Each developer, once:

1. **Create your own app** from [`manifest.json`](./manifest.json) (step 1 above),
   but first change the manifest's `name` to `Agent (yourname)` and `bot_user.display_name`
   to `agent-yourname`. Install it; grab your own `xoxb-…` + `xapp-…`.
2. Put your tokens in the repo-root `.env` (gitignored — see [`.env.example`](../../.env.example)).
   Pick a distinct core `PORT` per dev if you share a machine.
3. `npm start` at the repo root. In Slack, talk to **your** bot: `@agent-yourname` / DM it.

Nothing shared, nothing to coordinate. The repo's `manifest.json` is the template;
your per-dev name + tokens live only in your local app and your gitignored `.env`.

## How it maps to the core

| Slack                                                                                                            | Core                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app_mention` / `message.im`                                                                                     | `POST /v1/turns`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `message.channels` / `message.groups` in a thread where the bot has a stake (it posted or was really @mentioned) | `POST /v1/turns` with `unprompted: true` (+ serialized conversation context) → core runs turn detection; `status: "silent"` ⇒ post nothing; `status: "react"` ⇒ add reactions and post nothing                                                                                                                                                                                                                                                                                                                                                                         |
| `reaction_added` / `reaction_removed` on a message the bot has a stake in (DM / own message / followed root)     | `reactions.get` to read the message + its emoji → `POST /v1/turns` with `unprompted: true` → core runs turn detection; `status: "silent"` ⇒ post nothing; `status: "react"` ⇒ add reactions and post nothing                                                                                                                                                                                                                                                                                                                                                           |
| user id + `users.info` (guest/restricted/other-team → guest)                                                     | `actor` + `isExternalGuest`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `conversations.info.is_ext_shared`                                                                               | audience contains a non-internal member → core refuses                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| DM channel                                                                                                       | `threadRef` = `dm:<channel>` (one continuous session per DM — channel-scoped; the assistant `thread_ts` only routes the reply + native typing status, never session identity)                                                                                                                                                                                                                                                                                                                                                                                          |
| channel `thread_ts` (root)                                                                                       | `threadRef` = `ch:<channel>:<root>` (one session per thread)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `TurnResult.reply`                                                                                               | posted in-thread; unprompted `refused` (`security_quarantine` included) → silent, addressed `refused` → requester-only (ephemeral in channels) except addressed `security_quarantine` → fixed in-thread acknowledgement; `pending_approval` (or `ok` + `pendingApprovals`, collect-mode) → Block Kit approval buttons                                                                                                                                                                                                                                                  |
| Block Kit approval button (`block_actions`)                                                                      | `POST /v1/turns` with `approval` (`once` / `session` / `always` / deny)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Pending `/v1/surface-context` request (hanging long-poll, source-authed)                                         | The agent's mid-turn context pull: core resolved the target (current conversation via the capability's opaque destination, or a directory-resolved public channel) and parked the request; the plugin enforces visibility (public, bot-member, not externally shared — or the current conversation itself, even private), fetches history with `before` paging + `match` filtering, and POSTs the messages back (`buildContextWindow`). The default injected window is small (`MAX_RECENT_MESSAGES`, env `SLACK_RECENT_MESSAGES`); context beyond it is pull, not push |

## Notes / next

- The failed-turn admin link needs **no Slack config**: when a turn comes back `refused`
  with a real failure, the core supplies the admin transcript deep link (`TurnResult.adminUrl`,
  built from the session org's portal) and the plugin appends it as `Full error:`. Absent when
  there's nothing to link (boundary refusals, no portal configured).
- Channel audience is enumerated per-member: `computeChannelAudience` (lib.ts) resolves
  the full member list (with a Slack-Connect / guest external marker, and an actor-only
  fallback when membership is unreadable) so the core's audience-floor is fine-grained.

## History page size

`SLACK_HISTORY_LIMIT` optionally sets the live history/replies page size and default
`read_thread` result count for workspace-owned apps (integer 1–200). Without it,
owned apps request 200-message pages and return up to 100 messages by default.
Shared-app installations use 15 for both defaults regardless of this setting.
An explicit tool count overrides the result count, but not the live page size.
Stored mirror reads keep their existing window; shadow comparisons match the live page size.

## Mirror qualification

`SLACK_CONTEXT_SOURCE` controls conversation context and default Slack tool reads:

- `live` (default) keeps Slack history as the source, including automatic channel thread expansion.
- `shadow` returns the same live context while comparing stored events in the background. It logs `slack_mirror_shadow` counts for missing messages, text differences, and missing thread parents. It does not log message text or channel identifiers, backfill history, or wait for comparison before returning context. The live page is snapshotted before callers add trigger context, and shadow uses the same stored-message selection as mirror mode. Thread selection matches Slack’s first page, including its limitation that newer replies beyond that page may be absent. Channel roots use the newest page, and thread expansion truncation is tracked separately from root-page truncation. An extra stored row distinguishes exactly full pages from truncated pages; it does not prove complete Slack coverage. Counts cover raw message fields, not complete rendered context or author/subtype fidelity. At most one comparison runs per Slack client; concurrent reads are not all sampled.
- `mirror` explicitly enables stored context and mirror search, with the documented partial coverage and bounded live fallback. Keep this opt-in until shadow evidence and development qualification are satisfactory.

Ingestion continues in every mode. Shadow mismatch counts describe the sampled live window, not complete channel coverage. Compare channel and thread samples, edits/deletes, and normal agent turns before enabling mirror reads. No automatic mode promotion occurs.

Slack mirror records preserve provider subtypes so stored context uses the same system-message filtering as live context. A missing stored subtype means legacy or unobserved metadata; an empty subtype means a verified ordinary message. Partial event updates preserve omitted subtype and thread metadata. Mirror-mode history fallback shares event content normalization and prefers fresh Slack duplicates; live and shadow reads never backfill their comparison source.

---

### Overview (`superserve/templates/README.md`)

# Agent template

`qm-agent.ts` builds the `qm-agent-<release>` template used by the Superserve
sandbox backend.

## Contents

The template uses `ubuntu:24.04` on linux/amd64 and includes:

- Shell and file utilities, Git, curl, jq, and SSH.
- Node and npm, Python with a virtual environment at `/opt/agent-venv`.
- Coding-agent CLIs, `gh`, `aws`, and `x-api`.

Tool versions are pinned in the build script. Downloaded Node and CLI archives
are checksum-verified. Python and pip wrappers select the virtual environment
regardless of the exec environment's `PATH`. Commands run as root; QM sets `HOME`
to `SUPERSERVE_HOME_DIR` (default `/root`) and uses `<home>/workspace`.

Deployment tools and skills are installed during provisioning. The optional
browser engine is not included.

The default Superserve sandbox has 8 vCPUs, 16384 MiB (16 GiB) memory, and
32768 MiB (32 GiB) disk. The disk includes the OS and toolchain as well as workspace files,
dependencies, package caches, and build output. Override these with `--vcpu`,
`--memory-mib`, and `--disk-mib` when building. CPU, memory, and disk capacity are set
on the template, not at sandbox creation.

## Build

```sh
export SUPERSERVE_API_KEY=your-superserve-api-key
node superserve/templates/qm-agent.ts --release 0.1.0 --wait
```

`--release` names the template; use the release tag your deployment runs.
`--wait` streams logs until the build completes. Without it, the script returns
after queuing the build. Use `--base-url` or `SUPERSERVE_BASE_URL` to override the
API endpoint.

A ready template with the same name is reused. Failed builds are replaced;
`--force` also replaces a ready template. Names are unique within a team.

Set `SUPERSERVE_TEMPLATE=qm-agent-<release>` on core after the build succeeds.
The backend requires a ready template with this toolchain.

Rebuilding under the same name does not update existing sandboxes. Changing
`SUPERSERVE_TEMPLATE` to a different name replaces them and deletes their resident
files. Export needed files first; see [updates and retention](../../docs/superserve.md#updates-and-retention).

## Increase sandbox capacity

Existing templates and sandboxes keep their original capacity when these defaults
change. Passing `--vcpu`, `--memory-mib`, or `--disk-mib` with an already-ready
template name also reuses that template without resizing it.

To roll out larger sandboxes:

1. Pause ongoing jobs, commit and push source changes to Git, and export other
   needed resident files before replacing sandboxes.
2. Build under a new template name, for example:
   `node superserve/templates/qm-agent.ts --release 0.1.0-8cpu-16ram-32disk --vcpu 8 --memory-mib 16384 --disk-mib 32768 --wait`.
3. Verify it with
   `node superserve/templates/verify-qm-agent.ts --template qm-agent-0.1.0-8cpu-16ram-32disk`.
4. Set `SUPERSERVE_TEMPLATE=qm-agent-0.1.0-8cpu-16ram-32disk` on core and deploy. This replaces
   existing scope sandboxes on use and deletes their old resident files; it does
   not resize them in place. Restore exported work into the new sandboxes.
5. Check `nproc`, `free -h`, and `df -h /root/workspace` in a new sandbox, then
   rerun representative builds and full typechecking before resuming heavy jobs.

Use your deployment's release tag in place of `0.1.0`. A distinct suffix keeps the
old template available during verification. `--force` rebuilds a same-name
template but does not upgrade existing sandboxes. Confirm the requested shape is supported and allow
for the increased CPU, memory, and storage in provider quotas and costs before
rollout.

## Verify

```sh
node superserve/templates/verify-qm-agent.ts --release 0.1.0
```

The verifier creates a sandbox, checks its tools, workspace, Python environment,
and command timeout behavior, then deletes it. It exits nonzero if verification
or cleanup fails. `--template` selects a template directly instead of by release.

`--keep` retains the sandbox for inspection and schedules deletion one hour after
it pauses. Otherwise, automatic deletion on pause backs up explicit cleanup,
including when the verifier is interrupted.

---

### Overview (`test/live-slack/README.md`)

# Live Slack E2E (example)

Post-merge live scenarios against example.slack.com. The workflow
(`.github/workflows/live-e2e.yml`) boots the merged commit as a real instance on the GHA
runner — core (`SESSION_STORE=memory`) + Slack plugin in Socket Mode on a **CI-reserved
pool app** (`qm dev --ci`) — then `run.ts` drives the catalog in
`scenarios.ts` posting as a real example **user** (the "QA Human" account), so every
message takes the exact path a teammate's would: classification, lazy directory push,
threading, acknowledgments, tool execution, and file delivery.

## Layout

- `run.ts` — runner: lane scheduling (parallel lane fans out, the DM lane is serial
  because a DM is one session — `dm:<channel>` in src/slack), one retry (retried
  pass = _flaky_, still green), results + failing-session transcript dumps to `out/`.
- `scenarios.ts` — the catalog. Prompts embed `ctx.marker()` (an
  `ci-<runId>-<scenario>` token) so assertions grep for unforgeable strings and
  teardown can sweep crons/channels by marker.
- `harness.ts` — per-scenario `Ctx` (fresh channels, final-reply detection, LLM judge).
- `slack.ts` / `core.ts` — fetch-based Slack Web API + source-auth-signed core admin
  clients (transcripts via `/v1/admin/sessions`, crons via `/v1/admin/crons`).

A bot reply is accepted after it has stopped changing for 5s and satisfies the
scenario's content or attachment assertion. Legacy status text is ignored.
The long-task scenario requires a bot reaction or an acknowledgment before the
substantive answer. The audience scenario accepts an answer at the channel top
level or in the original thread; the threading scenario still requires a thread reply.
The performance scenario runs alone and requires successful command execution
before evaluating its timing budgets.

## Run locally

Bring up a dev instance (`/dev-instance up`) or `node cli/bin/qm.ts dev --ci up`, then:

```sh
SLACK_QA_USER_TOKEN=xoxp-… SLACK_BOT_TOKEN=xoxb-… \
CORE_API_URL=http://localhost:8181 CORE_SIGNING_SECRET=… ANTHROPIC_API_KEY=… \
LIVE_E2E_FILTER=@smoke node test/live-slack/run.ts
```

`LIVE_E2E_FILTER` takes a name substring, `@tag`, or `all` (`@smoke` = the two-scenario
subset; `sandbox`-tagged scenarios are auto-skipped unless `SPRITES_TOKEN` is set).
`LIVE_E2E_SHARD=n/m` splits the selected catalog deterministically for a CI matrix.
Set `LIVE_E2E_OBSERVATIONAL=1` to report completed catalog failures (and alert when
configured) without a nonzero exit; runner, setup, event-pump, and required-alert failures
remain blocking.

## Arga twin backend (no shared test workspace, no pool app, fully parallel)

With an Arga API key the same catalog runs against an **isolated Slack digital-twin
workspace** ([docs.argalabs.com](https://docs.argalabs.com)) instead of a shared test workspace: its own bot,
QA user, and minted `alice`/`bob`/`carol` actors per run — no shared workspace, no slot
serialization, no hand-provisioned accounts, and no Slack rate budget shared across shards.
The `twin-e2e` matrix job in the workflow provisions one twin per shard.

Mechanics: `arga-provision.ts up` provisions the twin, seeds users/tokens
(`arga.ts:seedTwinUsers`), pre-creates the reach target channel, and emits the boot env —
`SLACK_API_URL` (the plugin's Web API seam), `SLACK_EVENTS_MODE=http` +
`SLACK_SIGNING_SECRET` (the plugin listens for signed Events-API POSTs instead of Socket
Mode, which the twin doesn't speak), and the minted QA/actor tokens. `run.ts` then starts
an **event pump** (`arga.ts:startEventPump`) that polls the twin's `/admin/events` log and
delivers each recorded event — signed, per-channel FIFO — to the instance's receiver. The
pump also papers over two twin fidelity gaps: it stamps the missing `channel_type` and
synthesizes the `app_mention` twin of a bot-mentioning `message` event (real Slack sends
both; the plugin only dispatches channel mentions from `app_mention`).

Run locally (Free-tier keys cap the twin at 10 minutes — enough for `@smoke`):

```sh
export ARGA_API_KEY=arga_sk_…
eval "$(node test/live-slack/arga-provision.ts up | grep '^export ')"
node cli/bin/qm.ts dev --ci up
LIVE_E2E_FILTER=@smoke node test/live-slack/run.ts
node cli/bin/qm.ts dev --ci down; node test/live-slack/arga-provision.ts down
```

Twin-only scenarios (`scenarios-twin.ts`, tag `@twin`) exercise what a live workspace
can't: event redelivery (dedupe/idempotency), mid-session scope revocation, and per-method
rate limiting. Scenarios that mutate workspace-global twin config use the `exclusive` lane
(run alone, after the other lanes) and restore what they touched.

### App access requests (`@deploy-access`)

`scenarios-deploy-access.ts` drives the published-app access flow end to end on a twin:
a stranger hits an app's subdomain, clicks _Request access_, the owner gets a DM card with
Approve/Decline, a click (a signed `block_actions` POST to the receiver, as Slack would
send) grants view access and DMs the requester, and the requester then reaches the app
through the gateway. They carry the `apps-gateway` capability tag and skip unless the
instance has the gateway configured:

```sh
export DEPLOY_APPS_DOMAIN=apps.e2e.test AWS_DEPLOY_GATE_SECRET=$(openssl rand -hex 16) \
  DEPLOY_APPS_SESSION_SECRET=$(openssl rand -hex 16) DEPLOY_APPS_LOGIN_URL=http://localhost:8181
```

The default `docker` deploy provider needs a `docker` on PATH; a shim that runs each
published entrypoint as a host process is enough (the scenarios publish a one-file
`node server.mjs`).

## Tiers, triggers, quarantine

- **Tiers via tags.** `@core` is the fast high-signal subset run on every push to main;
  the full catalog (`all`) runs on PR-label + on-demand. `@multiuser` = the multi-user
  channel scenarios; `@sandbox` = needs Fly; `@smoke` = the tiny two-scenario subset.
  Capability tags gate _where_ a scenario can run: `twin` needs a twin backend, and
  `no-twin` is its inverse — a capability the twin cannot serve, skipped on twin runs
  (with the reason) so it can't produce a red that says nothing about the product.
- **Triggers.** push→main runs `@core`; add the **`live-e2e` label** to a PR to run the
  full catalog against that branch; `workflow_dispatch` takes a `filter` + a `slot` (1–3)
  so an on-demand feature-push run doesn't queue behind main's. Slots 2/3 need
  `LIVE_E2E_SLACK_{BOT,APP}_TOKEN_{2,3}` secrets (extra CI apps); slot 1 uses the existing pair.
- **Quarantine.** tag a known-flaky scenario `quarantine`: it still runs and shows in the
  gallery/summary (with a chip), but never counts toward the pass/fail signal (exit code +
  `#ci-alerts`) while it's being stabilized. "Don't accept flaky" — fix or quarantine, don't
  let a red scenario erode trust in the suite.

**Rollout state:** CI is required. Catalog failures alert `#ci-alerts` and fail the job; twin
failures alert through the real CI Slack app. Observational mode is available only for explicit
local runner invocations; the GitHub workflow does not enable it.

**Growing the catalog:** `scripts/mine-slack-scenarios.ts` reads a core Postgres, clusters
recent _multi-user channel_ sessions by shape, and emits a digest to hand-author new
scenarios from (curation over an auto-compiler, on purpose). Run it in the core box (see the
top-of-file usage). The multi-user set lives in `scenarios-multiuser.ts`.

## One-time example setup

1. **CI pool app** — create a Slack app from the same manifest as the local pool apps
   (`src/slack/README.md`), named "Agent (ci1)", Socket Mode on. Do **not** reuse
   a local `poolN.env` app: a CI run stealing a slot mid-debug would be miserable.
   → secrets `LIVE_E2E_SLACK_BOT_TOKEN` (xoxb), `LIVE_E2E_SLACK_APP_TOKEN` (xapp).
2. **QA Human** — a real example member account (full member, not guest — guests classify
   differently). Create a second tiny app "QA Driver" with **user** token scopes
   `chat:write, channels:read, channels:write, channels:history, groups:history,
im:history, im:write, reactions:write, files:read, files:write, users:read` and install
   it **as that account** → secret `LIVE_E2E_SLACK_QA_USER_TOKEN` (xoxp). (`files:write`
   lets the QA user upload the selfie in the `photo-identity` scenario; reinstall the app
   if you added it after first install.)
3. **Channels** — create `#ci-target` (public; invite the ci1 bot _and_ QA Human; repo
   var `LIVE_E2E_TARGET_CHANNEL` = its channel id) and `#ci-alerts` (invite the ci1
   bot; repo var `LIVE_E2E_ALERT_CHANNEL` = its id). Failures on main post there.
4. **Remaining secrets** — `LIVE_E2E_ANTHROPIC_API_KEY`, `LIVE_E2E_CORE_SIGNING_SECRET`
   (any random string; the runner instance is loopback-only), and the existing
   `SPRITES_TOKEN` (sandbox turns) and `FLY_API_TOKEN` (the cloudflared self-API tunnel).

Runs are serialized per Slack app by the workflow's concurrency group — that _is_ the
lease; there is no poolN.env machinery in CI.

## Fake-human actors (multi-user scenarios)

Multi-user channel scenarios need several real example member accounts, each with its own
user token, so a scenario reads like a play: `ch.as(bob).mention(...)`,
`ch.as(carol).threadReply(root, ...)`. A scenario declares the actors it needs
(`actors: ["bob","carol"]`); the runner **skips** it unless every declared actor has a
token, and **invites only** those actors to its channel. At boot each actor posts one
warm-up message in a scratch channel (the real lazy Slack→core directory push) and the
runner polls the admin directory until it classifies — an actor that never resolves is
dropped and its scenarios skip, so you never get a mysterious empty-directory miss mid-run.

**Provisioning (~1 hr for five, one-time; the install step is dashboard-manual):**

1. **Five member accounts** via Gmail plus-addressing — `alice+slackqa1@example.com`
   … `+slackqa5`, all landing in your inbox. Invite each to example as a **full member**
   (not guest — guests classify differently). Name them alice … eve.
2. **User tokens** — reuse the "QA Driver" app manifest (same user scopes as the QA Human
   above) and **install it once per account** while signed in as that account; each install
   yields that member's `xoxp` token.
3. **Configure** — either `LIVE_E2E_ACTOR_TOKENS='{"alice":"xoxp-…","bob":"xoxp-…",…}'`
   or one var per actor: `LIVE_E2E_ACTOR_TOKEN_ALICE=xoxp-…`. Names are lowercased.
4. **Verify** before a run: `node test/live-slack/actors-verify.ts` prints each actor's
   handle + user id, or the auth error.

## Screenshots (pixel-truth, local, best-effort)

The gallery reconstructs message content faithfully and links to the real thread; for
actual Slack-rendered pixels run the **decoupled** screenshot sweep after a run. It drives
a _dedicated_ Chrome profile (never your main Chrome), so it's non-disruptive, and gates
each capture on the conversation's text appearing in the DOM — a flaky capture is skipped,
never fatal.

```sh
npm run gallery:shots:login   # one-time: sign into example in the window that opens
npm run gallery:shots         # headless sweep → out/shots/*.png, folds them into gallery.html
```

`LIVE_E2E_SHOTS_PROFILE` overrides the profile dir (default `~/.cache/live-slack-shots-chrome`),
`LIVE_E2E_SHOTS_PORT` the debug port (default 9333).

## Reading a run

**Start with `out/gallery.html`** — one self-contained page (opens offline) that reconstructs
what happened in Slack for _every_ scenario, pass or fail, so you can verify by eye instead of
trusting the LLM judge. Each card shows the driver's messages, the bot's reply, its **full
streaming edit history** (the `⚙ Working…` ticker → `▌` cursor → final text, collapsed under
"N streaming edits"), and a **↗ slack** permalink into the real thread for pixel-true
inspection. Failures sort to the top with their error and core turn errors inline. This is the
primary artifact for the on-demand / feature-push use case.

For deeper debugging, the artifact also has `out/summary.md` (also in the step summary),
`out/results.json` (includes each scenario's timeline), `out/transcripts/*.json` — the failing
scenario's full session entries **and LLM requests** (ground truth for "what did the agent
actually see") — and `.ci-instance/{core,slack}.log`. Don't trust the agent's in-channel
self-explanations; read the transcript.

## Sandbox-provider release qualification

Set `LIVE_E2E_SANDBOX_PROVIDERS=all` with `LIVE_E2E_GATE=1` to require a real
agent `execute` on every implemented provider: Sprites, AWS, local Docker,
Smolmachines, E2B, Modal, Porter, and Agent37. These eight scenarios run concurrently
in their own lane alongside the selected catalog. The source type requires a
coverage entry when a provider is added. Missing providers, skips, retries,
execution errors, and cleanup errors block release. Provider checks do not retry; a failed first execution blocks qualification.
Release qualification cannot shard away a required provider check.

Alternatively, set `LIVE_E2E_SANDBOX_PROVIDERS` to an explicit comma-separated
provider list. Every listed provider remains mandatory and runs concurrently.
Providers omitted from that list are outside the gate, not failed or skipped
required tests. Empty, unknown, and duplicate provider names are rejected.

Each check creates an isolated sandbox, makes it the default only for its fresh
test channel, and asks the agent to execute a nonce command on its explicit ID.
The assertion requires a correlated tool call and result with exit code zero,
no timeout, and exact stdout. Agent reply text cannot satisfy it. Cleanup clears
the test channel default and retires its named test resources, including failed
provisioning records. Core requests are bounded, cleanup uses a separate deadline,
and the runner waits for provider cleanup to settle before recording an attempt
as timed out. Slack requests time out after 30 seconds without SDK retries
or automatic rate-limit waits; the scenario runner owns retry policy. A cleanup deadline failure remains a release blocker and requires
operator reconciliation of retained resource records.

The target instance must enable sandbox resources and configure every required
provider with real credentials and infrastructure. Local Docker additionally
requires a Docker-capable host and a built sandbox image; a Fargate service alone
cannot supply it. Unconfigured providers are failures, never optional coverage.

When staging cannot host Docker, run `scripts/qualify-local-provider.sh
/path/to/release-candidate.json` as a separate required job on an ephemeral
Docker-capable ARM64 runner. It pulls the candidate's immutable core image from
ECR and checks its embedded source SHA against the checkout. The runner builds
the local sandbox from that source, then invokes the candidate's real application,
Pi harness, model, and sandbox tools. Only the probe and transcript assertion are
mounted into the core container; application code comes from the candidate image.
Provide AWS ECR access and `ANTHROPIC_API_KEY`. Require this job alongside cloud
qualification before promotion. If capturing output through a pipe, use a shell
with `pipefail` so a failed probe cannot become a successful job.

---

### Overview (`vendor/pi-ai/README.md`)

# Vendored pi-ai 0.82.0 (qm.1)

qm installs `@earendil-works/pi-ai` from the GitHub release
[`vendored-pi-ai-0.82.0-qm.1`](https://github.com/yc-software/qm/releases/tag/vendored-pi-ai-0.82.0-qm.1),
built from stock npm 0.82.0 plus `provider-error.patch` (dist only; version unchanged; not sent upstream).

Why: stock pi-ai folds a provider HTTP failure into the display string `errorMessage`, so qm had to
parse text to tell a budget cap from a rate limit. The patch keeps the structured data next to it:

- `AssistantMessage.providerError?: { status?, type?, code?, body? }`, from the SDK error, in the
  error path of openai-completions, openai-responses, azure-openai-responses, openai-codex-responses,
  anthropic-messages, google-generative-ai, google-vertex, mistral-conversations and
  bedrock-converse-stream (`extractProviderError` in `dist/utils/error-body.js`).
- `AssistantMessage.rawStopReason?: string` when a stop/finish reason maps to `stopReason: "error"`
  (anthropic-messages, openai-completions, bedrock-converse-stream), e.g. Anthropic `refusal`.

`errorMessage` is byte-for-byte what stock 0.82.0 produces.

The vendored pi-coding-agent (`vendored-pi-coding-agent-0.82.0-security.6`) is security.5 with only its
`npm-shrinkwrap.json` pi-ai entry repointed at this tarball, so there is one pi-ai in the tree; the root
`overrides` entry enforces the same.

Rebuild (both are byte-reproducible and verify their inputs' SHA-256):

    vendor/pi-ai/build.sh                 # earendil-works-pi-ai-0.82.0-qm.1.tgz
    vendor/pi-ai/repack-coding-agent.sh   # earendil-works-pi-coding-agent-0.82.0-qm-security.6.tgz

Drop this directory when qm upgrades past 0.82.0 to a pi-ai that exposes equivalent fields.

---

### Overview (`cli/test/e2e/README.md`)

# `qm` CLI end-to-end tests

These drive the **real** `qm` binary as a subprocess and verify each command's
_objective_ — files actually scaffolded, containers actually running, the right `flyctl`
actually issued, the deployment actually torn down — not just that something was printed.

```bash
cd cli
npm run test:e2e
npm run test:all
npm test
```

They load the repo's `./.env` into the child environment (so `ANTHROPIC_API_KEY` /
`CORE_SIGNING_SECRET` / `SKILL_SIGNING_SECRET` are present, as an operator's shell would have them).

## What each file covers

| file               | command(s)                                                                              | how it's real                                              |
| ------------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `meta`             | `help` · `version` · unknown cmd                                                        | exit codes + stream routing through the shipped bin        |
| `init`             | `init` (+ `--org`/`--target`, clobber refusal)                                          | scaffolds, then **round-trips through `check`**            |
| `check`            | `check` (+ `--sandbox-dir`)                                                             | pass on a valid layer; exit 1 naming each break            |
| `plan`             | `plan` = `up --dry-run` (docker) (+ `--build-from`/`--config`/`--sandbox-dir`/basePort) | asserts the resolved plan; verifies it starts nothing      |
| `sandbox-build`    | `sandbox build --dry-run` (+ `--from`/`--app`/`--tag`)                                  | asserts the generated Dockerfile + push command            |
| `docker-lifecycle` | `up`→`status`→`logs`→`down`/`--purge`                                                   | **real Docker daemon**, asserts against docker's own state |
| `identity`         | `init`→`check`→`slack render`→`outputs` with a custom `botName`                         | brands the real manifest; outputs rejects a stale one      |
| `fly`              | `plan`/`status`/`logs`/`down`/`--target`/`--only`/`-f` (fly)                            | a **fake flyctl** records the exact commands issued        |
| `dev`              | `dev status`/`down`/`logs`/`up` precondition                                            | isolated pool store; never touches the real pool           |

## Design notes

- **Docker lifecycle uses stand-in images.** The CLI's job is to _provision and supervise_
  services, so the test builds tiny `alpine` stand-ins (they echo every service's readiness
  line then idle) via `up --build-from <fake checkout>`. That exercises the full
  build → run → wire → wait-readiness → status → logs → teardown path without depending on
  the heavyweight (and not-yet-published) service images. Skips when no daemon is reachable.
- **Fly is never really deployed.** A fake `flyctl` (`FLY_BIN`) answers the read/list verbs and
  logs every invocation; real Fly deploys are outward-facing and create apps.
- **Isolation.** Every deployment uses a unique org `qm-e2e-<label>-<pid>` (so it never collides
  with a real `acme` stack); dev tests set `QM_POOL_STORE` to a temp dir. Everything is
  torn down in `finally`/`after`.

## Note: `--env-file` and node's same-named flag

Node (≥24, incl. the repo's pinned 24.13.0) treats `--env-file` as its **own** flag and pre-scans
the whole command line for it — even after the script — so a naive `node bin/qm.ts … --env-file X`
lets node hijack the flag before the CLI runs (loading it into node's env, or exiting on a missing
file). The bin's shebang therefore ends node's option parsing with `--` (`#!/usr/bin/env -S node --`),
so the user's `--env-file` reaches the CLI. `runCli` mirrors that by spawning `node -- <bin> …`, and
the `plan --env-file` e2e guards it (it would exit 9 with node's error, not 1 with the CLI's, if the
shield regressed).

---

### Overview (`docs/qa/app-shell/README.md`)

# App shell and management QA

QA used a local web dev instance using the real Vite/Lit web UI, core, isolated Postgres, and a disposable Fieldnotes deployment. No UI or API responses are mocked. Loopback adapters supply the local development session and app host routing; the instance is not publicly accessible.

## App and chat

The agent identified the app from server-supplied conversation context, then published the requested background changes. The green version was verified during QA. The surrounding bar stays neutral.

## Manage

Long histories show the latest ten versions initially. “Show older versions” reveals ten more per click; tests exercise up to 150 versions.

## Permissions

Browser QA covered opening and closing the drawer, top-level escape, staging a person without granting access, the custom access menu, and the manage page. Earlier QA exercised adding view access, changing it to manage, and revoking the test grant. Tests cover dialog-bound menu placement and frame-denying upstream responses.

The picker adds people. Existing project/channel grants can be edited or removed; adding those scope types and an in-bar version selector are outside this change. The top-level escape applies to the current URL; navigation that drops its query parameter restores the shell.

---

### Overview (`docs/qa/chat-streaming/README.md`)

# Chat streaming and recovery QA

QA used the real browser UI and isolated local core/Postgres with synthetic arithmetic prompts. No UI or API responses are mocked. The unchanged main frontend and updated frontend displayed the same saved conversation at the same window size. The local instance was not publicly reachable.

## Before

The latest prompt occupies several lines, and narration and tool summaries remain outside a single completed-work disclosure.

## After

The latest prompt starts as a two-line preview with Show more. Completed work collapses into one timed disclosure while the answer remains visible.

Expanding the disclosure shows narration and commands in their original order.

## Interaction checks

Live browser checks covered streamed prose, code and tables; manual scroll-away and selection; opening and closing split panes during streaming; Stop followed by reload and another turn; queued-message editing with attachments; approval decisions and duration; a 25-second connection outage; and expanded full tool output after reload. Backend-specific cancellation and transport races have deterministic regression coverage. Resting-layout inspection alone does not establish timing behavior.

---

### Overview (`docs/qa/execution-credentials/README.md`)

# Execution credential QA

The retired command-scoped credential flag is removed from Customize → Feature flags. The screenshots show the real admin page against a local development instance with synthetic data.

| Before                                       | After                                   |
| -------------------------------------------- | --------------------------------------- |
|  |  |

Live Slack QA used Firefox, a real model, local Docker/Postgres, and credentials containing disposable synthetic values with no authority:

- An execution without selection saw the variable absent.
- An execution selecting the saved handle verified the expected value and returned `prefix=<redacted:credential>:suffix`.
- The next execution without selection again saw the variable absent.
- Saving a second credential and selecting the returned handle within that same turn returned `new=<redacted:credential>:end`.
- A failing command preserved exit code 7 and surrounding stdout/stderr while masking the synthetic value.
- The retired tool was absent from the model tool catalog.

Postgres inspection confirmed the selected executions' tool-result entries and tape result content contained the mask and no raw synthetic values. User messages and tool arguments deliberately containing the synthetic fixture are outside output masking.

The change covers known injected env values in execution output. File credential restoration, the raw keychain use endpoint, and unrelated asynchronous/file output paths are separate work.

---

### Getting Started (`docs/getting-started.md`)

# Deploy QM for an organization

Deploying QM does not require a copy of this repository: `qm init` materializes a
deployment directory from the published package. Start in an empty organization-owned
private repository:

```bash
npm exec --yes --package=@yc-software/qm@latest -- \
  qm init . --org <slug> --target <fly-or-aws>
npm install
```

Choose Fly.io or AWS before initialization; the slug is a local name derived from the
organization, not globally unique. Customize config, tools, skills, and services in this
directory. To change QM itself, use a public or private source fork and explicitly
build its source, as described in [the README](../README.md#customize-your-instance).
A private source fork can keep its deployment at `deploy/layers/<org>/`; a public source
checkout uses a separate private deployment directory. See
[`../deploy/layers/README.md`](../deploy/layers/README.md).

Provider choice is part of initialization because it determines the config,
secret rules, generated files, and teardown contract. Changing providers means
initializing a new empty directory. `qm init` materializes `deployment.md` and
`.codex/skills/deploy-qm/`. Hand that skill to an agent. It confirms the
operator-owned account and billing before mutation, configures email-gated web
onboarding first, optionally adds connectors and Slack, performs live checks,
and returns the operational URLs. Sign-in defaults to the built-in `auth`
broker, which emails a one-time link: supply the admin address, a verified
sender, and a Resend key or SMTP credentials, and the CLI generates and wires
everything else. Drop `"auth"` from `services` to use an external identity
provider instead; that provider must then register the exact
`<publicUrl>/auth/callback` redirect.

The installed package carries Fly and AWS provider templates and dispatches
their common lifecycle through the hosting-provider registry. Initialization
does not create deployment CI, and the QM source repository has no production
deployment workflow.

---

### SPEC (`docs/SPEC.md`)

# QM spec

> Human-written. Agents must not edit this file, except to add entries to the Wall of shame.

## North stars

1. **Unhobble.** Frontier models are smarter than we usually allow them to be. What scaffolds and supports the model today will restrain it tomorrow; keep the harness as thin as possible.
2. **Free the brain.** State lives in Postgres and the agent can query it. Unhobbling also implies freeing the agent from dependency on any specific provider: sandboxes, models, and harnesses are just swappable resources for the agent.
3. **Fast is the best feature.** Every bit of overhead above base inference time should be measured and driven down.
4. **Agent UX = human UX.** Tools, errors, hints and prompts are the agent's interface, so they get the same care as the web UI. Spend every context token on purpose.
5. **Delete before you add.** Via negativa. This codebase is already way too flabby. Always try the solution that simplifies or removes code before adding anything.

## Subsystems, most central first

**Agent turns.** The system revolves around durable execution against a central writeahead log: the session tape. To execute an agent turn, a session offers up a write lease, at which point a worker claims it from the run queue. The _orchestrator_ assembles context and drives a harness, which fulfills tool calls, and the result is committed to the session tape. The system should be robust to interruption at any time, end to end, and scale to swarms that are millions of agents strong.

**Swappable harnesses and models.** Agents have a single tool catalog; their turns can be fulfilled by Pi or Claude Code, which call into the model catalog, the canonical source for model varieties, effort levels, speed multipliers, and their prices. A particular selection of those variables fulfilling a turn is called the _runtime_.

**Scopes.** The original model was one OpenClaw per-person or per-channel. That means isolated compute, memory, files, crons, and apps. Breaking through that isolation requires an explicit and auditable _grant_.

**Sandboxes.** In OpenClaw and similar systems – the agent’s computer is home, but also a prison. In QM, no sandbox is precious. Parallel computation is encouraged, and durable state should be written in either Postgres or object storage wherever possible to enable that. Management and provisioning of compute is generally left to the model. This allows it to use its intelligence in the deployment of resources as it deems necessary.

**Credentials.** The most secure configuration is a secret that the agent never sees. Unfortunately, the world was not designed with agents in mind. Certain operations become awkward or impossible under this rule. So, in keeping with our capabilities-first north star – we allow escape hatches that trade off some level of security (for example, on-disk credentials that could in principle be lost to a prompt injected agent) to be used at the operators’ discretion.

**Context.** Primarily, this is system prompt, memory, guidance, and skills. Current model rule following is weak and prone to fail. System prompt is used for general orientation of the agent to the QM harness environment, not strict instructions or checks – these belong _locally_ – closer in context-distance to where the agent’s attention actually is. By default, memory is an append-only per-scope notebook recalled with vector search matching on every turn. Given that org memory is an unsolved problem, we allow hooks for swappable implementations of this. Guidance is intended for a per-scope's standing orders; and skills are implemented typically.

**Background work.** Crons, loops, and webhooks run with their owners permissions, but unattended work is generally considered more of a prompt injection risk. Therefore, explicit grants are required before credentials can be used for it.

**Surfaces.** There is one source of truth, which are the tokens sent to the model provider’s GPUs for inference. Everything else is a projection of that: Slack and the web UI read the same sessions and present them the way each host expects.

**Artifacts.** Apps, files, skills, crons, etc should all be shareable with a familiar Google-Docs style interface, be versioned, and tracked in Postgres or object storage.

## Wall of shame

Patterns from this repo's history, with the PRs that introduced or removed them. Lenses marked _always reviewed_ get a reviewer on every PR; triage cannot mark them not relevant.

### Overengineering (always reviewed, 6 examples)

Let sLOC +/- be your guide here. Small and net-negative LOC fixes will be rewarded. This codebase at baseline is flabby -- many bugs can be fixed by _deleting_, _removing_, _simplifying_. For features: use restraint when adding new subsystems that increase surface area and widen the config matrix we will have to support.

Past mistakes and examples:

- Amendments to the master spec that are way too large for the importance of what the PR actually adds [qm#2138](https://github.com/yc-software/qm/pull/2138).
- The loops subsystem should have just re-used the crons machinery, but it built its own little empire for no reason [qm#963](https://github.com/yc-software/qm/pull/963).
- The artifact pages in the web UI could have all used the same group-by backbone, but each one got its own design for no reason [qm#963](https://github.com/yc-software/qm/pull/963).
- **Delivery retries** (2026-08-30): Undeliverable Slack notices prompted a proposal for attempt counters, scheduled exponential backoff, a parked state, and admin visibility. The proposal optimized for eventually delivering messages long after they were useful; simply expiring stale deliveries addressed the actual need. [qm#2133](https://github.com/yc-software/qm/pull/2133).
- **Harness/model selection** (2026-07-17): A selector grew into a proposed "agent profile" platform with its own catalog, preference layers, upgrade acknowledgments, readiness handling, and cohort rollout. The smaller solution extended the existing scoped setting with a `{ harness, model }` pair and reused the existing configuration and picker machinery. [qm#2134](https://github.com/yc-software/qm/pull/2134).
- **Admin tool** (2026-09-17–18): Concern about oversized responses grew into a 43-file, roughly 4,580-line patch with field limits, custom JSON validation, layered timeouts, duplicate concurrency limits, and a special handshake. Only after pushback did the agent inventory actual calls and identify large transcripts as the concrete risk. The replacement used bounded transcript pagination and a thin read-only tool: 9 files, +329/−18. _Source/status:_ original transcript and patch not yet recovered; size and rejection claims are unverified.

### Band-aid fixes (always reviewed, 11 examples)

Hiding a symptom while leaving its true cause unfixed. Smells here are special cases (string matching and regex are especially bad), many-branched conditionals acquiring _yet another_ branch. A bug isn't a problem to fix, it is useful evidence that the system we have built is flawed.

- **Credential handle prefix:** The prompt lists shared credentials as `service_gitlab`, and `execute.credentials` takes that name, but the credential broker matches the bare slug `gitlab` and answers the prefixed name with `403 not_entitled`. QM read that as a missing grant. The proposed fix stripped a leading `service_` in the broker, so both spellings would work. That teaches one more caller to tolerate two names instead of removing the second name: a credential should have one name across the prompt, `execute.credentials`, and the broker. (2026-10-13)
- **Slack task completion:** A proposed acknowledgment fix reconstructed completion from descendant runs, mailbox returns, the latest conversation turn, and delivery-key prefixes. Core needed to own request completion through delegation and final delivery, then expose that state to Slack; moving the same guesses into a helper would not fix the missing ownership. [qm#2135](https://github.com/yc-software/qm/pull/2135).
- **Transcript row grouping:** A proposed UI fix joined adjacent assistant rows only when the second contained subagent activity. Multiple commentary messages, intervening tools, and resumed turns defeated the special case. Grouping events by their actual turn/run identity would give streaming and reloaded history the same response structure. [qm#2136](https://github.com/yc-software/qm/pull/2136).
- **Credential capture:** Indiscriminate credential collection swept up oversized browser profiles. The proposed fix recognized browser-profile marker files and skipped those directories, adding another exception to the sweep. The deeper fix was to let the agent identify which credential files to preserve, or explicitly request capture of changes from an authentication turn. [qm#2137](https://github.com/yc-software/qm/pull/2137).
- **[qm#1743](https://github.com/yc-software/qm/pull/1743)** (2026-09-30): Refusal fallback extended by regex-matching Anthropic usage-policy text and 'gateway model is unavailable' to trigger a hard-coded alternate-model ladder (claude-opus-5 / claude-sonnet-5), plus a new admin fallbackRuntime.
- **[qm#1748](https://github.com/yc-software/qm/pull/1748)** (2026-09-30): Every non-Modal sandbox took one exclusive sandbox-resource:<id> advisory lock around each command/file op, so sessions sharing a computer queued behind each other; replaced a backend!=='modal' special case with shared locks and a parksOnTeardown profile property. _Status:_ wound back in [qm#1748](https://github.com/yc-software/qm/pull/1748).
- **[qm#1432](https://github.com/yc-software/qm/pull/1432)** (2026-09-19): Per-turn reconciliation of a shared skills/ index under a sandbox-wide advisory lock (skill projection) stalled turns 5 minutes; replaced with an explicit skill tool and per-turn skill dirs. _Status:_ wound back in [qm#1432](https://github.com/yc-software/qm/pull/1432).
- **[qm#1133](https://github.com/yc-software/qm/pull/1133)** (2026-09-12): A factory-specific retry ladder (FACTORY_READ_RETRIES=6) was added around sandbox readProcess polling after one timeout under load. _Status:_ wound back (factory code absent on current main).
- **[qm#965](https://github.com/yc-software/qm/pull/965)** (2026-09-07): One legacy \u0000 payload bricked sessions; fixes proposed tolerating poisoned rows and sanitizing every Postgres text write. _Status:_ unknown.
- **[qm#394](https://github.com/yc-software/qm/pull/394)** (2026-08-13): Memory tool silently coerces malformed `remember` calls (content/query/bare string) into facts instead of fixing the tool schema.
- **[qm#469](https://github.com/yc-software/qm/pull/469)** (2026-08-13): Sandbox status collapses any unanswering shell into a 'wedged' verdict with baked-in 'restart the computer' advice.

### Duplication (always reviewed, 7 examples)

Giving the same behavior or fact multiple independent implementations or sources of truth. These copies drift, disagree, and multiply maintenance work; reuse the existing owner or consolidate the competing paths.

- **[qm#1776](https://github.com/yc-software/qm/pull/1776)** (2026-09-30): Sprites cold-boot '503 Process not ready' exec re-send implemented on main after the same fix ([qm#1489](https://github.com/yc-software/qm/pull/1489)) had already landed only on the long-lived factory side branch.
- **[qm#1520](https://github.com/yc-software/qm/pull/1520)** (2026-09-22): Factory required pasted factory-anthropic/-github/-linear/-slack keychain secrets duplicating core's own model auth and connectors (two sources of truth); QM-73..76 resolve from core config/owner connectors instead. _Status:_ wound back in [qm#1520](https://github.com/yc-software/qm/pull/1520), [qm#1522](https://github.com/yc-software/qm/pull/1522), [qm#1523](https://github.com/yc-software/qm/pull/1523), [qm#1524](https://github.com/yc-software/qm/pull/1524) (on factory branch).
- **[qm#1476](https://github.com/yc-software/qm/pull/1476)** (2026-09-21): Context settings had its own model picker separate from the composer's; switched to reuse the composer model/preset picker. _Status:_ wound back in [qm#1476](https://github.com/yc-software/qm/pull/1476) (both plugins/web-ui/src/model-picker.ts and context-model.ts still exist).
- **[qm#1272](https://github.com/yc-software/qm/pull/1272)** (2026-09-16): Session transcripts are kept in both session_entries and session_tape, with SESSION_TAPE_MODE shadow/serve choosing between them. Fixed in [qm#2273](https://github.com/yc-software/qm/pull/2273): code uses only session_entries. Both tables are now gone: `items` is the only transcript store.
- **[qm#1185](https://github.com/yc-software/qm/pull/1185)** (2026-09-15): Each subscriber held its own LISTEN connection and pg-boss kept its own pool; consolidated to one listener per process and the shared query pool. _Status:_ wound back in [qm#1185](https://github.com/yc-software/qm/pull/1185).
- **[qm#993](https://github.com/yc-software/qm/pull/993)** (2026-09-08): Chat search queried both the dedicated search index and legacy entry history for every visible conversation, causing timeouts. _Status:_ wound back in [qm#993](https://github.com/yc-software/qm/pull/993).
- **[qm#488](https://github.com/yc-software/qm/pull/488)** (2026-08-13): Web-UI /api router had ~50 hand-copied body-parse/relay/path-decode fragments that drifted (some returned 400 instead of 413). _Status:_ wound back in [qm#488](https://github.com/yc-software/qm/pull/488).

### Hand-rolling (always reviewed, 5 examples)

Reimplementing a capability that an existing SDK, library, or supported API already provides. Custom protocols and plumbing make us own edge cases that the maintained integration already handles; use that integration when it meets the requirements.

- **[qm#1005](https://github.com/yc-software/qm/pull/1005): Run-queue retries.** Added a `retry_after` column, a matching in-memory deadline map, a custom exponential-backoff-with-jitter function, delayed-claim predicates, and an inline polling loop to keep delayed retries moving. pg-boss already provides durable deferred jobs, retry limits, and backoff; we implemented those mechanics again beside the pg-boss cron queue. _Status:_ custom retry scheduling remains in `src/runs/`.
- **[qm#1255](https://github.com/yc-software/qm/pull/1255): Run-queue worker wakeups.** Sixteen idle workers issued about 64 claim UPDATEs per second. The fix built a `qm_run_available` notification channel, worker generation counters, reconnect rescans, recovery timers, and a shared availability probe. This is another layer of queue infrastructure to maintain when pg-boss provides worker polling and notification-based wakeups. _Status:_ the custom notification and worker machinery remains.
- **[qm#963](https://github.com/yc-software/qm/pull/963): Delivery-queue claims and recovery.** The imported implementation has its own `claim_expires_at`, `FOR UPDATE SKIP LOCKED` claims, expired-row sweep, and Slack poller with per-row expiry tracking and a three-attempt `reclaimOwnership` loop. pg-boss can own job claiming, expiry, and retries instead of making the delivery subsystem implement another queue. Delivery records, Slack idempotency, and session ordering still belong to the application; they are not reasons to rebuild the queue mechanics. _Status:_ the custom delivery queue remains.
- **[qm#1419](https://github.com/yc-software/qm/pull/1419)** (2026-09-22): Sprites backend used raw REST fetches; moved to Sprites SDK 0.2.3 (WebSocket exec, filesystem APIs, checkpoints); sibling PRs #1420-#1422 aligned Modal/E2B/Smolmachines with provider docs. _Status:_ wound back in [qm#1419](https://github.com/yc-software/qm/pull/1419).
- **[qm#1407](https://github.com/yc-software/qm/pull/1407)** (2026-09-22): Internal Fly transports piped data through `fly ssh console` (broke on Windows PTY); replaced with Machines exec API stdin. _Status:_ wound back in [qm#1407](https://github.com/yc-software/qm/pull/1407).

### Non-durability (always reviewed, 5 examples)

This is a distributed system that needs work to endure through deploys and crashes without hiccups. A common agent mistake has been to store data in process memory. Postgres is effectively the core's filesystem -- think of core memory as a cache at best.

- **Operational error history:** `createErrorLog()` kept the admin Errors history in a 5,000-event process-local ring buffer, even with a production database configured. A deploy erased the history; a request routed to another instance could not see it. The fix added Postgres `error_events` and wired production reads and writes to it. _Status:_ fixed; original code and fix preserved in [qm#2140](https://github.com/yc-software/qm/pull/2140).
- **Scoped configuration:** Soul overrides, command policies, egress rules, and publishing flags lived in process-local `Map`s. Restarting core discarded the overrides and restored defaults. The fix added Postgres backing and hydrated the read cache before serving. _Status:_ fixed; original code and fix preserved in [qm#2140](https://github.com/yc-software/qm/pull/2140).
- **Pending Slack approvals:** Run/Deny cards depended on `pendingSlackAgentRequests`, a `Map` in the Slack process. Deploys erased outstanding requests; clicks routed to another instance could report that the request had expired and leave the originating status stuck. The fix persisted the requests and approval linkage in Postgres with atomic claims. _Status:_ fixed; the Map removal and durable replacement are in [qm#963](https://github.com/yc-software/qm/pull/963).
- **Completed runs with missing replies:** A run was durably marked complete, but enqueueing its delivery depended on an in-process `onTerminal` callback. A crash between completion and enqueue left finished work with no delivery. The fix scans persisted terminal runs and reconstructs missing deliveries. _Status:_ fixed in [qm#2053](https://github.com/yc-software/qm/pull/2053).
- **Webhook work already in flight:** `runOnce` guarded running webhook work with a process-local `inflight` Set, while the receiver's durable idempotency check recognized only committed fires. A retry routed to another instance during a long turn could start the same work again. _Status:_ still present on main; [qm#1943](https://github.com/yc-software/qm/pull/1943) proposes shared Postgres claims and includes a two-instance regression test.

### Config-matrix expansion (always reviewed, 10 examples)

Helpful KPIs here are: how many env vars do we have? how large is the database schema? Implicit modes in the form of conditionals, text matching or hard-coded flags also count. The combined footprint of this config matrix is currently enormous and makes our task much more difficult. Driving this down is critical and PRs that reduce the size of these will be rewarded.

- **[qm#1784](https://github.com/yc-software/qm/pull/1784)** (2026-09-30): Security screening had three overlapping env knobs (SECURITY_SCREEN_BACKEND, SECURITY_SCREEN_ALL_POSTURES, SECURITY_SCREEN_PROXY_ROLLOUT) plus per-posture inboundScreening; collapsed into one SECURITY_SCREEN=off|observe|enforce. _Status:_ wound back in [qm#1784](https://github.com/yc-software/qm/pull/1784).
- **[qm#1747](https://github.com/yc-software/qm/pull/1747)** (2026-09-30): New SANDBOX_CAPABILITY_TTL_HOURS env var (48h default, or 0/none for non-expiring bearer tokens) right after #1518 hard-set 48h.
- **[qm#1619](https://github.com/yc-software/qm/pull/1619)** (2026-09-25): Separate org runtime defaults for conversations, crons/loops and sub-agents, then per-cron overrides (#1593) and a fallback runtime (#1743): four overlapping runtime settings with precedence rules.
- **[qm#1201](https://github.com/yc-software/qm/pull/1201)** (2026-09-15): Security screening gained an off/model/proxy backend plus ALL_POSTURES, four PROXY_* vars and a timeout, layered on HARNESS_SECURITY_POSTURE and the org-level Auto flagger settings from [qm#878](https://github.com/yc-software/qm/pull/878).
- **[qm#1208](https://github.com/yc-software/qm/pull/1208)** (2026-09-15): EAGER_PROVISION was an opt-in flag no deploy template set, so every deployment missed a 66s-to-3s median speedup until it defaulted on. _Status:_ partly wound back; flag remains (src/config.ts).
- **[qm#1162](https://github.com/yc-software/qm/pull/1162)** (2026-09-14): Gateway deployments had to maintain an environment model allowlist; replaced by discovering models from the gateway's key-scoped list. _Status:_ wound back in [qm#1162](https://github.com/yc-software/qm/pull/1162).
- **[qm#1044](https://github.com/yc-software/qm/pull/1044)** (2026-09-10): The unified `sandbox` tool shipped behind SANDBOX_RESOURCES_ENABLED alongside the legacy execute/background tools, so two tool surfaces and both flag states must be supported.
- **[qm#922](https://github.com/yc-software/qm/pull/922)** (2026-09-04): Another sandbox backend (agent37) was added, bringing providers to about ten (agent37, aws, e2b, modal, porter, smolmachines, sprites, superserve, local/docker); [qm#954](https://github.com/yc-software/qm/pull/954) proposed Kubernetes as well.
- **[qm#876](https://github.com/yc-software/qm/pull/876)** (2026-09-02): Porter added as yet another SANDBOX_BACKEND and DEPLOY_PROVIDER (plus a Helm chart), shortly after Modal and E2B.
- **[qm#478](https://github.com/yc-software/qm/pull/478)** (2026-08-13): Added smolmachines as yet another sandbox backend, then SMOLMACHINES_CPUS/MEMORY_MB/DISK_GB env knobs ([qm#507](https://github.com/yc-software/qm/pull/507)).

### Provider drift (2 examples)

Every sandbox provider should give the agent the same machine. When one provider's image or behavior quietly differs, the agent hits failures that only reproduce there.

- **Adding a provider that subtly deviates from the general sandbox contract (this has been done several times).** For example, ordinary teardown parks E2B, AWS, and local Docker computers, while Modal needs different handling. [qm#1748](https://github.com/yc-software/qm/pull/1748) removes a `backend !== "modal"` locking exception and introduces a `parksOnTeardown` profile property; the distinct lifecycle behaviors remain part of the matrix.
- **AWS CLI v1 on E2B and Modal** (2026-10-09): The E2B and Modal sandbox images came with no AWS CLI at all. When the agent needed `aws`, it ran `pip install awscli`, and pip only has version 1. Version 1 can't do device-code sign-in. The Fly, Porter and Superserve images already included version 2, so only these two were missing it.

### God files (4 examples)

Letting one file or module absorb unrelated responsibilities until changes require understanding the whole system. Keep responsibilities with clear owners and boundaries; splitting a file by line count alone does not untangle those responsibilities.

- **[qm#1296](https://github.com/yc-software/qm/pull/1296)** (2026-09-16): The AWS deploy backend keeps absorbing capacity proofs, ownership handover and candidate logic.
- **[qm#1636](https://github.com/yc-software/qm/pull/1636): Core orchestrator.** `src/core/orchestrator.ts` absorbed memory-audience changes, transcript cutoffs, provenance dependency capture, harness resets, and approval invalidation inside the same turn-execution closure. The diff shows memory-disclosure policy reaching into retry replay and pending approvals; those responsibilities remain embedded in the orchestrator despite separate memory helpers.
- **[qm#175](https://github.com/yc-software/qm/pull/175): Chat surface.** `plugins/web-ui/src/chat.ts` combines transcript rendering and pagination, pane lifecycle and URL state, command-approval submission, active-run recovery, session forking, and connector-auth widgets inside `createChatSurface`. The multiview change rewrites that shared closure, so changing how a pane mounts also means navigating approval and execution-recovery state. _Status:_ these responsibilities still share `chat.ts`.
- **[qm#488](https://github.com/yc-software/qm/pull/488): Web server entrypoint.** `plugins/web-ui/server/index.ts` combines session cookies and sign-in, OAuth callback forwarding, core API transport, request-body handling, and route handlers for sessions, files, projects, and crons. The PR extracts a shared router and relay helpers, but keeps the product domains and authentication flows in the same server module. _Status:_ partial cleanup; the shared entrypoint still owns those domains.

### Mismatched UI (4 examples)

Building interface pieces that disagree with the surrounding product in appearance, structure, or interaction. Reuse the existing components and design rules, and check the result in its containing surface so duplicated chrome and inconsistent typography do not slip through.

- **Admin UI versus web UI:** The same product has separate admin and web interfaces with different layouts, styling, and controls. Moving between them should not feel like switching products. [qm#1441](https://github.com/yc-software/qm/pull/1441) migrated admin views to Lit while explicitly retaining the existing admin CSS and layout; sharing a rendering library did not unify the UI.
- **Artifact pages under Browse:** Skills, crons, webhooks, apps, and files each developed their own search, filters, and toggles when they could use more or less the same controls. Skills has its own scope/source/status filters; crons has ownership tabs and a disabled toggle; apps has another tab implementation; files renders its own search and ownership/type filters. The implementations are visible together in [qm#963](https://github.com/yc-software/qm/pull/963). Artifact-specific options can vary, but the common search, filtering, and toggle interactions should be shared across Browse.
- **[qm#1545](https://github.com/yc-software/qm/pull/1545)** (2026-09-22): Transcript elements each hardcoded their own font-size, so multiview panes showed 15px/14px headers beside 12px text; unified on one --chat-font-size base. _Status:_ wound back in [qm#1545](https://github.com/yc-software/qm/pull/1545).
- **[qm#513](https://github.com/yc-software/qm/pull/513)** (2026-08-13): Multiview panes showed two stacked headers (pane chrome plus the hosted chat's own top bar). _Status:_ wound back in [qm#513](https://github.com/yc-software/qm/pull/513).

### Over-indexing on YC (3 examples)

QM is for organizations in general. YC's usage guides development but architecture should not be _warped_ to fit our purposes and customizations. Leaking YC _data_ is a separate concern.

- **Dedicated people-directory configuration:** Role lookup from a people website became its own People directory admin card, org-wide URL setting, and save path in the generic product. This could have been ordinary organization knowledge or a deployment tool. _Status:_ [qm#1441](https://github.com/yc-software/qm/pull/1441) removed the dedicated admin card; [qm#2456](https://github.com/yc-software/qm/pull/2456) removes the setting, its admin resource and its system-prompt line.
- **[qm#1008](https://github.com/yc-software/qm/pull/1008)** (2026-09-09): A 29-file 'software factory' loop (Linear auto-triage, forge ship contract) built for YC's own workflow was ported into public src/loops/factory before it had ever run end to end. _Status:_ wound back in [qm#1026](https://github.com/yc-software/qm/pull/1026).
- **[qm#530](https://github.com/yc-software/qm/pull/530)** (2026-08-15): Assistant and org names were fixed across prompts, manifests, auth and UI; made deployment-configurable with neutral defaults. _Status:_ wound back in [qm#530](https://github.com/yc-software/qm/pull/530).

### Regex (3 examples)

Some people, when confronted with a _**problem**_, think “I know, I'll use _**regular expressions**_.” Now they have _**two problems**_.

Main has 1,443 production regex sites in 380 files; cleanup is the regex-removal backlog.

- **[qm#1354](https://github.com/yc-software/qm/pull/1354)** (2026-09-17): The web UI re-parses shell commands with a hand-written tokenizing regex (and sniffs `sed -n Np` and Markdown headings by pattern) to decide how to present tool activity, instead of using the structured call data.
- **[qm#1210](https://github.com/yc-software/qm/pull/1210)** (2026-09-15): Composio consent links are found by regex-scanning model text for URLs and Markdown links, then stripped with dynamically built `RegExp`s, rather than arriving as a typed connector-link field.
- **[qm#169](https://github.com/yc-software/qm/pull/169)** (2026-08-03): The agent self-API allowlist is a chain of path regexes (`/^\/v1\/projects\/[^/]+$/`, …) instead of matching on the router's declared routes.

### YC info leaking into public qm (2 examples)

Publishing organization-specific information in the public repository through code, docs, fixtures, screenshots, or change descriptions. Inspect outgoing content for private identities, operational details, and internal references; keep private deployment material in private storage.

- **[qm#1315](https://github.com/yc-software/qm/pull/1315)** (2026-09-16): The generic web UI onboarding says 'the agent harness we use to run YC' and 'your YC partner in a box', and the welcome ideas cite 'YC Deal' and 'the YC investor database'.
- **[qm#1504](https://github.com/yc-software/qm/pull/1504)** (2026-09-22): Public docs/test fixtures had org-specific rollout guidance and identity examples plus 92 tracked screenshots (8.1 MB); scrubbed and AGENTS.md now bans committed screenshots. _Status:_ wound back in [qm#1504](https://github.com/yc-software/qm/pull/1504) (partially; YC welcome copy remains).

### Discarded error evidence (3 examples)

Replacing or swallowing a failure so the evidence needed to diagnose it disappears.

- **Sandbox startup and cleanup** (2026-10-02): A provider authentication failure reached the tool transcript as a bare "Command execution failed." The cleanup wrappers in `src/sandbox/sandbox.ts` dropped the original causes, destruction retries in `src/core/orchestrator/sandboxes.ts` swallowed every exception, and `src/harness/agent-tools.ts` recorded only the generic status. _Status:_ wound back in [qm#2018](https://github.com/yc-software/qm/pull/2018).
- **Rejected session titles:** `sanitizeTitle` returned `undefined` for rejected model output. The fallback title made the request look successful, while neither the rejected text nor the rule that rejected it reached the error log. _Status:_ [qm#1303](https://github.com/yc-software/qm/pull/1303) records the rejection rule and a bounded sample in the durable error log.
- **Failed transaction rollback:** `withPgTransaction` and copied transaction handlers awaited `ROLLBACK` before rethrowing the original error. If rollback also failed, its exception replaced the actual transaction failure. _Status:_ still present on main; [qm#1834](https://github.com/yc-software/qm/pull/1834) proposes preserving the original error and discarding the broken connection.

### Historylessness (always reviewed, 5 examples)

Growing the codebase to keep every path it ever took alive. The canonical case: a conditional that string-matches IDs or prefixes because older rows lack a typed field. Trust the typed field; rows without it get no special handling, and if they matter, migrate them once. The same goes for compatibility shims, dual reads and writes, and old endpoints or data shapes kept beside their replacement. Make the breaking change and let people adapt; agents can usually smooth over the breakage for existing users. If keeping an old shape truly seems necessary, ask the user rather than building it. The codebase represents the system at its current best, without sedimentary layers of how it got there.

- **Sidebar surface** (2026-10-14): The web sidebar's `surfaceOf` (`plugins/web-ui/src/sessions.ts`) is a conditional that string-matched `threadRef` prefixes (`web:`, `dm:`, `ch:`) because older sessions lacked a typed `surface`, and trusted the stored field only for subagents. When automation sessions landed in the wrong group, the proposed fix added more prefixes. Every session now stores its surface; read that field alone. _Status:_ still present on main.
- **Credential capture redesign** (2026-10-08): The login-as-a-skill handoff planned that "a stale skill calling /v1/keychain/use gets a clear refusal" and that "logins saved under the old capture system still load." Both keep the old system on life support. Remove the endpoint and the old capture system outright; stale skills fail and get fixed. _Status:_ [qm#2146](https://github.com/yc-software/qm/pull/2146) deletes `/v1/keychain/use` (open).
- **Sandbox routing retirement** (2026-10-02): Retiring `sandbox_routing` came with a locked, marker-guarded one-time upgrade import that preserved legacy IDs, explicit nulls, and machine IDs, kept the old table as rollback input, and told operators to keep credentials for legacy-mapped providers. A plain migration would have done. _Status:_ merged in [qm#2019](https://github.com/yc-software/qm/pull/2019).
- **Session tape beside session entries** (2026-09-16): The tape was meant to replace `session_entries`, but caution kept both. `SESSION_TAPE_MODE` shadow/serve chose between them, exact-transcript annotations were added so the tape could reproduce every historical field, and a verifying, lease-aware operator backfill with dry runs and partial-scan statuses guarded the cutover. We got stuck here for weeks; one migration and a hard cutover would have left one system instead of two. _Status:_ both are gone. Code reads and writes only `items`; startup migrations copy older history into it and then drop `session_tape`, `session_entries` and the separate search table.
- **Legacy Inbox loop:** `src/loops/inbox-migration.ts` migrates each owner's legacy Inbox loop at runtime, with its own phase record in UI-state preferences, a pause-and-disable sequence, and a repair path for half-finished runs. _Status:_ still on main; deletion approved for the loops rebuild.

### Over-testing

Description and examples pending human input.

### Prod-like QA (1 example)

QA every behavior change in a prod-like setting on the real system it touches before merge. Mocks and unit tests don't count; a missing credential is a blocker to solve or report, not a reason to merge.

- **Shipped changes for a provider without testing on that provider** (2026-10-12): [qm#2226](https://github.com/yc-software/qm/pull/2226) changed how the Fly deploy provider starts and supervises apps, and merged with only mocked unit tests for Fly. The agent had no working Fly token and shipped anyway.

### Performance (3 examples)

Doing unnecessary work, repeating expensive work, or making independent work wait. Watch for hot polling loops, unbounded database reads, and locks that serialize unrelated operations. Token costs belong under Token performance.

- **Polling that writes even when nothing is due:** The cron scheduler sent a synthetic tick job every second and repeatedly submitted cron jobs. Idle scheduling still generated database writes and queue churn. _Status:_ [qm#2007](https://github.com/yc-software/qm/pull/2007) removes the per-second tick job; mutation and completion hooks keep jobs current, with periodic repair on the scheduler lease holder.
- **Unbounded admin list queries:** The session list combined per-row JSON subqueries with full-history sorting and OFFSET pagination. A routine admin page could saturate Postgres and starve the shared connection pool used by actual turns. _Status:_ [qm#2076](https://github.com/yc-software/qm/pull/2076) makes the list query bounded and removes repeated work from each row.
- **Serializing independent sandbox operations:** A single exclusive machine lock covered commands, file operations, and status checks. One long-running command blocked unrelated sessions using the same computer. _Status:_ [qm#1748](https://github.com/yc-software/qm/pull/1748) lets ordinary operations share the lock while keeping destructive lifecycle operations exclusive.

### Token performance (4 examples)

Tokens are the hot path. Anything sent to the model on every turn is paid for on every turn, in money, latency, and attention. Keep prompt prefixes stable so the KV cache holds, and make every token in the system prompt and per-turn context earn its place: no duplicates, no inventories the request doesn't need, no boilerplate repeated per item. Default to small and let the agent fetch the rest on demand.

- **Defeating prompt caching with incidental changes:** Changing countdowns, fresh sandbox/job snapshots, and inventories rendered in discovery order kept changing otherwise reusable prompt prefixes. _Status:_ [qm#1618](https://github.com/yc-software/qm/pull/1618) stabilizes snapshots and timestamps; [qm#1690](https://github.com/yc-software/qm/pull/1690) fixes the remaining inventory-order churn.
- **Per-turn `<environment>` note:** Memory, scheduled-work snapshots, and time blocks ride along with every turn whether or not the request needs them. The whole memory notebook is sent at session start and after compaction, then as deltas; on a long-lived DM that is hundreds of bullets. The spec describes vector recall, but the whole notebook is sent. _Status:_ still present on main.
- **Redundant system prompt:** The same instructions appear more than once: scope guidance that copies the org block and adds to it is re-sent in full (only exact matches are skipped), and every credential is listed twice (as execute handles and again as keychains), on top of a full skills index and a shared-file listing. Caching softens the price, not the attention cost. _Status:_ still present on main.
- **Per-fact provenance in memory:** Each remembered fact carries its own date and scope tag, and the same fact recalled from two scopes is printed twice, so the boilerplate grows linearly with the notebook. Dedupe across scopes when rendering, save each fact once, and state provenance once per group. _Status:_ still present on main.

### Honorable mentions

These can be addressed by a single combined reviewer.

- **Screenshots:** The repo is not to be cluttered with screenshots or images of any kind that aren't actually used in UI.
- **Code comments:** Not allowed -- these historically have reinforced drift and reward-hacky agent decisions, providing air cover for future agents to do the same.

---

### Admin Model Verification (`docs/admin-model-verification.md`)

# Admin-managed model verification

In the admin model registry, **Verify and enable** checks a proposed OpenAI or Anthropic model before saving it. Only organization administrators can run the check. API clients must explicitly consent by including `verify: true` with the registry PUT; it is not part of the model definition.

The check uses Pi's model runtime, the selected template's protocol, the exact model ID, and the organization's serving credential and endpoint configuration. It sends only synthetic text and a synthetic tool schema; no tools are executed or conversation data sent. It requires a completed, nonempty text response. The output cap is the lesser of 128 tokens and the model's configured limit. Fast-mode definitions require ordinary and fast checks. Verification has a 15-second deadline; provider retries may apply. These requests may incur provider charges.

A failed check does not create or save the proposed definition. Failed edits preserve the previously saved settings. A failed recheck of the same saved settings removes their earlier verification. Errors distinguish missing credentials, access denial, unavailable models, quota/rate limits, rejected configurations, timeouts, and other provider failures without returning raw provider details.

Successful checks store a timestamp and a private fingerprint of the definition, resolved template metadata, and serving credential/configuration revision. Each durable refresh checks that fingerprint without making another provider request. Relevant changes invalidate verification and remove the model from selectable choices until an administrator verifies it again. Previously stored definitions without verification are unavailable until checked. Existing selected IDs are preserved rather than silently replaced.

Slow probes run outside the registry write lock. Before saving, the store rechecks the definition, serving configuration, and prior stored revision. A late result cannot undo a deletion or overwrite another edit.

## What verification does not promise

- The timestamp records a successful check at that time, not permanent provider access. Entitlements, quota, or provider behavior can subsequently change.
- Organization verification does not certify any user's personal API key or subscription. Existing credential boundaries still apply.
- A small probe does not certify prices, the maximum advertised context/output limits, image support, every reasoning level, or correct tool execution. Check provider documentation for those fields.
- Configuring a model does not add support for a new protocol or native harness. Admin-managed API models remain Pi-only in normal use.
- Live provider compatibility requires running the check against that provider. Development tests use local streaming provider fixtures.

## Find a model before entering metadata

Start with **Provider** and **Model ID**, then **Look up model**. Exact matches use the bundled, maintained model catalog, including models not currently offered in the picker. The result shows its source and catalog generation date when available. Existing models can be verified and added to the web picker without cloning them or changing the organization default. This uses existing built-in model policy; it does not create an admin-managed override or persistent overlay verification record.

For a new ID, lookup requests that exact record from the selected provider using organization credentials and configured endpoints. Redirects are refused. Lookup is bounded to five seconds and 64 KiB. Available name and token-limit fields are imported; missing prices, limits, and the compatible protocol template are requested explicitly. Provider APIs do not consistently publish pricing or complete metadata. Unknown values are never replaced with values from a similar model, and no third-party pricing source is queried.

The common path requires only the initial two inputs. New-model details that are already known remain under **Advanced overrides**, while missing fields are shown separately. All new or edited definitions still require **Verify and enable**. Metadata lookup is not a generation-access test and cannot certify a personal credential. Editing the provider or model ID clears a prior lookup result. The existing full-definition PUT remains supported.

---

### Background Ownership (`docs/background-ownership.md`)

# Background ownership

Production runs two core deployments (blue and green) against one PostgreSQL database. Exactly one of them may run background work at a time: Slack ingress, run claims, cron callbacks, and periodic maintenance. A single shared owner record names that deployment. Every core process polls the record and admits background work only while it names the process's own deployment.

## Enable the capability

Configure every core replica with:

| Variable                    | Requirement                                                                                                                                              |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BACKGROUND_DEPLOYMENT_ID`  | An immutable identity shared by replicas of one deployment revision, distinct from every other deployment revision.                                      |
| `DEPLOYMENT_CONTROL_SECRET` | At least 32 characters without surrounding whitespace, distinct from `CORE_SIGNING_SECRET`; distribute only to core and the trusted deployment operator. |
| `CORE_SIGNING_SECRET`       | The normal source request signing secret.                                                                                                                |
| `DATABASE_URL`              | The shared PostgreSQL database.                                                                                                                          |

With `BACKGROUND_DEPLOYMENT_ID` set, `BACKGROUND_WORK_ENABLED` is ignored: the owner record alone decides. A fresh database has no owner, so no deployment runs background work until an operator sets one. Without `BACKGROUND_DEPLOYMENT_ID`, the process follows `BACKGROUND_WORK_ENABLED` as before and the legacy build-heartbeat supersession applies.

## The owner record

The record lives in the `background_ownership` durable map under the key `ownership`:

| Field               | Meaning                                                            |
| ------------------- | ------------------------------------------------------------------ |
| `ownerDeploymentId` | The deployment that may run background work, or `null` for nobody. |
| `setAt`             | When the owner last changed.                                       |
| `setBy`             | The deployment whose API accepted the change.                      |

There are no per-process rows. Processes never write the record; only the operator endpoint does. A record written by the earlier member protocol (`enabled`, `desiredDeploymentId`, `members`, …) is read as `enabled ? desiredDeploymentId : null`; reads and no-op sets leave it untouched. The next owner change rewrites it, and the rewritten row also carries `enabled: true`, `desiredDeploymentId` equal to the owner, and the previous `generation` and `members` so that processes and CLIs still running the member protocol keep reading the same owner, acknowledge their relinquish normally, and refuse to admit: a build that predates this record can be replaced through the normal `qm up` path. If that older build's CLI changes `desiredDeploymentId` after the rewrite, the member fields win until the next owner change, so both builds always agree on the owner; do not use the older CLI once this one has set an owner. Ownership cannot be handed back to a color that still runs the member protocol with this CLI (its endpoint rejects the new request body), so the first rollout rolls forward only. The compatibility fields can go once no deployment runs the member protocol.

## Operator endpoint

`GET /v1/background-work` and `POST /v1/background-work` require both normal source request signing and `Authorization: Bearer <DEPLOYMENT_CONTROL_SECRET>`. Agent capabilities cannot invoke these routes. Portal forwards only those exact methods and path, preserving the supplied credentials.

Status is:

```json
{
  "protocol": 2,
  "deploymentId": "core:release-b",
  "instanceId": "2b1c…",
  "ownerDeploymentId": "core:release-a",
  "setAt": "2026-10-04T18:02:11.148Z",
  "setBy": "core:release-a",
  "active": false
}
```

`deploymentId` and `instanceId` identify the responding process; `active` says whether that process has finished starting background work and is admitting it. Because requests reach one process behind the load balancer, `active` describes one process; the CLI polls until it has seen every expected process of the deployment report the same state.

To change the owner, post:

```json
{ "ownerDeploymentId": "core:release-b", "expectedOwnerDeploymentId": "core:release-a" }
```

`expectedOwnerDeploymentId` is an optional compare-and-swap guard: the change is refused with `409 background_ownership_conflict` when the current owner is neither the expected one nor already the requested one. Setting the owner to the value it already has is a no-op success, so a lost response is confirmed by re-reading the record or by repeating the same request. Set `ownerDeploymentId` to `null` to pause background work everywhere.

## What processes do

Each process polls the record every second. When the record names its deployment, it starts background work: run claims, the cron scheduler, maintenance, and Slack ingress. While it owns, it keeps a local validity window of ten seconds that each successful read renews; a failed or hanging read lets the window expire, which fences new local claims until the next successful read confirms ownership again.

When the record stops naming its deployment, the process stops claiming new work and closes its Slack ingress within one poll interval, or within the ten-second validity window if its reads are failing. The new owner may start inside that window; run claims stay exclusive through the run store's leases, not through ownership, so the overlap costs at most a few redundant poll cycles. Turns and callbacks already admitted continue under their existing leases and heartbeats until they finish. There is no durable acknowledgment: the deploy workflow does not wait for old processes to report anything. It relies on ECS task replacement, which sends `SIGTERM` and allows a drain window, and on the new owner resuming any run whose lease lapses.

Synchronous turns and manually started cron callbacks are admitted work too. A deployment that is not the owner refuses new synchronous execution while still accepting durable asynchronous submissions for the owner to pick up.

## Deploying

`qm up` and `qm rollback` refuse to replace a controlled stack's core tasks while the owner record names that stack. The deploy workflow therefore:

1. Deploys the new release to the stack that is not the owner.
2. Verifies it, then sets the owner record to the new stack (`awsSetBackgroundWork(..., true)`), which waits until every one of the new stack's core tasks has reported `active`.
3. Switches public routing. The previous owner stops claiming within a second of the change and drains under `SIGTERM` when its tasks are later replaced.

Rolling back ownership is the same operation in the other direction. Nothing needs to be retired, proven stopped, or cleaned up: a hard-killed process leaves nothing behind in the record. If the record names a deployment that no longer has tasks (for example after `qm down`), no background work runs until an operator sets the owner again; `awsSetBackgroundWork(..., true)` on the live stack does that, since its compare-and-swap expects whatever owner it just read.

## Live deployment session check

A deployment that owns background work accepts `POST /v1/deployment/live-session` with the same source signature and distinct deployment bearer credential. The body contains exactly `requestId` (a fresh UUID) and `expectedDeploymentId`. The endpoint rejects unknown fields, a mismatched deployment, a non-owning deployment, and an inactive responder before starting work.

The check runs the same fixed session command used by the standalone deployment smoke: a real model reply, persisted turns, generated title, session error log, session archival, then the configured PostgreSQL catalog checks. The request cannot select a principal, URL, model, prompt, or command. Model HTTP requests have a five-minute limit; other HTTP requests and database operations have 30-second limits.

The response is newline-delimited JSON with an initial newline and five-second whitespace heartbeats, followed by one final object containing `ok`, `requestId`, `deploymentId`, and `instanceId`. A failed result adds a fixed error code without raw model, database, or credential details. Ownership is checked again before success. Heartbeats alone never prove success; the caller must receive and verify the complete final object.

Request IDs are durably consumed across replicas and cannot be replayed. Each process permits one check at a time, including its cleanup. Disconnecting the caller does not cancel the check or release that guard before cleanup finishes. An uncertain result must fail the release; do not retry automatically or fall back to a second canary execution.

---

### Capability Tokens (`docs/capability-tokens.md`)

# Capability token encoding and rollout

Room capabilities carry participant assertions for authorization. Large rosters appear
in both the publishing and keychain audiences, so uncompressed tokens can exceed HTTP
header limits before a request reaches core. Increasing core's header limit does not
fix limits imposed by upstream proxies.

`CAPABILITY_TOKEN_COMPRESSION=1` enables compression of claim payloads larger than
4096 bytes for every capability audience. The signed envelope contains a base64url
raw-DEFLATE payload. Verification authenticates the envelope before inflating it,
limits decoded claims to 1 MiB, and applies the existing claim checks. Small tokens
retain their existing encoding. Tokens remain self-contained across replicas and
restarts; no authorization state is moved into a process-local cache. Compression
reduces roster repetition but is not a fixed-size token: unusually large or poorly
compressible claims can still exceed a deployment's header limit.

Compression is off by default for a staged protocol rollout:

1. Keep `CAPABILITY_TOKEN_COMPRESSION` unset or `0` while deploying this verifier to
   every core API replica, worker, and separately deployed egress proxy. Complete the
   blue-green drain of older replicas before enabling issuance.
2. Refresh saved copies of the file upload helper from `/v1/files/upload-client`;
   older copies cannot decode compressed tokens.
3. Set `CAPABILITY_TOKEN_COMPRESSION=1` on every process that issues capabilities,
   including core and workers, and restart them. New turns receive compact tokens;
   already-issued oversized tokens are not rewritten, so retry in a new turn.

To disable issuance, set the switch to `0`. Both encodings remain readable regardless
of that switch. Do not roll verifiers back to a version predating compression until
all compressed tokens have expired or been revoked. Sandbox tokens live 48 hours by
default (see below); other capability audiences may have longer lifetimes, so inventory those
before rolling back. Leaving the updated verifiers in place is the safe rollback.

## Sandbox token lifetime

Turn tokens handed to sandboxes (agent API, credential broker, egress proxy, OAuth
consent) expire 48 hours after the turn starts, which bounds how long background jobs
launched by that turn keep their access. Set `SANDBOX_CAPABILITY_TTL_HOURS` on every
process that issues capabilities to change it: a positive number of hours, or `0` /
`none` for no expiry (`exp: 0`). The value is read
once at startup; an invalid value fails startup. Tokens already issued keep the
lifetime they were minted with. Revocation and scope checks still apply to
non-expiring tokens. This does not remove run/lease, background-job, provider, or
credential lifetime limits. Non-expiring bearer tokens increase exposure if leaked;
keep the default unless unattended work requires longer access.

Deploy the updated verifier to every core and worker, and any separately deployed
egress proxy, before enabling no expiry. Older verifiers reject `exp: 0`.
Changing the setting affects newly issued tokens only; rotate the signing secret
if existing non-expiring tokens must be invalidated.

---

### Combined Services (`docs/combined-services.md`)

# Combined web and sign-in services

QM deploys three application workloads: core, web-ui, and portal. The web-ui workload serves the chat interface and the admin module under `/admin`. Portal remains the public entry point and hosts the built-in auth broker when enabled. Slack remains in core.

The deployment's `services` list still declares capabilities. Keep `admin` to enable the admin module and `auth` to enable email sign-in. Their configuration remains under `env.admin`, `env.auth`, and the corresponding secret mappings. The CLI combines these into web-ui and portal respectively, rejects conflicting environment values, and preserves secret aliases. Omitting auth retains external OIDC sign-in. Logs and selective deployments for admin or auth resolve to the containing workload; the components cannot be released independently within a combined workload.

The modules retain separate handlers. Admin continues verifying signed portal identities and forwarding them to core, which authorizes every privileged action. Portal continues filtering inbound identity headers, checking sessions and CSRF, and gating admin access. The embedded auth broker listens only on `127.0.0.1:8099`, with its existing browser route allowlist exposed through portal. Token and JWKS endpoints stay on loopback. Auth signing keys are now available to the portal process; core's cloud permissions do not move into either surface workload.

## Upgrade an existing deployment

1. Record the existing release's source, image digests, configuration, secret versions, and workload sizes. Preserve database backups and the previous workloads for rollback.
2. Review the combined workloads' CPU and memory allocations, execution-role access to the union of their component secrets, and network access needed by the auth mail transport. Resolve conflicting environment or secret mappings before deployment.
3. Build web-ui and portal from the same source release. The CLI orders AWS routing transitions and Fly deployments with web-ui before portal. Ordinary AWS image updates can start both workloads together when the recorded and live cohorts are stable and their web/admin routing and enabled admin capability are unchanged. Missing or uncertain evidence preserves the ordered rollout. Deploy the combined web-ui before changing portal's admin upstream to its `/admin` endpoint. Deploy portal with its embedded auth configuration and both names for the broker client secret. Keep the public issuer, callback URL, session keys, token secret, and signing JWK unchanged.
   On AWS, a secrets push uploads values but defers task activation when the live tasks still use the separate-service configuration. Complete the image rollout with `qm up`; keep existing signing and session secret values during this migration.

4. Verify signed-in chat, administrator and non-administrator access, login, logout, remembered sessions, and a live admin operation. Verify forged admin cookies are rejected, token endpoints are not publicly exposed, and streaming responses still work.
5. After qualification, scale the old admin and auth workloads to zero through the deployment provider. Docker containers can be stopped; Fly Machines and ECS services must have their old desired capacity explicitly reduced. The CLI does not destroy legacy workloads during this transition. Retain their images, configuration, and resource definitions until the rollback window closes.
6. Retire the obsolete infrastructure in a separate reviewed change. On AWS, new scaffolds contain only core, web-ui, and portal; existing `aws.services.admin` and `aws.services.auth` entries may remain temporarily to retain their Terraform resources. Do not blindly render and apply a plan that deletes rollback resources.

Rollback restores the old admin/auth capacity first, then the prior portal/web-ui image digests and routing configuration. This change introduces no database migration and does not rotate authentication keys.

The Helm chart treats admin/auth entries as module configuration and renders only their hosting workloads. Existing Helm releases require a reviewed upgrade because removing old Deployment resources happens with that upgrade. The separately deployable legacy image entrypoints remain available for rollback.

For source development, install root and web-ui dependencies. Portal-only package checks also need auth dependencies (`npm ci --prefix plugins/auth`). The local dev supervisor runs core, combined web, and portal; its localhost login bypass is for local wiring checks only.

---

### Composio (`docs/composio.md`)

# Composio through the QM backend

QM discovers apps, creates consent links, and executes Composio tools through its authenticated backend. Agent code never receives the project API key. The composio skill calls `/v1/composio` through the existing execute tool, retaining its command-policy and approval gates.

## Setup

Store a personal keychain credential with env key `COMPOSIO_API_KEY`, or an org service credential with `delivery: "env"` and that env key. Existing records and service-credential grants remain compatible. The env key identifies a reserved backend credential: despite the legacy delivery label, QM never materializes it into a computer, command handle, or keychain-use script. Multi-field records containing that key are also backend-only.

An org administrator configures the project once. Users connect their own apps through QM's picker or agent-provided consent links. Composio handles provider authentication and refresh. Some apps still require provider setup. Never paste a project key into chat, scripts, or Git.

## Authorization and execution

The runtime attests whether the initiating actor may use their connections in the current context. This follows existing owner-keychain access: personal conversations, explicitly Open sharing on a human-started shared turn, and authorized automations using their owner's keychain. Shared human requests recheck sharing posture. Org credentials require current grants for the conversation audience. Published apps and bot actors cannot use this API.

The backend derives the Composio user ID from the organization and canonical QM principal. New consent uses that canonical identity; existing accounts under verified linked aliases remain available. Removing a link revokes access through that alias after the existing directory refresh propagates. Before every execution it verifies the chosen account's owner, ACTIVE status, disabled flag, and toolkit, then checks the tool and concrete version. Request bodies cannot supply another user identity or override authentication. There is no raw proxy, workbench, or tool-router execution endpoint. The generic credential broker rejects Composio destinations so it cannot serve as a project-wide bypass.

Tool discovery returns schemas; connection discovery returns account IDs and toolkit names, not credentials. Execution returns provider data and success/error status. Audit records contain the actor, scope, tool, account and execution stage without request arguments or response bodies. An uncertain execution failure is never retried automatically. Agent calls require the current running turn's lease; detached scripts cannot keep using this capability after the run ends.

Calls run through the ordinary execute tool. Existing policies inspecting provider URLs or CLI syntax must be updated to recognize the new QM API calls; these are not a semantic per-provider operation approval system. Strict posture continues to block direct control-plane mutations.

## Browser callback verification

Enable callback identity verification before production use, including for personal projects. Backend execution isolation alone does not prevent a forwarded consent link from attaching the wrong human’s account. For a project dedicated to one QM deployment, configure Composio's project verifier URL as:

```text
https://<QM web origin>/api/composio/callback
```

The authenticated web surface passes the opaque `session_uri` to core. Core supplies the signed-in user's derived identity to Composio's `connected_accounts/complete_auth` endpoint. Agent capabilities cannot complete verification. The browser must be signed into the same QM account that started consent.

Composio ignores a link's `callback_url` when project verification is enabled. QM saves the original return URL and expiry in its durable store, keyed by principal and connected account, and restores it after successful verification. This preserves app-picker state and personal Slack linking. The web surface accepts only same-origin return destinations. The backend never changes project settings automatically.

Verification is project-wide. A project shared across several company deployments needs a trusted central verifier/router or separate company projects before enabling it. Do not point that shared project's verifier at one company's QM URL. Dashboard-originated connections cannot complete against a verifier that only recognizes QM users. See [Composio's verifier contract](https://docs.composio.dev/reference/api-reference/connected-accounts#callback-identity-verification).

## Existing deployment cutover

A source or image pin must adopt this change before behavior changes. Existing credential records, grants, canonical user IDs, and connected accounts are preserved; upgrading alone does not disconnect users or rotate keys.

1. Inventory project use across deployments and identify any personal credentials, generic broker records, custom skills, scripts, or scheduled jobs that directly call the SDK. Connections created under ad hoc user IDs require explicit reconciliation or reconnection; QM never guesses another account owner.
2. Update custom callers to the authenticated `/v1/composio` API. Standard seed-managed skills update through normal startup seeding. User-authored or deployment-layer overrides are not silently overwritten and must be updated separately. SDK scripts that expect a sandbox `COMPOSIO_API_KEY` stop working after upgrade.
3. Deploy the same version to all core/worker instances that share the key and drain old runs. During a rolling mixed-version deployment, old workers can still deliver the old key.
4. Rotate the Composio project API key and update its existing QM credential records after old workers are retired. Keep the same project so connected accounts and user IDs remain valid. Coordinate every deployment sharing the key. Historical sandbox files, snapshots, environment captures and scripts may retain old copies; blocking new materialization cannot revoke them. Rebuild or clean affected sandboxes as appropriate; rotation invalidates old key copies.
5. Configure and test callback verification only after deciding project routing. Existing connections do not need new OAuth consent merely because verification is enabled. Verify one new connection and one read-only operation, plus affected scheduled jobs and shared conversations.

No production credentials, projects, consent settings, fleet pins, or connections are changed by installing this code in a development checkout. Rolling back to an older worker restores its ability to deliver whichever project key it can read and therefore reopens the old boundary.

## SDK assets

Older images and the build pipeline may still contain the credential-free Composio SDK bundle. It is not an authorization mechanism and is no longer used by the standard skill. Keeping or removing that asset does not replace rotating previously delivered keys.

---

### Credential Refresh Handoff (`docs/credential-refresh-handoff.md`)

# Credential login and refresh

## Design

Logging in is a skill the agent follows (`skills-seed/interactive-login`):

1. The agent runs the native login as an ordinary background job with `HOME` and the XDG
   directories pointed at a fresh `mktemp -d` directory and relays the verification URL and
   code.
2. After the job exits, `scripts/save-login.mjs <service> <dir>` uploads only the files under
   that directory through `POST /v1/keychain/credentials` and deletes the directory. Secrets
   never enter model context. The keychain limits a login to 500 files and 8 MiB.
3. Later commands get the login only by naming its handle in `execute.credentials`.

Re-saving a service replaces the entry in place. The credential id is derived from owner,
service and slot, so the handle, owner and grants are unchanged; `createdAt` is kept, while
files, origin, label and expiry come from the new save.

## Execution

`src/credentials/execute-files.ts` stages requested bundles in a private
`/tmp/qm-credentials.<random>/home`, points the CLI at it, captures changed files after the
command and writes them back with `keychain.updateFiles`, which rejects stale writes. Only
the owner's executions write back; a grant uses the saved files as they are. Each execution
removes its own directory in `finally`.

Background jobs accept the same `credentials` handles. The staging plan, credential ids,
grant ids and baseline fingerprints (no secrets) are stored on the job's `process_sessions`
row (`credential_files`). Any path that observes the exit (background poll/stop, monitor,
reaper, reconcile on provision) re-checks the baseline against the keychain, writes
refreshes back, clears the column and removes the directory. If a write fails for a reason
other than a keychain rejection, the column and directory stay so a later observer or the
next provision retries. A start that fails after the process launched leaves cleanup to
these paths. The start call masks the job's env credential values in its initial output;
later polls are unmasked because no secret values are stored.

Directories older than two hours are swept when the next credentialed execution starts,
which is longer than any execution or background job may live, so concurrent operations never
remove each other's files.

## Known gaps

- The staging directory is protected by mode 0700 and a random name, not by a separate user.
  Another process running as the same user during the command can read it.
- No proactive refresh for idle AWS sessions; the CLI refreshes only when a command runs.
- Refreshes made through a grant are not saved.

---

### Deploy Directory (`docs/deploy-directory.md`)

# Deployment directory contract

Contract v1 makes a QM deployment a committed, portable directory. The `qm` CLI is the only interpreter of that directory: it validates the same inputs it uses to render containers, task definitions, secret routing, and the agent-computer layer.

## Layout

`package.json` pins the `@yc-software/qm` deployment engine at the exact version that scaffolded the directory, so the directory records which CLI interprets it rather than drifting with whatever version an operator has installed; `contract: 1` remains only the compatibility floor. `package-lock.json` records the installed artifact. `qm.config.jsonc` is the deployment config. `deployment.md` and `.codex/skills/deploy-qm/` are materialized package assets an operator can hand to an agent. `sandbox/` adds tools and skills to agent computers; `plugins/` adds services; `.env.example` documents the computed secret names; `.env` supplies local values and is never committed. `qm init` writes `slack-app-manifest.yml` for the optional Socket Mode bot. It also writes `slack-sso-manifest.yml` only when the portal is configured to use Slack OpenID. `qm slack render` refreshes the applicable manifests after `publicUrl` changes, and `qm outputs` returns their creation links and the web coordinates. `qm init --target aws` also vendors the reference `infra/` Terraform module and its derived `terraform.tfvars`; the copy belongs to the deployment after generation. Init never overwrites an existing deployment config.

The sandbox layout is:

```text
sandbox/
  Dockerfile
  tools/<id>/tool.json
  tools/<id>/<binary>
  skills/<id>/SKILL.md
  skills/<id>/<text assets>
```

The Dockerfile is optional when every declared binary is present in its tool directory. Skill assets delivered through the deployment-layer API are text in v1. Executable text scripts can be delivered as skill assets. Native binary tools must already be installed in the platform image.

## Configuration

The root object requires `contract: 1`, `orgId`, `publicUrl`, `target`, and `services` including `core`. On AWS the sandbox substrate is an explicit choice: omitting the `sandbox` block runs named Lambda MicroVM images; declaring one requires `sandbox.backend` — `"sprites"` runs Fly Sprites, `"aws"` states the MicroVM default in the file. Unknown contract majors fail closed. `target` is `docker`, `fly`, or `aws`.

Common optional fields select the model, plugins, extra skill directories, per-service non-secret environment values, image overrides, sandbox settings, and an external security screen. `botName` (at most 31 characters, so the generated "`<botName>` SSO" app name fits Slack's 35-character cap) names the bot everywhere users see it — the generated Slack app manifests, the prompt identity, and sign-in pages — and `orgName` (at most 40) is how the bot refers to the organization; both default to neutral values and can be changed live from the Admin page's Branding card, which then takes precedence over the deployed values. `sandbox.backend` selects the aws-target sandbox substrate (see above); `sandbox.baseImage` records the digest-pinned build input for local layer builds; `sandbox.env` is non-secret runtime environment; `sandbox.secretEnv` lists org-wide secret names whose values are forwarded to every sandbox. `securityScreen` sets `mode` (`off`, `observe`, or `enforce`) and `classifier` (`model`, the default, or `proxy`). A proxy classifier adds a lowercase `provider` label and an HTTPS `endpoint`, and requires `secretEnv.core.SECURITY_SCREEN_PROXY_TOKEN`. Absence disables screening. See "Content screening" below.

Fly requires `region` and `flyOrg`. AWS requires a 12-digit account, region, deployment label, ECS cluster, deploy-role ARN, Secrets Manager prefix, DNS-valid Cloud Map namespace, and an entry for every enabled first-party service and discovered plugin containing a unique valid ECR repository, a unique valid ECS service, and a valid Fargate CPU/memory combination. The cluster is constrained so every IAM, RDS, ALB, and related name derived by the reference module is valid. `imageLabel` identifies the complete deployment manifest used by rollback and live drift checks; the matching OCI/ECR tag is a convenience pointer. Workloads may also set `arm64`/`amd64` architecture, non-secret build arguments, or role ARNs. External prebuilt images must declare their architecture; built-in workloads default to amd64 to match published images, while source-built plugins default to arm64. Explicit architecture choices are honored for source builds. Cloud Map names are the private workload addresses. The reference AWS module exposes CloudFront over HTTPS and restricts its HTTP ALB origin to CloudFront's managed origin prefix. With portal enabled, it is the ALB's sole target; access to private core, web, and admin surfaces requires signed portal identity. Without portal, only core is an ALB target. A real harness requires an HTTPS `publicUrl`.

`publicUrl` is the one public coordinate. The CLI derives the core, Slack, web, admin, and portal URL environment from it. Config `env` is for non-secret values only; secret-shaped keys are rejected.

## Security screen proxy

The proxy endpoint receives one or more HTTPS `POST`s per bounded classification with `content-type: application/json`, the routed token in `x-api-key`, redirects disabled, and this body:

```json
{
  "text": "untrusted content",
  "hook": "user_input",
  "metadata": {
    "surface": "webhook",
    "origin": "automation",
    "qm": {
      "request_id": "uuid",
      "input_index": 0,
      "chunk_index": 0,
      "chunk_count": 1
    },
    "provider-label": {
      "request_id": "uuid",
      "input_index": 0,
      "chunk_index": 0,
      "chunk_count": 1
    }
  }
}
```

`hook` is `user_input` or `tool_response`; metadata fields appear only when known. The chunk coordinates are also mirrored under the configured provider label so a direct provider endpoint can consume its own namespace without a built-in adapter. Inputs are capped at 16,000 characters and split into overlapping 1,600-character requests with at most two in flight per classification. All chunks share a request ID. A successful provider returns finite `score` and `threshold` numbers from zero through one plus an optional lowercase `primary_outcome` label:

```json
{
  "score": 0.91,
  "threshold": 0.7,
  "primary_outcome": "prompt_injection"
}
```

A chunk whose score is at or above its threshold resolves to Strict, and any Strict chunk makes the whole classification Strict. When chunks agree, the highest-scoring result supplies the diagnostics. The configured provider is an audit label and metadata namespace, not a built-in adapter name, so any service implementing this contract can be selected. Throttled requests retry with bounded backoff inside the classification deadline. Invalid responses, timeouts, redirects, and other provider errors are unavailable classifications, audited as `error`. Observe mode changes authority, not disclosure: it still sends the full screened content to the configured endpoint, so operators must trust that provider with external messages, files, and surface results.

## Spend export

`secretEnv.core.SPEND_EXPORT_TOKEN` (at least 32 characters, without surrounding whitespace, and distinct from every other environment value) turns on `GET /v1/spend/export?from=YYYY-MM-DD&to=YYYY-MM-DD` for an external spend or chargeback system. Requests send `Authorization: Bearer <token>`; the window is whole UTC days, half-open, and at most 93 days long. The response carries `scopeId`, the `window`, `asOf` when the report is served from saved daily totals, `rows` at the Spend page's ledger grain — one per UTC day, scope, source, cron and model — each with `scopeId`, `kind`, `principalId` (people only, after identity links), `displayName`, `source` (`live`, or the background kind: `cron`, `loop`, `swarm`, `webhook` or `monitor`), `cronId` (the cron that ran it, including its subagents' calls, else `null`), `calls`, `costUsd` and the four token counts, and `crons`: each billed cron's `id`, `title`, `owner`, `ownerScopeId`, `schedule`, `enabled` and `archived`, without its task. A deleted cron keeps its `cronId` on rows but has no `crons` entry. `costUsd` is the same estimate the admin Spend page shows, not a provider bill. Without the secret the route answers 404, and every export is audited as `spend.export`. Portal does not forward the route, so callers use the origin that reaches core directly.

## Secrets

First-party services publish a typed `SecretSpec` schema. The CLI combines the enabled services and feature predicates with plugin `secrets` and `sandbox.secretEnv` to form the computed secret set. That same schema determines which task receives each secret. Core validates its own required runtime secrets at production boot.

`init` renders the set as `.env.example`; that file has names and descriptions, never values, and is not an input to deployment. Operators place values in gitignored `.env`. Docker reads the file locally. `qm secrets push` uploads supplied operator-managed values to Fly secrets or AWS Secrets Manager without printing them. Terraform owns `DATABASE_URL` on AWS because it owns RDS. `doctor` treats missing and placeholder required values as failures and reports absent optional plugin secrets without blocking deployment.

## Tool descriptors

Only `id` is required. The remaining fields buy these runtime guarantees:

| Field                       | Guarantee                                                                                                                                                                                                                                                                                                                      |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `label`                     | Human-readable name in resident-login status.                                                                                                                                                                                                                                                                                  |
| `advertise`                 | Added to the agent computer's installed-CLI list.                                                                                                                                                                                                                                                                              |
| `hints`                     | Added to the model's deployment-tool guidance.                                                                                                                                                                                                                                                                                 |
| `auth.check`, `auth.reauth` | Merged into the resident-login connector registry.                                                                                                                                                                                                                                                                             |
| `auth.credentialPaths`      | One `$HOME`-relative set of `{ path, kind }` entries drives resident capture, ephemeral linking, and device-flow persistence. Each entry explicitly declares `file` or `directory`; absolute paths and traversal are rejected, and `.ssh` warns.                                                                               |
| `egress`                    | Validated as host names and checked for dangerous wildcards. Runtime enforcement is not claimed in v1.                                                                                                                                                                                                                         |
| `approvals`                 | Appended to the command-policy floor. A rule may deny or require approval for its own tool; it may never add an allow or loosen administrator policy.                                                                                                                                                                          |
| `install.binary`            | Must be present in the layer, declared under `install.files`, or installed by its Dockerfile; the image build checks PATH.                                                                                                                                                                                                     |
| `install.files`             | UTF-8 text files beside `tool.json` with an absolute destination under `/usr/local/bin/` or `/usr/local/lib/` and an octal mode (default `0755` under bin, `0644` under lib). They ride the layer bundle and every sandbox backend converges them on each provision by content hash, so a tool reaches imageless machines too. |

Raw approval patterns must start with the canonical `\b<install.binary-or-id>\b` boundary and cannot use a top-level alternative, so every match begins with their own tool; nested alternatives after that prefix remain available. A `command` rule is safely anchored to that same effective binary by the CLI. Duplicate tool ids fail. Skills require `name` and `description` frontmatter.

Deployment-specific safety belongs here too. For example, an ambiently authenticated CLI declares a deny `approvals` rule for its login command, a `hints` entry telling the model not to log in, and its `auth.credentialPaths`; generic core carries no vendor-specific command exception or credential path.

## Delivery and pins

When `sandbox/` exists, every `up` sends its descriptors and complete text skill trees to source-authenticated `PUT /v1/deployment-layer`. Without `sandbox/`, `up` skips layer sync and leaves the deployed layer unchanged. Core validates submitted bundles again, stores them in Postgres table `deployment_layer`, versions them by a canonical SHA-256 content hash, records an audit event, hydrates them before serving, and returns the restorable bundle with its metadata and resolved runtime state from source-authenticated `GET /v1/deployment-layer`. Removed layer-owned skills are archived. Filesystem `DEPLOYMENT_LAYER` remains a bootstrap input for local and recovery use.

The sandbox handoff is a layer content hash. Sprites boxes boot the platform's stock image; the layer's tool descriptors and skills arrive through the deployment-layer sync. AWS with `sandbox.backend: "aws"` (or no sandbox block) uses `infra build-image` to package the guest agent as a Lambda MicroVM image and records its immutable image version and execution role. Service task definitions use immutable pins, not mutable tags.

Postgres stores create their tables lazily with idempotent DDL through the shared pool. The Terraform module creates RDS and its `DATABASE_URL` secret. On AWS, `up` records a point-in-time restore point before its first mutation — refusing an unavailable database, one whose automated-backup retention is below `aws.dbRetentionMinDays` (default 1), or one whose `LatestRestorableTime` lags more than `QM_AWS_DB_MAX_RESTORE_LAG_MS` (default 10 minutes) — timestamped in the deployment manifest it precedes, and re-checked after the roll (a note while the backup stream is still inside the allowed lag, a loud warning once the restore point has gone uncovered longer than that, since a point is restorable only once the stream covers it and only within the retention window); `aws.predeployDbSnapshot: false` opts a deployment out. Restore remains operator-run: `rollback` prints the restore point (or the legacy snapshot on older manifests) alongside the code it rolls back.

## Targets and prerequisites

| Requirement                                                                                |                          Docker |                             Fly |                                             AWS |
| ------------------------------------------------------------------------------------------ | ------------------------------: | ------------------------------: | ----------------------------------------------: |
| Node 24 and `qm` CLI                                                                       |                             yes |                             yes |                                             yes |
| Docker daemon                                                                              |                             yes |                      build path |                       image transfer/build path |
| Agent-computer image and credentials                                                       |      Fly app for real execution |        Fly app and scoped token | Lambda MicroVM image/version and execution role |
| Slack bot app created from generated manifest, bot token, app token                        |              when Slack enabled |              when Slack enabled |                              when Slack enabled |
| Admin email, verified sender, and a Resend key or SMTP credentials                         | with the built-in `auth` broker | with the built-in `auth` broker |                 with the built-in `auth` broker |
| Slack SSO app, client id/secret, team gate, and exact `<publicUrl>/auth/callback` redirect |            only with Slack OIDC |            only with Slack OIDC |                            only with Slack OIDC |
| Postgres                                                                                   | local container or supplied DSN |       Fly Postgres/supplied DSN |                                   Terraform RDS |
| AWS credentials, ECS/ECR/RDS/ALB/Cloud Map, exact GitHub OIDC trust                        |                              no |                              no |                                             yes |

`doctor` checks target resources read-only. When user-owned CI is requested, the AWS account must already have the account-level GitHub provider at `arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com`; check it with `aws iam get-open-id-connect-provider --open-id-connect-provider-arn <arn>` and, if absent, have an account administrator run `aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com`. The AWS doctor verifies ECS, ECR, RDS, CloudFront-to-ALB routing, the deploy role and its exact operator-owned GitHub repository plus configured branch or environment trust, required secret values, and the Lambda MicroVM image pin. Environment-based trust must be paired with GitHub deployment-branch restrictions because its OIDC subject does not contain a branch. Fork pull requests cannot assume the deploy role. No workflow in the qm source repository deploys a production stack.

## Commands, conformance, and versioning

The normal gate order is `check`, `doctor`, `plan`, `up --yes`, then `check --live`. On AWS with MicroVM sandboxes, `infra build-image` precedes `plan`. First-party services come from the package's matching image manifest; `--build-from` explicitly builds modified services from a source fork or contributor checkout; use the checkout's CLI when changing deployment logic too. `check` is static and has JSON output keyed to clause ids. `doctor` makes read-only external checks. `plan` renders without mutation. On AWS, rollback restores the prior recorded deployment manifest as one unit under the deployment lease; `--to` selects another complete manifest by manifest id or recorded release label. Because rollback restores code and configuration but never data, it prints the pre-deploy database point-in-time restore timestamp recorded on the deployment it rolls back (or the legacy snapshot for older manifests). Fly and Docker do not claim rollback.

AWS `aws.dbInstanceClass` selects the RDS class rendered as `db_instance_class` and defaults to `db.t4g.small`. Operators set `db_backup_retention_days` in `infra/terraform.tfvars` to override its 35-day default. `aws.dbRetentionMinDays` is separate: it is the minimum retention that `qm up` accepts for its restore-point safety check and does not configure RDS. Before applying, the account must permit the chosen class and retention in the selected region and account plan; AWS availability and plan restrictions vary, and the rest of the deployment is not guaranteed to fit any free tier.

Upgrading the CLI alone does not update vendored Terraform. Existing deployments must preserve their customized Terraform and tfvars, copy only the validated `db_instance_class` variable (including its `db.t4g.small` default) from the current `infra/variables.tf` scaffold, and change only the RDS assignment to `instance_class = var.db_instance_class` before setting `aws.dbInstanceClass`; then run `qm infra render` and review the Terraform plan before applying.

AWS `up` is mutually excluded by a DynamoDB lease, verifies RDS continuous backups and records the exact pre-mutation restore point, registers digest-pinned task definitions, enables the ECS circuit breaker, updates services, and waits stable. AWS `check --live` compares environment, secret routing, task definitions, and the configured release label in both directions. Fly `check --live` verifies every configured workload has a live image-bearing machine and the public health endpoint responds. `qm conformance` remains the later cross-check between the static contract and core's resolved deployment-layer descriptors.

The semver-stable `@yc-software/qm/contract` export contains only config loading, layer validation/parsing, env derivation, approval compilation, and the contract version; AWS task rendering joins it with the AWS backend. A new incompatible directory shape increments the contract major. A CLI may add optional fields within a major.

Built-in targets live in one registry that owns discovery, initialization files and ignores, accepted deploy flags, backend creation, and provider output coordinates. To add one, implement that provider contract and backend lifecycle (`up`, status, logs, down, rollback, doctor, secret delivery, and live checking), add a namespaced config block and templates, render through the shared environment and secret pipelines, document prerequisites honestly, and add conformance fixtures. Loading arbitrary provider packages at runtime is outside contract v1.

## Clause status

| Clause                            | Status         | Verifier                                                                         |
| --------------------------------- | -------------- | -------------------------------------------------------------------------------- |
| `config.v1`                       | ENFORCED       | `loadConfigAt`, `qm check`                                                       |
| `config.no-secret-values`         | ENFORCED       | `qm check`                                                                       |
| `secrets.computed-set`            | ENFORCED       | typed schema, `qm check`                                                         |
| `sandbox.descriptors`             | ENFORCED       | `validateSandboxLayer`, core PUT validation                                      |
| `sandbox.approvals-tighten`       | ENFORCED       | descriptor parsers, command-policy composition                                   |
| `runtime.layer-resolved`          | ENFORCED       | deployment-layer store/API and core PUT validation, `qm conformance` cross-check |
| `aws.rendered-task`               | ENFORCED       | `renderTaskDefinition`, AWS `plan`/`up`, digest and task-diff tests              |
| `aws.live-drift`                  | ENFORCED       | `qm check --live`, bidirectional task/environment/secret/release checks          |
| `sandbox.egress`                  | VALIDATED-ONLY | wildcard/host warnings in `qm check`; no runtime enforcement claimed             |
| `sandbox.aws-substrate`           | ENFORCED       | Lambda MicroVM image/version validation and live drift                           |
| `target.provider-registry`        | ENFORCED       | provider registry and packed-artifact tests                                      |
| `extension.deployment-data-proxy` | RESERVED       | optional env-gated org adapter; not part of contract v1                          |

ENFORCED means code rejects or tests the clause today. VALIDATED-ONLY means the directory is checked but runtime enforcement is explicitly absent. RESERVED names a compatibility slot without claiming implementation.

## Published-app credential calls

When core has a signing secret and API base URL, it supplies each deployed app whose
publisher is an internal principal with `AGENT_API_URL` and `AGENT_CREDENTIAL_TOKEN`,
the same pair an agent turn receives. The app's server sends a POST request to
`$AGENT_API_URL/v1/credentials/broker` with the token in `x-agent-capability` and the
same `credential`, `url`, `method`, `headers`, and string `body` fields the agent HTTP
credential broker accepts. Keep the token on the server; it authorizes calls as the
immutable publisher, not the person viewing the app.

Every enabled broker-delivery credential is available to published apps by default; an
administrator switches a credential off for apps without affecting agent turns. The
token is long-lived and names the app, so the broker refuses a credential whose switch
was flipped after the app shipped and the audit row records which app called. Disabling
the credential revokes access on the next call. The token is minted at deploy time from
the credentials switched on at that moment, so a credential added later reaches an app
on its next deploy.

The destination host, HTTP methods, and paths remain constrained by the credential
record. If its injection configuration enables actor attestation, the broker sets
`x-qm-actor` from the stored publisher. Caller-supplied actor headers are rejected.
The destination receives the configured credential, never the deployment token.

### Public AWS plugin routes

Plugins stay private by default. An AWS plugin workload can declare
`publicPaths: ["/hooks/*"]` under `aws.services.hooks` to expose its own
namespace. Each of the one to five unique patterns must start with the plugin
name and end in `/*`; broader catch-all paths and paths outside that namespace
are rejected. The plugin must authenticate its requests.

The AWS scaffold renders these paths before core host rules and portal catch-all
rules. Deployment preflight verifies the exact paths, target-group attachments
and listener precedence, including native blue/green weighted routing. Update
vendored Terraform with the `public_paths` service attribute and
`public_path_services` routing before enabling this setting.

### Sandbox egress proxy

Add `"egress-proxy"` to `services` to deploy the sandbox egress proxy
(`deploy/egress-proxy`) with every `qm up`, from the same release as core. It
receives `CAPABILITY_SECRET` and `CORE_SIGNING_SECRET` from the computed secret
set and relays its audit records to core.

On AWS the reference module puts it behind its own network load balancer, which
terminates TLS on 443 with the module's `certificate_arn` (required) and forwards
plain TCP to the proxy. The proxy runs in its own security group, outside the
services' private network, so it relays audit records through `apiUrl` (or
`publicUrl`). Point a hostname covered by the certificate at the
`egress_proxy_hostname` output. On Fly it serves TLS on 443 at
`<appPrefix>-egress-proxy.fly.dev`. Docker publishes the plain listener on a host
port and leaves TLS to the operator. Then set the sandbox backend's proxy URL, for
example `env.core.SPRITES_EGRESS_PROXY_URL` or `MODAL_EGRESS_PROXY_URL`, to
`https://<proxy hostname>`. Update vendored Terraform from the current scaffold
before enabling it on AWS.

### Shared AWS foundations

Existing AWS deployments retain their company-owned resources by default. For a shared DynamoDB deployment table, set `aws.deploymentState` to `{ "table": "fleet-deploy-locks", "namespace": "acme" }`. Every lease, current pointer, label pointer and immutable manifest uses that namespace. Use a unique namespace for each company and scope its item IAM permissions with `dynamodb:LeadingKeys` to `acme/*`; grant `DescribeTable` separately. Changing an existing deployment's state location requires explicitly migrating its history while deployment operations are stopped.

To use a shared ALB, set `aws.alb` and `aws.sharedAlb: true`. The supported shared topology has one blue/green portal ingress per company, with an exact host-header condition plus `/*` path, a fixed-404 listener default, separate primary/alternate target groups and a company-owned production rule. Additional public plugin/core routes are not supported in this topology. Other companies' rules must use explicit non-wildcard hostnames and must not reference this company's target groups. Set each service's `targetGroup`, `taskRoleArn`, and `executionRoleArn` explicitly when sharing an ECS cluster. Retain company-specific service permissions and allow ECS to modify only that company's listener rule.

An exact `--candidate` deployment consumes its digest-pinned images without changing ECR tags. Its publisher owns image retention; retain every active and rollback digest. Builds performed by the deployment CLI still stage and promote their images as before.

## Content screening

When `securityScreen.mode` is not `off`, the classifier screens external content — inbound data, attachments and documents, shared skills, steering context, and external tool output — under every security posture. Posture only caps enforcement: Dangerous scopes observe, and Auto and Strict follow the deployment mode. Scope posture changes cannot turn screening off; they keep their own tool approvals and private-network policy.

- `observe` classifies in the background and never delays a turn, quarantines, prompts, or adds untrusted-content markers. Each observation is bounded by `SECURITY_SCREEN_TIMEOUT_MS`, the same deadline enforce uses; there is no separate concurrency cap. Observed model screens are not charged to the user's budget.
- `enforce` waits for the verdict. Flagged content is quarantined pending release approval. Outages, timeouts, and unscreenable content fail open with an untrusted-content marker and an audit record.

Every classification writes one `security_screen.classify` audit record with status `allow`, `block`, `would_block`, or `error`. Its detail carries the hook, source labels, surface, origin, session, run and thread identifiers, request ID, attempt count, and any score, threshold, or outcome — enough to find the transcript behind a would-block. It never includes the screened text.

`SECURITY_SCREEN_BACKEND`, `SECURITY_SCREEN_ALL_POSTURES`, and `SECURITY_SCREEN_PROXY_ROLLOUT` (and the `backend`, `allPostures`, and `rollout` config keys) are retired. A configuration that only said `backend: "off"` (or `SECURITY_SCREEN_BACKEND=off`) still loads as `mode: "off"` with a deprecation warning; any other use of the old names refuses to start.

New tool-output release approvals deliver the exact saved text without running the tool again. They are once-only and retain the source scope in durable history. A release is refused if the current audience is no longer entitled to that source scope. Legacy persisted screening grants remain effective and are audited; remove those grants explicitly if a deployment requires every result to be classified. New release approvals cannot create session or always grants.

---

### Deployment Broker Credentials (`docs/deployment-broker-credentials.md`)

# Deployment broker credentials

An operator can give every agent in a deployment access to shared API keys without storing them in the
settings database. Set them in the server environment, the same way as `MODEL_GATEWAY_API_KEY`. The core
reads them once at startup.

Agents use these credentials only through the credential broker. They never see the secret, and org admins
can see them in Settings but can't edit, delete or replace them.

## Format

`BROKER_CREDENTIALS` is a JSON array. Each entry names the env var that holds its secret:

```json
[
  {
    "slug": "web-search",
    "displayName": "Web search",
    "host": "api.search.example",
    "secretEnv": "WEB_SEARCH_API_KEY",
    "injection": { "header": "X-API-KEY", "scheme": "" },
    "allowedMethods": ["POST"],
    "allowedPathPrefixes": ["/search"],
    "maxRequestsPerDay": 500
  }
]
```

| Field                 | Required | Meaning                                                                                                                                                                                                                                                                                                                                                            |
| --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `slug`                | yes      | Lowercase kebab-case name agents use. Must be unique.                                                                                                                                                                                                                                                                                                              |
| `host`                | yes      | Bare hostname the broker pins requests to (subdomains match, as for stored credentials).                                                                                                                                                                                                                                                                           |
| `secretEnv`           | yes      | Name of the env var holding the secret. It must be set and non-empty.                                                                                                                                                                                                                                                                                              |
| `displayName`         | no       | Label shown in Settings and to agents. Defaults to the slug.                                                                                                                                                                                                                                                                                                       |
| `injection`           | no       | `header` and `scheme` for the secret. Defaults to `Authorization: Bearer <secret>`; set `scheme` to `""` to send the raw secret. `headers` adds fixed, non-secret headers the API needs on every call, such as `{"x-api-version": "2025-11-01"}`; callers cannot override them, and they cannot replace the secret's header, `content-type`, or transport headers. |
| `allowedMethods`      | no       | Allowed HTTP methods. Defaults to `GET`.                                                                                                                                                                                                                                                                                                                           |
| `allowedPathPrefixes` | no       | Allowed path prefixes. Defaults to any path.                                                                                                                                                                                                                                                                                                                       |
| `maxRequestsPerDay`   | no       | Cap on calls let through per UTC day, shared by every core process. Calls over the cap get `429 daily_cap_reached`.                                                                                                                                                                                                                                                |

Never put a secret in `BROKER_CREDENTIALS` itself; a JSON syntax error can quote part of the value.
Unknown fields, a missing secret, a duplicate slug or any invalid value stop the core at startup with an
error naming the entry. Errors never include secret values.

## Behaviour

- **Who can use them:** every conversation whose participants all belong to the org, as if the credential
  had an org-wide grant. Admins can't narrow that. Published apps can't use them.
- **Delivery:** broker only. They are never injected into sandbox environments.
- **Precedence:** a deployment slug is reserved. Lookups return the deployment credential even if the
  settings database still holds one with the same slug, and admins can't create, edit or delete a stored
  credential with that slug.
- **Broker only, everywhere:** decision providers and skill-pack Git auth can't use a deployment credential.
- **Usage:** broker calls are logged and summarised in Settings like any other credential.

## Operating

1. Put each secret in your secret store and expose it to the core as its own env var.
2. Set `BROKER_CREDENTIALS` to the JSON array and restart the core.
3. To rotate a key, change the secret's env var and restart. To remove a credential, drop its entry and
   restart.
4. If the settings database already holds a credential with the same slug, the core logs a warning at startup
   and the stored one is ignored. Delete the stored one before adding the env entry; otherwise it becomes
   active again if the env entry is later removed.

---

### Do Managed Agents (`docs/do-managed-agents.md`)

# DigitalOcean Managed Agents sandboxes

The `do-managed-agents` backend runs each QM scope on a DigitalOcean Managed Agents microVM. QM keeps its own agent loop; sessions start with `agent: none`, so DigitalOcean supplies the machine and nothing else.

## Set it up

1. Create a DigitalOcean API token with Managed Agents access.
2. Point core at it:

   ```sh
   SANDBOX_BACKEND=do-managed-agents
   DO_AGENTS_API_TOKEN=your-token
   ```

That's enough to run. Sessions land on DigitalOcean's bare base image.

## Optional

**A QM template.** For an image with the tools agents usually reach for (Node, git, jq, ripgrep, Python), register one on your team. You need Docker, an authenticated `doctl`, and a container registry:

```sh
deploy/do-managed-agents/register-template.sh
DO_AGENTS_TEMPLATE=qm-sandbox
```

Templates belong to a team, so each team registers its own. Rerun the script after changing the Dockerfile.

**A snapshot bucket.** When a scope parks, QM takes a DigitalOcean checkpoint, which lets it roll a wedged computer back in place. A checkpoint is tied to its session, though, so if the session is deleted the checkpoint goes with it. Set `DO_AGENTS_SNAPSHOT_S3_BUCKET` to also keep a copy of the home directory that survives that. Without it, the copy lives in core's memory and is lost when core restarts.

**An egress proxy.** Set `DO_AGENTS_EGRESS_PROXY_URL` and the proxy host becomes the session's only allowed destination.

Other knobs: `DO_AGENTS_SIZE_SLUG`, `DO_AGENTS_IDLE_TIMEOUT_SEC`, `DO_AGENTS_NAME_PREFIX` (use a distinct one per deployment sharing a team), `DO_AGENTS_SNAPSHOT_INTERVAL_SEC`, and `DO_AGENTS_API_BASE_URL`.

## Good to know

- Commands and file transfers go over a port-forward to the microVM rather than DigitalOcean's REST exec, which caps output and runtime. Long commands are bounded only by `SANDBOX_TIMEOUT_SEC`.
- QM doesn't reap idle machines itself. DigitalOcean's idle timeout does, and paused sessions still count against team quota.
- Sessions allow `bash` outright, since QM already runs its own approval gates.
- `scripts/managed-agents-sandbox-smoke.ts` checks a token and template end to end before you point a deployment at them.

---

### Document Inputs (`docs/document-inputs.md`)

# Conversation document inputs

Inbound attachments are read from the authorized artifact store and included in model input on the first turn, follow-ups and uploads during an active answer, including after compaction and runtime handoff. No model tool call is needed to locate or parse supported attachments. Filename extensions and supported MIME types identify documents. Images continue through the existing image input path.

| Route                             | Native input                                                                                          | Other documents                       |
| --------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------- |
| OpenAI Responses                  | PDF, Word, Excel, PowerPoint, ODT, text and code                                                      | ODS, ODP and RTF use text extraction  |
| Anthropic Messages and Claude SDK | PDF and plain-text document blocks                                                                    | Text extraction                       |
| OpenAI Chat Completions           | PDF with vision models                                                                                | Text extraction                       |
| OpenRouter Chat Completions       | PDF on vision-capable OpenAI, Anthropic and Google models, with the native parser explicitly selected | Text extraction                       |
| Gemini and Vertex                 | PDF with vision models                                                                                | Text extraction                       |
| QM gateways                       | Responses files for OpenAI/Azure groups; native PDFs for verified provider families                   | Text extraction for unknown providers |
| Other model routes                | No assumed native document capability                                                                 | Text extraction                       |

PDF native inputs retain page visuals. Non-PDF OpenAI file inputs extract text; embedded images and charts are not preserved. OpenAI spreadsheet processing covers up to the first 1,000 rows per sheet, with additional metadata. Native support remains subject to the selected model, provider limits, document encryption and document validity. See the [OpenAI file input documentation](https://developers.openai.com/api/docs/guides/file-inputs), [Anthropic PDF documentation](https://platform.claude.com/docs/en/build-with-claude/pdf-support), [Gemini document documentation](https://ai.google.dev/gemini-api/docs/document-processing), and [OpenRouter PDF documentation](https://openrouter.ai/docs/guides/overview/multimodal/pdfs).

Text fallback handles UTF-8 text/code, CSV, TSV, PDF, DOCX, XLSX, PPTX, ODT, ODS, ODP and RTF. It explicitly identifies missing images/charts, empty extraction, truncation, unsupported formats and malformed documents. Legacy DOC/XLS/PPT and Pages/Keynote require a supporting native route or conversion. Scanned PDFs require a native visual route or OCR; text fallback does not pretend to read them.

At most ten documents and 8 MB of original bytes are included per turn, newest attachments first. Extraction runs in cancellable workers with a ten-second deadline, memory and decompression limits. Each extraction is capped at 200,000 characters and combined fallback input at 100,000 characters. Pi routes further constrain text to the remaining model context. Binary provider inputs remain subject to provider page and token limits.

Authorization and integrity checks run on every turn. Document content is untrusted data; the external-data security policy applies when enabled. Unscreenable visuals remain marked unscreened. Stored conversation tape does not duplicate the injected document bytes or extracted text; authorized documents are rehydrated from their artifact references.

## QA

The checked-in fixtures contain synthetic verification markers. Format tests exercise genuine PDF, DOC/DOCX, XLS/XLSX, PPT/PPTX, ODT/ODS/ODP, RTF, TXT, Markdown, CSV/TSV, JSON, XML, HTML, YAML, Python, JavaScript, TypeScript, CSS, SQL, EML, ICS, VCF, SRT and VTT files. Additional cases cover scanned/malformed PDFs, hostile instructions, MIME-only filenames, byte/context limits, cancellation, revoked authorization, failed historical reads, follow-ups, compaction and tape redaction.

Provider serialization tests verify request shape and byte preservation; they do not establish live provider acceptance. Before release, run real model calls for each enabled native route with its credentials, including scanned PDFs and unsupported/encrypted files. Check both initial uploads and follow-ups through web and Slack, verify each independent marker, inspect tool calls, and record latency separately from sandbox startup.

## Recorded development validation

The browser upload flow on the Codex text fallback route recovered all 26 format-specific markers in PDF, DOCX, XLSX, PPTX, ODT, ODS, ODP, RTF and the 18 text/data fixtures. An eight-document follow-up recovered the same markers without reuploading. A mixed batch retained all readable documents and explicitly reported scanned and malformed PDFs unavailable. These turns made no model tool calls.

A real Claude SDK request using the same document-block builder recovered the native text-PDF and scanned-PDF markers, plus DOCX/XLSX/PPTX text fallback and a native plain-text document, in 8.0 seconds end to end. This is a small-fixture smoke test, not a latency guarantee.

Firefox Slack uploads recovered all 26 supported fallback-format markers across three batches, with zero model tool calls. Legacy DOC/XLS/PPT correctly reported unavailable on the Codex fallback route. Worker execution took 6.1–13.7 seconds per batch, excluding Slack delivery and conversation routing. A Slack follow-up recovered project and revenue details from the retained RTF/ODP attachments without reuploading or tools. The same synthetic fixtures were used in the web and Slack checks.

Direct OpenRouter native acceptance remains unverified because the configured gateway exposes no OpenRouter provider and no direct credential was available. Request-shape tests cover that route; they do not substitute for provider acceptance.

Authenticated LiteLLM checks verified scanned PDF comprehension on Anthropic Messages and OpenAI/Gemini Chat Completions. OpenAI Responses read 26 native fixtures across office, legacy office and text formats; RTF used text fallback after the provider accepted its file but returned no readable content. Explicit filename labels preserve attachment identity when provider extraction omits filename metadata. Text MIME types are normalized to accepted wire types; these checks caught rejected XML and TypeScript labels that serialization tests had missed. Gateway discovery selects Responses for OpenAI/Azure groups and retains provider-based document capabilities, so unknown vision models do not inherit assumed PDF support.

A live Pi gateway session using discovered OpenAI metadata also passed a read-tool round trip, a follow-up retaining the tool result without another call, and native scanned-PDF comprehension without tools. The measured turns took 5.6, 1.6 and 2.2 seconds respectively. After the gateway fixes, 99 affected tests, TypeScript and changed-file lint passed.

Encrypted PDF fallback was verified to report unavailable content without revealing its verification marker. Native OpenAI requests rejected encrypted and malformed PDFs with HTTP 400 `invalid_file`; they require an unlocked, valid replacement. Supported formats now receive bounded local preflight before native submission; unreadable files become individual unavailability notices. Scanned PDFs with no extractable text remain native visual inputs. Legacy formats without a local parser and provider-specific page/token limits still depend on provider acceptance. A live Pi mixed batch read the scanned marker while reporting malformed/encrypted files unavailable, and repeated that result on a follow-up without tools (2.9 and 3.5 seconds). A twelve-page native PDF check recovered independent markers on the first and last pages in 5.5 seconds.

A live Pi gateway task also accepted a scanned PDF uploaded while its read tool was running, recovered the visual marker in 4.6 seconds, and kept document bytes out of its tape. Regression tests cover active-turn uploads across all four harnesses, shared text/count/byte budgets, screening and runtime handoff.

---

### E2b Template (`docs/e2b-template.md`)

# E2B sandbox template

## Public template

E2B publishes a ready agent-computer template, `qm-sandbox`. It has the same tools as
the sandbox base image (`fly/Dockerfile`): Claude Code, Codex, GitHub CLI, AWS CLI v2, the
Python venv at `/opt/agent-venv` and `x-api`. Each sandbox gets 8 vCPU and 8 GiB where the
plan allows.

```jsonc
"sandbox": { "backend": "e2b" },
"env": { "core": { "E2B_TEMPLATE_ID": "qm-sandbox:v1.0.0" } }
```

Set `E2B_API_KEY` with `qm secrets set`. Pin a version tag, so a new release does not
change the image of new scopes.

With `E2B_TEMPLATE_ID` set, the agent computer's profile lists gh, aws and `x-api` as
installed. Without it, core uses E2B's stock `base` image and lists them as missing.

On E2B plans capped at one hour, also set `E2B_MAX_LIFETIME_SEC=3600` in `env.core`.
Core otherwise asks for a 61 minute sandbox timeout, and those plans reject every create
with `400: Timeout cannot be greater than 1 hours`. The setting also caps a command at 59
minutes.

To run core on E2B as well, `e2b sandbox create qm` starts it from the `qm-core` template
with a setup page for the E2B key, org, admin and model keys. See
[E2B's docs](https://e2b.dev/docs) for the CLI.

## Build your own

E2B stopped accepting V1 template builds (`e2b template build` against a bare
Dockerfile with an access token) on 2026-08-01. `deploy/e2b/e2b.Dockerfile` is still
the single source for the sandbox image, but it is now fed to the Template SDK, which
E2B's migration guide names as the replacement for Dockerfile-only builds.

From the matching source checkout, install dependencies with `npm ci`, export
`E2B_API_KEY`, then run:

```bash
npm run build:e2b-template
```

The command builds the Dockerfile as the template `qm-base` and prints
`E2B_TEMPLATE_ID=qm-base` with the template and build identifiers. Set
`E2B_TEMPLATE_ID` in the core deployment to that name. Sandboxes created from the
template run as the `user` account with `/home/user/workspace` as the working
directory, which is what `src/sandbox/e2b-sandbox.ts` assumes.

`E2B_TEMPLATE_NAME` renames the template. `E2B_TEMPLATE_CPUS` and
`E2B_TEMPLATE_MEMORY_MB` set the sandbox shape recorded on the template; E2B's plan
limits cap them, and the running shape is reported back through `Sandbox.getInfo`, so
the profile advertises whatever the sandbox actually received rather than a guessed
value. Leaving them unset uses E2B's default of 2 vCPU and 512 MiB.

## Layer tools baked into a child template

Core installs the deployment layer's tool files into every new sandbox on first
provision, after checking their checksums. To skip that step at runtime, point
`DEPLOYMENT_LAYER` at the layer directory when building:

```bash
DEPLOYMENT_LAYER=deploy/layers/<org> npm run build:e2b-template
```

This builds a second template, `qm-base-layer` (override with
`E2B_LAYER_TEMPLATE_NAME`), derived from `qm-base` with `fromTemplate` and containing
each declared install file at its destination path with its declared mode. Set
`E2B_TEMPLATE_ID` to the layer template instead. The runtime checksum probe finds the
files already present and installs nothing; when the layer changes, rebuild the child
template or let the runtime installer replace the stale copies on the next provision.

Rebuilding from unchanged inputs uses E2B's build cache. Existing sandboxes keep the
template they were created from; a paused scope sandbox only picks up a new template
after it is destroyed or restored from a recovery snapshot, which is itself a template
derived from the old image.

## Egress

E2B can filter a sandbox's outbound traffic itself (`network.allowOut` / `denyOut`), but
those rules cannot carry QM's egress policy, so QM keeps its own proxy as the enforcement
point and uses E2B's rules only to make that proxy unavoidable:

- QM policies are deny lists over open egress as well as allow lists. E2B accepts host
  names only in `allowOut`; `denyOut` takes IPs and CIDR ranges only, and allow rules
  always win over deny rules, so "allow `github.com` but not `gist.github.com`" has no
  E2B form.
- E2B matches host names only on ports 80 and 443 (HTTP `Host` and TLS SNI). Other
  ports, such as SSH to a git host, fall back to IP rules.
- QM resolves the policy per turn and per speaker and signs it into the turn's egress
  token. E2B rules are per sandbox, and one scope's computer serves concurrent turns from
  different people.
- The proxy also writes the egress audit log and checks the turn token; E2B's firewall
  does neither.

Set `E2B_EGRESS_PROXY_URL` to a QM egress proxy reachable on port 443, for example
the `egress-proxy` service (TLS on 443 in front of the proxy's port 48080). Core then
creates every agent computer with `allowOut: [<proxy host>]` and `denyOut: ["0.0.0.0/0"]`
and points the computer's proxy settings at it with the turn's token. Without it, core
warns at boot and computers have open egress.

The proxy cannot run inside a core that is itself an E2B sandbox: E2B's public URL edge
forwards HTTP but answers `CONNECT` with 405, so sibling sandboxes cannot use it as a
forward proxy. Run it where it gets a real TCP listener on 443, and give it the same
`CORE_SIGNING_SECRET` as core so it can verify turn tokens.

See [Internet access](https://e2b.dev/docs/network/internet-access).

## Published apps

`DEPLOY_PROVIDER=e2b` runs each published app in its own E2B sandbox, created from
`E2B_TEMPLATE_ID` so an app gets the same machine as the agent that built it. It uses
the same `E2B_API_KEY`.

- The sandbox is created with `lifecycle: { onTimeout: "pause", autoResume: true }`. It
  pauses ten minutes after it starts or resumes, and the next request to its URL resumes
  it with memory intact, so the app process keeps running across pauses. E2B keeps paused
  sandboxes until they are killed.
- Its URL is `https://<port>-<sandbox>.<e2b domain>`, created with
  `network.allowPublicTraffic: false`. Only core holds the sandbox's traffic access token
  and sends it as `e2b-traffic-access-token` when it proxies a request, so viewers still
  go through QM's sign-in and sharing at `/d/<app>/` or the apps domain.
- A new version replaces `/home/user/app` in the running sandbox and restarts the
  entrypoint. `/home/user/data` (`$DATA_DIR`) survives redeploys and pauses; deleting the
  app kills the sandbox and its data.
- An always-on app is created with a 24 hour timeout. E2B still pauses it when that
  window ends, and the next request resumes it.
- Apps served at `/d/<app>/` always require sign-in, so "anyone with the link" sharing
  needs an apps domain (`DEPLOY_APPS_DOMAIN` plus `AWS_DEPLOY_GATE_SECRET`) with a
  wildcard DNS record routed to the portal. `e2b.app` cannot carry per-app subdomains, so
  a core running inside an E2B sandbox needs a domain you control in front of it. Without
  one, a request to make an app public is refused.

See [Auto-resume](https://e2b.dev/docs/sandbox/auto-resume) and
[Restricting public access](https://e2b.dev/docs/network/restrict-public-access).

---

### Error Reporting (`docs/error-reporting.md`)

# Backend error reporting

Set `SENTRY_DSN` in each backend service's deployment environment to enable Sentry. Leave it unset to disable reporting. Core (including in-process Slack), workers, web/admin servers, and portal/auth servers report to the configured project. This does not add browser instrumentation.

Use a separate project for deployments with different operators or data boundaries. Configure `SENTRY_ENVIRONMENT` (for example, `production` or `staging`) and `SENTRY_DEPLOYMENT` (a non-sensitive deployment identifier). `SENTRY_RELEASE` overrides the image's `GIT_SHA`. Backend images built by the AWS CLI receive the source revision automatically. A combined web/admin process uses service `web`; a combined portal/auth process uses service `portal`.

Reporting captures uncaught exceptions, unhandled promise rejections, HTTP handler and authentication delivery failures, and the core's operator error records. Recorded failures carry `error_code` and a matching grouping fingerprint. Recorded exceptions preserve their original stack; records without an exception carry the recording call's stack. Repeated capture of the same error object is deduplicated by the SDK. Ordinary console logs and expected failures outside these boundaries are not reported.

Only error events are enabled by default. Profiling, logs and replay are disabled. Sampled performance tracing, including request instrumentation, is described below. Events are sent unredacted: error messages, cause chains (as linked exceptions), stacks, and the failure's context (the `detail` passed to `reportFailure`, or the operator error record's category, code, message, scope and session) all reach Sentry so issues can be debugged there directly. Each recorded failure's log line also carries its Sentry event id (`[sentry=<id>]`).

The SDK preserves existing fatal-error drain handlers. When no rejection handler exists it flushes and exits unsuccessfully. Core's explicit shutdown paths and web startup failures allow up to two seconds for pending error delivery. Delivery is best effort and never a substitute for application logs or infrastructure health alarms.

After deployment, verify a synthetic backend failure reaches the intended project with the expected release, environment, deployment and service. Check that its message and stack are readable. Test alerts against that event before relying on notifications. Disable reporting by removing `SENTRY_DSN` and redeploying; retain the prior immutable application candidate for code rollback.

## Optional performance tracing

Set `SENTRY_TRACES_SAMPLE_RATE` (a fraction between 0 and 1; unset, 0, or an invalid value keeps tracing off) on a backend service that already has `SENTRY_DSN` to sample transactions. Start with `0.1`. With a sample rate set, the SDK's automatic instrumentation records incoming and outgoing HTTP requests (including `fetch`) and Postgres pool checkouts, connects and queries. No model, tool or prompt instrumentation is registered. Health checks, server-sent event streams (`GET …/events` or `Accept: text/event-stream`) and the SDK's default 401–404 and redirect responses are not traced.

Each sampled request becomes an `http.server` transaction. The core names it by the registered route template, such as `GET /v1/sessions/:id`. Unregistered paths and every other service's requests are named `METHOD /*`, and deployment subdomain proxying is named `/deployment-proxy/*`. Errors captured during a request carry the same name. Each run attempt a worker processes becomes a `queue.process` transaction named `run`, with `messaging.message.id` (the run id), `messaging.message.retry.count`, `messaging.message.receive.latency` (queue wait in milliseconds), `qm.surface` and `qm.origin` attributes and a `time_to_first_token` measurement (milliseconds from the attempt's start to the first streamed text delta of the reply). Its children are the attempt's Postgres and HTTP spans.

Trace context is propagated only to QM's own services: the origins named by `CORE_API_URL`, `WEB_UI_UPSTREAM`, `ADMIN_UPSTREAM` and `AUTH_BROKER_UPSTREAM`. A run records the `sentry-trace` value of the request that queued it in its stored request (`trace`), so its worker transaction joins that trace. The run inherits the request's sampling decision, sampled or not, so it is never sampled again. Swarm dispatches never record one: a swarm fans out to many workers that would otherwise pile into the dispatching request's trace, so each swarm run starts its own trace and is found by its run id. Runs queued without trace context start their own trace. Browser timing is not linked to backend traces.

For requests without an upstream trace, the first traced service (normally the portal) makes the sampling decision and downstream services follow it. Incoming `sentry-trace` and `baggage` headers are honored as the SDK does by default, so a client that sends its own trace headers to a traced public endpoint can force its requests to be sampled. That costs Sentry quota. Upstream trace metadata is not exported: each envelope's trace header is rebuilt from the event's own trace id and this service's public key, release, environment and sample rate. A run stores only the `sentry-trace` value. With tracing on, error envelopes get the same rebuilt trace header; error event bodies are unchanged.

Transaction payloads are scrubbed before sending: only request (`http.server`) and run (`queue.process`) transactions are sent, so work that outlives its request or run is not exported on its own. Span attributes are reduced to an allowlist (Sentry's own, method, status, route, URL scheme, protocol, database system and name, pool settings, the run's `messaging.*` attributes and `qm.surface`/`qm.origin`). URLs, query strings, hosts, headers, cookies, request bodies and SQL text are dropped. Database spans are named by their SQL verb (`SELECT`, `UPDATE`, …; anything else is `query`), outgoing HTTP spans by method and origin, and any other span by its operation. Error events keep the unredacted policy above.

Browser timing uses `SENTRY_BROWSER_TRACES_SAMPLE_RATE` on the web server together with `SENTRY_BROWSER_DSN`; see the web-ui README.

Browser Sentry ignores plain network `TypeError` failures (`Failed to fetch`, `NetworkError when attempting to fetch resource.`, `Load failed`) and `AbortError` cancellations; HTTP 4xx/5xx failures still report.

## Published apps

Published-app failures become operator error records in the app owner's scope, so they reach the admin errors view and Sentry: `deploy:app_start_failed` (publish failed, including an app that only answered 5xx on `/` for the readiness window), `deploy:app_restarted` (the supervisor restarted the app since the last visit-time check), and `deploy:app_down` (the app and its supervisor are gone). Each record names the deployment and carries up to 4 KB of the app's recent output, with the app's env values and known secret patterns masked. Every app host (AWS, Porter, Fly, Docker) runs the app under the same supervisor and probes, so the app image must provide `sh`; with `curl` or `wget` in the image the readiness check also rejects an app that only answers 5xx, and without either it waits only for the port to listen.

---

### External Slack (`docs/external-slack.md`)

# External Slack workspaces

External workspace access is opt-in. Without this configuration, existing Slack identity, context, and authorization behavior is unchanged.

Add `externalAccess` to the selected `SLACK_ACCOUNTS` entry:

```json
{
  "id": "partners",
  "botToken": "<bot token>",
  "appToken": "<Socket Mode token>",
  "externalAccess": {
    "companyDomains": ["company.example"],
    "companyTeamIds": ["TCOMPANY"],
    "serviceCredentials": ["public-search"]
  }
}
```

For the default Slack account, set `SLACK_EXTERNAL_ACCESS` to the JSON value of `externalAccess`. The same policy works with HTTP Events accounts. Account IDs must be unique, including the implicit `default` account. Apply the configuration to core and workers together, then restart them.

## Identity and public work

Set `companyTeamIds` to Slack workspace IDs administered by your company. This required, non-empty list identifies the workspaces trusted to establish employee identity; do not include the partner workspace. Existing external-access configurations must add this list before restarting.

The user's Slack `team_id` must be in `companyTeamIds`, and their profile must contain an email whose exact domain is in `companyDomains`. Email case is normalized; workspace IDs are matched exactly. Subdomains are not implicitly included. Company members connecting from those workspaces through Slack Connect are supported. Accounts belonging to the partner workspace, missing workspace IDs or emails, non-company emails, restricted guests, deleted users, and bots cannot initiate work, including DMs. `allowFrom`, if configured, can further restrict access.

Every non-DM conversation in an opted-in workspace has an external audience, including private channels and group DMs with an apparently staff-only roster. Its computer and conversation use a workspace-and-policy namespace. Enabling this policy, or changing its allowlists, starts a fresh restricted namespace rather than exposing an older computer or retained private context.

The channel agent can answer and execute code using its own restricted computer. It does not preload personal or organization memory, files, skills, private instructions, keychain inventory, connected apps, or MCP tools. Open sharing and admin privileges do not widen this context. Other resource operations must continue privately rather than using the channel as a route to private data.

`serviceCredentials` is an explicit external-use allowlist, not an alternative to existing service credential grants. A service must pass both checks and remain enabled. An organization-wide grant alone does not make a service safe for an external audience. Leave the list empty unless the service and everything it can return are suitable for the workspace's audience. In particular, do not add a confidential company search or database credential just because everyone at the company can use it internally.

## Private continuation

When the request needs personal context or a private service, the agent finishes the channel turn with a self-only continuation directive. The platform:

1. Loads the originating request, not a model-selected recipient.
2. Rechecks the employee's company workspace membership and email against the current policy on the originating account.
3. Opens that employee's DM and acknowledges the move in the source thread.
4. Queues a normal personal-context turn with the original request, relevant channel history, and incoming attachments.
5. Delivers results and approvals privately. There is no automatic result bridge back to the channel.

This is a general continuation mechanism, not a calendar-specific workflow. Existing personal authorization and approval rules still apply. Run idempotency prevents Slack redelivery from executing the private task twice. If a process stops after admission, the existing durable result-delivery path recovers the DM response using its recorded Slack account and workspace.

## Operational limits

- Explicit mentions, genuine replies in an engaged thread, and employee DMs are supported. Passive ambient ingestion is disabled for opted-in accounts so it cannot start an unclassified alternate turn.
- Existing shared-scope approvals and legacy personal-agent bridges are not resumed in an opted-in workspace. Start a fresh request so it receives the current boundary.
- Old shared replies without the restricted namespace are not released into an opted-in workspace during delivery recovery. Unprovenanced historical shared Slack requests are refused when an external policy is configured.
- Recovered DM run results also require the current external-account policy. Results admitted before this policy configuration or under a previous policy are not delivered.
- Restricted sessions cannot be resumed through a surface that omits their authenticated workspace policy. External-account DMs also require admission under the current policy; after a policy change, start a fresh request. DMs on other accounts are unaffected.
- Configuration is deployment-level; there is no new admin UI in this change.

---

### Files Publication (`docs/files-publication.md`)

# Durable Files publication

Files publication is independent of a sandbox's lifetime. The S3/Postgres deployment supports direct multipart transfers from a sandbox or API client. File bytes travel to S3; core authorizes the upload and publishes its metadata after verifying the completed object.

## Agent usage

Run this in a sandbox with the normal agent API environment:

```sh
curl -fsS "$AGENT_API_URL/v1/files/upload-client" \
  -H "x-agent-capability: $AGENT_API_TOKEN" -o /tmp/qm-upload.py
python3 /tmp/qm-upload.py path/to/report.pdf
```

The helper prints the published file and its content URL only after completion. It needs Python and curl (curl carries every request, so it works through the sandbox's HTTPS egress proxy), holds at most one 64 MiB part in memory, retries transfers, and obtains another signed URL if one expires. A local `<filename>.qm-upload.json` tracks progress without storing credentials or signed URLs. Rerunning the same command resumes the same upload and returns the same artifact. If the file changed since an unfinished attempt, the helper cancels that attempt and starts a new upload, so it does not hold one of the four active-upload slots. `--state` selects another progress file; `--name` changes the published name. `--list` shows the caller's unfinished uploads in this conversation and `--abort ID...` aborts them; when the quota refuses a new upload, the helper prints that list and these commands. Keep the source file unchanged during transfer. The helper binds its progress to the API origin, actor, conversation scope, and complete part manifest.

The file belongs to the conversation scope. An agent token cannot publish into another scope or inspect another scope's upload sessions, even if its actor belongs to both scopes. Retirement of a sandbox does not affect a completed file. An unfinished upload is not a file and does not appear in Files.

## Direct protocol

Each JSON request requires the existing authenticated agent or user identity. A user client follows the existing principal-bound source authentication contract; an agent uses `x-agent-capability`.

| Method | Endpoint                              | Meaning                                                                                                            |
| ------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| POST   | `/v1/files/uploads`                   | Begin with `name`, optional `mimetype`, optional `scopeId`, `sizeBytes`, ordered `checksums`, optional `requestId` |
| GET    | `/v1/files/uploads`                   | List the caller's unfinished sessions in this scope; `elsewhere` counts ones in other scopes                       |
| GET    | `/v1/files/uploads/:id`               | Read the caller's session state                                                                                    |
| POST   | `/v1/files/uploads/:id/parts/:number` | Obtain a signed PUT URL plus required headers                                                                      |
| POST   | `/v1/files/uploads/:id/complete`      | Verify uploaded parts and publish the file                                                                         |
| DELETE | `/v1/files/uploads/:id`               | Abort before completion starts                                                                                     |

Split bytes into 64 MiB parts and supply each part's base64 SHA-256 checksum. An empty file has one empty part and the SHA-256 checksum of empty bytes. `requestId` is 32 lowercase hexadecimal characters; persist it before initiation to make response-loss retries idempotent. Reusing it with another manifest is rejected. Upload every part using the returned headers, including the signed content length and SHA-256 checksum. Completion uses S3's actual part sizes, checksums, and ETags rather than accepting a client claim that the transfer succeeded.

The limit is 100 GiB per file, four active uploads per actor, and 500 GiB of active declared bytes per actor. Sessions expire after 24 hours and the sweeper then aborts them; a quota refusal (`429 upload_quota`) carries the same listing as `GET /v1/files/uploads`; signed URLs last at most 15 minutes and never outlast their session. Abort and completion have mutually exclusive durable state transitions. Once completion has begun, retry completion instead of aborting. Core can recover completion after S3 succeeded but a response or database write failed. Deleting an already-published artifact records an ID-only tombstone. Publication and deletion serialize on that ID, so recovery cannot recreate the file even if the upload state update failed after publication.

S3 stores a SHA-256 composite checksum for multipart objects. This is a checksum of the ordered part checksums, with a part-count suffix. It is not the SHA-256 of the entire file: these artifacts intentionally have `sha256: null`. Their unique blob key and server-verified multipart manifest identify the uploaded bytes.

## Deployment and recovery

New direct uploads require S3 file storage, Postgres, and `FILES_DIRECT_UPLOADS_ENABLED=true` (default `false`). Deploy this release with the flag disabled to every API server and worker before enabling it in a separate rollout. Older releases cannot read the new object keys or preserve deletion fences; after activation, any rollback target must include this release's reader and deletion compatibility. Turning the flag off disables initiation while preserving authenticated helper downloads, existing session status, signing, completion, abort, and background recovery. The helper can resume an existing session while initiation is disabled, including after a sandbox replacement. Upload sessions reside in `file_uploads`; process-local state is not required for recovery. Missing multipart uploads with no completed object become terminal failed sessions and release their quota. Expired sessions are claimed in bounded batches with durable retry times, so repeated failures cannot starve later sessions. The worker sweeper retries expired sessions each minute and aborts old multipart uploads under the Files upload prefix, including uploads abandoned before their session could be recorded. It does not change bucket lifecycle configuration. Deployments should also configure S3 abort-incomplete-multipart lifecycle rules as protection when core is unavailable for an extended period.

The core's S3 role needs `s3:GetObject`, `s3:PutObject`, `s3:AbortMultipartUpload`, and `s3:ListMultipartUploadParts` on its Files prefix, plus `s3:ListBucket` and `s3:ListBucketMultipartUploads` on the bucket. Existing generated AWS infrastructure includes these permissions. KMS-encrypted buckets also need the applicable KMS permissions to complete uploads and retrieve checksums. The sandbox needs outbound HTTPS to the S3 endpoint but receives no AWS credentials.

The existing browser uploader remains on its existing attachment-size limit. It does not automatically use this protocol. A browser client adopting the protocol must configure appropriate bucket CORS for PUT and the checksum header; Python and other non-browser clients do not require CORS.

Local and proxied S3 writes spool incoming bytes to temporary disk while hashing, with backpressure and the existing size checks. Local publication renames the completed temporary file atomically; S3 uploads the spool as bounded-memory parts. Temporary files are removed on success and failure. This fallback uses core disk proportional to the upload size; use the direct path for large files.

---

### Loop Ingestion (`docs/loop-ingestion.md`)

# Loop ingestion

A Loop can receive work from a schedule, signed webhooks, Slack Events API callbacks, or Gmail Pub/Sub. Open the Loop and use **Ingestion → Add event source**. Adding an event source leaves schedules and approval rules unchanged. No event source is enabled automatically on upgrade.

Events are acknowledged only after durable receipt. The worker transfers them to the original Loop ledger and works queued items without running the discovery stage. Duplicates reuse the same item. Paused Loops retain received work; disabled sources stop accepting new deliveries. Re-enable a source to continue pending work. Pending jobs survive restarts; completed receipt metadata is retained for 30 days. Returned items run again even when no new event arrives.

Source updates invalidate old held drafts before processing the new conversation. Human edits remain in the item thread. A shipping output or an output awaiting confirmation must settle before that conversation refreshes. Existing ship gates remain authoritative; Email and Slack reply proposals stay held for human review.

## Preview rollout

The durable `inbox_loops` feature flag defaults off. Enable it only for the intended `personal:<principal>` scopes in Admin → Feature flags. Existing Web UI `INBOX_USERS` and `LOOPS_USERS` allowlists still apply. Both the UI permissions and server routes enforce the flag; Inbox migration starts only for enabled people. Event receipt and background processing also check the source owner's flag. Revocation retains queued work without accepting or processing more events.

Slack receipt recovery covers events already persisted by QM. There is no Slack history reconciliation yet. Enable Slack's Delayed Events option for extended delivery retries; Gmail has history reconciliation as described below.

## Signed webhooks

Choose **Signed webhook** on a Loop without a source restriction. Save the signing secret shown once. POST a JSON object to the displayed endpoint with `X-Signature` containing the hexadecimal HMAC-SHA256 of the exact request body. The optional `title` property is used as the work summary. The entire JSON object is untrusted input to the Loop playbook. Payloads are limited to 64 KB.

Identical request bodies are duplicate deliveries; include a stable event ID in the JSON when otherwise-identical events should be distinct. Do not change the body during retries. An invalid signature returns 401. Disabled or nonexistent endpoints return 404. Accepted receipts return 202, which does not mean the model has finished processing.

## Slack events

Choose **Slack events**, enter the Slack app signing secret, workspace ID, and channel IDs. Configure the displayed endpoint as the app's Events API request URL and subscribe to the message events needed for those channels. The receiver handles Slack's signed URL-verification challenge. App membership and Slack OAuth scopes still determine which events Slack sends; this setting grants no additional Slack permissions.

Only human message events from the configured workspace and channels are imported. Bot messages and message subtypes, including edits and deletions, are excluded. Deliveries deduplicate by Slack event ID. Replies share their conversation's work item; DMs use the channel ID as their conversation key. A newer message refreshes that item.

This is HTTP Events API ingestion, independent of the deployment's existing Socket Mode bot. Slack has one Events API request URL per app, so use an app dedicated to this endpoint if another service already owns that URL. Slack Workflow requests can use the generic signed webhook only if the sender can produce the required HMAC signature; an unsigned workflow URL is not supported.

See [Slack HTTP request URLs](https://docs.slack.dev/apis/events-api/using-http-request-urls/) and [request signature verification](https://docs.slack.dev/authentication/verifying-requests-from-slack/).

## Gmail Pub/Sub

An administrator must configure:

```text
GMAIL_PUBSUB_TOPIC=projects/PROJECT_ID/topics/TOPIC_ID
GMAIL_PUBSUB_AUDIENCE=https://QM_HOST/v1/loop-ingress/gmail
GMAIL_PUBSUB_SERVICE_ACCOUNT=pubsub-push@PROJECT_ID.iam.gserviceaccount.com
```

1. Create a Pub/Sub topic in the Google Cloud project associated with the Gmail OAuth application. Grant `gmail-api-push@system.gserviceaccount.com` publisher access to that topic.
2. Create an authenticated push subscription pointing to `https://QM_HOST/v1/loop-ingress/gmail`. Set its OIDC audience and push service account to match the settings above. Follow Google's service-agent/token-creator requirements for authenticated push subscriptions. Use the normal wrapped Pub/Sub message format.
3. Restart core with those settings. Set `PUBLIC_WEB_URL` to the externally reachable HTTPS origin so the UI displays the correct endpoint. The portal forwards push requests without a portal login; core verifies Google's signature, issuer, audience, and verified service-account email.
4. Connect the Loop owner's personal Gmail account, then enable **Gmail Pub/Sub** on a personal Loop. Gmail watch/history access needs an appropriate Gmail OAuth scope. Company credentials and shared-scope Loops are not used for personal mailbox ingestion.

The initial watch starts at the mailbox's current history ID; it does not import the existing mailbox. New Inbox messages and messages newly labeled Inbox become work items. Watches renew daily without advancing the saved processing cursor. Notifications trigger paginated history reads; every ten minutes a history reconciliation also checks for dropped notifications. An expired history cursor triggers a paginated Inbox resync. The cursor advances only after all discovered items are durably imported.

Disabling a source stops receipt processing and watch renewal for that registration. It does not issue Gmail's mailbox-wide stop call, because another Loop may watch the same mailbox. If every registration is disabled, the remote watch expires naturally. Re-enabling preserves the processing cursor and catches up on subsequent reconciliation.

See [Gmail push notifications](https://developers.google.com/workspace/gmail/api/guides/push), [watch API](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users/watch), and [authenticated Pub/Sub push](https://docs.cloud.google.com/pubsub/docs/authenticate-push-subscriptions).

## Operations

Keep background work enabled: ingestion maintenance runs alongside the scheduler, without requiring any user cron. Registration state, Gmail cursors, retry times, receipts, and queued work live in the configured durable store. Production uses Postgres; memory persistence is only suitable for disposable tests.

The Loop's Ingestion panel shows the last receipt and processing error. A 202 acknowledgment followed by an error means the event is still durable and will retry or remains represented by a queued/parked ledger item. A failed Loop may require operator attention under its existing governor and retry limits. Watch renewal errors retry after five minutes.

Do not paste webhook signing secrets into chat, playbooks, or committed configuration. The secret is returned only on creation for generic webhooks and is omitted from later listing responses.

## Conversation events (inbox)

Every new message in a conversation already on an inbox ledger is an event, and
every event is judged by the Loop's work stage. Nothing resolves an item in code.

- **Sources.** QM's Slack bot, Gmail history (INBOX and SENT), the inbox open-time
  source check, and the scheduled sync (`POST /v1/loops/:id/events`).
- **Queue.** Events land durably in a per-Loop internal `conversation` ingress
  source, deduplicated by conversation and message time. They merge
  `sourcePayload.latestEvent` into the existing item and refresh `sourceAt`, which
  reopens a resolved item. Events never create items.
- **Decision.** The work stage returns a draft, or
  `{"resolution":{"kind":"replied"|"no_reply_needed"}}`, which dismisses the item.

---

### Mcp Connectors (`docs/mcp-connectors.md`)

# MCP connector credentials

Administrators register HTTP MCP servers through `PUT /v1/admin/mcp-servers/:id`
from an admin session (`/admin/api/mcp-servers/:id` behind the portal); agent tokens
are refused, so an agent cannot change which tools it may call or which calls need
approval. Registration controls the outbound destination and the tools available to agents.

`approval` decides which calls pause for a person's approval, bound to the exact
arguments and good for one call: `annotations` (the default; tools annotated
`destructiveHint: true` and not `readOnlyHint: true`), `all`, or `none`. An update
that omits `approval` keeps the current policy.

`credentialScope` selects the identity used for `tools/call`:

- `shared` (the default for existing and new registrations): all callers use the
  configured `auth`, `bearerToken`, or `clientId` and `clientSecret`.
- `per-user`: each call uses the initiating actor's bearer token from the encrypted
  connector keychain, under `credentialHost` in the selected `credentialAccountType`
  slot (`default`, `personal`, or `company`; defaults to `default`). The
  runtime supplies the actor; tool arguments cannot select another user.

For example:

```json
{
  "name": "Customer tools",
  "url": "https://tools.example.com/mcp",
  "auth": "none",
  "credentialScope": "per-user",
  "credentialHost": "accounts.example.com",
  "readOnly": false,
  "enabled": true
}
```

Per-user mode requires persistent keychain encryption (`CONNECTOR_SECRET_KEY`).
Connect the user's account using an existing QM OAuth connector, or have a trusted
integration save its token through the source-authenticated
`POST /v1/connectors/token` endpoint with `host`, `principalId`, `accessToken`, and
optional `expiresAt` (Unix milliseconds). Supported OAuth providers can also store
and refresh a `refreshToken`. This change does not add OAuth provider discovery or
an interactive MCP login flow. An unsupported provider must manage token renewal
in its integration; do not store a refresh token that QM cannot refresh.

Missing, expired, revoked, or unrefreshable user credentials fail the call with a
connection-required message. Per-user MCP calls do not use operator environment
tokens, organization credentials, another user's account, or the shared discovery
credential as fallback. Tokens are resolved again for every call rather than
cached across users. Triggered work uses its existing initiating actor identity.

Tool discovery (`tools/list`, including registration probes) remains connector-wide
and uses the configured `auth`. In per-user mode those credentials are only for
catalog discovery. The server must expose a common, non-sensitive catalog and
check each caller's permissions when executing a tool. A public catalog can use
`auth: "none"`; a private catalog can use a dedicated shared discovery credential.
Do not put user-specific data into names, descriptions, or schemas.

`credentialHost` explicitly authorizes sending that connector's user token to the
registered server. Only register a trusted service permitted to receive those
tokens. Per-user endpoints require HTTPS, except on loopback for local development.
HTTP redirects are rejected for every MCP authentication mode. Updating an endpoint
or credential host is an administrative trust decision. Tool call auditing records
the initiating actor as before; authentication does not expand the audience allowed
to receive the result.

---

### Memory Providers (`docs/memory-providers.md`)

# Memory providers

QM keeps its built-in notebook memory unless `MEMORY_PROVIDER_CONFIG` defines a scope-aware provider router. Routes independently select where each scope recalls from, accepts explicit writes, and receives automatic post-turn capture.

## Prompt memory

Each turn carries at most 6,000 characters of memory, selected across every notebook
the conversation may read, one heading per notebook. With PostgreSQL and an embedding
model, facts are ranked by exact cosine similarity to the current message and recent
dialogue; otherwise the newest facts come first. Later turns add only newly selected facts.

Choose the model with the org setting `memory-embedding-model`
(`{ "modelId": "gateway/<model>" }`, or `null` to turn ranking off).
Gateway models with `mode=embedding` are listed automatically; a direct OpenAI-compatible
model is registered in the model registry with `"kind": "embedding"`. The provider
receives fact text during background indexing and the query during recall. PostgreSQL
stores typed vectors in `memory_fact_vectors`; recall ranks live, disclosed facts once per
scope in SQL and returns compact ranked matches. No approximate vector index is used.

Semantic recall requires the PostgreSQL `vector` extension. Its migration is registered
only after a configured embedding model resolves, not during an unconfigured startup.
If the application database role cannot install extensions, an administrator must install
`vector` first. File-backed installations use newest-first recall, even with a model selected.

A single background indexer embeds at most 64 missing facts and removes stale vectors in
bounded batches every 30 seconds. It also sends a short dimension probe every ten minutes so a provider's
vector-width change is detected even when its model identifier stays the same.

New or edited facts may wait for a background tick before becoming semantic matches.
Unindexed facts participate at the recency fallback score alongside indexed semantic
matches. If the provider/database fails, recall falls back to newest-first. Text search (`memory` search, `POST /v1/memory/search`) remains
immediate and does not use embeddings.

## External routing

```json
{
  "providers": [
    {
      "id": "org-knowledge",
      "type": "mcp",
      "url": "http://memory-provider.internal:8080",
      "timeoutMs": 3000,
      "read": {
        "tool": "search_knowledge",
        "clientIdEnv": "KNOWLEDGE_RO_CLIENT_ID",
        "clientSecretEnv": "KNOWLEDGE_RO_CLIENT_SECRET"
      },
      "write": {
        "tool": "write_knowledge",
        "clientIdEnv": "KNOWLEDGE_RW_CLIENT_ID",
        "clientSecretEnv": "KNOWLEDGE_RW_CLIENT_SECRET"
      }
    }
  ],
  "routes": [
    {
      "provider": "default",
      "scopes": ["personal", "channel", "group", "team"],
      "capture": "automatic"
    },
    {
      "provider": "org-knowledge",
      "scopes": ["org"],
      "capture": "explicit",
      "manage": false,
      "label": "Organizational knowledge"
    }
  ]
}
```

Set the compact JSON document as `MEMORY_PROVIDER_CONFIG`. `default` names QM's local/Postgres notebook. A scope selector may be a kind (`org`) or an exact scope ID (`org:acme`). Earlier matching routes can compose multiple recall providers; the first route with `manage` enabled supplies notebook editing and revision history.

Capture policies are:

- `off`: recall only;
- `explicit`: writes only through deliberate memory actions;
- `automatic`: explicit writes plus post-turn capture.

MCP reads receive `query` and `acting_user` by default. Writes receive `content` and `acting_user`. Operation entries can map optional fields with `queryArg`, `contentArg`, `actorArg`, `scopeArg`, `maxCharsArg`, `inputArg`, `replyArg`, `capturedAtArg`, `sourceArg`, and `idempotencyArg`. Only configured optional fields are sent, so providers can match strict MCP schemas.

Read and write operations use separate OAuth client-credential pairs. Omit `write` and set route capture to `off` for a read-only provider. External routes fail open by default so an outage does not block recall; set `failOpen: false` on a route to make it strict. Provider calls time out after `timeoutMs` (3 seconds by default). Explicit writes always fail visibly. QM continues to decide readable/writable scopes and passes the acting user to the provider.

## Migrating from the retired `BRAIN_*` variables

Earlier releases wired an external knowledge server through `BRAIN=mcp`, `BRAIN_MCP_URL`, `BRAIN_QUERY_TOOL`, and the `BRAIN_RO_*`/`BRAIN_RW_*` OAuth client pairs, exposing `read_brain` and `write_brain` tools. Those variables are ignored now; startup logs a `[config]` warning while any of them is still set. Express the same server as an `mcp` provider above: `url` takes the old `BRAIN_MCP_URL`, `read.tool` the old `BRAIN_QUERY_TOOL`, and `read`/`write` name the env variables holding each OAuth client pair. Static bearer tokens (`BRAIN_AUTH=bearer`) have no equivalent; the provider framework authenticates with client credentials only.

## Procedural memory (Memorable)

A provider with `type: "memorable"` records _procedures_ rather than facts: when a turn's
automatic capture fires, QM derives a deterministic tool-call trace from the session (which
files changed, which commands verified the work), redacts any secret values, and hands it to
the [Memorable](https://memorable.sh) CLI with `memorable record`. Recall runs `memorable inject`
with the turn's task and appends the returned pointer to the prompt. No model is involved in
either direction.

```json
{
  "providers": [{ "id": "procedures", "type": "memorable" }],
  "routes": [
    { "provider": "default", "scopes": ["personal", "channel", "group", "team", "org"], "capture": "automatic" },
    { "provider": "procedures", "scopes": ["personal"], "capture": "automatic", "manage": false, "label": "Procedures" }
  ]
}
```

Options: `bin` (default `memorable`; a string or an argv array such as `["node", "/opt/memorable/cli.js"]`),
`passEnv` (extra environment variable names to hand the CLI, e.g. `["MEMORABLE_STORE_KEY"]`),
`injectTimeoutMs` (default 15000) and `recordTimeoutMs` (default 120000). The CLI is not bundled:
install it with `npm i memorable-cli@latest` — the `qm` backend needs 0.5.9 or newer, and an
npm `min-release-age` setting can silently pick an older release, so check `memorable --version`
(its `qm` backend also needs the `pg` package resolvable
from QM's working directory). Recording calls the Memorable extraction service, so set both
`MEMORABLE_API_URL` and `MEMORABLE_API_KEY`; recall is local. Consent is the CLI's own act, per
scope: nothing is recorded for a scope until `memorable enable --scope <scope-id>` has been run
with the same `MEMORABLE_BACKEND=qm` and `MEMORABLE_DB_URL`. It sees only an allow-listed environment —
`MEMORABLE_*`, `PATH`, `HOME`, proxy and TLS variables — with `MEMORABLE_BACKEND` defaulting to
`qm` and the database reachable solely as `MEMORABLE_DB_URL`. Routes to this provider accept
`capture: "automatic"` or `"off"`; explicit `remember` writes are facts, not procedures, and are
left to the notebook. A consent refusal from the CLI is reported as a capture error; like any
external route it fails open by default, so the notebook write still lands and the refusal is logged. The provider never exposes a notebook, so keep `manage: false` and let
`default` handle editing.

---

### Memory Records (`docs/memory-records.md`)

# Memory records and audience isolation

## One notebook representation

Built-in memory uses versioned records: stable ID, text, sensitivity, source
scope/session references and an incomplete-provenance flag (`sourceUnknown`).
Markdown is the rendered editing/display format, not a second source of truth.
Headings and freeform prose are records too.

Postgres stores the authoritative snapshot in `memory_revisions.records`. New
revisions derive `body` from those records for compatibility. A legacy revision
with NULL records imports its body as unknown-source records; its sources are never
guessed from where the notebook happens to live. An older writer during a rolling
deployment can still produce a NULL snapshot, which takes that conservative path.
The additive migration preserves existing revisions.

The workspace backend stores one JSON snapshot at its existing reserved memory
path. Existing Markdown is imported on read and migrated on the next write. The
memory API still returns Markdown. Malformed structured data fails closed rather
than silently falling back to potentially stale text. Old file-only binaries cannot
read the new snapshot format; export through the memory API before downgrading one.

## Capture and curation

Automatic extraction classifies each batch as `ordinary`, `unknown`, `sensitive`
or `restricted`. Explicit capture uses the same labels. Missing or malformed
classification and model failure produce `unknown`. Classification is a model
estimate, not proof of harmlessness.

Runtime context supplies source scope/session, never model-authored source claims.
Captures inherit known input restrictions. Untracked history, tools, attachments
or incomplete dependencies add unknown provenance. Cross-scope personal copies keep
their original source. Duplicate capture can tighten metadata without adding text.

Built-in consolidation edits records by stable identity under a revision check.
Each model request sees only facts with identical sensitivity and provenance;
updates and additions inherit that group's metadata. Unchanged records keep their
IDs and metadata. Separate-source facts never share a consolidation prompt.
Bookkeeping is a snapshot counter, not a Markdown marker that rewrites every fact.
A concurrent edit wins over a stale consolidation. Opaque providers retain control
of their own consolidation; generic whole-Markdown automatic rewriting is removed.

An arbitrary whole-notebook rewrite cannot establish which facts its new text
came from. It conservatively inherits the old notebook's combined restrictions.
Restoration likewise inherits the restrictions of all intervening revisions.
Neither operation declassifies information. Clearing removes current content, not
historical revisions.

## Fresh reads

One disclosure view serves recall snapshots, search, primitive tools, memory-file
redirects and agent memory APIs. Provider routes apply it per provider. Structured
search runs over filtered records, not an unfiltered provider query.

The actor must retain access to every recorded source. Carrying memory into another
conversation requires current Open policies at both ends and a known destination
audience. An ordinary personal fact can follow its owner into an eligible internal
Open room; sensitive or restricted facts require every recipient to be entitled to
the sources. Ordinary does not mean public. Unknown provenance stays within the
notebook's existing narrow audience. Lookups fail closed, and authorization results
are not cached between reads.

A partially visible notebook cannot be rewritten. Structured edits require a
revision check. Historical reads apply a conservative restriction floor from newer
revisions; restore checks the complete intervening history in storage.

Opaque external providers and the optional scratch-promotion workflow remain
compatibility paths, not sources of trusted cross-context provenance. Their
unclassified content stays narrow. Scratch captures with foreign dependencies and
cross-origin personal copying are refused. Scratch bookkeeping lives in snapshot
metadata, not fact text; a no-change promotion preserves every fact's provenance.
Direct MCP tools and authorized private
administrator inspection are separate access paths.

## Retained working context is separate

Fresh-read restrictions do not erase information legitimately learned by the same
audience. Source revocation, notebook edits or disabling recall do not reset that
audience's conversation.

A durable session checkpoint records the destination/audience identity hash and a
replay cutoff. An actual audience change advances the cutoff: earlier model
history, summaries, native harness state, retries, approvals and historical tape
cannot re-enter the next turn. Later turns retain post-cutoff context normally.
An absent checkpoint establishes a baseline rather than deleting old context.
Project conversations keep their existing member-inherits-history behavior.

Agent conversation APIs and subagent reads also respect the cutoff, including
derived titles and metadata. Human transcripts and administrator audit records
remain intact. Unversioned external prior turns cannot safely be imported after a
cutoff. This is next-turn isolation, not cancellation of a model request already
in flight, nor retroactive deletion of exported or copied artifacts.

## Verification limits

Synthetic tests cover storage migration, metadata-only capture, consolidation,
revision races, restoration, provider routing, fresh authorization and durable
context boundaries. Live HTTP tests use synthetic facts through the real server
and Postgres. Deterministic model responses isolate consolidation behavior; these
tests do not establish classifier accuracy or qualify every real model and Slack
configuration.

---

### Modal Image (`docs/modal-image.md`)

# Prebuilt Modal connector image

Build the sandbox image before deploying core to keep package installation and image
building out of the first user turn. The image contains the same locked Composio SDK
bundle that core supplies to sandboxes without a baked image. Connector credentials
are supplied at runtime; none are used during this build.

From the matching source checkout, install dependencies with `npm ci`, configure Modal
authentication, then run:

```bash
MODAL_APP_NAME=qm npm run build:modal-image
```

The command eagerly builds the image on Modal and prints `MODAL_IMAGE=im-...` only
after the bundle's checksum and import checks pass. Save that exact image ID in the
core deployment's `MODAL_IMAGE` setting. Use the same Modal workspace and environment
for building and running; `MODAL_ENVIRONMENT` selects the SDK environment. The build
does not change running sandboxes or deploy core.

With no `MODAL_IMAGE` set during the build, the image starts from QM's pinned Node
base and installs its usual shell, Python, Git, curl, and archive utilities. It also
installs a pinned, checksum-verified `uv`, configures `uv pip install` to target the
system Python without a virtual environment, and preinstalls pinned requests,
beautifulsoup4, lxml, openpyxl, pypdf and python-docx, plus a pinned, checksum-verified
AWS CLI v2 so agents don't fall back to `pip install awscli` (v1, which lacks SSO
device-code login). Packages installed later with
`uv pip install` land in `/usr/local`, outside home checkpoints, so they last until the
sandbox is replaced. To extend an existing custom image, set `MODAL_IMAGE` to its
registry reference (prefer an immutable digest) or an existing Modal image ID. Custom bases must already contain
Node, npm, and QM's baseline utilities; this preserves the existing custom-image
contract. Building again from the same inputs uses Modal's layer cache.

The connector build uses `deploy/connector-sdk/package-lock.json` and the shared
bundle builder. The resulting SDK, checksum, and license notices live in
`/opt/qm/composio`, outside `/root`. Restoring a home snapshot therefore cannot hide
or replace image-installed dependencies. Core validates the baked artifact against
its own bundle and exposes the matching copy through the usual home launcher path;
an older image can use the runtime bundle fallback.

Native home checkpoints are directory snapshots of `/root`, so everything installed
under the home is captured again in every checkpoint. Deployment-layer tool files are
converged under `/usr/local/bin` and `/usr/local/lib` and stay out of checkpoints;
the runtime connector bundle fallback lands under `/root/.qm/composio` and adds about
1.3 MB to each checkpoint until `MODAL_IMAGE` points at a baked image, after which
only a symlink remains in the home. Set `MODAL_IMAGE` before enabling native
checkpoints on a deployment with many active users.

Rebuild the image when updating the SDK lockfile or builder, and retain the previous
image ID with the previous core release for rollback. Existing running sandboxes
keep their current image until normal replacement. Do not delete image IDs still
used by running deployments or retained rollback releases.

For an isolated provider smoke test after the build:

```bash
MODAL_APP_NAME=qm-image-smoke MODAL_IMAGE=im-... node scripts/modal-live-smoke.mjs
```

This creates temporary sandboxes and terminates them afterward. It does not switch
production scopes. Sprites and Modal deployments without a baked image continue to
use the shared runtime bundle installer.

---

### Model Gateway (`docs/model-gateway.md`)

# Model gateways

Pi can route model calls through an authenticated gateway instead of holding provider keys. Set `MODEL_GATEWAY_URL`, `MODEL_GATEWAY_API_KEY`, and `MODEL_GATEWAY_API_KEY_HEADER`. The key stays server-side. No model list is required.

QM discovers models using authenticated `GET /v1/models` and `GET /model_group/info`, the end-user metadata endpoint implemented by LiteLLM. A base URL ending in `/v1` is supported; both endpoints retain any preceding path prefix. Discovery uses the same key as inference, rejects redirects, and ignores upstream credential and endpoint fields.

Only models listed by both endpoints, marked as chat models with tool support, and carrying valid context, output and pricing metadata are offered. Discovered IDs are namespaced as `gateway/<upstream-id>`. Groups whose metadata identifies only Anthropic providers use native Messages, preserving signed thinking blocks across tool calls. Groups whose providers are exclusively OpenAI or Azure use Responses, including native file inputs. Other groups use OpenAI-compatible streaming chat completions. Reasoning controls are enabled for native Anthropic and OpenAI/Azure groups; other provider combinations use standard chat without requesting reasoning effort. They are available in Pi, not process harnesses. The gateway owns provider translation and group capabilities. QM selects the protocol from provider metadata, never model names. Gateways must expose `/v1/messages` for Anthropic groups and `/v1/responses` for OpenAI/Azure groups. PDF inputs on chat-completion routes require vision capability and a provider set consisting only of Anthropic, OpenAI, Azure, Gemini or Vertex. Unknown or mixed unsupported provider groups use bounded text extraction.

Availability refreshes on demand every five minutes and before inference when expired. Successful refreshes replace the catalog, including an empty catalog. After discovery has succeeded, a failed refresh disables gateway routes until recovery without interrupting independent direct providers, with retries after thirty seconds. In-flight requests are not cancelled; the gateway still enforces its key permissions on every call. Retired selections remain in conversation history but cannot issue a new request through a different provider.

`MODEL_GATEWAY_MODELS` is optional and accepts the existing `local-id=upstream-id` pairs. These provide compatibility aliases using QM's existing native model definitions; they do not limit discovered models. After discovery succeeds, aliases are usable only while their upstream IDs remain advertised. Gateways without the discovery endpoints can continue to use explicit mappings. Before the first successful discovery, failures leave those configured routes available. This compatibility fallback never restores a route removed by a successful discovery.

For LiteLLM deployments, allow the two authenticated GET endpoints alongside inference paths. Do not expose administrative `/model/info` or key-management routes to enable discovery. Gateways implementing only the OpenAI model-list endpoint can use explicit mappings or implement the metadata contract below.

The metadata endpoint returns `{"data": [...]}` with one object per model group. QM uses `model_group`, `mode`, `supports_function_calling`, `max_input_tokens`, `max_output_tokens`, `input_cost_per_token`, and `output_cost_per_token`. Optional fields include `providers`, `supports_vision`, `supports_reasoning`, `supports_adaptive_thinking`, `supported_openai_params`, `cache_read_input_token_cost`, and `cache_creation_input_token_cost`. Prices are per token and converted to per-million-token prices for QM. Missing cache prices use the input rate. Input capacity is used conservatively as the context budget; output capacity must be smaller.

LiteLLM group metadata may combine capabilities and maximum limits from multiple deployments. It is a gateway contract, not a guarantee that every backing deployment has identical capabilities.

## Browser agent

The browse skill follows the user's saved AI access and default model. Company
access uses the configured model gateway, or the deployment's default provider
credentials when no gateway is configured; ChatGPT access uses the user's connected
OpenAI account, including refreshed subscription access; Claude access supports
API keys. Claude subscription access is unavailable for the inner browser agent
and returns an actionable error. No account silently falls back to another.
Kernel, Anchor, Browserbase and Browser Use still use their own credentials for browser sessions.

Core gives internal, non-strict turns a one-hour capability bound to the account,
model and conversation scope. The sandbox sends OpenAI-compatible, non-streaming
chat completions to `/v1/browser-model/chat/completions`; core reloads the saved
selection and credentials for each request. Changing the account or model requires
a fresh turn. Credentials stay on core. Without a gateway, company inference reloads the same provider credentials as normal
agent inference, including administrator changes and configured provider endpoints.
No separate browser model key is needed. Missing credentials fail closed.
Personal inference uses native provider
transports, bypassing organization endpoint overrides, and converts structured
browser responses and screenshots through the existing model runtime.

Company browser inference uses the same gateway key permissions and budgets as
other company model calls. The selected model must be in the gateway catalog or
configured aliases. Private gateways need no public listener. The endpoint accepts
up to 16 MiB for screenshot history, rejects provider overrides, and rechecks scope
membership, strict posture and gateway availability. Gateway budget exhaustion is
returned as HTTP 429; missing or retired models fail closed.

The managed browse runner needs browser-use 0.12.9 with
`ChatOpenAI.default_headers` support. It never requests separate browser model
credentials or falls back to them on account or gateway errors.

## Ultrafast

Pi offers `gpt-6-astra-ultrafast` and `gpt-6.1-sol-ultrafast`, each labeled
**Ultrafast (6× cost)**. These are QM selection identifiers: requests use the
corresponding base model (`gpt-6-astra` or `gpt-6.1-sol`) with
`service_tier: "ultrafast"` through the Responses API. Standard models and their
Fast switches retain their existing behavior. Ultrafast has its own pricing card,
including cached input, cache writes, and long-context rates.

Use an OpenAI API key or explicitly map the Ultrafast model ID to your gateway's
corresponding base-model group in `MODEL_GATEWAY_MODELS`. The gateway must support Responses and
forward `service_tier`. The choice is supported in Pi only, and is unavailable
with ChatGPT subscription authentication. It is never selected as the default
merely by enabling Fast.

OpenAI supports Ultrafast for Astra and Sol 6.1 with separate rate limits.
See [Ultrafast availability](https://developers.openai.com/api/docs/guides/ultrafast-mode)
and [pricing](https://developers.openai.com/api/docs/pricing?latest-pricing=ultrafast).

In the web picker, select Astra or Sol 6.1 and use the Ultrafast switch. The picker shows a
distinct active badge and the 6× cost before enabling it. Fast and Ultrafast are
mutually exclusive, and both speeds share one preset per base model. The internal model
IDs above preserve routing, authorization, pricing, and saved selections.

---

### Persistent Subagent Sessions (`docs/persistent-subagent-sessions.md`)

# Persistent subagent sessions

A delegated task runs in a durable session with its own transcript and a mutable parent. The `subagents` tool opens children, reads their transcripts, and sends attributed messages. Subagents have no sidebar entry. The separate `sessions` tool is offered only on web UI turns and works with the user-visible conversations in the sidebar: it lists and reads them, sends them private notes, and, on a turn a person is attending, starts a new clean session or forks the current one. It refuses subagents; use the `subagents` tool for those. Parentage is organization data: it survives restarts and can be changed independently of the original delegation.

## Lifecycle

`subagents.open` accepts a task, title, model, harness, thinking level, and optional `noComputer` flag. `noComputer: true` removes computer access entirely: no shell, filesystem, browser, or computer-backed integrations. Only memory/history, session coordination, runtime inspection, and permitted read-only connectors remain. Leave it unset for tasks that need a computer to read email, files, or code; express any no-write constraint in the task.

Reusing a request key recovers the same child and initial run after a lost receipt; using that key for different work is rejected. Children inherit the caller's scope and audience. The existing internal `readOnly` restriction remains durable: `noComputer: false` cannot lift a caller's restriction or an existing child's restriction. The session tree admits at most ten pending or running qm runs, including direct turns and explicit follow-up tasks.

`subagents.send_message` adds attributed data to a durable recipient inbox without starting a turn. Parents, children, siblings, and other accessible sessions can exchange messages. Targets accept IDs, child/sibling titles, or `parent`. Messages arrive at tool boundaries; `subagents.wait` waits up to 60 seconds for mail. Idle recipients see messages when they next use a tool. Agents should continue independent work and wait for required results before giving one combined answer.

`subagents.followup_task` explicitly queues new work for an attached child, behind any active turn. Like `open`, it takes the instruction in `task`. Stable call IDs deduplicate retries. `send_message` with `interrupt: true` aborts active child work. Messages are external data, not human authorization. Each message is screened independently of its carrier tool and persisted at the receiving session's scope. Quarantined messages remain recoverable for approval; denying the approval consumes the message. Read-only and automated authorization restrictions still apply to assigned work.

Read access checks scope, current participant access, audience visibility, and participant tenure. Sending and adoption require current scope access. A wider target audience cannot receive a narrower source's contents.

## Parent changes and completion

Subagents appear as clickable, draggable inline chips in their parent conversation’s collapsible tool rows, outside the sidebar session list. Dragging a chip into the sidebar detaches it and makes it a top-level session. The web UI does not offer reparenting onto other sessions. A child transcript links back to its current parent. The underlying parentage API rejects cycles, unauthorized changes, and cross-scope moves.

A terminal child run produces a deduplicated internal result for its current parent. With `responsive_spine` enabled for the delegating actor, a Slack or spine parent also receives a durable, deduplicated completion wake so it can report the result after ending its dispatch turn. Other parents retain passive mailbox delivery without a new run. The immutable delegating run supplies the actor, authorization, and delivery destination; the current parent request supplies the audience. A freshly adopted parent without runtime context leaves completion pending until that context exists. Detached children produce no automatic parent return.

Terminal receipts live in the run store. A paginated sweep retries pending returns after restart and continues past blocked entries. Completion receipts are acknowledged only after durable inbox insertion and any required completion wake succeed. Inbox messages are consumed after their content is persisted to the recipient transcript; an acknowledgement failure can cause redelivery. Silent completions use a generic notice rather than borrowing text from a later child run.

## Storage and scope

The session migration adds parent and spawn metadata. The run migration adds terminal-return acknowledgements and an index restricted to unreturned terminal child runs. Signal replay uses the existing signal acknowledgement columns.

The ten-run limit governs qm session runs. Coordinator turns use durable session delegation and disable provider-native subagent tools; other turns retain their existing native-harness behavior.

## Swarms

Swarm workers continue to coordinate through the swarm API, which owns their computer assignment, membership, and execution budgets. Session tools cannot spawn or send from swarm turns, send to swarm workers, or adopt a child under a swarm worker. Ordinary human turns in a swarm root remain ordinary session turns. All session-generated work remains automated. Explicit unattended grants belong to the assigning turn, never an earlier task. For `responsive_spine` participants, an attached child and its completion continuation can retain the original live requester’s sharing and author authority through verified durable run provenance. Current audience and sharing policy still apply; read-only, private-message, swarm, detached, and unrelated automated work cannot borrow that authority.

## Feature flag

`persistent_subagents` defaults off. Enable it through the existing feature-flags admin resource for individual actor scopes (`personal:<principal-id>`). The actor flag applies even when that actor works in a shared conversation. Enable only the intended people in each deployment; other deployments remain off without their own opt-in. Actors with neither `persistent_subagents` nor `responsive_spine` enabled are not offered the subagents or sessions tools, and syscall authorization rechecks these flags. Existing transcripts remain accessible.

`responsive_spine` also defaults off and is independent of `persistent_subagents`. Enable it for individual `personal:<principal-id>` scopes through the feature-flags admin API to pilot responsive Slack and spine turns. It applies to the acting person even in shared conversations. Enabled coordinators cannot use command execution, background process tools, credential execution, or provider-native delegation. They dispatch substantial work through `subagents.open`, end promptly, and receive completion wakes. Child sessions retain command tools. Web conversations without spine tools and swarm workers retain their existing behavior. Turning the flag off restores the actor's ordinary toolset and passive completion handling on subsequent turns; it does not interrupt already running work.

Messages use the existing durable artifact store under `session_mailbox`; no process-local queue owns delivery. The flag configuration is also durable and local to each deployment.

---

### Pgbouncer (`docs/pgbouncer.md`)

# PostgreSQL transaction pooling

Keep DATABASE_URL pointed at PostgreSQL. Set DATABASE_POOL_URL to a transaction-mode PgBouncer endpoint with the same database and credentials. Set DATABASE_POOL_CA_CERT to the PEM CA certificate for that endpoint to verify its TLS identity independently of the direct database's configured trust.

Ordinary queries and transactions use the pooled endpoint. Session advisory locks and LISTEN subscriptions use separate direct connections. Schema initialization and dynamic migrations use a separate direct migration pool. pg-boss retains its existing direct endpoint, including its notification connection.

DATABASE_POOL_MAX bounds each process's shared query pool (default 10). DATABASE_DIRECT_POOL_MAX bounds its separate shared session pool (default 32). Both accept integers from 1 to 100. Migrations use a shared one-connection pool. Count every process, database and pooler replica when calculating database capacity; PgBouncer limits are per database/user and per pooler process.

Without DATABASE_POOL_URL, queries and sessions use separate pools to the direct database. Stores sharing a URL share the corresponding underlying pool, with reference-counted close. A closed store cannot reopen itself.

Use PgPool.pool() for ordinary transactions and PgPool.sessionPool() only when the PostgreSQL session must remain pinned, such as session locks and subscriptions. Register migrations through the helper; never run session-lock-based migrations through the transaction endpoint. Query-specific timeouts execute inside a transaction using SET LOCAL, preventing timeout state from leaking between PgBouncer borrowers.

Run scripts/test-pgbouncer.sh to create isolated PostgreSQL and TLS-enabled PgBouncer containers and exercise pooling, lock-held writes, timeout cleanup, migration ordering and shutdown races. The fixture uses a single backend connection to verify multiplexing and a single direct session slot to catch connection starvation. The PgBouncer compatibility workflow runs it on every pull request.

Before enabling pooling on a deployment, verify schema initialization, notifications, job scheduling, TLS failures, cross-database access denial and application reconnect behavior. Roll back routing by removing DATABASE_POOL_URL and restarting the application; database contents do not change.

---

### Porter (`docs/porter.md`)

# Running qm on Porter

[Porter](https://porter.run) provisions and manages a Kubernetes cluster inside your own
AWS, GCP, or Azure account. qm can use it two ways, independently:

- `SANDBOX_BACKEND=porter` — every agent computer is a Porter sandbox with a persistent
  home volume.
- `DEPLOY_PROVIDER=porter` — apps the agent publishes run on the same cluster.

Both need a Porter API token and the project and cluster that own the sandbox API. The
complete environment for a Porter-backed instance, verified against a live deployment:

```bash
SANDBOX_BACKEND=porter
DEPLOY_PROVIDER=porter
PORTER_DEPLOY_API_TOKEN=<ADMIN-role api token from Settings -> API tokens>
PORTER_DEPLOY_PROJECT_ID=<project id>
PORTER_DEPLOY_CLUSTER_ID=<cluster id>
PORTER_SANDBOX_IMAGE=ghcr.io/porter-dev/qm-sandbox:latest
PORTER_DEPLOY_RUNNER_IMAGE=ghcr.io/porter-dev/qm-app-runner:latest
# PORTER_DEPLOY_URL=            # only when the API host is not https://dashboard.porter.run
# PORTER_DEPLOY_APPS_DOMAIN=    # optional; puts apps on sandbox ingress (see below)
# PORTER_DEPLOY_VISIBILITY=internal  # default: no ingress host, core reaches apps in-cluster;
                                     # private/public put apps on sandbox ingress, and public
                                     # serves them to ANYONE with the URL, bypassing qm's gate
```

## Onboarding checklist

Everything below this section is reference and field notes; this is the order that
actually gets a new operator from zero to a working instance (the deployment workflow's
agent-facing version lives at `cli/templates/deployment/references/porter.md`). If you are an agent doing
this deployment: install and prefer the `porter` CLI over raw API calls wherever a
command exists ([install instructions](https://docs.porter.run/cli/installation) — on
Homebrew that is the `porter-dev` tap, Homebrew core's `porter` is an unrelated tool, and
`docker-credential-porter` is a separate checksum-verified binary the tap does not
install); use
the operator's browser for the dashboard steps when you have browser access, and
otherwise hand the operator the exact URL and wait — several steps below cannot be done
any other way.

1. **A cloud account must be linked to the Porter project** at
   [dashboard.porter.run/cloud-accounts](https://dashboard.porter.run/cloud-accounts)
   before any cluster can exist. Check first — with a token,
   `GET <PORTER_DEPLOY_URL>/api/v2/alpha/projects/<project>/cloud-accounts` lists linked
   accounts and their connection state; without one, look at the dashboard page. If none
   is connected, the operator has to link one themselves: the flow grants Porter IAM
   roles via a CloudFormation stack (or the Azure/GCP equivalent) in _their_ cloud
   console, which no agent should drive unattended. Two AWS gotchas from the field: the
   quick-create link opens in `us-east-2`, so an org SCP that pins regions makes the
   stack fail with an explicit deny — reopen the same URL under the permitted region —
   and the account ID Porter wants is the one shown in the AWS console's own top-right
   menu, not whatever account the operator's CLI happens to be logged into.
2. **Mint an Admin-role API token** (Settings → API tokens) and record
   `PORTER_DEPLOY_PROJECT_ID` and `PORTER_DEPLOY_CLUSTER_ID`. The role matters: a
   Developer token survives every read until it dies mid-deployment with
   `PERMISSION_DENIED` (details under egress below) and cannot delete clusters. Check
   the role before provisioning anything you will later need to remove.
3. **Create the cluster with the sandbox load balancer in the creation contract** —
   sandbox ingress, `sandboxesEnabled`, and the apps root domain all belong in the
   contract the cluster is born with; attaching them later is the wedged-cluster
   forensics that "Giving published apps stable hostnames" below exists to debug. The
   Route53 hosted zone for the root domain is yours to create either way (dedicated
   zone, `NS` delegation in the parent, wildcard record inside — then reconcile; see
   below), and so is the LoadBalancer in front of the egress proxy ("Forcing sandbox
   egress through the proxy" — without it egress runs fail-open).
4. **Provision Postgres and set `DATABASE_URL` before first boot.** Sign-in is not
   optional here: the auth broker's single-use claim endpoint needs core's durable
   store, so an instance without a database accepts no sign-ins at all. The Porter
   path has no `qm`-CLI database provisioning — create a datastore in the dashboard
   (Datastores tab) or run in-cluster Postgres. A datastore created through the API
   can come back with `connected_cluster_ids` naming your cluster while its security
   group still refuses it — verify with a `psql` from a pod before booting, and
   prefer in-cluster Postgres when in doubt.
5. **Name the administrator before first boot.** Set `ADMIN_GRANTS=<email>:org_admin`
   on core and the sign-in allowlist (`AUTH_ALLOWED_EMAILS=<email>` — the Helm chart
   bridges it to the portal's `OIDC_ALLOWED_EMAILS`). This is the step the old docs
   never mentioned: with Postgres and no `ADMIN_GRANTS`, the instance boots, everyone
   allowed can sign in, and the admin console is permanently unreachable — there is no
   in-product way to grant the first admin afterwards.
6. **Build, push, and apply**: images built `--platform linux/amd64` and pushed to
   repositories that already exist (ECR does not create them on push — create or seed
   each repository first), then
   `for f in porter/apps/*.yaml; do porter apply -f "$f"; done` (twice — the hostname
   lands on the second pass), then wire the service URLs per the table below. In the
   v2 app YAML, `env` belongs at the app level: nested under a service it is silently
   dropped, and the app boots without its configuration. Verify by signing in as the
   admin and opening the Admin tab.

## Hosting the qm surfaces

`porter/apps/` declares four services (core, web-ui, portal,
egress-proxy) as one Porter app per file, built from `deploy/*/Dockerfile` — `porter
apply` takes exactly one app per invocation and silently ignores extra YAML documents,
which is why they are separate files:

```bash
for f in porter/apps/*.yaml; do porter apply -f "$f"; done
```

Porter runs the services and assigns each **public** web service an `onporter.run`
hostname with a Let's Encrypt certificate, so this path needs no DNS record, no TLS
certificate, and no ingress controller. Only the portal is meant to be Internet-facing;
core and web-ui carry `private: true` so Porter creates no ingress for them
and they are reachable only at their in-cluster address.

Two things `porter apply` will not do for you:

- **Building on an Apple Silicon machine fails.** `build.method: docker` builds locally
  for `linux/amd64` and the legacy builder cannot cross-compile: the apply dies with
  `image ... does not provide the specified platform (linux/amd64)`. Build and push with
  `docker buildx build --platform linux/amd64 --push` to the registry Porter already
  connected for the project (`porter registry list` prints it), then point the app at the
  pushed image with an `image: {repository, tag}` block. `porter apply --remote` builds
  server-side but is an opt-in project feature and fails with `remote build is not
enabled for this project` until Porter enables it.
- **Env groups created by `porter env create` are not visible to `porter apply`.** Both
  the `envGroups:` key and `--attach-env-groups` fail with `internal: unable to find
latest environment with provided name`, because `porter env create` makes a
  project-scoped group and apply resolves cluster-scoped ones. Put non-secret wiring in
  the app's `env:` block and pass secrets with `--secrets KEY=value` (note that pflag
  splits those on commas, so a JSON value like `AUTH_SIGNING_JWK` has to go in `env:`).

Nothing generates the inter-service wiring for you — `cli/src/services.ts` has `fly` and
`docker` targets but no Porter one — so set it by hand on each app:

| Service      | Wiring                                                                                                                                                                                                                                                                                                                                            |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| all          | `CORE_ORG_ID`/`ORG_ID`, `CORE_SIGNING_SECRET`, `PORTAL_IDENTITY_SECRET`                                                                                                                                                                                                                                                                           |
| all but core | `CORE_API_URL=http://qm-core-core.<namespace>.svc.cluster.local:8080`                                                                                                                                                                                                                                                                             |
| core         | `MODEL_PROVIDER`, `HARNESS`, `SANDBOX_BACKEND`, `PUBLIC_WEB_URL`/`WEB_UI_PUBLIC_URL` (the portal's hostname), `CAPABILITY_SECRET`, `CONNECTOR_SECRET_KEY`, `SKILL_SIGNING_SECRET`, `DATABASE_URL` and `SESSION_STORE=postgres` — production refuses to boot without them                                                                          |
| portal       | `PORTAL_PUBLIC_URL`, `PORTAL_SESSION_SECRET`, `WEB_UI_UPSTREAM`, `ADMIN_UPSTREAM`, `AUTH_BROKER_UPSTREAM`, `AUTH_BROKER_PREFIX=/idp`, the `OIDC_*` set from `brokerWiring` in `cli/src/services.ts` (including `OIDC_CLIENT_SECRET`), and `OIDC_ALLOWED_EMAILS` or `OIDC_ALLOWED_EMAIL_DOMAIN` — production refuses to boot without an allow-list |
| admin        | `ADMIN_BASE_PATH=/admin`                                                                                                                                                                                                                                                                                                                          |
| auth         | `AUTH_ISSUER=<portal>/idp`, `AUTH_CLIENT_ID`, `AUTH_CLIENT_SECRET`, `AUTH_REDIRECT_URI=<portal>/auth/callback`, `AUTH_TOKEN_SECRET`, `AUTH_SIGNING_JWK` (a P-256 private JWK), `AUTH_EMAIL_FROM`, and `AUTH_EMAIL_TRANSPORT` with a `RESEND_API_KEY` or SMTP credentials — without a mail transport nobody can sign in                            |

Core also reads `RESEND_API_KEY` and `AUTH_EMAIL_FROM` when they are set — optional there,
they let admins email external-user invitations from the admin Users tab or by chatting
with QM.

The admin module runs in web-ui at `/admin`; its environment belongs to web-ui.
The auth module runs inside portal: move its environment and secrets to portal,
set `AUTH_EMBEDDED=1`, and use `http://127.0.0.1:8099` for
`AUTH_BROKER_UPSTREAM` and the OIDC token, userinfo, and JWKS endpoint base.
Set `ADMIN_UPSTREAM` to the web-ui service URL followed by `/admin`.
See [the combined-service migration](combined-services.md) before upgrading existing apps.

`src/deployment/secret-schema.ts` is the authoritative list of what each service
requires; when a boot refusal names a variable this table doesn't, that file is the place
to look.

The portal's hostname is only knowable after its first apply, and core, portal, and auth
all need it, so expect to apply twice: once to mint the hostname, once to wire it in. The
CLI does not print the assigned hostname — read it from the app's page in the dashboard,
or from the ingress itself: `porter kubectl --print-kubeconfig` writes a short-lived
kubeconfig, and `kubectl get ingress` lists every assigned host.

Porter's own Postgres datastores are created from the dashboard. An RDS instance in the
cluster's VPC works too, but Postgres 17 defaults to `rds.force_ssl=1`, so the URL needs
`?sslmode=no-verify` (or a CA pinned with `DATABASE_CA_CERT`) or core dies on `no pg_hba.conf
entry ... no encryption`.

`deploy/helm/` carries the same topology as a Helm chart for operators who would rather
manage the release themselves; it expects the images published at
`ghcr.io/yc-software/qm/<service>` and an ingress you configure. Pin `image.tag` to a
commit that actually carries the Porter backend — the published tags are commit SHAs, and
one from before Porter support landed rejects `SANDBOX_BACKEND=porter` at startup. Set
`publicUrl` and the chart derives the broker wiring (`AUTH_ISSUER`, `AUTH_REDIRECT_URI`
and the portal's `OIDC_*` endpoints) the way `cli/src/services.ts` does for the other
targets; what it cannot invent is the identity allow-list, so production still refuses to
boot until `AUTH_ALLOWED_EMAILS`/`AUTH_ALLOWED_EMAIL_DOMAIN` is set, and the auth broker
needs a real mail transport (`RESEND_API_KEY` or the `SMTP_*` set) because there is no
console transport. `SANDBOX_BACKEND` has no default and core refuses to start in
production without it.

`porter kubectl --print-kubeconfig` is enough to read a Porter cluster but not to install
into one: the kubeconfig it prints authenticates as `porter-readonly-sa-customer`, which
holds `get`/`list`/`watch` and nothing else, so `helm install` fails on the first write. On
EKS, grant yourself real access through AWS instead — `aws eks update-cluster-config
--access-config authenticationMode=API_AND_CONFIG_MAP`, then an access entry for your own
principal with `AmazonEKSClusterAdminPolicy`, then `aws eks update-kubeconfig`.

## Giving published apps stable hostnames

App serving is tiered so the zero-config path works first. With nothing set, every
published app is reachable signed-in at `/d/<app>/` on the portal, private to its owner
and whoever the deployment is shared with; proxied HTML is served under a sandbox CSP so
app code cannot act on the portal's origin. Setting `DEPLOY_APPS_DOMAIN` to a wildcard
domain you control (e.g. `apps.example.com`, with `*.apps.example.com` pointed at the
instance) upgrades every app to its own origin — `https://<app>.apps.example.com/` — with
portal single sign-on, the request-access flow, and live editing; the portal derives its
cookie and returnTo settings from it, and boot probes the wildcard record and prints the
exact DNS record to add when it is missing. Point the wildcard at the ingress that fronts
core, never at Porter's sandbox ingress — the gate runs in core, so DNS that skips core
skips the gate. `PORTER_DEPLOY_APPS_DOMAIN` is the separate, ungated mechanism: it
registers each app's domain on Porter's own sandbox ingress and is deliberately NOT
defaulted from `DEPLOY_APPS_DOMAIN`.

By default published apps get no sandbox ingress host at all. Core reaches each app at the
cluster-internal address the cluster reports, so `/d/<app>/` and `DEPLOY_APPS_DOMAIN` work,
no Porter hostname skips qm's gate, and the cluster needs no sandbox ingress. This needs core
running in the same cluster as the sandboxes and sandbox-api 0.1.58 or newer. Apps published
before an upgrade keep their ingress host until they are redeployed. The rest of this section
applies only when `PORTER_DEPLOY_VISIBILITY` is `private` or `public`, or
`PORTER_DEPLOY_APPS_DOMAIN` is set (which implies `private`).

With ingress, published apps get their address from the cluster, not from qm. Declare the sandbox load
balancer with a root domain at cluster creation and **Porter names every published app
itself**: `<app>.<root domain>`, served on a wildcard Let's Encrypt certificate that Porter
also obtains. `PORTER_DEPLOY_APPS_DOMAIN` is then only for choosing a different name — leave
it unset and the cluster-assigned hostname is used as-is. Set it when the cluster serves
more than one apps domain, or when you want a name other than the cluster's own root domain;
it has to resolve to the same sandbox ingress either way.

The address comes from Porter's **sandbox ingress**, which is separate from the ingress
that serves Porter apps and is configured per cluster. A cluster without it rejects every
publish before DNS is ever consulted, with `HTTP 400: validation error: visibility: no
private sandbox ingress is configured on this cluster` (`no public sandbox ingress` when
`PORTER_DEPLOY_VISIBILITY=public`). A cluster that has it but no root domain names no host,
and the deploy fails after the body is created (the body is then retired, so nothing leaks).

The dashboard has a toggle for it, but it is also a plain field on the cluster contract,
so it can be turned on headlessly. `GET /api/projects/<project>/contracts` returns the
revisions for the project, newest last, each with a `base64_contract`; decode the current
one, add the sandbox load balancer, and `POST` the whole contract back to
`/api/projects/<project>/contract` (singular, raw protojson — not base64, not wrapped):

```json
{
  "kind": "LOAD_BALANCER_KIND_NLB",
  "owner": "LOAD_BALANCER_OWNER_SANDBOX",
  "networkAccess": "NETWORK_ACCESS_PUBLIC",
  "dnsProviderConfig": { "provider": "DNS_PROVIDER_TYPE_ROUTE53", "route53Domain": "" },
  "rootDomains": ["apps.example.com"],
  "waf": { "enabled": false }
}
```

appended to `cluster.additionalLoadBalancers`, alongside `cluster.eksKind.sandboxesEnabled:
true`. Route53 authenticates through the cluster's own pod identity and needs no token;
`DNS_PROVIDER_TYPE_CLOUDFLARE` needs one stored first at `POST
/api/v2/projects/<project>/clusters/<cluster>/dns/credentials`. The same route creates a
cluster in the first place — omit `cluster.clusterId` and Porter provisions a new one.

Note where that array hangs: `additionalLoadBalancers` is a field of `cluster`, a sibling of
`eksKind`, while `sandboxesEnabled` is inside `eksKind`. Putting the load balancer in the
obvious-looking spot next to `sandboxesEnabled` does not fail — Porter parses the contract
with unknown fields discarded, so a misplaced or misspelled key is dropped in silence and
the POST still answers `201` with a revision id. The cluster then provisions with no sandbox
ingress and the mistake only surfaces 40 minutes later, when the first publish is refused.
**Always read the contract back after submitting it** and confirm the fields you set are
actually there:

```bash
curl -s -H "Authorization: Bearer $PORTER_DEPLOY_API_TOKEN" \
  https://dashboard.porter.run/api/projects/<project>/contracts |
  jq -r '.[] | select(.cluster_id==<cluster>) | .base64_contract' | tail -1 | base64 -d | jq .
```

The same silent-drop rule makes the contract useless for guessing at fields the schema may
not have: an invented key and a real one are equally accepted. To test whether a field
exists, send it together with a deliberate control key that certainly is not a field, then
read back and compare which of the two survived.

Put the sandbox load balancer in the contract you create the cluster with, and treat every
later revision as a one-at-a-time operation: **submit a revision only when the cluster
reads `READY`, and let it finish.** A revision submitted while another is reconciling
supersedes it, and the interrupted one stops with `CONCURRENT_UPDATE` ("contract revision
is obsolete, and has been acked"). That path can strand the cluster in `UPDATING`, and
Porter gates both recovery routes on the status — a new contract is refused with `cluster
status forbids updating` and deletion with `unable to delete cluster that is updating`.
Cancelling the dead revision returns 200 without clearing it, so the cluster has to be
unstuck by Porter support.

A stranded cluster is not idle. Porter's Cluster API controllers keep reconciling it from
Porter's own account, so tearing its AWS resources out by hand does not work: delete the
EKS cluster and the nodes and they are rebuilt within minutes, by
`arn:aws:iam::<account>:role/porter-manager` with a `aws.cluster.x-k8s.io` user agent —
`aws cloudtrail lookup-events --lookup-attributes
AttributeKey=EventName,AttributeValue=CreateCluster` shows the recreate. The reconcile
loop enters the account through one door, so close that first: `porter-manager` and
`porter-access-manager` each trust `arn:aws:iam::108458755588:role/CAPIManagement` under an
external id, and dropping those two statements from their trust policies (`aws iam
update-assume-role-policy`) stops the rebuild immediately. Only then does a manual sweep
hold. **For a wedged cluster the IAM revoke comes first, not last** — the usual teardown
order that leaves IAM until the end cannot terminate.

Deleting a cluster through the API needs an admin-role Porter token: `DELETE
/api/projects/<project>/clusters/<cluster>` answers a project API token with `403
{"error":"insufficient permissions to perform action"}` even when that token may create
clusters. Check that you hold one before you provision anything you will need to remove.

The same status gate makes the contract endpoint useless for schema discovery on a wedged
cluster. Porter resolves the cluster and checks its status _before_ it parses the protojson
body, so an unknown field and a well-formed one come back with the identical `cluster status
forbids updating`, and a nonexistent `clusterId` returns `sql: no rows in result set` just
as uniformly — probing for whether a contract field exists needs a healthy cluster.
Once the revision reconciles, `GET
/api/v2/projects/<project>/clusters/<cluster>/load-balancers` returns the new
`owner: "sandbox"` entry whose `address.value` is what the wildcard record points at.

Routing, DNS and TLS all come up on their own, provided the sandbox load balancer was in
the contract the cluster was created with. Porter reads its `dnsProviderConfig` and
`rootDomains`, but **the hosted zone itself is yours to create** — confirmed against
Porter's team and a live cluster: even with the sandbox load balancer in the creation
contract, Porter never creates the zone, the delegation, or the wildcard record. Create a
dedicated Route53 hosted zone for the root domain, add its `NS` delegation to the parent
zone, put the wildcard record inside it pointing at the sandbox load balancer, then
reconcile the cluster (the dashboard's infra **Update**, or re-POST the contract) so Porter
creates the certificate issuer against the zone. cert-manager's DNS-01 challenge then has a
zone it can write to, and the wildcard certificate issues — a real Let's Encrypt
`*.<root domain>` certificate, verified by ordinary clients with no `-k`. A wildcard CNAME
sitting in the parent zone is not enough: DNS resolves, but there is no delegated zone for
the challenge and the certificate never issues.

Porter also mints the credentials for it: a per-cluster role
`porter-cert-manager-route53-<cluster id>` whose inline policy allows
`route53:ChangeResourceRecordSets` and `ListResourceRecordSets` on that one delegated zone,
plus `GetChange` and `ListHostedZonesByName`. If TLS is not issuing, check that this role
exists — its absence, not a cert-manager misconfiguration, is the usual cause.

Two things follow. The parent zone must be in the same AWS account the cluster runs in.
And **teardown has to remove what you created**: the delegated hosted zone and the `NS`
record in the parent zone both outlive the cluster.

This is worth stating plainly because the failure is so confusing: a cluster whose root
domain was never delegated has no usable zone for the DNS-01 challenge, so the certificate
never issues and the ingress serves nginx's self-signed _Kubernetes Ingress Controller Fake
Certificate_. Every client then fails certificate verification against an app that is
otherwise serving correctly — including the agent's own probe of the app it just published, which is why it
reports a gateway error it cannot explain. `kubectl get certificate -n porter-sandbox` tells
you which situation you are in before you go hunting.

Naming the domain is optional. Porter assigns every sandbox that exposes a port a hostname
of its own, `<sandbox name>.<cluster root domain>`, whether or not the create request asked
for a domain — sending `networking: [{port: 8080}]` with no `domains` key, or with
`domains: []`, both come back with a populated `host`. So a cluster whose sandbox load
balancer carries a root domain publishes apps at a working HTTPS address with nothing set:

```bash
PORTER_DEPLOY_APPS_DOMAIN=apps.example.com   # optional; overrides the assigned hostname
```

Set it only to choose the name yourself; the wildcard record it relies on is the one Porter
already created. `PORTER_DEPLOY_VISIBILITY=private` serves apps on the private sandbox
ingress, and `public` opts them into public ingress. Note the
cluster-assigned hostname is Porter ingress only — qm's sign-on gate, request-access flow,
and live editing need `DEPLOY_APPS_DOMAIN` (a domain you control, not a shared platform
domain like `onporter.run`).

## Forcing sandbox egress through the proxy

`PORTER_SANDBOX_EGRESS_PROXY_URL` makes core pass `egress.allowed_destinations: [<proxy
host>]` when it creates a sandbox, and inject `HTTPS_PROXY`/`HTTP_PROXY` pointing at the
proxy with a capability token as the password. Two cluster-side facts decide whether that
works at all, and both fail in ways that do not name the real cause:

- **The cluster must have egress restriction turned on.** Without it Porter rejects the
  create outright — `HTTP 400: validation error: egress: egress restriction is not
available on this cluster` — so the agent ends up with no computer rather than an
  unrestricted one. It is a per-cluster setting on the `sandbox-api` system application:
  `PATCH /api/v2/projects/<project>/clusters/<cluster>/system-applications/sandbox-api`
  with `{"sandbox_config":{"egress_enabled":true}}`, then `POST
.../trigger-system-application-reconcile` with `{"dry_run":false,
"system_application_name":"sandbox-api"}`. `GET` on the same path reads it back, and
  turning it on installs Cilium, which enforces the allowlist. There is no contract field
  for any of this — the cluster contract carries no egress key at all, so it cannot be set
  at creation time and this PATCH is the only route. The call is gated on the token's role,
  not on being a human: a Developer-role API token is refused with `PERMISSION_DENIED` even
  though it may create clusters, while an **Admin-role API token** (Settings → API tokens)
  is accepted. Minting an admin token is enough; no dashboard session is required. The same
  distinction governs `DELETE /api/projects/<project>/clusters/<cluster>`, which answers a
  Developer token with `403 insufficient permissions to perform action`.
- **The proxy has to be reachable from outside the cluster.** Porter attaches a
  NetworkPolicy to every sandbox that permits DNS plus `0.0.0.0/0` _except_ `10.0.0.0/8`,
  `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` and `100.64.0.0/10` — every RFC1918
  range, which is where both the pod and service CIDRs live. A sandbox therefore cannot
  reach an in-cluster Service, so a `*.svc.cluster.local` proxy URL times out with no
  error worth reading. Give the proxy an internet-reachable address (a `LoadBalancer`
  Service in front of it is enough) and point `PORTER_SANDBOX_EGRESS_PROXY_URL` there.
  This is also why `deploy/helm/`'s `egress-proxy` service cannot serve Porter sandboxes
  as-is: it is a ClusterIP worker.

The proxy itself runs fine unprivileged, but `deploy/egress-proxy` wants `NET_ADMIN` to
install the iptables rule that blocks the cloud metadata endpoint. Without the capability
it logs `could not install metadata firewall ... NET_ADMIN missing?` and keeps serving, so
grep for that line rather than assuming the container's own metadata block is in place.

## Other knobs

| Variable                                           | Meaning                                                                                                        |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `PORTER_DEPLOY_URL`                                | Porter API host, when it isn't `https://dashboard.porter.run`                                                  |
| `PORTER_SANDBOX_IMAGE`                             | Image agent computers boot from                                                                                |
| `PORTER_DEPLOY_RUNNER_IMAGE`                       | Image published apps boot from (defaults to the sandbox image)                                                 |
| `PORTER_SANDBOX_EGRESS_PROXY_URL`                  | Forces sandbox traffic through the egress proxy; unset means no egress enforcement (see the constraints below) |
| `PORTER_SANDBOX_NAME_PREFIX`                       | Prefix for sandbox and app names on the cluster                                                                |
| `PORTER_DEPLOY_VISIBILITY`                         | `internal` (default) serves apps in-cluster only; `private`/`public` put them on sandbox ingress               |
| `PORTER_DEPLOY_TTL_SEC` / `PORTER_SANDBOX_TTL_SEC` | Reap bodies after this long                                                                                    |

To QA a branch against a real Porter cluster before deploying it, the dev instance takes
the same backend:

```bash
bash scripts/dev-instance.sh up --sandbox porter
```

---

### Postgres Connections (`docs/postgres-connections.md`)

# PostgreSQL connection ownership

Ordinary queries, including the pg-boss cron queue, share the process-wide query pool for a database URL. When DATABASE_POOL_URL is configured, these queries use transaction pooling. Queue shutdown releases its pool reference without closing other stores' connections. Queue migrations retain pg-boss's single-call transaction boundaries.

Notification subscriptions share one direct connection per database URL per process. Session state, ledger events, run signals, and run streaming register their channels on that connection. Closing a consumer removes its subscription; the last unsubscribe closes the listener. Reconnection restores the active channel set before notifying consumers to resynchronize from durable state. Notifications remain wake-up hints, not durable storage.

Session advisory locks and leader leases continue to use direct session connections. Query pool sizes, PgBouncer backend limits, and database placement are unchanged. Multiple processes and separate company databases still have separate listeners and pools.

Validate notification delivery, reconnection, and teardown with test/postgres-listener.test.ts against a disposable DATABASE_URL. The race and queue lifecycle tests run with the standard module-mocking test command. scripts/test-pgbouncer.sh exercises queue startup, execution, shutdown, and restart through a one-backend transaction pool.

This removes three notification connections when all four channels are in use, plus pg-boss's independent query pool. It does not promise a fixed total connection count: active queries, session locks, deployment overlap, and pooler replicas still determine server usage.

---

### Principal Links (`docs/principal-links.md`)

# Principal links

A person can reach qm through several sign-ins: a Slack account, an email
magic link, or a trusted OIDC provider. Each produces its own principal id
(`jordan@acme.test`, `U0JORDAN`, `oidc:<issuer-hash>:<subject>`). Without a
link, qm treats them as different people: a credential dropped from Slack is
owned by the email principal, and the same founder signed in through OIDC is
told the link is for someone else.

A **principal link** records that two principal ids are one person. One id is
the **canonical principal**; the other is a **linked sign-in**. Every
same-person check in core (`personKey`, `samePerson`) folds a linked sign-in to
its canonical principal, so ownership of credentials, admin grants, sessions,
approvals, and secret drops recorded under either id belongs to the person.
New writes use the canonical id: the identity service classifies a linked
sign-in as its canonical principal, the portal resolves the session subject
before proxying, and the keychain stores credentials under the canonical owner.

## Rules

- **Administrative links keep the directory member canonical.** When the company runs Slack, the
  Slack-verified directory principal is the person's canonical id and the
  trusted OIDC subject is the linked sign-in. The admin API refuses a link
  whose linked side is a directory member, by principal id or Slack id.
- **A link never creates an admin.** A sign-in that holds an org admin grant
  can only be linked to a canonical principal that is already an admin.
- **Links are flat.** A canonical principal is never itself linked onward, and
  a linked sign-in cannot become canonical for others.
- **Evidence is required.** Every link records how the two identities were
  verified as one person and which admin created it.
- **Links are reversible.** Deleting a link restores two separate principals.
  Records written while the link existed stay under the canonical id, so a
  wrong link is an incident to reconcile, not just a row to delete.
- **Deactivation follows the person.** Deactivating either id deactivates both.

## Admin API

Org admins manage links through the admin surface; the agent cannot.

```
GET    /v1/admin/principal-links
POST   /v1/admin/principal-links      { principalId, canonicalId, evidence }
DELETE /v1/admin/principal-links/:principalId
```

Surfaces resolve a sign-in with a source-authenticated read:

```
GET /v1/principals/:id/canonical   → { principalId, canonicalId }
```

The portal caches the answer for one minute and refuses to proxy (503) when
core cannot answer, rather than acting as the unresolved subject. A deleted
link therefore stays effective for at most a minute of cached sessions.

Links live in the `principal_links` durable map of the company database.

## Self-service Slack linking

The dedicated Connect Slack flow preserves the signed-in web principal as canonical, keeping its existing connector ownership. Completion verifies a short-lived signed ticket, Composio account ownership, Slack user identity, and the installed company workspace before linking the directory member. Existing credentials or Composio accounts on the Slack principal require support instead of being silently stranded. Email similarity is not proof of identity.

---

### Sandbox Preservation (`docs/sandbox-preservation.md`)

# Sandbox recovery

See [prebuilt Modal images](modal-image.md) to prepare system dependencies and the
connector SDK before creating user sandboxes.

The agent runs in core. Sandbox home state is working state with a provider-specific recovery window. Publish code to git and deliverables to Files when they must remain durable and accessible independently of compute.

`MODAL_NATIVE_SNAPSHOTS_ENABLED` defaults to false. First deploy this reader-compatible release with native capture disabled; keep portable checkpointing until every active core and the retained rollback release can read native checkpoints. Enable the flag only in a later deployment after that condition holds. Once a scope has a native checkpoint, disabling the flag continues its native capture and recovery; it does not revert that scope to stale portable state. After activation, rolling back to releases without the native reader is unsupported and can lose working state.

When enabled, Modal uses native directory checkpoints of `/root`, mounted into a fresh sandbox when the current sandbox is replaced. It does not stream the home through core during routine checkpointing or rollover. The base image supplies system dependencies outside the home; a home checkpoint does not preserve processes, RAM, or system-wide package installs. Runtime credential files under `/tmp` are outside the directory checkpoint; ordinary home files and symlinks are included.

`MODAL_NATIVE_SNAPSHOT_INTERVAL_SEC` defaults to 300 and controls checkpoint throttling independently of the legacy `MODAL_SNAPSHOT_INTERVAL_SEC`. Used-turn teardown, replacement, and idle reaping capture checkpoints. The sandbox maintenance sweep runs every five minutes whenever background maintenance is enabled and either idle window is positive; it checkpoints active machines when due, including machines running background work. A machine within thirty minutes of Modal's 24-hour sandbox lifetime is checkpointed and terminated by the sweep even while background work runs, because Modal would otherwise kill that work unsaved; the next provision restores the checkpoint into a fresh machine. This is best-effort recovery, not a guarantee that a process running into the provider deadline has saved its latest writes. Captures while processes write are not application-consistent database backups.

`MODAL_SNAPSHOT_RETENTION_SEC` defaults to 2592000 (30 days) and must be positive. Checkpoint IDs, capture timestamps, errors and expiration times are stored with sandbox records. `DATABASE_URL` must point to durable Postgres to retain these references across core restarts; without it the sandbox record map is memory-only. Status reports `recovery.checkpointExpiresAtMs`; using a checkpoint does not renew its expiry, because Modal retains a directory snapshot for a hard cutoff measured from creation. The maintenance sweep therefore renews the checkpoint of a reaped idle scope once less than seven days (or half the checkpoint's lifetime, whichever is shorter) remain: it restores the checkpoint into a fresh machine, captures a new checkpoint, and terminates that machine again, so an idle user never loses a home to retention alone. Renewal holds the same lifecycle lock as provisioning and leaves the scope's idle history untouched. Expired or failed native restores stop with an explicit recovery error instead of replacing the home with an empty directory or silently using an older portable backup. A core crash during hydration leaves a durable pending marker; another core refuses to expose that potentially incomplete home until an operator confirms the original core is no longer hydrating and invokes `sandbox` with `action: "restart"`. Restart terminates only the incomplete replacement, retains checkpoint references, and clears the pending marker; the next provision retries that retained checkpoint. It does not archive or promote the incomplete home. Handled restore failures can retry after successful cleanup of the failed replacement.

E2B creates persistent machines with an explicit pause-on-timeout lifecycle and pauses them at teardown unless `keepWarm` retains running background work. See [E2B sandbox template](e2b-template.md) for building the sandbox image.

The provider timeout is managed, not left to expire under work. Every command first extends the sandbox timeout to cover the command plus a one-minute margin when the remaining lifetime is shorter, `E2B_SANDBOX_TTL_SEC` defaults to the largest command timeout plus that margin instead of fifteen minutes, and `keepWarm` teardown extends the timeout to `BACKGROUND_JOB_TTL_MAX_SEC` so background processes survive as long as the background-work contract promises. Every extension is capped by `E2B_MAX_LIFETIME_SEC` (default 86400, E2B's Pro-plan maximum; Hobby plans must set 3600). If a sandbox is nevertheless lost while a command is running, the command is not re-run: it may have partially executed, so the turn receives an explicit error and the next command reconnects. A sandbox found gone before a command starts is reconnected or replaced transparently, as before.

Routine native pause does not create a tar backup. Instead, used-turn teardown captures a provider-native persistent snapshot (E2B `createSnapshot`, an image that survives deletion of the sandbox) when one is due under `E2B_NATIVE_SNAPSHOT_INTERVAL_SEC` (default 300), then pauses. Only the newest snapshot per scope is kept; the superseded one is deleted after the new capture succeeds, and destroying the scope deletes the snapshot too. The capture briefly pauses the sandbox and drops any open connections, which is why it runs only at teardown and never while a command is in flight. Status reports the snapshot as `recovery.checkpointId` with no provider expiry. When the paused sandbox itself is gone, the next provision creates a replacement from that snapshot; a scope with no snapshot still requires explicit recovery rather than an automatic blank replacement. Snapshot capture failures are recorded as the recovery error and reported, and the pause still proceeds.

Provider pause retains disk and normally memory; provider fallback may retain disk only, so reconnectable processes must not depend on RAM surviving. Status can inspect a paused machine without waking it and, for a running one, reports CPU, memory and disk usage from the provider's metrics. Pause failures are recorded and returned, with the running machine retained for retry. Existing E2B machines whose lifecycle does not confirm pause-on-timeout retain legacy portable checkpointing under `E2B_SNAPSHOT_INTERVAL_SEC`.

With `E2B_EGRESS_PROXY_URL` set, sandboxes are created with E2B network rules that deny all outbound traffic except the proxy host, and existing sandboxes receive the same rules when core reconnects, so the `HTTPS_PROXY` environment cannot be bypassed from inside the sandbox. E2B matches allowed hostnames by SNI on port 443 and by Host header on port 80 only, so the proxy must listen on one of those ports or be given by IP; core refuses to start with a hostname on any other port rather than silently blocking all egress. Without the setting, sandboxes have unrestricted egress and the profile reports no enforcement.

Sprites keep the whole disk across sleeps, so the sprite itself is the durable home. Every used-turn teardown that may have changed the home takes a provider checkpoint (copy-on-write, throttled to one per five minutes per core); `computerStatus` reports the newest one as `recovery.strategy: "provider_snapshot"`. Checkpoints are the rollback path: when a computer restart is refused and the health check positively reports an unhealthy status, restart restores the newest checkpoint and records `checkpoint_restored`, which discards changes made since that checkpoint. Deleting a sprite is irreversible and deletes its checkpoints with it, so with `SPRITES_SNAPSHOT_S3_BUCKET` set a scope's home is exported as a portable tar before its sprite is destroyed, a failed export refuses the destroy, and a replacement sprite for that scope hydrates from the export. Initialization is recorded as pending before provider creation in a durable map, and provision, capture, restore and destruction share a cross-core lifecycle lock. A failed initialization remains pending across core restarts and cannot overwrite a saved home; a later provision retries hydration. Failed or unknown health observations never authorize checkpoint rollback. Background processes started with process sessions hold the sprite awake through the Tasks API while they run; they still do not survive a cold wake, a restart or a checkpoint restore. `SPRITES_MEMORY_MB` sets an explicit memory resources policy per sprite; without it memory is provider-managed. Commands run over the WebSocket exec channel and files move through the filesystem API.

Smolmachines machines keep their disk across stop and start, and an idle machine stops after `SMOLMACHINES_AUTOSTOP_SEC` seconds without activity when that knob is set (the provider sweeps every 15 seconds; an in-flight exec keeps the machine alive). The provider documents that stopping is not a backup and that the cloud API has no machine snapshot; export to a `.smolmachine` artifact is the only native capture and it is a whole-disk registry artifact, so core keeps portable home tars instead. Every non-scratch teardown snapshots the home to `SMOLMACHINES_SNAPSHOT_S3_BUCKET` when the home changed, throttled by `SMOLMACHINES_SNAPSHOT_INTERVAL_SEC`; a replacement machine for a scope hydrates from that tar and a failed hydration deletes the incomplete replacement instead of adopting it. Initialization is marked pending in the durable sandbox record before create, so a lost create response or core restart cannot make the next core skip restoration. Provisioning, capture, and destruction share a lifecycle lock across cores; initialization is only marked complete after hydration succeeds. Status reports `recovery.strategy: "workspace_snapshot"` with the last capture time and error. Without the bucket the backend offers no `persistHomeSnapshot` and reports no recovery. Scratch machines are created ephemeral with a 24-hour `ttlSeconds` so a core crash cannot leave them running.

Explicit home export/import and `persistHomeSnapshot` continue to use the portable tar format for cross-provider moves and chosen recovery. Configure `MODAL_SNAPSHOT_S3_BUCKET`, `E2B_SNAPSHOT_S3_BUCKET`, `SPRITES_SNAPSHOT_S3_BUCKET` or `SMOLMACHINES_SNAPSHOT_S3_BUCKET` to retain those portable copies durably. They are not silently selected instead of a failed newer provider checkpoint.

Modal machines are created with a one-core, 2 GiB reservation (`MODAL_CPUS`, `MODAL_MEMORY_MB`), optional placement (`MODAL_REGIONS`, a comma-separated list; place machines beside `MODAL_SNAPSHOT_S3_BUCKET`), a 24-hour lifetime, and a provider idle timeout of twice `MODAL_REAP_IDLE_SEC` (12 hours by default) as a backstop when no core is sweeping. Every machine carries `qm-org`, `qm-prefix`, and `qm-kind` tags; the sweep lists running scope machines by tag and terminates any that no sandbox record or live session has claimed for ten minutes, so machines orphaned by store loss stop billing without operator action. Scratch machines are excluded because other cores hold them only in memory.

When `MODAL_EGRESS_PROXY_URL` is set, machines are created with Modal's outbound allowlist limited to the proxy host (a domain allowlist for an https proxy on port 443, a CIDR allowlist for an IP address; other proxy URLs are rejected at startup because Modal domain allowlists pass only TLS on 443), and the profile reports `egressEnforcement: "domain"`. Command execution, file transfer, and checkpoints travel over Modal's control plane and are unaffected. Without the setting, core warns that Modal sandboxes run fail-open.

Turn environment reaches Modal commands through the exec `env` parameter rather than inlined `export` statements, exec deadlines are rounded up to whole seconds with a single thirty-second grace beyond the in-guest `timeout`, and a command longer than 64 KiB is written to `/tmp` through the filesystem API and executed from there.

Managed Agents sessions name a sandbox template explicitly, which yields a microVM carrying no managed agent loop, and the disk persists across the provider's pause and resume. Used-turn teardown calls `POST /v2/agents/sessions/{id}/checkpoints` when the home changed, throttled by `DO_AGENTS_SNAPSHOT_INTERVAL_SEC` from the last successful capture, then snapshots the home as a tar on that same interval measured from the last successful tar. The tar goes to `DO_AGENTS_SNAPSHOT_S3_BUCKET` when it is set; without it the tar is held in core's memory and does not survive a core restart. A failed tar is retried without taking another checkpoint. The checkpoint is the provider's machine-state capture of the live session (`CreateCheckpoint` blocks until the capture is `READY`); only the newest is kept, and the previous one is deleted after the new capture succeeds. A delete that fails stays recorded and is retried on a later teardown. A capture failure is recorded on `recovery.error` and the pause still proceeds. A pause failure is recorded as `pause_failed` and the running session is retained for retry. Status reports `recovery.strategy: "workspace_snapshot"` plus `recovery.checkpointId`. Restart of a wedged computer calls `POST /v2/agents/sessions/{id}/checkpoints/{checkpoint_id}/rollback` with that stored checkpoint: the session id stays, the response's sandbox id replaces the stored one, and any open port-forward is closed so the next command dials the restored microVM. A paused session is rolled back directly, without being resumed first. Restart with no checkpoint fails and leaves the session where it is. A checkpoint is bound to its session, so a replacement session for a destroyed or reclaimed scope still hydrates from the portable tar when one exists, and a failed hydration kills the replacement rather than adopting a blank home over a saved one. Session lifecycle travels over the Managed Agents REST surface with a DigitalOcean IAM token; commands and file transfer travel over a bearer-authenticated WebSocket port-forward to the guest's `sandbox-agent` gRPC server, which has no request deadline, so command length is bounded by `SANDBOX_TIMEOUT_SEC` rather than by the control plane. Sessions are created with a permissions block that allows `bash` outright, because Managed Agents would otherwise hold every command at its own approval gate behind core's.

---

### Sandbox Resources (`docs/sandbox-resources.md`)

# Sandbox resources and defaults

A sandbox is an independently recorded resource with an owning scope, provider, backing identity, and lifecycle state. Creating a sandbox provisions a blank machine without copying files or changing a default. Setting a default changes routing only. Background jobs retain the target on which they started.

Sandbox resources are always on. The `sandbox` tool provides lifecycle management; `execute` is the sole command launcher and `background` controls existing processes. `list` returns the available providers and supported actions. Unsupported provider operations fail explicitly. Files publication is separate from sandbox management.

`execute` accepts `sandbox_id` for both foreground and background commands. Without a target, execution requires the scope's stored default. `set_default` accepts an ID or null; null clears the default. A new scope has no default. Retiring a sandbox requires clearing its default and stopping its jobs first. Retirement is available only for providers with direct scope deletion. It never provisions or restores a machine. Once retirement begins, execution stays blocked; interrupted or failed cleanup remains visible and can be retried. Retirement deletes its working state, so durable outputs should be published to Files or git beforehand.

In Isolated posture, agent inventory and operations stay in the current owning scope. In Open posture, authenticated human turns and owner-authorized scheduled turns can also target the acting person's personal sandboxes and Open shared sandboxes where they are a current member. Access is rechecked for each operation; changing conversations does not transfer ownership or grant other participants personal access. Cross-context execution uses the target's existing workspace without copying the calling conversation's credentials, files, or capability tokens onto it. Defaults remain local to their owning scope. Cross-context commands must satisfy both the calling conversation's and target sandbox's command policies. Network access is narrowed to satisfy both egress policies. Provider-native recovery status includes its own expiry; a sandbox record is not an indefinite backup guarantee.

## Agent actions

| Action              | Parameters                                                        |
| ------------------- | ----------------------------------------------------------------- |
| `list`              | `purpose`; returns providers, capabilities, resources and default |
| `create`            | `backend`, optional `name`, `purpose`; creates a blank resource   |
| `set_default`       | `sandbox_id` or null, `purpose`; changes routing                  |
| `status`, `restart` | optional `sandbox_id`, `purpose`                                  |
| `retire`            | `sandbox_id`, `purpose`                                           |

Commands use `execute(command, sandbox_id?, credentials?, background?)`. Set
`background:true` for a detached job and a process ID. The short `purpose` is saved
with the job and displayed instead of the command; the raw command remains available
in expanded output. `timeout_seconds` is the foreground wall-clock timeout or the
background job lifetime. Background jobs require scoped, durable execution; disposable
scratch, owner-auth and reached-room routing remain foreground-only.

The `background` tool controls existing jobs with `poll`, `send_input`, `stop`, `list`,
`watch`, and `unwatch`. Process IDs retain their durable saved target even after the
scope default changes. Watches retain their durable monitor registration and wake this
conversation with output or exit. Provider loss or expiry can still interrupt a process.

Execution and process handlers retain existing command-policy, credential authorization,
and output-screening checks. Strict approvals use the called tool identity (`execute`,
a process action such as `background:poll`, or a lifecycle action such as `sandbox:retire`); a sandbox grant cannot
authorize commands. Transcript entries identify the called tool. Removed sandbox command
and process actions and `background start` are not aliases. No database migration or
backfill is needed; existing jobs remain controllable by process ID. Interrupted old tool calls may return an unsupported-action error and require agent recovery with the new tools.

## Upgrade and rollback

Resources and explicit defaults are always enabled. At startup, a one-time, locked import adopts old routing entries, known session scopes, and provider records. It preserves existing defaults (including an explicit null), resource IDs, and original backing identities. It makes no provider calls or disk copies. Inferred resources start unverified; the completion marker is written last, so an interrupted import can be retried. Before writing adoption state, the import refuses any newly selected default whose provider is not configured, naming the affected scope and provider. Installations with the existing `explicit-defaults` marker have already completed this import.

The old routing table is retained only as upgrade input. It is never read when selecting a computer, and there are no routing-table write or migration endpoints. `SANDBOX_SCOPE_BACKENDS` is accepted only to interpret old bindings during the import; remove it afterwards. `SANDBOX_RESOURCES_ENABLED` no longer changes behavior.

Before upgrading from a release without resource-aware readers, drain its cores and workers. Rollback must use a resource-aware release that honors the permanent completion marker. A missing default after import means no default, not permission to recreate an old scoped computer.

Scoped commands require an explicit target or selected default. Disposable scratch and owner-auth computers use the selected computer's provider without reusing its disk or resource identity; with no selection they use the installation provider, without creating a default. An unavailable selected provider fails explicitly rather than substituting another provider.

To move work between providers, create a computer, restore the required work from git or Files, verify it, and select it as the new default. Selection does not copy files or migrate running jobs.

---

### Session Sharing (`docs/session-sharing.md`)

# Session sharing

The Share button beside a session title creates a read-only copy of its visible messages, replies, and attachments. Every creation produces a fresh link. Existing links keep their original content; there are no update or revoke controls.

Choose **Anyone in your organization** to require a signed-in internal account, or **Anyone with the link** to allow viewing and downloading without signing in. The latter is a bearer link: recipients can forward it. External links use the deployment's public portal origin; localhost links work only on the machine running the dev instance.

The server constructs the snapshot from an allowlist of user messages, final assistant replies, successful published replies, and their attachments. Thinking, intermediate text, tool commands/results, hidden messages, raw payloads, and source metadata are never sent to the shared webpage. Successful attachment delivery is projected to file metadata only. Text and attachments can themselves contain sensitive information; choose the audience accordingly.

Each attachment is authorized for the creator and copied into separate durable share storage. Share-specific file identifiers authorize only that snapshot's copied files. Downloads cannot fetch arbitrary session files. Raster images may render inline; other types download with a sandbox policy. Shared markdown cannot embed remote media or private resource links.

Snapshots live in the durable `session_shares` map, keyed by an unguessable random token. Copied bytes live in the configured durable byte backend under `session-shares`. Both internal and external reads verify that the creator is still internal and can access the original entries. If that access disappears, the share becomes unavailable.

The public portal permits only external share pages, their file downloads, and built static assets without sign-in. It forwards no browser cookies or identity headers on those routes. The web server calls the dedicated signed core projection endpoint; it never loads the private session API. Shared pages use an isolated frontend entry with no session fetches, no-store responses, no-referrer, noindex, and a restrictive content security policy. External pages use the production build even during development.

---

### Skill Registry (`docs/skill-registry.md`)

# Skill registry (Phase A)

Import skills from a **git repo of `SKILL.md` files** into the governed skill store — no code change,
no image rebuild. An admin registers a _pack_, browses it, and imports skills **org-wide**; the
skills then show on the admin Skills page and materialize onto boxes through the normal path.

## How it works

```
git pack ──fetch(pinned ref)──▶ general normalizer ──▶ planIngest (classify) ──▶ importPack
                                                                                      │
                                                          create→review→publish (shared upsert)
                                                                                      ▼
                                                          org-scope SkillStore ──▶ materialize ──▶ box
```

- **One general normalizer** (`src/skills/normalize.ts`) maps any repo's frontmatter dialect onto the
  canonical manifest via an alias table + ignore-unknowns + safe defaults. **No per-repo code** — a
  repo's specifics are declarative `config` on the pack (`skillGlobs` / `exclude` / `fieldOverrides`).
- **Eligibility** (org import): a skill is imported only if its scope hint is org-shareable
  (`company` / `both` / `org` / `shared` / …). Excluded with a visible reason: `scope` (personal/missing),
  `private` (owner-only flags or a `THE-AGENT-ONLY` body), `collision` (name already owned by a
  different pack / native seed), `binary-asset`, `malformed`.
- **Pinned** in Phase A: an install is frozen at the pack's `ref`. "Update" = bump the ref + re-import
  (idempotent; unchanged skills are skipped, changed ones re-published, upstream-removed ones archived
  on `remove`). The sync engine (tracked mode) is Phase B.
- **Org scope only** in Phase A (team scope needs resolution work — Phase D). Authz: any `org_admin`.

## Admin API (`/v1/admin/skill-packs*`, org_admin, audited)

```
POST   /v1/admin/skill-packs              register { url, ref, config?, authCredentialSlug?, trustTier? }
GET    /v1/admin/skill-packs              list packs + last-import status
GET    /v1/admin/skill-packs/:id/catalog  browse (planIngest: candidates + eligibility + counts)
POST   /v1/admin/skill-packs/:id/import   { selected: "all" | string[] }
PATCH  /v1/admin/skill-packs/:id          edit ref / subset / trustTier / config
DELETE /v1/admin/skill-packs/:id          remove pack + archive its imported skills
```

Or use the **Admin UI → Skill packs** tab (register form, browse with select-all/pick, import, remove).

## Registering a private pack

First vend a read-only deploy token as an org service credential, then register the pack at a pinned
commit.

1. **Vend the deploy token** (Admin UI → Governance → Shared service credentials, or
   `PUT /v1/admin/scopes/org:<org>/service-credentials`): create a credential with a slug like
   `skills-repo-token` whose secret is a GitHub token with read access to the repository.

2. **Register the pack** (Admin UI → Skill packs → Register, or `POST /v1/admin/skill-packs`):

   ```json
   {
     "url": "https://github.com/example/acme-skills",
     "ref": "<pinned commit SHA>",
     "authCredentialSlug": "skills-repo-token",
     "trustTier": "third-party"
   }
   ```

   Personal/missing-scope/private skills and any name colliding with a native skill are excluded
   automatically and shown with their reason in the catalog.

3. **Browse + import**: open the pack, review the candidate list (≈ the company/both skills, minus
   the `publish` collision), and **Import all**. The imported skills appear on the **Skills** page as
   `published`, `createdBy: pack:<id>`, with `pack.commit` provenance.

4. **Updating**: bump the pack `ref` (PATCH) and re-import. Removing the pack archives its skills.

The skills are governed store records, visible and auditable on the admin Skills page, rather than
ungoverned files on each box.

## Loading published skills

The skill index lists each visible skill. The agent's `skills` tool resolves the currently
visible, screened published skill and returns its SKILL.md, or a relative manifest file
named by `path`, without creating or waking a sandbox. Unavailable skills and invalid
paths return no file.

When a skill ships supporting files or pack bundles, the same call writes them to
`.agent-skills/<conversation>/<name>/` on the computer (packs under
`.agent-skills/<conversation>/.packs/<id>/`) and reports that absolute path; the body's own
`skills/<name>/` references are rewritten to it. The copy stays across turns and each read
overwrites it with the published version. When a turn first prepares the computer, it
removes every copy of a skill that turn's speaker cannot see, so archived, revoked, blocked,
or another person's skills do not linger on a shared computer. Publish changes through the
skill API to update the source.

---

### Superserve (`docs/superserve.md`)

# Superserve agent sandboxes

The `superserve` backend runs each QM scope in a sandbox with a persistent workspace,
file transfers, and background process sessions. Paused sandboxes resume on use.

## Configure

Build and verify an [agent template](../superserve/templates/README.md), then set:

```sh
SANDBOX_BACKEND=superserve
SUPERSERVE_API_KEY=your-superserve-api-key
SUPERSERVE_TEMPLATE=qm-agent-0.1.0
SUPERSERVE_NAME_PREFIX=my-qm
```

Store the API key as a core service secret. Production requires `DATABASE_URL` for
sandbox records, provisioning locks, and configuration ordering across instances.
Use a distinct name prefix for each independent deployment sharing a Superserve
team, including development instances.

For CLI deployments, add these fields to the deployment config and supply the API
key through the CLI's secret configuration:

```json
{
  "sandbox": { "backend": "superserve" },
  "env": {
    "core": {
      "SUPERSERVE_TEMPLATE": "qm-agent-0.1.0",
      "SUPERSERVE_NAME_PREFIX": "my-qm"
    }
  }
}
```

`env.core.SANDBOX_BACKEND` overrides `sandbox.backend`. Agents and admins select Superserve per
scope by creating a Superserve sandbox and setting it as the default.

For local development, export the key and template and run
`npm run dev-instance -- --sandbox superserve`.

## Sandbox capacity

Sandboxes inherit CPU, memory, and disk capacity from their template. The agent
template builder defaults to 8 vCPUs, 16384 MiB (16 GiB) memory, and 32768 MiB
(32 GiB) disk. Override these with `--vcpu`, `--memory-mib`, and `--disk-mib` at
build time; there are no runtime resource-size settings. Existing sandboxes are
not resized by updating QM or rebuilding a template. Follow the [capacity rollout steps](../superserve/templates/README.md#increase-sandbox-capacity)
to build and verify a new template, preserve resident work, and replace sandboxes.

## Settings

| Variable                       | Default     | Purpose                                                                            |
| ------------------------------ | ----------- | ---------------------------------------------------------------------------------- |
| `SUPERSERVE_API_KEY`           | Required    | Team API key, held by core.                                                        |
| `SUPERSERVE_TEMPLATE`          | Required    | Ready template name.                                                               |
| `SUPERSERVE_BASE_URL`          | SDK default | API endpoint override.                                                             |
| `SUPERSERVE_NAME_PREFIX`       | `qm`        | Namespace for scope discovery.                                                     |
| `SUPERSERVE_HOME_DIR`          | `/root`     | Guest home; workspace is `<home>/workspace`.                                       |
| `SUPERSERVE_IDLE_PAUSE_SEC`    | `900`       | Active-time limit before pause, not an inactivity timer.                           |
| `SUPERSERVE_RETENTION_SEC`     | `2592000`   | Delete the sandbox after this many continuous seconds paused (30 days by default). |
| `SUPERSERVE_EGRESS_ALLOW`      | Unset       | Comma-separated outbound allow rules.                                              |
| `SUPERSERVE_EGRESS_DENY`       | Unset       | Comma-separated outbound deny rules.                                               |
| `SUPERSERVE_CONFIG_GENERATION` | Automatic   | Explicit rollout sequence.                                                         |
| `SANDBOX_TIMEOUT_SEC`          | `600`       | Default command deadline in seconds.                                               |

Ending a turn leaves its sandbox running until the active-time limit, so other
turns sharing it can continue. Background work can extend that limit to QM's
configured maximum background job lifetime. Scratch sandboxes are deleted when
their last local handle closes, with time limits as a cleanup fallback.

Command output is limited to 2 MiB per stream; write larger results to files.
Guest restart and the optional browser engine are not supported.

## Updates and retention

QM rediscovers sandboxes by scope metadata after a restart and checks their actual
egress policy before reuse. Changing the template replaces existing scope
sandboxes and deletes their resident files. An egress change can also require
replacement when a paused sandbox cannot accept the update. Export needed files
before either change.

A sandbox that remains paused for `SUPERSERVE_RETENTION_SEC` is automatically
deleted, including its disk. Explicit scope destruction also deletes the disk.

Automatic configuration ordering assigns a generation to each build and
configuration. Older instances cannot overwrite a newer sandbox configuration.
A rollback reuses the earlier generation: to apply it, set
`SUPERSERVE_CONFIG_GENERATION` to a strictly increasing rollout sequence and
continue using that sequence for subsequent deployments. Template and egress
changes during rollback have the same replacement behavior described above.

## Tests

Backend tests run without an account:

```sh
node --experimental-test-module-mocks --test test/superserve-*.test.ts
```

The template verifier requires an API key and creates a temporary sandbox.

---

### Swarms (`docs/swarms.md`)

# Agent swarms

A swarm coordinates ordinary QM sessions. The initiating session is its root;
workers have their own durable transcripts, runs, identity, and editable JSON
character/context. The existing authenticated session viewer lists worker sessions
with a `Swarm worker` title. Worker transcripts remain read-only for ordinary messages in the web UI, but the requesting human can allow or deny pending approvals there. Discovery returns their session IDs and portal-relative
`sessionUrl` links. Session activity and results use the normal viewer and run APIs.

## Deployment

Use Postgres for production (`DATABASE_URL`, `SESSION_STORE=postgres`, and
`RUN_STORE=postgres`) (sandbox resources are always on). Configure a sandbox backend with its required
credentials and deployment-specific settings. Workers use the provider of the scope's selected computer, or the deployment default when none is selected. An initial
`backend` override chooses another configured provider that supports creation and
retirement. That choice is stored once; later default changes never move workers
to another provider, and the selected computer's files are never copied.

Swarms are also gated per person by the `swarms` feature flag, off by default.
Enable it for a person's personal scope (for example `personal:alice@example.com`)
or the org scope in the admin feature-flag table. Without it the swarm routes refuse
the caller, `/v1/apis` omits them, and existing swarms owned by that person stop
reconciling and delivering. `SWARMS_ENABLED=false` remains the deployment kill switch.

The `swarms` durable-map table has its own registered migration,
`durable-map/swarms/0001`. Apply registered migrations through the normal QM deploy
path before starting workers. Memory stores implement the same contract for tests;
they are not a production durability substitute. Swarms are unavailable with
mixed memory/Postgres stores so execution leases and writes share one authority.

## Agent API

These routes appear in `/v1/apis` and work from every harness that can use QM's
self-API. Use the existing `x-agent-capability: $AGENT_API_TOKEN` header. Swarm
operations require a token bound to the current session and a real durable run.
An agent using a token minted during a human turn is still an **agent**, not a
human author. Request bodies cannot set identity, authorization, or provenance.

### Spawn one or a pool

`POST /v1/swarm`:

```json
{
  "action": "spawn",
  "requestId": "initial-pool",
  "count": 3,
  "contexts": [
    { "group": "implementation", "role": "planner" },
    { "group": "implementation", "role": "worker" },
    { "group": "implementation", "role": "reviewer" }
  ],
  "text": "Coordinate a small implementation. Report findings through swarm messages."
}
```

Any spawn — initial or later, by the root or by a worker — may choose the
model for the workers it creates with optional `model`, `effort` (a thinking
level) and `fastMode`, e.g.
`{"action":"spawn","requestId":"r1","count":8,"model":"gpt-6.1-sol","effort":"medium","fastMode":false,"text":"..."}`.
The choice is stored on each new member (visible in inspect), applies to every
turn that worker takes, and is inherited by workers it spawns unless they choose
again. Workers do not copy the root's runtime: fields nobody chose resolve like any
subagent turn, from the org subagent default and then the owner's default. An
unavailable model fails closed at dispatch.

Optional `settings` overrides the defaults below at initial spawn. The backend
can configure defaults with `SWARM_DEFAULTS`, a JSON object such as
`{"agents":16,"turnMs":900000}`. Values must be positive safe integers within the
safety bounds; unknown settings are rejected. Resolved settings and the chosen `backend` are stored once. Identical
initial requests can be retried. Later spawns by the root session may pass
`settings` again to change any setting except `turnMs` (for example, to raise
`agents`); workers inherit the settings and cannot reconfigure the swarm. The
backend cannot change after creation.

Omit `count` to spawn one; `contexts` also determines the count when provided. A
single `context` supplies the same initial JSON to every worker. Context may be any
JSON value, not just a role object. `contexts` must match the requested count.
The root is enrolled on the first spawn; workers can recursively call the same API.

The response is `202` with reserved members. A durable outbox provisions their
computers, creates sessions, adds the original participants, and queues initial
turns. Discovery shows `reserved`, `ready`, or `failed` and any provisioning error.
Restarts resume the same reservations rather than allocating another computer.
Provisioning gets three attempts. Terminal failures retire owned computers and
remove empty sessions; unresolved cleanup remains recorded and retried. Failed
reservations still consume the finite lifetime agent budget, preventing unlimited
provider creation/retry loops.

Each worker gets a dedicated **blank** computer owned by the parent's
authorization scope. No parent files are copied. Data access, keychain policy,
memory resolution, and approval grants continue through QM's existing scope and
identity rules, with unattended rather than human-attended permissions. Session
grants belong only to the session that received them, not its workers. A computer's backing storage key
is not an authorization principal. Existing provider persistence behavior is
unchanged; swarms add no filesystem snapshots, immutable copies, or restores.

Every swarm gets one shared board computer in the swarm scope, created with the
first worker. `inspect` returns it as `board.sandboxId`, and every worker carries
it as `forumSandboxId`; the root session and all workers reach it with
`execute`'s `sandbox_id`. Board creation is part of each worker's provisioning and
fails that worker like any other provisioning failure. The board outlives worker
cleanup so its files remain available to the root. To use an existing computer instead,
supply `forumSandboxId` naming an authorized sandbox in the same scope; no board is
then created. Every worker still gets its own blank private computer.
The forum ID appears in peer metadata and the worker prompt; select it explicitly
with `execute`'s `sandbox_id` for commands that should use the shared computer.
This is not a new filesystem synchronization feature. Workers using a forum share
that disk and must coordinate concurrent writes themselves. Failed worker creation
never retires the existing forum.

### Identity, peers, and character

`GET /v1/swarm` returns `id`, `self`, `peers`, `backend`, `settings`, and `expiresAt` when a work window is set. Peer
records keep trusted IDs, ancestry, session links, storage selection, and lifecycle
state separate from arbitrary `context` data. Only this swarm is listed, not every
session with the same owner, and never all private sessions in the organization.

`POST /v1/swarm` with `{"action":"context","context":{"role":"reviewer"}}`
replaces the caller's own context. There is no update-other-peer operation. The
character is supplied to the harness as untrusted metadata on subsequent turns.
Setting fields such as `id`, `scopeId`, or `depth` in context changes no authority.

### Messages and audience selection

`POST /v1/swarm`:

```json
{
  "action": "send",
  "requestId": "review-request",
  "audience": ["member-id"],
  "text": "Please check the proposed change and reply with your findings."
}
```

The response includes a durable message `id` and monotonic per-swarm `seq`.
`audience` is either a list of recipient IDs or the string `"all"`. IDs are
validated against eligible, ready peers. Duplicates collapse into one recipient;
reordering IDs does not change a retry. `"all"` includes every eligible member,
including the sender. An empty list records a shared message without waking anyone.

Messages record intended recipients, author kind, authenticated actor, sender
agent identity (`senderId`), ordinary QM session (`senderSessionId`), and optional
`replyTo`. Other authorized swarm members can read
them even if they were not in the intended audience. Root and worker participant
rosters must remain the original roster; changes fail closed. Current scope and
identity authorization is rechecked before access and execution.

Notification state is persisted as `pending`, `queued` with a durable `runId`, or
`failed`. A queued notification means the run was accepted, not that the agent has
finished. Idle recipients wake through the normal durable worker queue. Active
recipients receive queued unattended turns, not forged human steer signals.
Notification enqueue uses a stable deduplication key so a crash between enqueue
and acknowledgment does not enqueue another run. Agent-only turns have no Slack
delivery target, and the delivery layer also refuses swarm result delivery.

Set `notify:false` for a durable message without a wakeup. Sending to oneself does
not wake oneself. There are no automatic reply-to-reply notifications: agents
explicitly decide whether a response warrants another turn. When `notifications`
is set, every generated turn consumes that budget.

### Worker goals

A worker turn normally ends when the worker replies, and the worker then waits
for its next message. To keep a worker working, its parent can attach a goal to
`spawn` or `send`:

```json
{
  "action": "send",
  "requestId": "lane-goal",
  "audience": ["member-id"],
  "text": "Work through every archive for your lane.",
  "goal": { "objective": "Exhaust the lane's archives", "floor": { "minMs": 3600000 }, "token_cap": 2000000 }
}
```

The goal is installed as the worker session's goal when that turn starts, and the
turn runs under the normal goal loop: the worker is prompted to continue until the
goal's governor approves completion or pauses it, or an optional `token_cap` is
spent. A turn that carries a goal runs without the swarm `turnMs` deadline; other
turns keep it. `floor` and `token_cap` take the same keys as the goal tool, and
neither is required. Only a parent can assign goals, and
only to its own workers; a goal needs `notify`. The worker treats its parent as
the user for the goal and messages it when it needs a decision. Each goal message
installs a fresh goal once (a retried delivery does not reinstall it), so sending
a goal again replaces a paused or finished one and starts a new token budget.

While a goal turn runs, messages sent to that worker do not wait for the turn to
end. The turn checks for the worker's queued messages after each tool call (at
most every few seconds) and each time the worker ends a reply, including the reply
that would end the turn. It screens them like a mid-turn message and hands them
over in order, on the tool result or with the next prompt. When the turn returns
without being stopped or cancelled, it marks them handed in the swarm and
withdraws their queued turns, so they never run again; if the turn fails or is
stopped first they stay queued and run as their own turns. A goal in one of them replaces the running goal at once,
so a parent can redirect a busy worker; its work floor counts from when the
message was sent. A message the screen blocks stays queued, with every message
after it, and runs as its own turn later.

A worker blocked on another agent can call goal action update `"wait"` with a note
naming what it waits for. The goal's clock stops and the call returns with the
next message that reaches the worker, resuming the goal, or after 30 minutes so the
worker can reassess.

### Ask, read, and tail

`GET /v1/swarm?read=1&after=0&waitMs=0` returns at most 32 messages. Advance `after`
to the last `seq` to tail without rereading prior pages. `waitMs` may be at most
the stored `settings.waitMs` (10,000 by default); a timeout returns an empty
`messages` array, not a fabricated response.

To ask, send a message, keep its `id`, then read with `replyTo=<id>&waitMs=10000`.
The responding agent sends with `replyTo` set to that ID. Replies can use
`notify:false` when the requester is already waiting. Waiting does not hold a
swarm transaction, provision lock, or other agent's run lease. It does not promise
an answer: an active recipient may finish its current turn first, and mutually
waiting agents time out rather than holding each other indefinitely. Prefer
asynchronous work over nested waits when the worker pool is saturated.

`requestId` is required for spawn and send, at most 128 bytes. Retry the **same
payload and requestId** to get the original reservation/message. Reusing a key
with different content fails. Context updates are simple replacements.

## Human API

Human callers use `GET` and `POST /v1/sessions/:id/swarm` through the existing
source-authenticated API with a signed portal identity. The caller must already
be allowed to view that session and belong to the swarm scope. Request actor
and provenance selector fields are rejected rather than ignored. Arbitrary keys
inside `context` remain permitted. Bodies and read parameters match the
agent API. Initializing a root from this endpoint additionally requires a `runId`
belonging to that session and actor. Human-origin actions are separately recorded;
their asynchronous notifications still run unattended.

Agent operations require credentials bound to the exact session, run attempt, and
unexpired execution lease. Swarm writes recheck that lease inside their database
transaction; credentials from replaced attempts cannot authorize new work. Completed, failed, queued, and expired runs cannot authorize agent swarm
operations. Authenticated human initialization may reference a historical run.
Worker sessions never inherit the root session's command or security-screen
approval grants; a worker must obtain its own approval. Auto screening receives host-verified swarm delegation provenance, so routine delegated tasks and in-swarm reporting are distinguished from external instructions. Delegation does not authorize credential disclosure, permission changes, or overriding higher-priority instructions; those remain screened. Frozen swarm roster checks
apply to swarm notifications, not ordinary human follow-ups in the root session.

Each outbox sweep selects at most 16 pending swarms and reconciles resources for at
most four concurrently, advancing through pending pages so failed cleanup cannot
monopolize the batch. Notification delivery uses a separate four-slot pool and
per-swarm lock, so ready recipients can receive work even when all resource slots
are occupied. Each phase has a 30-second reconciliation deadline and each provisioning
attempt has a 10-second deadline. A timed-out worker fails without receiving work;
its private-resource cleanup remains durable and retries on later sweeps, including
after a late provider completion. The explicitly shared forum is never retired by
worker cleanup. Unsettled operations retain their execution slot and swarm lock
until their side effects finish; timeout never permits overlapping cleanup or
unbounded provider calls. A later process can retire stale provisioning after the
original process releases its locks or exits. Providers that never settle leave
cleanup pending rather than being treated as successfully cleaned up; four stuck
resource operations exhaust only this instance's resource capacity until a slot is
released. Pending selection remains single-flight until its underlying database
query settles, even when a sweep reports a selection timeout.

### Worker controls

`POST` with `{action:"control",memberId,state}` sets a worker to `paused`,
`active`, or `stopped`. Humans in the swarm may control any worker; an agent may
control only itself or its descendants. The root session cannot be controlled.
Controls apply to descendants too. Pausing holds new notifications as pending
until the worker is resumed; runs already queued continue. Stopping is terminal:
pending notifications fail, unstarted queued runs are withdrawn, running turns are
aborted, and later turns in that worker session are refused. `inspect` reports each
peer's `control`. The web UI shows a Swarm strip on swarm sessions: a collapsed summary of
active, starting, paused, stopped, and failed workers; when expanded, workers
grouped under their parent (20 per group, with "show more"); per-worker Pause,
Resume, and Stop; and Pause all, Resume all, and Stop all (with a confirm), which
apply this action to the root's direct children so it cascades. The strip
refreshes every few seconds only while workers are active and the tab is visible.

## Defaults

| Setting                                          | Default              |
| ------------------------------------------------ | -------------------- |
| `agents` (root and failed reservations included) | 32                   |
| `depth` below root                               | 4                    |
| `contextBytes` / `textBytes`                     | 8,192 each           |
| `spawnRequests`                                  | unset (no limit)     |
| `messages` including initial work                | unset (no limit)     |
| `notifications` including initial work           | unset (no limit)     |
| `lifetimeMs` from first spawn                    | unset (no limit)     |
| `turnMs` per notification                        | 600,000 (10 minutes) |
| `waitMs` per read                                | 10,000               |

The root and at least one worker require `agents >= 2`. All other settings accept
any positive integer; `turnMs` is limited only by the 2,147,483,647 ms timer ceiling. Read requests can still use `waitMs:0`.
`spawnRequests`, `messages`, `notifications`, and `lifetimeMs` are off unless a
spawn or `SWARM_DEFAULTS` sets them. When set, they are enforced as budgets that are
reserved atomically with the members and messages that consume them. Both the
orchestrator and independent worker cancellation honor the stored turn deadline;
existing organization-wide limits can shorten it. Internal reconciliation limits,
the 32-message read page, and bounded run retry counts are not swarm
configuration.

When `lifetimeMs` is set and the window passes, history remains readable, but new
work, notifications, and character changes are rejected until the root extends
`lifetimeMs` on a later spawn. Swarms without `lifetimeMs` never expire. Human-initiated
ordinary session turns remain ordinary QM actions.
Workers and their durable results remain visible; computers can be retired
through the existing sandbox inventory. There is no automatic
filesystem export on retirement: publish useful outputs through existing Files
or your version-control workflow first.

## Verification

Run the focused suites with `node --experimental-test-module-mocks --test test/swarm*.test.ts`.
Run `npm run test:pg` against a disposable Postgres database. The swarm suite uses
a separate schema so migration-reset tests cannot invalidate its state.
Set `SWARM_TEST_DATABASE_URL` when running `test/swarm-orchestrator.test.ts` to
exercise the HTTP spawn/reply flow across an application restart with real durable
state. These tests use deterministic model and sandbox doubles; live provider and
model acceptance remains a separate deployment check. The agent-board UI is
intentionally deferred.

Operators can set `SWARMS_ENABLED=false` to disable the swarm service, API,
agent discovery, and background reconciliation. Existing swarm records and
computers are retained. Already queued swarm turns fail without model execution
and are not retried. The default is `true`. Restart core and workers after changing
this deployment setting.

---

### Upgrading Runtime (`docs/upgrading-runtime.md`)

# Runtime upgrade compatibility

Pin the runtime source or image digest and deployment CLI together. Run migrations
before serving traffic, then qualify that exact candidate against an isolated copy
of the existing database. Keep the previous images and deployment configuration
available until the new revision has passed operational checks.

## Configuration changes

- `qm sandbox publish` and the `sandbox.image` runtime pin are retired.
  `qm sandbox build` remains available for validating local layer builds;
  `sandbox.baseImage` identifies their build input. Supply sandbox tools through
  deployment layers and configure the selected backend through its supported
  settings.
- `SANDBOX_SECONDARY_BACKEND` is retired. `SANDBOX_BACKEND` selects the primary
  backend; additional configured backends become available through their
  credentials and backend settings. Remove the old secondary setting.
- The `sandbox_routing` table, `SANDBOX_RESOURCES_ENABLED`, and the sandbox
  migration endpoints and agent action are retired. Sandbox resources are always
  on; scoped execution uses the selected sandbox resource. Disposable computers use
  that resource's provider, or the installation provider when no resource is selected. On upgrade,
  existing routes and `SANDBOX_SCOPE_BACKENDS` mappings are imported once as
  sandbox records so existing computers are preserved; afterwards
  `SANDBOX_SCOPE_BACKENDS` has no runtime effect. To move a scope to another
  provider, create a sandbox there, copy durable work via git or Files, and set
  it as the default. Keep credentials and provider settings for all legacy mappings
  until the import completes; an unconfigured provider needed by the import stops
  startup before any adoption state is written.
- Reach-denied Slack notifications are retired. Consult the audit log for denied
  requests.

## Database upgrades and rollback

Migration identifiers and SQL checksums retain their original meaning. An existing
checksum mismatch stops the upgrade; do not clear the migration ledger or edit its
checksums to bypass this check. A previously completed legacy webhook sweep is
adopted into the checksum ledger without disabling webhooks that an operator has
subsequently re-enabled.

The cron journal imports existing `cron_fire_log` history into `cron_fires` and
continues importing writes from older workers during a rolling upgrade. History
retention also removes expired entries from the legacy table, preventing a later
restart from importing them again. The legacy table remains available for older
binaries.

Upgrading from a release that predates the `items` table copies session history
into `items` during startup migrations, then drops `session_entries`, `session_tape`
and the old search table. Stop every older core and worker first: they cannot run
against the new schema. `src/sessions/legacy-history-migrations.ts` keeps those released
migrations verbatim; delete it once every supported deployment has applied
`sessions/store/0036-items-cutover`. Upgrading from a release that still writes `session_tape`
first copies its step metadata into `legacy_tape_steps`, then drops the tape. Stop every
older core and worker before starting the new release: a tape write after the copy
starts makes the drop refuse, and that startup fails with the reset to run. The copy
runs on the direct migration connection without a statement timeout and resumes
after an interrupted startup. It walks every session at roughly 30 sessions per
second, so a few thousand sessions take a couple of minutes; above that, run
`node src/migrate-main.ts` with the release's configuration before starting the
servers so readiness checks do not time out. Instances that start meanwhile wait for the copy on
the migration lock.

Sessions created before QM recorded each session's origin get `origin` and `origin_id`
from their thread reference during startup migrations. The backfill commits 5,000
sessions at a time, resumes after an interrupted startup, and fills roughly 10,000
sessions per second; with more than a few hundred thousand such sessions, run
`node src/migrate-main.ts` with the release's configuration before starting the servers
so readiness checks do not time out.

Rolling back the runtime restores code and configuration, not database contents.
Older binaries do not display cron history written only to the new journal; those
records remain stored in `cron_fires`. Validate both old and new readers against the
candidate database before promotion, and retain a database recovery point for any
rollback that requires restoring data as well as code.

---

### Qa Fly Published Apps (`docs/qa/fly-published-apps.md`)

# Fly published application acceptance

Status: final-image HTTP/runtime and private-network qualification passed. Production SSO/browser qualification remains outstanding; existing deployments are not approved for migration yet. Automatic suspension is verified with variable timing, not a fixed deadline. See the final entries below for the immutable image and measured results.

The Fly adapter must retain QM's application access controls and durable application data while delegating idle suspension and request-driven wake-up to Fly Proxy. Existing deployments remain on their current provider until the following checks pass against the candidate source and a real QM instance.

| Area           | Required live evidence                                                                                                             |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Publishing     | Publish through QM, open the resulting URL, verify actual application responses                                                    |
| Authentication | Signed-out, unrelated-user and unrelated-company denial; authorized-user success; direct-origin bypass denied                      |
| Idle and wake  | Observe automatic suspension with zero active Machines, then wake through the normal QM URL; repeat with concurrent first requests |
| Cold start     | Force a stopped state, then verify request-driven boot and data recovery                                                           |
| Persistence    | Write SQLite and file data, verify after suspend, stop, update, rollback and replacement                                           |
| Updates        | Publish v2 and roll back to v1 without losing data; failed entrypoint must preserve the working version                            |
| Transport      | Streaming responses, WebSockets, uploads, cookies, redirects and proxy headers                                                     |
| Lifecycle      | Always-on enable/disable, explicit stop/restart, deletion, core restart and overlapping core instances                             |
| Isolation      | Company credentials cannot inspect, modify or delete another company's apps; application code cannot reach sibling networks        |
| Capacity       | Bounded concurrent publishes and wake-ups; retry partial failures without orphaning resources                                      |

Record candidate commit, image digest, timestamps, HTTP assertions, machine states, data checks and cleanup in private receipts. Unit tests and direct provider calls are supporting evidence, not end-to-end acceptance.

The following entries record the implementation and QA chronologically; later entries resolve earlier blockers.

The prototype adds Fly service autostop/suspend, autostart and a zero minimum. The application reaper must not delete provider-managed sleeping apps. AWS-to-Fly company-isolated private connectivity remains unresolved.

Observed live results from the initial prototype:

- Publish v1 succeeded with private Flycast ingress.
- A tunnel on the application's custom network could not reach its Flycast address allocated on the organization network. Using a tunnel on the matching organization network returned HTTP 200. Production connectivity still needs a company-isolated path from AWS.
- Forced suspension was confirmed through the Machines API. A normal request through Flycast resumed it in 0.37 seconds, preserving a file counter.
- Replacement publish v2 succeeded, but the file counter reset from 1 to 0. This is a failing acceptance check; the existing ephemeral filesystem cannot provide durable application data across replacement.
- Automatic idle suspension was observed after about five minutes. A request resumed the app in 0.83 seconds and preserved the counter. This used a private Flycast tunnel, not the complete QM request path.

Set `FLY_DEPLOY_DATA_VOLUME_SIZE_GB` to a positive integer to opt into persistent `/data`. The prototype creates one encrypted volume per application, updates the attached Machine in place, and retains the volume on archive. It refuses to silently convert existing ephemeral applications or downgrade a volume-backed app when configuration is missing. Volumes are host-local; this is not replicated storage or high availability. Machine replacement must retain the existing volume, and host-loss recovery needs separate qualification.

The direct live volume fixture passed file and SQLite persistence after an update, failed-entrypoint rollback, explicit version rollback, cold stop/request wake, and archive/recreation. Follow-up tests passed both always-on toggles and recovery after confirming a broken configuration had stopped, then recreating the provider. The fixture uses a private file-backed configuration store; application wiring uses Postgres, which still needs full QM qualification. Unit tests cover accepted-create response loss, missing volume configuration, failed configuration persistence, rollback after provider recreation, and always-on changes.

The first live always-on toggle from suspension failed: updating the Machine configuration left it stopped. Treat configuration acceptance and a running application as separate conditions. The candidate now requests a start when either the update response or subsequent readiness polling reports stopped/suspended. The successful retry verified the toggle, live data, and persisted rollback recovery. Durable mode requires Postgres in application wiring; an in-memory configuration map does not qualify restart recovery.

A possible credential arrangement is one pre-created Fly app per company with an app-scoped token and multiple deployment Machines. The proposed routing uses a distinct raw TCP external port for each deployment. This requires live wake/routing tests and durable port ownership before it is accepted. No company credentials or production provider settings have changed for this prototype.

An app-scoped token passed direct Machines API operations but failed `flyctl proxy`'s organization lookup. Do not distribute an organization-wide token to work around this. Network policies and a separately provisioned private tunnel are being evaluated for company-isolated ingress. Policies do not filter Fly Proxy traffic, so direct-ingress restrictions alone cannot establish isolation. The API also rejects an ingress rule with no allowed ports; do not assume an empty rule means deny-all.

The live network fixture established direct private-IP access to port 8080, then denied that access with an ingress policy allowing only managed SSH on port 22. Flycast continued serving the fixture and its data. A separately provisioned custom-network WireGuard peer and userspace wireproxy tunnel also returned HTTP 200 without a Fly API token at runtime. This was one local tunnel, not an AWS core deployment.

Design review identified two further requirements. Prefer a distinct raw TCP external port per deployment over the HTTP force-instance header: an untrusted app can emit Fly replay instructions when the HTTP handler is enabled. Port assignments need durable collision-free ownership. Also give simultaneously running core tunnels distinct WireGuard peers; sharing one peer causes endpoint roaming between replicas. Exclusive peer assignment and blue-green overlap remain unimplemented and unqualified.

A live two-Machine fixture in the same Fly app passed six raw TCP routing assertions: plain responses, `fly-replay` headers, and replay JSON each stayed on the assigned deployment port, both with and without a forged `fly-force-instance-id` request header targeting the sibling Machine. Replay instructions were returned as ordinary bytes; sibling application data was not returned. The initial requests ran before the new service was ready and received empty replies; after a successful plain-response baseline, all six assertions passed. This supports raw TCP port routing, but does not qualify port allocation, concurrent wake-up, QM authentication, or overlapping core tunnels.

The same two Machines were then confirmed suspended and received 16 concurrent requests split evenly between their raw TCP ports. All 16 returned the correct application response, both Machines started, and the durable fixture retained its file and SQLite counters. Requests completed in 0.56–0.67 seconds; total request wall time was 0.675 seconds. This is a two-Machine private-tunnel measurement, not a fleet throughput estimate or a full QM cold-start measurement.

The provider now has an optional shared-app mode requiring a durable configuration store, durable port claims, and persistent volumes. Machine metadata selects the owning deployment; each deployment keeps its volume and port across archive and restore. Tests cover sibling preservation through update, always-on, archive and restore, plus an occupied initial port and provider recreation. Set `FLY_DEPLOY_SHARED_APP_NAME` to select this mode; wiring supplies Postgres-backed port and configuration maps. The shared app and private ingress must already exist. Private transport integration remains unfinished, and this mode has not been exercised through QM.

Independent review found no additional sibling resource deletion or port collision defect, but rejected a database lease alone as fencing for WireGuard peer reuse: a paused core can leave its tunnel child alive after the database releases the lock. Peer reuse must wait for authoritative previous-task termination or equivalent fencing. Acceptance must include a paused core plus server-side database-session termination. Shared provisioning must also establish and verify the separate ingress network and direct-Machine ingress restrictions; the provider's private-IP check alone does not establish this boundary.

The shared provider itself passed a live two-deployment fixture: parallel publish assigned distinct ports, a file/SQLite write in one deployment left its sibling unchanged, archiving that deployment left the sibling serving, and provider recreation restored the original port and persisted data. This fixture uses file-backed maps and the existing local tunnel; Postgres-backed QM and AWS task lifecycle remain unqualified.

The peer-claim prototype now uses sticky ECS task ARN ownership, exact STOPPED verification, and atomic compare-and-swap on reclamation. Unit tests cover concurrent tasks, stale observations, missing records and lookup errors. Independent review accepted the cross-task fencing premise for a tunnel contained in a Fargate task, but requires one manager per task and trusted task metadata. Integration must persist STOPPED evidence promptly or replace stranded peers because ECS task history is temporary. This claim helper is not yet connected to a running tunnel or fleet rollout scripts.

Peer claims can now retain confirmed termination evidence and reuse it after ECS history disappears; tests ensure that evidence is cleared on reassignment and cannot mark a replacement owner stopped. The collector still needs runtime scheduling and rollout recovery integration. The local tunnel lifecycle passed two start/serve/stop cycles against the live shared provider, preserving file and SQLite data and rejecting an occupied listener. This does not yet prove task identity, singleton behavior across process failure, or ECS rollout handoff.

QM HTTP deployment proxy and agent fetch now support the local SOCKS tunnel. A live route fixture reached the shared Fly app and preserved data: unsigned requests returned 401, unrelated actors 404, owners 200, and the ordinary proxy 200. Authorization decisions were stubbed, so this proves transport and route enforcement, not production SSO or real scope ACL acceptance. The affected proxy/fetch and peer-claim tests passed (29 tests).

Runtime wiring now constructs a lazy Fargate tunnel manager from `FLY_DEPLOY_WIREGUARD_PEERS`, requires private connectivity for shared-app mode, and persists SOCKS routing on deployment endpoints. Task-termination collection runs while the tunnel manager is active. The core Dockerfile builds wireproxy v1.1.2 in a separate stage; that image has not yet been built or deployed. Hostname resolution through the private tunnel passed the live route fixture. ECS startup, task failure and deployment overlap still need live qualification.

Runtime review fixes now have tests: concurrent requests restart a confirmed-dead tunnel once; termination collection starts with core runtime even without application traffic; and peer claim IDs derive from the actual X25519 public identity, preventing duplicate private-key identities or label changes from bypassing ownership. The initial local core-image build is in progress; the final source must be rebuilt before image qualification.

The local ARM64 core image built successfully after the review fixes (`sha256:3b5e80d716c48902f06d580bf73fdef0116d29b84e8dd547cb215be1fb208b4f`). Its wireproxy executable runs. The pinned v1.1.2 source reports its upstream hard-coded `1.0.8-dev` banner when built without release linker flags; source/module provenance, rather than that banner, identifies this build. This image is not yet an ECS candidate or a production release.

A standalone ARM64 Fargate task passed private transport QA with source `ce4b53eaad246ab2733fdf98ef160c24ceda8ff6` and ECR digest `sha256:15fdf763ed76e0e6376fec44087a20da405d027c934dcaf438451b85e6c94144`. It verified its exact running ECS task record, started wireproxy inside Fargate, resolved the private Flycast hostname and read the expected file/SQLite data. It exited with code 0, reached STOPPED, and its task definition was deregistered. This used the tunnel helper directly, not the full QM runtime or shared Postgres claims; overlapping core-task ownership remains to qualify.

The live Fargate overlap fixture passed against disposable Postgres. An observer confirmed core A was SIGSTOP-paused while its tunnel remained in the task; B terminated A's database sessions, acquired a different peer and served requests while A remained RUNNING. After ECS confirmed A STOPPED, C reused A's peer and served requests while B continued on its original peer. The first attempted pause ran Node as PID 1 and did not pause; it was discarded and repeated with ECS init enabled and an independent process-state observer. Full QM login/ACL and published-app acceptance still remain.

The deployment contract now requires `FLY_DEPLOY_WIREGUARD_PEERS` when shared Fly publishing is selected, in both CLI secret discovery and runtime validation. AWS core task roles gain cluster-scoped `ecs:DescribeTasks` for peer reclamation. The Fly token check uses the shared application's Machines list, avoiding an organization-list permission requirement for app-scoped credentials. All 155 affected config, secret, Terraform and Fly CLI tests passed; typecheck and lint passed. These source changes have not been applied to company infrastructure or included in the earlier candidate image.

A full core runtime in Fargate passed HTTP API acceptance using the immutable candidate above and a fresh disposable Postgres sidecar. The test invoked real `src/index.ts`, published through `/v1/deployments`, and exercised persisted ACLs: signed-out access returned 401, an unrelated user received 403, and that user could not archive the app. Owner access, file/SQLite writes, a 1 MiB upload, two separate SSE chunks, redirects and cookies passed. Publishing v2 and archive/restore preserved both counters. The successful run took 54.6 seconds from the QA process start, excluding ECS provisioning/image pull. The first run incorrectly expected unrelated-user denial to be 404; its 403 response was valid and the assertion was corrected before the successful rerun. Both runs archived their test apps and their exact orphan volumes were removed. This test used signed test identities, not production SSO; browser login, cross-company isolation, WebSockets and idle/wake through QM remain acceptance work.

Independent comparison with deployed source `208086ab` found WebSockets unsupported on the existing published-app path: neither core nor portal has an upgrade handler, and AWS app subdomains still enter core's authentication/proxy path. The Fly candidate does not remove an existing WebSocket path. Record this as a product limitation rather than a Fly regression; raw provider WebSocket support alone would not establish QM support. The QA app-scoped token also passed own-app Machine listing (200) and was denied a separate app's Machine listing (403); this is a control-plane credential check, not proof of cross-company network isolation.

The full runtime idle test observed the exact deployment Machine suspended before eight concurrent authenticated QM reads; all succeeded with intact file/SQLite counters in 763 ms. With no further application requests, automatic suspension was observed after 324.3 seconds, and the next authenticated QM request resumed it in 622 ms with both counters intact. This used the actual Fargate core proxy and Postgres-backed provider wiring. A separate local dev-instance browser check on branch source loaded the signed-in web Apps and admin Apps surfaces; it used a disposable local database and development identity, not production SSO or the Fly backend. Local Docker disk exhaustion was worked around with a QA-only tmpfs database, and that dev instance was stopped after inspection.

The published-code network probe passed with positive controls: the trusted ingress served the target app and the attacking Machine reached its own listener, while that Machine could not connect to its sibling's private address, the private ingress Flycast address, or the original organization-network Flycast address. A second QA company then used its own app network, separate ingress network, private Flycast address, app-scoped token and WireGuard peer. Both trusted ingresses served their own canary; A's ingress could not reach B's Flycast address and vice versa. Code executing in A could reach its own listener but neither B's private Machine nor B's ingress. Both publisher tokens received 403 for the other app's Machines API. The canaries contained no company data or application credentials.

Final review fixes preserve the latest ephemeral publication when always-on changes, retain legacy idle cleanup for existing standalone Fly apps, and persist changed always-on settings only after successful version application. Regression tests cover failed redeploy and rollback in both setting directions. Shared publishing now verifies dedicated nondefault ingress networks and all-Machine TCP22-only policies; narrowed selectors are rejected. Fleet provisioning additionally binds the exact company ingress network, rejects existing published Machines pending explicit migration, and validates that trusted ingress contains only a recoverable initializer.

After removing the QA app's obsolete default-network Flycast address, the trusted ingress still served version 2 with file and SQLite counters intact. Published-code probes could reach their own loopback listener but could not reach a sibling Machine or the trusted ingress address. This verifies address cleanup independently of final-image acceptance. Production SSO/browser qualification and the rebuilt final candidate remain outstanding.

The rebuilt image from source `6d51832edfa442019ee03005c2f0c8d0c3b73c35` (`sha256:e7a431a6232b533fadedd8a43fb6b374cfdeef0835b178d78a9ed4b3cc68dce4`) passed publishing, persisted ACL checks, upload/streaming and eight concurrent requests after forced suspension (871 ms). It failed the automatic-suspension assertion: the Machine remained running for the entire 360-second observation window. The task exited 1; its test Machine, exact disposable volume and task definition were cleaned up. Preserve this failed acceptance result; the earlier successful idle measurement does not qualify the rebuilt candidate. Investigate idle timing and active connections before activation.

A diagnostic run of the same immutable image extended observation without changing the production runtime. The disposable app added passive connection-count logging. Automatic suspension occurred after 536,868 ms (8m57s), and the subsequent request woke it in 987 ms with data intact. Update and archive/restore persistence passed afterward. ECS independently records both containers exiting 0 and the exact candidate digest. Twelve captured application-connection samples were zero; they cover part of the idle interval rather than continuous observation. The Machine, its exact volume, and the task definition were cleaned up. Independent review checked the success and earlier failure receipts. This establishes eventual automatic suspension and request-driven wake, with variable timing; it does not establish an upper bound or erase the earlier six-minute failure. A separate single-Machine comparison in a separate prepared company app passed both trusted peers and observed automatic suspension after 376,319 ms (6m16s). The comparison Machine was deleted, and live inventory confirmed the app empty afterward. This simpler fixture supports timing variability; it does not replace the full-runtime checks or establish a deadline.

---

### Qa Resource Search Demo (`docs/qa/resource-search-demo.md`)

# Cmd+K resource search demo

Build two self-contained HTML demos without starting the application:

```sh
npm ci
npm --prefix plugins/web-ui ci
node docs/qa/resource-search-demo.mjs
open /tmp/qm-resource-search-demo/resource-search-after.html
```

The files include all scripts and styles and work directly from disk. The Before and After links switch between the original chat-only palette and the current palette. Search for `release`, `dashboard`, or `digest`; use arrow keys and Enter, or click a result. Escape closes the palette; the Open search button reopens it.

The demo bundles the real search component, shared result formatting, icons, and palette CSS. Synthetic responses provide a release-review skill, weekly release digest cron, release dashboard app, release planning project, and a release-readiness conversation. API calls, session access, relative recency, and navigation are mocked; selecting a resource displays its destination instead of opening the application. Asking QM displays a local status message. No backend or network access is involved.

The default before source is commit `e0966ba8`. Optional arguments choose the output directory and before revision:

```sh
node docs/qa/resource-search-demo.mjs /tmp/search-review e0966ba8
```

For the PR, link this document and attach the generated HTML pair if the review host allows HTML attachments. The generated bundles stay outside Git. DOM checks verified four resource results plus a conversation, before/after differences, and resource navigation. Browser verification confirmed the rendered demo and Enter navigation to `/skills/demo-skill`.

---

--- METRICS ---
- Files Extracted: 58
- Estimated Token Budget: ~124200 tokens
- Canonical Reference: https://codewiki.google/github.com/yc-software/qm
