# Technical Documentation: withcoral/coral

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

### Overview (`README.md`)

# Coral

**One MCP server for every service your agents use, with your credentials and
your rules.** Agents write short TypeScript programs. Coral runs them in a
sandbox, adds the credentials, and checks every call against your policy.

[](https://github.com/withcoral/coral/actions/workflows/ci.yml)
[](./LICENSE)
[](https://withcoral.com/docs)
[](https://withcoral.com/discord)

## Why Coral

Connecting an agent to many services usually means many MCP servers, a token
pasted into each one, and every tool definition loaded into every prompt.
Coral replaces that with one server:

- **Smaller context, fewer round trips.** The agent searches for the
  operations it needs instead of loading them all up front. It then sends one
  program that can call several operations and return only the fields the
  answer needs.
- **Agents build on what already worked.** When a program works, the agent
  saves it as a function. The next time, it calls that function in one step
  instead of finding the operations and writing the program again. Saved
  functions are typed and appear in search alongside provider operations.
- **Agents never hold credentials.** Coral stores your tokens and adds them to
  each call. Programs run in an isolated sandbox with no secrets, no network
  access, and no imports.
- **You decide what runs.** Set each operation to allowed, approval required,
  or disabled. A call that needs approval waits until a person approves it.
- **Every run is recorded.** Review each program and the calls it made in the
  Coral UI.
- **You host it.** Run Coral on your laptop or on your own server. Your data,
  credentials, and usage history stay with you.

Coral includes setup recipes for GitHub, Linear, Jira, Notion, Gmail, Stripe,
Datadog, Sentry, HubSpot, and more. You can add any service that has an
OpenAPI document or an MCP server.

## Get started

1. **Install Coral.** Download the desktop app for macOS, Linux, or Windows
   from the [releases page](https://github.com/withcoral/coral-releases/releases),
   or [run a shared server](https://withcoral.com/docs/getting-started/self-host)
   for your team.
2. **Add a provider.** A provider is one service your agents can use. Pick one
   from the catalog, or bring your own OpenAPI document or MCP server. See
   [Add a provider](https://withcoral.com/docs/getting-started/quickstart#add-a-provider).
3. **Connect your agent.** Each workspace in Coral has its own MCP URL. Copy it
   from the Coral UI and add it to your MCP client:

    ```bash
    claude mcp add --scope user --transport http coral <your-mcp-url>   # Claude Code
    codex mcp add coral --url <your-mcp-url>                            # Codex
    ```

    For Cursor, VS Code, OpenCode, and other clients, see
    [Connect an MCP client](https://withcoral.com/docs/getting-started/quickstart#connect-an-mcp-client).

Now ask your agent something that needs that provider, such as "What are my
open pull requests?"

Setup guides and the configuration reference are at
[withcoral.com/docs](https://withcoral.com/docs).

## How Coral works

```mermaid
graph LR
    Agent["Your agent"] -->|"search, exec"| MCP["Coral MCP server"]
    You["You"] -->|"providers, policy, approvals"| UI["Coral UI"]

    subgraph Coral["Coral (your machine or server)"]
        MCP --> Run["Sandboxed program run"]
        UI --> Gate["Credentials and policy"]
        Run --> Gate
    end

    Gate --> GH["GitHub"]
    Gate --> SL["Slack"]
    Gate --> Own["Your OpenAPI or MCP service"]
```

The agent searches for the operations it can use and reads their TypeScript
signatures. Then it sends Coral a short program. This one finds open pull
requests that have no reviewer and posts them to Slack:

```ts
const { data: pulls } = await coral.providers.github.pulls.list({
    owner: "withcoral",
    repo: "coral",
    state: "open",
});

const unreviewed = pulls.filter((pull) => pull.requested_reviewers.length === 0);

await coral.providers.slack.chat.postMessage({
    channel: "#engineering",
    text: unreviewed.map((pull) => `#${pull.number} ${pull.title}`).join("\n"),
});

return unreviewed.map((pull) => pull.number);
```

Coral adds the credentials and checks each call against your policy. If
posting to Slack requires approval, the run pauses there until you approve it.
Only the pull request numbers go back to the agent, not the full GitHub
response. Once this works, the agent can save it as a function, and later
programs call it as `coral.functions.<name>()`.

To learn about providers, accounts, policy, and workspaces, read
[Concepts](https://withcoral.com/docs/guides/concepts).

## Upgrade from Coral 0.x

Coral 1.0 replaces the SQL interface with TypeScript programs. It is a rewrite
and starts from fresh state: it does not convert what 0.x stored, and it tells
you at startup if it finds 0.x state. See
[Upgrade from Coral 0.x](https://withcoral.com/docs/getting-started/self-host#upgrade-from-coral-0-x).

## Development

To run Coral from a checkout, follow the
[development guide](./docs/development.md).

## Community

Ask questions, share ideas, and show what you built in our
[Discord](https://withcoral.com/discord) or on
[GitHub issues](https://github.com/withcoral/coral/issues).

## Security

Do not report security issues in public issues or pull requests. See
[`SECURITY.md`](./SECURITY.md).

## License

Coral is licensed under the Apache License 2.0. See [`LICENSE`](./LICENSE).

---

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

# Coral Desktop

Coral Desktop supervises two local processes and shows Reef in a window:

- the `coral` runtime, as a child process
- Reef's server, in an Electron utility process, listening on loopback HTTP

The window loads Reef's loopback URL. Desktop has no preload script and no
product logic of its own. [ADR 0012](../../docs/adr/0012-desktop-hosts-reef-as-a-loopback-server.md)
records why.

## Ports

Every listener binds `127.0.0.1`.

| Listener                | Port                                                                       |
| ----------------------- | -------------------------------------------------------------------------- |
| Runtime MCP             | Allocated on first launch and kept: agent clients store the MCP URL.       |
| Reef                    | Allocated on first launch and kept: Run Status Page links name Reef's URL. |
| Runtime gRPC            | Picked at each launch. Only Reef calls it.                                 |
| Provider OAuth callback | Fixed at 8779 by the runtime's Desktop defaults.                           |

When a kept port is in use, Desktop asks before it uses a new port. After a new
MCP port, reconfigure your agent clients. After a new Reef port, Run Status Page
links from earlier agent runs stop working.

## Background

Agent clients store Coral's MCP URL, so Coral must run when no window is open.
Desktop itself stays running for that; the runtime is not an OS service.
[ADR 0014](../../docs/adr/0014-desktop-serves-in-the-background.md) records why.

- Closing the last window keeps Coral running. This is not a setting. The
  first time, Desktop says so in a dialog and keeps `toldStillRunning` in
  `desktop.json`.
- On macOS, Coral is in the Dock and the app switcher only while a window is
  open. With no window, the menu bar item is the way to open or quit Coral.
  A Coral that could not make its menu bar item stays in the Dock.
- Start at login starts Desktop with no window. macOS and Windows register a
  login item. Linux writes `coral-app.desktop` in the XDG autostart folder,
  which names the AppImage; a package that is not an AppImage has no such
  setting, and nor has a development build.
- Start at login is a checkbox in the tray menu and in the Coral menu on macOS,
  the File menu elsewhere.
- On macOS, Quit in the Coral menu (Command+Q) only closes the windows, so it
  does not stop Coral. Quit in the Dock menu and Q in the app switcher
  (Command+Tab) do the same. Quit Coral in the menu bar item, or Quit
  Completely in the Coral menu or the Dock menu, stops Coral. So does a
  logout, a restart or a shutdown. On Windows and Linux, Quit stops Coral.
- The Dock menu on macOS also offers Restart Coral, and the update item in a
  package that can update itself.
- The tray item, the menu bar item on macOS, offers Open Coral, Start at login
  and Quit Coral. On Windows a click on it opens the window. Stock GNOME shows
  no tray item.
- Launching Coral again while it runs opens the window.

## Lifecycle

- Quit stops Reef, then closes the runtime's stdin. The runtime treats that as
  a stop request (`CORAL_STOP_ON_STDIN_CLOSE=1`) and finishes its work. Desktop
  kills a process that has not exited after 10 seconds. The OS closes the
  stdin too when Desktop itself dies, so the runtime never outlives Desktop.
- When the runtime exits unexpectedly, Desktop stops Reef, shows the failure,
  and starts both again. When Reef exits alone, Desktop restarts only Reef.
  A restart that does not become ready in time counts as another attempt.
  After three restarts within a minute, Desktop waits for the user to choose
  Restart.
- A runtime that refuses to start is not retried. The one exception is exit
  code 75: another runtime still holds the state directory, for example one
  that is still finishing its work, so Desktop tries again until it is ready
  or the start times out.
- Provider consent opens in the system browser. When Reef answers a form with a
  consent redirect marked `X-Coral-Authorization-Return-To` (ADR 0009), Desktop
  opens the redirect's target in the system browser and sends the window to the
  marked page on Reef instead. A device sign-in form targets a new tab, which
  Desktop runs as a hidden window. That window closes after the hand-off. If
  Reef answers it with a page, the window shows that page.

## Development

```shell
npm install --prefix apps/desktop
npm install --prefix apps/reef
npm run dev --prefix apps/desktop
```

`npm run dev` builds the runtime with Cargo, then starts Electron. Electron
starts that runtime and Reef's Vite dev server, with sign-in disabled.
Development runs on macOS and Linux. On Windows, package the app instead.
Development keeps its data in `<appData>/com.withcoral.app.dev`, apart from an
installed app.

The runtime completes provider OAuth on the fixed port 8779. A development
Desktop, an installed Desktop and a local `coral` runtime cannot run together:
the second one to start fails on that port.

## Packaging

```shell
npm run stage --prefix apps/desktop
npm run package --prefix apps/desktop
```

`npm run stage` puts two things in `resources/`, which the package carries
outside the asar:

- `coral/coral`: a release runtime, `coral.exe` on Windows. On macOS it is
  built for Apple silicon, so install the `aarch64-apple-darwin` Rust target.
  Pass `-- --runtime <path>` to stage a binary you built.
- `reef/`: Reef's bundled `server.mjs` and its client files, built in a
  scratch directory. It is the same Reef build that Docker ships. The bundle
  carries every dependency, so the package has no `node_modules`.

`npm run package` builds the main process and runs electron-builder for this
platform: an Apple silicon (arm64) dmg and zip on macOS, an AppImage on Linux,
and an x64 NSIS installer on Windows. Pass electron-builder options after
`--`, for example `-- --linux --dir`. These packages are unsigned, name no
update feed and cannot update themselves.

The Windows installer installs for the current user, with no UAC prompt, into
`%LOCALAPPDATA%\Programs\Coral`. The 0.x app installs into the same folder,
so uninstall it first. On macOS both apps are `/Applications/Coral.app`, so
this app replaces the 0.x one there. On Linux every name differs
(`coral-app` against `coral-desktop`), so the two apps install side by side.
On every platform the two apps keep separate data folders. The Start Menu
entry is "Coral". Uninstalling keeps the user's data. Run the installer with
`/S` to install silently.

Desktop's icons use Reef's `public/favicon.svg` as their source. Run
`npm run icons --prefix apps/desktop` to regenerate the PNG, ICO and ICNS files,
and the macOS menu bar's template images (`trayTemplate.png`).
Desktop enlarges the mark by 35% within the same tile. The macOS icons add
transparent padding around the flat artwork.

The dmg window's background is `resources/dmg/background.svg`, light artwork
with the coral symbol from withcoral.com. After you change it, run
`npx electron scripts/render-dmg-background.mjs` in `apps/desktop` to write
`background.png` and `background@2x.png`. Keep `dmg.contents` in
`electron-builder.config.ts` on the arrow's ends.

The Reel `desktop` suite drives the packaged Linux app. The Desktop on Windows
workflow also drives the installed Windows app. See
[Reel's run guide](../reef/e2e/README.md).

## Releases

The Release workflow (`.github/workflows/release.yml`) builds signed release
packages and publishes them to the v1 update feed, GitHub releases in
`withcoral/coral-releases`. It never publishes to `withcoral/coral`, whose
releases the 0.x app polls.
[ADR 0013](../../docs/adr/0013-desktop-release-channel.md) records why.

Each platform job stages and builds with no credentials. Then
`xtask release-desktop-package` packages with `CORAL_DESKTOP_RELEASE=1`:

- macOS: electron-builder signs every binary with the hardened runtime and
  JIT, then notarizes the app. The runtime needs JIT as much as Electron does:
  it runs Programs in V8, which aborts at startup without it. xtask verifies
  the signatures and the staple, and checks JIT on the app and the runtime.
- Windows: electron-builder signs the app, the runtime and the installer with
  Azure Trusted Signing. xtask verifies their Authenticode signatures.
- Linux: the AppImage is unsigned.

A release build fails when a credential is missing. xtask also checks that each
feed matches its packages and that the packed `app-update.yml` names the v1
feed. The workflow uploads every asset to a draft release and publishes it last.
The runtime and coral-web images ship in the same release. The workflow builds,
tests and signs them by digest, and the last job tags them just before it
publishes the draft, so no artifact is visible until every artifact passed.

The tag, `Cargo.toml` and `package.json` must name one version.
release-please moves them together. To cut a beta, merge a commit with the
footer `Release-As: 1.0.0-beta.1`, then merge the release PR. Only builds that
are themselves prereleases see prereleases, so you can test the update path
before the swap.

electron-builder signs and notarizes the app, and staples its ticket. The dmg
that carries it is not signed; Gatekeeper checks the app inside.

The workflow reads these from the `release` environment:

| Name                                                                                                                                                             | Kind     |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `APPLE_CODESIGN_IDENTITY`, `APPLE_DEVELOPER_ID_CERTIFICATE_BASE64`, `APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD`                                                    | Secret   |
| `APP_STORE_CONNECT_API_KEY_ID`, `APP_STORE_CONNECT_API_ISSUER_ID`, `APP_STORE_CONNECT_API_KEY_P8_BASE64`                                                         | Secret   |
| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`                                                                                                      | Secret   |
| `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`, `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME` | Variable |
| `RELEASE_APP_PRIVATE_KEY`                                                                                                                                        | Secret   |
| `RELEASE_APP_ID`                                                                                                                                                 | Variable |

The release GitHub App needs contents write access to `withcoral/coral-releases`.

## Updates

A release package checks the feed at launch and every four hours, and
downloads a new version in the background. When the download is ready, a
notification says so, and the update menu item becomes Restart to Update. The
item is in the Coral menu on macOS and in the Help menu elsewhere. While the
update waits, the Dock icon shows a badge on macOS while Coral is in the Dock,
as do Linux launchers that support badges. The tray menu starts with Restart to Update, the tray tooltip
names the version, and the macOS menu bar item shows a dot.

- Choose Restart to Update, or select the notification, to install now.
  Desktop stops the runtime and Reef, hands the update to the platform
  installer, and Coral starts again. If the hand-off fails, Coral starts
  again on the old version.
- Otherwise the update installs the next time you quit Coral, after Desktop
  stops the runtime and Reef. Desktop never restarts Coral by itself, so an
  update can wait a long time while Coral runs in the background.

## Data

Desktop keeps everything under `<appData>/com.withcoral.app`, which is
`%APPDATA%\com.withcoral.app` on Windows:

- `runtime/`: the runtime's state. Desktop rewrites `runtime/config.toml` on
  every launch.
- `desktop.json`: the kept MCP and Reef ports, the background notice, and the
  database, if you set one. See [Ports](#ports), [Background](#background) and
  [Database](#database).
- `logs/`: `coral.log`, `reef.log` and `desktop.log`, Desktop's own record of
  starts, failures and restarts. Help > Show logs opens this folder. A log
  that reaches 5 MB moves to `<name>.1.log`, which replaces the previous one.

On Windows, the runtime's owner-only file permissions have no effect. The data
folder relies on the access control list that it inherits from the user's
profile.

### Database

The runtime keeps its state in `runtime/coral.db`, a SQLite file. To use a
Postgres database instead, quit Coral and add a `database` section to
`desktop.json`. Keep the sections that are already there:

```json
{
  "ports": { "mcp": 58100, "reef": 58101 },
  "database": { "backend": "postgres", "url": "postgres://user:password@127.0.0.1:5432/coral" }
}
```

Desktop then writes `[database]` in `runtime/config.toml` and gives the runtime
the URL in `CORAL_DESKTOP_DATABASE_URL`, so the file never holds the password.
Desktop never writes this section. An invalid section stops Coral from
starting; it never falls back to SQLite. The runtime does not copy state
between the two databases, and `runtime/credentials.key` must stay with the
state that it sealed.

---

### Overview (`apps/docs/README.md`)

# Coral Documentation

This directory contains the Mintlify documentation for Coral hosted at [withcoral.com/docs](https://withcoral.com/docs).

## Local Development

To preview the documentation locally:

1. Run the Mintlify dev server: `npx mint dev`
3. Open [http://localhost:3000](http://localhost:3000)

## Authoring Guidelines

If you are an agent or a human contributing to these docs, please read [AGENTS.md](./AGENTS.md) first for our writing standards, content strategy, and workflow rules.

---

### Overview (`apps/reef/README.md`)

# Reef

Reef is Coral's shared human interface, served on the web or packaged inside
Coral Desktop. Its npm package is `reef`; public deployment environment variables
use the `CORAL_WEB_*` prefix. Default cookies use the `coral_web_` namespace;
existing development sessions are not carried forward under the old cookie names.

## Local Development

For the complete authenticated Coral + Reef stack over your tailnet, follow the
[development setup guide](../../docs/development.md) and run `./dev setup` from
the repository root. It creates isolated, Git-ignored state and supports ordinary
terminals or a two-pane Herdr layout.

For UI-only work without hosted authentication, install dependencies:

```bash
npm install
```

Start the React Router development server:

```bash
npm run dev
```

Open `http://localhost:5173` unless the dev server prints a different URL.

Authentication is disabled by default for local development and for Coral
Desktop. No auth environment variables are needed for those modes.

Hosted Reef must choose its auth behavior explicitly. Set
`CORAL_WEB_AUTH_MODE=required` together with the server-only Coral authorization
issuer, Reef public URL, and session values documented in `.env.example`. Reef
derives its OAuth resource, client metadata URL, callback URL, and cookie
security from `CORAL_WEB_PUBLIC_URL`. A non-desktop production server without an
explicit mode fails closed rather than accidentally publishing an
unauthenticated app.

### Hosted authentication

`CORAL_WEB_PUBLIC_URL` is Reef's single public identity. It must be an origin without
a path, query, or fragment. Declare that HTTPS origin as Coral's first-party web
interface:

```toml
# Coral config.toml (alongside the normal [auth] configuration)
[listeners.auth]
bind = "127.0.0.1:9080"
public_url = "https://auth.coral.example.com"

[listeners.web]
public_url = "https://coral-web.example.com"
```

```bash
CORAL_WEB_AUTH_MODE=required
CORAL_WEB_PUBLIC_URL=https://coral-web.example.com
CORAL_WEB_AUTH_ISSUER=https://auth.coral.example.com
CORAL_WEB_SESSION_SECRET=replace-with-at-least-32-random-characters
CORAL_ENDPOINT=https://coral.internal.example.com
```

The `CORAL_WEB_PUBLIC_URL` value and Coral's `listeners.web.public_url` must canonicalize
to the same URL. Coral derives the accepted resource and trusted client from
that setting, so the first-party UI skips Coral's client-consent screen. No
separate `auth.allowed_audiences` or `trusted_clients` entry is needed for it.
The origin can be private (for example, a Tailscale Serve hostname): Coral
permits fetching only its exact `/.well-known/oauth-client` document over that
private network, with normal TLS verification, redirect rejection, and response
bounds. Reef derives the OAuth resource
`https://coral-web.example.com`, client ID
`https://coral-web.example.com/.well-known/oauth-client`, and callback
`https://coral-web.example.com/auth/callback` from it. Do not configure separate
client, resource, callback, or scope values. Coral pins that client's callback
and resource to those derived values; sign-in and domain permissions still apply.
Local Desktop needs no `[listeners.web]` setting: its UI and backend are application-managed
and local authentication uses the implicit User.

`CORAL_ENDPOINT` is only the Reef-to-Coral data-plane address and is required
whenever authentication is enabled. Reef never derives an authenticated Coral
destination from the browser request URL, `Host`, or forwarded-host headers.
Authenticated Reef uses native gRPC over HTTP/2 and attaches the server-held
bearer token. It does not use Coral's MCP HTTP endpoint or protected-resource
metadata for login or data access.

Choose one transport topology:

1. Use `CORAL_ENDPOINT=https://coral.internal.example.com` when TLS terminates
   at Coral or at a trusted proxy directly in front of it.
2. Use explicit-loopback HTTP, such as `http://127.0.0.1:14555` or bare
   `http://localhost:14555`, when Reef and Coral share a host. Acceptance of
   `localhost` trusts that host's name resolution; use a loopback IP when that
   trust is undesirable. Names such as `coral.localhost` are not treated as
   explicit loopback.
3. On a trusted private network without Reef-to-Coral TLS, set an `http://`
   endpoint and `CORAL_WEB_ALLOW_INSECURE_CORAL_ENDPOINT=true`. This sends bearer
   tokens in cleartext on that network and emits a process warning once per
   Coral origin.

The insecure endpoint flag relaxes only the Reef-to-Coral hop. It does not
relax `CORAL_WEB_PUBLIC_URL`, the OAuth issuer, browser-to-Reef transport, audience,
cookie, or callback validation.

`CORAL_WEB_SESSION_MAX_AGE_SECONDS` defaults to 3600 and caps how long Reef keeps a
login, even if Coral issues a longer-lived access token. A shorter token expiry
still wins. When the encrypted Reef session expires, or Coral rejects its token
with `UNAUTHENTICATED`, Reef clears the session and sends the browser through a
new login while preserving the requested path. There is no token refresh,
silent renewal, token revocation, or upstream IdP logout in this release;
signing out clears only the Reef session.

Over HTTPS, Reef names its session and CSRF cookies with the `__Host-` prefix:
`__Host-coral_web_session` and `__Host-coral_web_session_csrf` by default. The
browser stores a `__Host-` cookie only when it carries no `Domain` attribute, so
it drops a domain-scoped cookie of the same name that another host under the
same registrable domain tries to set. A sibling site cannot replace a User's
session with its own.
`CORAL_WEB_SESSION_COOKIE_NAME` sets the part after the prefix. Over
explicit-loopback HTTP the names carry no prefix, because browsers discard
prefixed cookies there. Upgrading a hosted deployment to these names ends every
existing session, and each User signs in again.

Live Activity updates reach the browser over server-sent event streams that
Reef heartbeats every 5 seconds. A proxy that terminates the browser's HTTP/2
connection, such as an AWS Application Load Balancer, can keep its own request
to Reef open after the browser has dropped a stream, and the heartbeats keep
that request past the proxy's idle timeout. Reef therefore ends every stream
after two minutes and the browser reconnects, so a stranded stream costs at
most that long. Give the proxy an idle timeout above 5 seconds and let it pass
`text/event-stream` responses through unbuffered.

#### Local end-to-end authentication

A public HTTPS tunnel is unnecessary when Reef, Coral's authorization server,
and the gRPC endpoint are all local. Use explicit loopback URLs for the complete
topology:

```toml
# Coral config.toml
[listeners.grpc]
bind = "127.0.0.1:14555"

[listeners.mcp]
bind = "127.0.0.1:14556"
public_url = "http://localhost:14556"

[listeners.auth]
bind = "127.0.0.1:9080"
public_url = "http://localhost:9080"

[auth]
allowed_audiences = ["http://localhost:5173"]
```

```bash
CORAL_WEB_AUTH_MODE=required
CORAL_WEB_PUBLIC_URL=http://localhost:5173
CORAL_WEB_AUTH_ISSUER=http://localhost:9080
CORAL_WEB_SESSION_SECRET=replace-with-at-least-32-random-characters
CORAL_ENDPOINT=http://127.0.0.1:14555
```

The remaining Coral `[auth.session]` and `[auth.provider]` settings are still
required. `listeners.auth.public_url` must equal `CORAL_WEB_AUTH_ISSUER`, and the provider callback must use that issuer's
origin. Plain HTTP is rejected if only one side is local, for hostname aliases,
or for a non-loopback address.

#### Troubleshooting login

Coral intentionally returns the same opaque `invalid_request` response for
client-metadata and redirect failures. Check Coral's server logs for the
sanitized resolution cause, then inspect Reef directly:

```bash
curl -i "$CORAL_WEB_PUBLIC_URL/.well-known/oauth-client"
```

The response must be a direct HTTP 200 JSON response no larger than 5 KiB. Its
`client_id` must exactly equal
`$CORAL_WEB_PUBLIC_URL/.well-known/oauth-client`; its callback must exactly equal
`$CORAL_WEB_PUBLIC_URL/auth/callback`; and it must advertise authorization code,
PKCE-compatible public-client metadata. Common failures are an unreachable or
TLS-invalid Reef URL, a redirect, a non-canonical client ID, an unexpected HTTP
status, oversized or invalid JSON, unsupported metadata, or a callback mismatch.
For loopback HTTP, Coral also requires the exact derived client ID to come from
the loopback `auth.allowed_audiences` entry and requires its own authorization
issuer to use explicit-loopback HTTP.

The approval form POST must carry one `Origin` header exactly equal to Coral's
configured `listeners.auth.public_url`. If approval fails or expires,
verify that the browser opens the configured public issuer and that a reverse
proxy preserves that origin instead of exposing an internal hostname.

Reef form actions (including sign out) allow the public host from
`CORAL_WEB_PUBLIC_URL` through React Router's origin check when authentication is
required. This supports TLS-terminating proxies whose backend URL is plain HTTP.
Development reads the value from `.env`; production sets it at server startup,
not image build time. Forwarded headers do not grant additional allowed origins,
and logout still validates its session-bound CSRF token. Restart the server after
changing the public URL.

Run checks:

```bash
npm run format:check
npm run lint -- --deny-warnings
npm run typecheck
npm test
npm run build
```

## Recorded browser scenarios

See [Reel](e2e/README.md) for the disposable Coral + Reef stack,
Vitest/Playwright scenarios, film mode, video/trace review, and harness self-check.
Run it separately with `npm run test:e2e`; ordinary `npm test` remains fast.

## Storybook

Run the Wax component library locally:

```bash
npm run storybook
```

`npm test` also renders every story in headless Chromium and runs its `play`
function (`npm run test:stories` runs only the stories). Install the browser once with `npx playwright install chromium`. To
keep a story out of the test run, add `tags: ['!test']` with a comment that says
why.

---

### Overview (`crates/coral-task/README.md`)

# Task orchestration

## Interface and ownership

- `TaskModule` owns goal lifecycle, visibility, bounded Task-scoped summaries,
  exact history, associations, and retention-unit decisions.
- `TaskCommands` composes concrete Functions, Search, and Run modules.
  MCP passes authenticated scope, Task identity, per-call intent, and operation
  inputs. The commands own durable begin and final transaction choreography.
- Lower modules remain independently usable. Their preparation and transactional
  facets share the implementation used by ordinary standalone methods. They have
  no Task identifiers, dependency, context field, or recording callback.
- Run acceptance returns scope-bound **staged** evidence. Task commits acceptance,
  its association, and completion before calling Run's post-commit launch facet.
  Run owns claims, deadlines, execution, authority fences, and recovery. An
  interrupted handoff leaves accepted evidence, not permission to replay a request.
- Search owns the complete current safe response projection. Task persistence
  retains that response plus accepted request context without reinterpreting it.
  Summary reads never select the response or diagnostics columns.

## State and failure windows

An attempt has a generated identity before preparation. Begin and ending serialize
on the goal: closing rejects later attempts, not an already-begun command.
Completion takes the same lock but does not require an open goal. Mutation,
association, and completion commit together. Database uncertainty remains unknown;
only an owning domain's safe definite rejection can complete a failed attempt.
Post-commit delivery errors cannot replace successful completion.

Task completion atomically stores outcome, optional feedback, and terminal time.
Feedback accepts either outcome, is nonblank, contains no null character, and has
at most 4,096 Unicode scalar values. The first durable tuple wins; retries cannot
add feedback or renew retention. Feedback shares Task visibility and retention
and appears only in authorized exact detail, never activity summaries or telemetry.

The exact-unit retention methods take an explicit current time. They lock and
recheck the goal and owning Run evidence before deleting a group.
Standalone cleanup checks for an association before applying its independent
clock. Lower deletion facets are transactional only: they do not independently
expire associated work. Run deletion retains Administrative Lifecycle Events and
shared identity/configuration workflows. Missing historical detail is unavailable,
not a lookup of replacement current content.

## Verification and delivery seams

`command_tests.rs` exercises real compiler and Run behavior, exact snapshots,
closure and handoff interruption, and real statement/deferred-commit faults.
Private test controls stop at domain timing seams; they do not enlarge the
production interface. Tests use `coral-test-support` for both SQLite and Postgres.
MCP's credential-free tracer verifies transport attribution and recorded admission
failure. Run's required-action tests also exercise shared-workflow-safe deletion.

---

### Overview (`apps/reef/e2e/README.md`)

# Reel

Reel is Coral's standalone tool for recording and reviewing browser-test scenarios,
not a feature of Reef. One real-browser scenario produces automated verification and watchable evidence.
This suite is separate from Reef's fast Vitest tests, architecture tests and
Storybook/Chromatic. It uses Vitest, Playwright Chromium and optional ffmpeg.

## Run and review

Run the Linux container on macOS or Linux with Node 24+ and Docker or Podman.
The image builds Coral, Reef, Chromium, ffmpeg and the fixtures. No host certificates,
network aliases, browser dependencies or administrator networking setup are required.
From the repository root:

```sh
npm ci --prefix apps/reef

# Main suite (default): genuine Coral sign-in, real Coral and production Reef, SQLite.
npm run test:e2e --prefix apps/reef
# The same product scenarios, with a disposable Postgres database.
npm run test:e2e --prefix apps/reef -- --database postgres
# Small suites for deployment differences, not repeated product journeys.
npm run test:e2e --prefix apps/reef -- --suite local-mode
npm run test:e2e --prefix apps/reef -- --suite desktop
# Remaining arguments reach Vitest unchanged; select a file or test name.
npm run test:e2e --prefix apps/reef -- providers/catalog/
E2E_FILM=1 npm run test:e2e --prefix apps/reef -- shell/navigation
npm run test:e2e --prefix apps/reef -- --help

# Also verify assertion/timeout cleanup, playable WebM fallback, and viewer safety.
REEL_SELF_CHECK=1 npm run test:e2e --prefix apps/reef

npm run e2e:serve --prefix apps/reef
# Open http://127.0.0.1:9324/ and select a scenario.
```

### One product suite, plus small tests for the differences

| Suite            | What it proves                                                                                         | Discovery                           |
| ---------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------- |
| `main` (default) | Provider, Account, Workspace, Agent, activity, identity and permission journeys, with genuine sign-in  | `scenarios/main/**/*.test.ts`       |
| `local-mode`     | No sign-in ceremony, implicit Owner Membership, lifecycle fences, loopback access and parity scenarios | `scenarios/local-mode/**/*.test.ts` |
| `desktop`        | The packaged Desktop app: its supervisor, its window, background, quit and relaunch                    | `scenarios/desktop/**/*.test.ts`    |

`main` is the default; `--suite main` selects it explicitly. `local-mode` means the
no-sign-in deployment, not tests running on your machine.

`--database sqlite` is the main suite's default. `--database postgres` changes its
storage configuration, not its scenario selection. Both run all discovered product
files. Database selection is unavailable for the focused local and Desktop suites.
Single-User and multi-User stories belong together. Provider OAuth is ordinary
product coverage, separate from the Coral sign-in used to arrange actors.

Every suite runs Reef's bundled server, `server.mjs`, as Docker and Desktop ship it.
Every deployment ships the same Reef build. `local-mode` runs it with sign-in disabled,
as Desktop does, so it tests Desktop's Reef without Electron.

`--suite desktop` stages the runtime and Desktop's Reef, packages the unpacked
Linux app, and launches it in Electron for each session. Each scenario owns a home
folder, so Desktop's data lands in it through `HOME` and `XDG_CONFIG_HOME`; the app
has no test hook. Playwright reaches Electron through Node's inspector, which the
shipped fuses turn off, so the runner turns on that one fuse in its disposable
package. Every other fuse stays as shipped. Electron contexts take no `baseURL`, so
the harness resolves a relative `page.goto` against the window's Reef origin.
`withDesktopApp` resolves with the app's exit, so a scenario can tell a clean Quit
from the harness's fallback kill. The system browser is a test boundary:
`recordSystemBrowser` replaces `shell.openExternal` in the main process and
records each URL, and a separate Playwright browser plays the person's browser. A native
question is one too: `answerDialogs` replaces `dialog.showMessageBox` and records each
question. The suite needs a display: the container
provides Xvfb. It proves only what the Electron host adds. A scenario that would
pass without Electron belongs in `local-mode`. Desktop's runtime completes Provider
OAuth on the fixed port 8779, so one Desktop runs at a time per network namespace.
A native run collides with an installed Desktop; each container run has its own.

`E2E_DESKTOP_EXECUTABLE` names an installed app for the desktop suite to drive
instead; the runner then builds nothing. Turn on the inspector fuse in that copy
first. The Desktop on Windows workflow drives the installed Windows app this way,
with `npm run test:e2e:native`. On Windows the suite does not rely on variables to
move Desktop's profile, so each scenario uses the real
`%APPDATA%\com.withcoral.app`. Setup refuses to start if that folder exists. Each
scenario deletes what the previous one left, and the last one's data stays. Run
it only on a disposable machine.

Each command creates and prints **one run directory** for its selected suite and
database. Only the selected suite appears in the run; an unrequested suite is not
reported as passed. Vitest name filters can report other tests in selected files as skipped. Missing prerequisites fail setup, rather than skipping tests.
Separate commands always get separate run identities. Direct links have the form
`http://127.0.0.1:9324/<run-id>/index.html`; scenario pages are linked from there.
The landing page and run pages group scenarios by their source folders, with nested
folders and scenarios indented. Folder headings show outcome counts for their full
subtree. Folders sort by name; failures and incomplete scenarios lead within each
folder. Each scenario retains its own outcome, target, duration and evidence link.
Run history adds readable dates, commit/dirty state and film/fast mode;
harness self-checks live in a separate disclosure because they include deliberate
failures. Refresh to discover newly finalized evidence. Each row is one actual
Vitest test scenario, identified independently of its name. Its outcome and total
elapsed time come from the runner after assertions and hooks finish; session
durations are never summed to manufacture a test duration. A started test without
a final outcome stays visibly incomplete. Skipped tests and failures before any
browser opens still have scenario results.

Open a scenario to see its source, whole-test failure, and recordings in start
order. Actor, recording label and setup/interaction purpose are separate fields;
repeated logins retain separate recording numbers. Retries/repeats keep their
recordings under separate attempts, with the runner's final outcome on the parent.
Recording pages provide compact named screenshot thumbnails and inline source.
Recordings autoplay muted with playback controls and inline mobile playback.
Use **Playback speed** to select 0.1×, 0.25×, or 1× (normal). Changing speed
does not restart playback or change the recording. Step times are
since session start, **not video seek positions**. Source snapshots may contain
other tests from the same file. Raw metadata, source, video and traces remain
available as secondary downloads. Inspect traces locally from `apps/reef` with:

```sh
npx playwright show-trace e2e/runs/<run>/<target>/<scenario>/sessions/<session>/trace.zip
```

Fast mode records without artificial pauses. Film mode uses 400 ms Playwright
slowdown and 1.5 seconds after each successful step. Neither replaces readiness
checks. Playwright records the page viewport, **not** native browser chrome,
Desktop windows or other popup pages.

Serve the HTML through `e2e:serve`, rather than opening it as a local file. The
standalone viewer lives in `e2e/viewer`, outside Reef's app and routes. It renders
Wax on the server using the shared static-build tooling. There is no SPA or Reef
browser runtime. A small, hash-authorized script enables playback-speed buttons;
navigation, source disclosure, and native video controls work without JavaScript.
Each generated page stores a content-addressed Wax stylesheet snapshot beside its
evidence, including fonts and any SSR layout styles. Archived pages therefore keep
their styling when Wax changes. The landing page compiles once per process and reads
current run metadata on each request. The CSP permits only the exact trusted
playback script, not scripts from evidence or arbitrary inline code. Exact-host
and file-containment checks remain in force; inline styles are not permitted.
Both themes follow the browser's system preference.

To apply the current Wax design to already-recorded runs (including pages created
before stylesheet snapshots), regenerate their presentation without rerunning the
tests or changing recorded media/source:

```sh
npm run e2e:refresh-viewer --prefix apps/reef
```

Restart `e2e:serve` after changing viewer code; its renderer is compiled once.

## View over a tailnet

Remote viewing is opt-in. On the machine running the viewer, use its exact
Tailscale HTTPS origin (replace the example hostname with your own):

```sh
E2E_RESULTS_ORIGIN=https://reef.example.ts.net:9324 \
  npm run e2e:serve --prefix apps/reef
# In another terminal; this command changes Tailscale configuration, not the viewer.
tailscale serve --bg --https=9324 http://127.0.0.1:9324
# Open https://reef.example.ts.net:9324/ from your tailnet.
```

The viewer still binds only to `127.0.0.1`. It accepts the local address and the
exact configured host and port, not other tailnet names or forwarded-host headers.
Use one canonical HTTP(S) origin, optionally ending in `/`, without credentials,
a path, query, fragment or wildcard. This setting does not configure TLS or
Tailscale; HTTPS terminates at Tailscale Serve. Omit it to retain localhost-only
Host checks. Restart the viewer when changing settings.

**The viewer has no authentication.** Anyone permitted by your tailnet policy can
read all retained evidence through Serve. Review that policy before enabling access;
the hostname check is DNS-rebinding protection, not authentication. Do not use
Tailscale Funnel or expose the viewer publicly. When finished, stop the viewer and
remove just this Serve listener with `tailscale serve --https=9324 off`.

## Environment ownership

All suites run real Coral plus the production Reef server in private temporary
state. The main suite uses SQLite or Postgres and genuine Coral sign-in.
Local and Desktop checks use SQLite and the implicit Organization Owner. They use
loopback port zero. The product fixture reserves ephemeral loopback ports before constructing
OAuth origins, releases each reservation just before its owning child binds, and
fails startup if another process claims the port. It never falls back to that
other server. It discovers only the child processes' reported
addresses and waits for MCP and Reef `/readyz` (including Reef's real private gRPC
health call). It never attaches to a conventional port or reads developer Coral,
Postgres, auth or telemetry settings. Local mode uses the actual implicit
Organization Owner and normal authorization, not an injected bypass.

Declaration-only Provider fixtures use HTTPS placeholder hosts and make no outbound
calls. On the main suite, discovery and dispatch fixtures advertise
`http://sidecar:<ephemeral-port>`.
The container network resolves `sidecar` to this container's private address, not loopback.
Authenticated Coral explicitly selects `private_networks = "http-and-https"`.
Loopback stays denied, so rejection scenarios retain independent negative controls.
Local Coral keeps its default policy, which reaches loopback services and denies
private networks, so local and Desktop fixtures listen on loopback.

No hosted credentials are needed. Dummy Provider credentials use filesystem
storage inside disposable fixture state,
never the OS keychain. Do not seed tables directly or add production test controls.

Teardown stops Reef before Coral, escalates a stuck shutdown to SIGKILL, and removes
state only after the processes stop. Logs remain with the run. Each browser session
has a fresh context and unique artifact directory, including multiple sessions or
retries. Scenarios run serially until resource isolation supports parallelism.

## Container ownership and HTTPS acceptance

`test:e2e` builds and runs `container/Dockerfile`. Each invocation owns a unique
container and network. Browser, application, identity provider, fixture listeners
and (when selected) Postgres all run inside it. No daemon socket is mounted, no
host ports are published, and no host networking mode is used. The browser's OAuth
hops resolve the same fixture hostname as Coral; MCP streaming and cancellation
use ordinary direct connections, without a reverse proxy.

Cargo registry/git/target and npm build caches survive image rebuilds. Reef builds
inside each run. The cost is a Linux build even on macOS; native builds cannot be reused. Linux
container evidence proves the Linux Electron app, not native macOS, Windows or
installer behavior.
For focused native checks, install native build/browser dependencies and use
`npm run test:e2e:native --prefix apps/reef -- --suite local-mode`. A native
`--suite desktop` run packages and launches the Linux app, so it needs Linux, a
display and Electron's libraries, unless `E2E_DESKTOP_EXECUTABLE` names an installed
app.
The native process runner also supports explicit disposable attached local entry
checks with `E2E_BASE_URL` and `E2E_ALLOW_ATTACHED=1`. It does not provision host trust.

At every container start, `container/network.ts` runs the real guarded Rust client
and Chromium against `http://sidecar:5000`, trusted `https://sidecar:5443`, and
untrusted `https://sidecar:5444`. HTTP must work only with the selected permission;
HTTPS must verify its certificate even with HTTP enabled. The main
`providers/setup/network-policy` scenarios also publish and invoke Providers over
HTTP and both HTTPS certificate cases using a real signed-in User and Workspace MCP.
`providers/setup/consent-navigation` separately verifies private HTTP browser consent
and reuse while Coral permits only private HTTPS requests, including a real TLS token
exchange. It rejects an HTTP hostname mapped to a public IP and an HTTPS metadata
literal before browser navigation. The public DNS probe entry exists only inside
the disposable container. This storage-independent control owns a separate SQLite
instance on either product suite. Browser screening is issuance-time evidence, not
a guarantee about browser DNS or subsequent redirects; see ADR 0009.

The entrypoint generates two short-lived CAs. Only one enters the container's
native and Chromium trust stores. Keys and trust disappear with the container;
none is mounted from, installed on, or exported to the host. The existing fixture
TLS preflight remains: explicit TLS settings need complete, readable files and a
certificate matching the advertised host. Product certificate rejection is tested
at the real connection, not by treating preflight rejection as transport evidence.

Artifacts write directly to `apps/reef/e2e/runs/<timestamp-uuid>/` while tests run.
The runner prints each host path before starting the container. Refresh the viewer
to inspect completed recordings and current scenario outcomes without waiting for
shutdown. Do not delete an active run directory. Each container mounts only its own
final run directories; a harness self-check has a separate directory from product
scenarios. Recordings, traces, screenshots, logs and source snapshots survive failed
runs and interruption without moving to another path. Signals stop the owned container;
its init process reaps children. The runner removes its container and network on normal
completion or failure. An uncatchable host kill can leave resources named
`coral-reel-<uuid>`; its evidence remains at the printed run paths.
Inspect and recover only that invocation's resources.
Never prune unrelated work. Podman disables SELinux labeling for this test container
rather than relabeling host artifact directories; no host CA or interface changes occur.

Supported host options are `REEL_CONTAINER_ENGINE` (`docker` or `podman`),
`REEL_SELF_CHECK=1`, and `E2E_FILM=1`. Other developer credentials, database URLs,
fixture settings and TLS-disable variables do not cross into the container.
Viewer settings remain `E2E_RESULTS_PORT` and `E2E_RESULTS_ORIGIN` as described above.

### Remove an older host-managed setup deliberately

The removed native HTTPS fixture setup is not automatically uninstalled. Stop its native E2E runs.
Use the last implementation in an isolated historical worktree solely to inspect
and uninstall its owned resources:

```sh
git worktree add --detach /tmp/coral-fixture-uninstall 22674f1288f6d5b1760c925535efa04cc25b85a5
cd /tmp/coral-fixture-uninstall
./dev e2e-fixtures status
# Review the reported address and certificate ownership before proceeding.
./dev e2e-fixtures uninstall
```

Use the same OS user and original `E2E_FIXTURE_HOME`, if customized. Uninstall
refuses active runs and removes only recorded owned aliases and exact CA trust.
It may request administrator authorization. If ownership is uncertain or removal
fails, retain the setup records and inspect the reported resources manually;
do not delete whole keychains, NSS stores or unrelated interface addresses.
Remove the historical worktree after successful cleanup. This is a deliberate
removal path, not a maintained provisioning or compatibility interface.

## Product on Postgres

`npm run test:e2e --prefix apps/reef -- --database postgres` runs the same discovered
product files. The entrypoint starts an isolated Postgres database inside the run
container. The native process runner passes its explicit disposable URL through
`scripts/postgres.ts`; deleting the container removes all database storage.
No User session is relabeled as an Agent. Separate Coral/Reef processes and genuine
OAuth stay identical to SQLite. Connection strings remain outside artifacts.
The storage-independent default-policy negative control always owns a separate
SQLite instance; it never shares the product target's database or Organization.

`make postgres-tests` remains the Rust repository gate and owns its own disposable
database through `scripts/postgres.ts`. Repository tests alone do not prove product
journeys. Native macOS and Windows 11 Desktop checks remain separate acceptance work.
Coral runs as one process, so Reel has no multi-worker variant; multi-process parity
belongs to the work that introduces multi-process Coral. The restart scenario for boot
recovery is separate, open work.

## Authenticated multi-User fixture

The main suite boots the maintained `oidc-provider` package in memory,
with per-run keys and synthetic accounts. Its development-adapter warning is
intentional: this test IdP must disappear at teardown. It is not a Coral auth
replacement and must never be exposed outside loopback.

Reef and Coral run their normal OAuth/OIDC paths: discovery, authorization,
PKCE, signed ID-token validation, User provisioning,
Coral code/token exchange, and genuine Reef session cookies. Loopback HTTP uses
explicit registered resources and trusted clients. `listeners.web.public_url`
names the actual reserved Reef origin for human navigation; loopback HTTP does not
acquire the HTTPS metadata client's derived audience or consent-bypass trust.
Local-mode fixtures likewise reserve Reef's port before Coral starts and pass its
actual origin as `CORAL_LOCAL_WEB_URL`. Without sign-in, they also pass the loopback
Provider OAuth callback that Coral prints at startup, the listener Desktop uses, as
`CORAL_UPSTREAM_OAUTH_CALLBACK_URL`. Provider secrets are explicit child bootstrap
values and Coral's signing key is a private file in disposable state.

For scenarios that require authenticated actors (for example, under `scenarios/main/workspaces/`):

```ts
import { test } from 'vitest'
import { createActors } from '../../../support/actors.ts'
import { withBrowserSession } from '../../../support/browser-session.ts'

test('A management journey', async (context) => {
  const { owner, alice, bob } = await createActors(context)
  await withBrowserSession(
    context,
    async ({ page, step }) => {
      await step('Open Reef as Alice', async () => {
        await page.goto('/')
        await page.getByRole('button', { name: 'Open account menu', exact: true }).waitFor()
      })
    },
    { actor: alice.label, storageState: alice.storageState },
  )
})
```

- `owner` logs in as the exact configured bootstrap Organization Owner subject.
  `alice` and `bob` are fresh eligible identities provisioned as Organization
  Members, not Workspace Members. There is no role claim or test grant bypass.
- `newUser(context, label)` plus `loginActor(context, user)` creates additional
  Users. `signIn(page, user)` drives a fresh browser's actual login flow when
  login itself is the recorded behavior. Setup logins are also recorded, with
  actor labels, then normal scenarios reuse their genuine storage state.
- `subject` is the upstream OIDC subject; `userId` is Coral's separate, stable
  User identity. Repeated login retains `userId`; another User gets a different
  one. Do not use the upstream subject as a Workspace Membership's User ID.
- An Actor includes a `reef`-audience bearer obtained through a second genuine
  PKCE OAuth client, using the same IdP session. This enables private management
  setup without decrypting Reef cookies or importing Reef server internals.
  `management.ts` exposes resource-specific Workspace, Membership, Agent, and
  Registration clients. Test bindings are generated from the canonical proto files into the
  ignored `e2e/generated/` directory by `npm run e2e:proto:gen` (automatically
  run by E2E and typecheck). These clients and the fixture are not part of the
  production build; `e2e/` is excluded from production image contexts; only the test image includes it.
- Keep tokens and storage state in memory, never in result metadata or checked-in
  files. **Playwright traces of login and token exchange do contain disposable
  credentials**. Token acquisition leaves the recorded page on Reef; the login
  recording also shows the clearly labeled synthetic identity provider.
- Test finalizers remove synthetic IdP accounts; global teardown removes the
  entire Coral state. Removing an IdP fixture account is not Coral User
  disablement and does not revoke an already-issued Coral credential.

The fixture tests prove distinct/stable authenticated identities, real private
credential acceptance versus unauthenticated rejection, independent Reef sessions,
logout isolation, and successful eligible login with unverified email while exact
email grants remain unavailable. Colliding verified addresses preserve separate
identities and reject grants without candidate disclosure. A committed request replay
after email reassignment cannot select a new target. The management scenarios also
prove that an Organization Owner has no Workspace access without a Membership and can
assign an Owner from the organization Workspaces page, one-submit Membership
creation, role changes and removal/leave, concealed unrelated Workspaces,
Member-only view models, denied forged actions, and Agent lifecycle. Agent probes
use the real Registration credential at the MCP listener, not a User session
labeled as an Agent. No hosted account, invitation, or live WorkOS credentials are
required. Arrange additional prerequisites through real APIs, never database
seeding or test-only production routes.

## Writing a scenario

Keep all scenarios together, separated first by suite:

```text
scenarios/
  main/
    accounts/
    activity/
    agents/
    identity/
    organization/
    providers/
    shell/
    topology/
    workspaces/
  local-mode/
    parity/
  desktop/
```

Group `scenarios/main/` by the product behavior being proved:

- `shell/`: navigation, breadcrumbs, scrolling, and browser-shell behavior.
- `identity/`: sign-in, stable identity, and independent sessions.
- `organization/`: Organization Membership administration and removal.
- `workspaces/`: Workspace lifecycle, authority, and Membership management.
- `agents/`: Agent and Registration lifecycle.
- `providers/`: Provider lifecycle and authority, with `setup/`, `catalog/`,
  `policy/`, and `updates/` for focused scenarios.
- `accounts/`: scoped Account lifecycle, authority, renewal, and connection requests.
- `activity/`: execution history and its navigation.
- `topology/`: what a composed deployment does not expose.

Keep authority tests beside their feature. Place OAuth and MCP scenarios by the
behavior they prove, not the protocol fixture they use. Keep cross-feature journeys
intact under their primary product area. Shared scenario definitions live beside
their entry files; `support/` contains harness code, fixtures, and helpers.

Every `*.test.ts` under `scenarios/main/` belongs to the main suite on both databases.
New or renamed files need no registry edit. Keep the few deployment-difference
checks in `scenarios/local-mode/` and `scenarios/desktop/`; do not add capability tags
or per-database lists. `configuration.test.ts` retains the formerly shared assertions;
`oauth-configuration.test.ts` retains the formerly local-only configuration assertions.

### Run one scenario in both topologies

A parity scenario asserts the same domain facts in the remote and local topologies.
Its shared definition is a plain `.ts` module beside its main entry file. The main
entry and a `scenarios/local-mode/parity/` entry each register it with the same
variants. The shared assertions are the parity catalog; do not compare runs.

```ts
// scenarios/main/accounts/agent-account.ts
export async function agentAccount(context: TestContext) {
  await withOwner(context, async ({ page, target, step, accessToken, approveConsent }) => {
    // Pass accessToken to management clients; it is undefined locally.
    // approveConsent() replaces a click on the fixture's approval link.
  })
}
```

`withOwner` in `support/topology.ts` holds how the Owner acts in each deployment.
On the main suite it records a genuine Owner sign-in. Locally it uses the implicit
Organization Owner. Facts about the composed stack, such as its public origins,
come from the target. `approveConsent()` approves fixture Provider
consent and returns to Reef. Remote Reef's callback does that itself. Locally, Coral's
loopback listener completes the sign-in, and the browser opens the page that Reef
named in `X-Coral-Authorization-Return-To`, as Desktop does.
Assert identity through Coral, such as the Workspace's `currentUser`, not a signed-in
Actor. Each source snapshot appends the shared definitions its entry imports, so the
evidence shows the journey that ran.

The local topology runs every Workspace-local Provider and Provider Account journey through `parity/`.
Journeys that need a second User, such as Workspace authority or Organization
Membership, cannot apply without sign-in and stay in `main`.

Use a product-relative path to select a file, such as `workspaces/lifecycle`, or a
folder, such as `providers/catalog/`, with either database.

For a scenario in a product directory under `scenarios/main/`:

```ts
import { test } from 'vitest'
import { withOwnerSession } from '../../../support/actors.ts'

test('A named human journey', async (context) => {
  await withOwnerSession(context, async ({ page, target, actor, step }) => {
    await step('Open Coral', async () => {
      await page.goto('/')
      await page.getByRole('navigation', { name: 'Coral', exact: true }).waitFor()
    })
    // Use actor.accessToken for private management setup.
    // programRunner uses this browser User's real Workspace OAuth grant by default.
    // Perform the behavior under test through the UI and assert domain facts.
  })
})
```

The scenario owns actions, meaningful readiness checks, assertions and resource
cleanup. The harness owns browser lifecycle, tracing groups, numbered screenshots,
pacing, video and metadata. `page` is ordinary Playwright; no workflow DSL or
browser request mocks are necessary. Use locators/response conditions, not global
`networkidle` or scenario sleeps. API prerequisites must not perform the behavior
that the recording claims to exercise through the UI.

`withOwnerSession` records a genuine Owner login, then opens the interaction with
that Actor's storage state. It never silently falls back to the implicit local User.
For multi-actor and identity scenarios, pass an Actor's genuine Playwright
`storageState` and safe label to `withBrowserSession`, as above. The label
only names review evidence; it does not select authority. Optional `label` and
`purpose: 'setup' | 'interaction'` identify the recording's role. For example,
`{ actor: 'Alice', label: 'Login', purpose: 'setup' }` records a login without
creating another scenario. Ordinary browser sessions default to a Reef interaction.

Browser actions/navigation have finite timeouts. Vitest's per-test finish hook
closes and finalizes tracked resources even if the callback hangs on a promise
that does not observe cancellation. Normal callbacks close their session promptly
so multi-actor tests do not accumulate idle recordings and live browsers. A later
assertion or cleanup failure belongs to the parent scenario; completed recordings
retain their own outcomes. A closed page cannot produce a later failure screenshot.
The custom Vitest reporter owns scenario identity, final result and total timing;
`withBrowserSession` owns only its uniquely identified child recording. Artifact
attempts are bounded and independent;
a screenshot/trace failure cannot prevent browser shutdown or replace an original
assertion/timeout. A green recorded test requires video, trace and browser cleanup.
Only optional MP4 conversion can fail without failing an otherwise passing test;
`session.webm` is preserved. Each command checks ffmpeg once before target setup. If it
is missing or cannot start, one setup notice explains the WebM-only fallback;
recordings do not repeat conversion attempts or warnings. If an available ffmpeg
later fails to convert a recording, that recording still gets its normal warning. The self-check deliberately removes ffmpeg from its
configuration and decodes passing, failing and timeout recordings in Chromium.
It also checks nested sessions, duplicate labels/names, retries, skips, failures
before recording and failures after sessions close, including cleanup hooks.

Hard process kills, OS crashes and failures before browser launch cannot promise
complete artifacts. For interruption during startup, inspect each target's
`<run>/<target>/target.json` and its `coral.log` / `reef.log`. Target setup and
cleanup failures stay visible alongside successful scenarios from other targets. Do not use `test.concurrent` with the initial serial harness.

## Scope and next journeys

`shell/navigation` proves sidebar preference persistence and missing-page recovery
with genuine sign-in. Focused `scenarios/local-mode/` checks prove no-login entry and implicit
Owner behavior; neither replaces the product Workspace/Agent journeys.
`scenarios/local-mode/parity/` reruns shared journeys in the local topology:

- The Provider/Account journeys: protected discovery and shared promotion, Account
  Connection Request continuation, an Agent Account with its Agent bearer, and a
  failed shared update.
- `topology/absent-surfaces`: the MCP listener serves only
  `/workspaces/{workspace_id}/mcp` with the tool set `crates/coral-mcp` pins, and
  no public listener serves a private gRPC method. Audience rejection stays tested in `crates/coral-grpc`.
- `activity/binary-output`: a binary Final Value reaches an MCP client as an image
  block and shows unchanged on Reef's Run Status Page.

Maintained management recordings:

- `workspaces/lifecycle`: Workspace creation, MCP endpoint copy from the sidebar
  header, the settings dialog over the Overview (shareable URL, closing replaces the history entry),
  stable identity across rename, archive cancellation/confirmation, configuration
  lockout, restoration, sidebar Workspace creation, tree expansion without
  navigation, direct links, and browser Back/Forward. The sidebar tree is the only
  Workspace listing.
- `workspaces/first-workspace`: a User without a Workspace creates one from the
  home empty state, copies its MCP URL from the empty Activity, and the first MCP
  call replaces that empty state live.
- `workspaces/authority`: an Organization Owner denied without a Membership, Owner
  assignment from the organization Workspaces page, one-submit
  exact-email grants, designated Memberships, concealed unrelated Workspaces,
  restricted Member views and forged-action rejection, and leaving a Workspace.
- `workspaces/member-selection`: ambiguity with dialog/input retention and safe
  correction; committed replay after email reassignment; obsolete URL normalization.
- `workspaces/membership-dialog-lifecycle`: live archive/restoration and demotion
  while Add member is open, including navigation refresh after rejected mutations.
- `workspaces/membership-actions`: the last Owner cannot leave or change their role
  to Member; removing another person keeps the administrator in the Workspace.
- `shell/management-scroll`: wheel/keyboard scrolling in the settings dialog and the
  real shell, long Members and Agent pages, mobile drawer navigation/focus, settings
  from the drawer, and its nested archive dialog.
- `agents/lifecycle`: one-time issuance, credential rotation in the key dialog
  with a client setup that authenticates, no credential recovery on reload, Agent and Registration disablement, expiration,
  and destructive cleanup, checked against real MCP authentication.
- `providers/lifecycle-journey`: the same credential-free Provider journey on
  authenticated SQLite and Postgres. Add through Reef, discover and execute through
  MCP, distinguish independent Providers, change exact policy, rename and Update,
  disable/restore, then remove/recreate without inheriting identity or policy.
  Observe the retained original Run after removal.
- Other `providers/` and `accounts/` scenarios: stale mutations, failed
  Updates, unavailable Operations, exact lost-response recovery, secret-free
  responses, protected MCP discovery, OAuth callbacks/rejected codes, external-browser
  consent, application-secret rotation, member authority and exact Workspace
  concealment. Scoped Accounts replace the old inventories. Disposable upstream
  fixtures live in `support/provider-fixtures.ts`.

Keep later journeys with the work that owns them:

- **Organization administration:** Organization role/disablement screens and last-enabled-Owner protection.
- **Scoped Accounts:** each scoped Account feature owns its journeys. The integrated
  cutover scenarios compose their existing assertions and own joint Update/offboarding
  races, rather than adding alternative product flows.
- **Activity:** real MCP Run, required action in Reef, approve/decline, terminal outcome
  and upstream dispatch count.
- **Program authoring and evidence:** Program authoring, diagnostics/results, history and concealment.
- **Boot recovery:** the restart scenario, which interrupts started and unstarted Runs
  alike.
- **Desktop:** the `desktop` suite proves Desktop's first launch, Provider consent
  hand-off, crash restart, and quit and relaunch, on Linux. It also proves that Coral
  serves with no window, that a second launch opens the window, and that Quit stops it. The Desktop on Windows
  workflow runs it against the installed Windows app. macOS gets a manual
  click-through, and Windows 11 a manual visual pass.

## Evidence and CI

`runs/<timestamp-uuid>/<target>/<scenario-id>/` contains the runner-owned
`scenario.json`, source snapshot and scenario page. Each
`sessions/<session-uuid>/` beneath it contains its own `result.json`, source
snapshot, video, `trace.zip`, numbered screenshots and optional `failure.png`.
The session metadata references its parent scenario ID and records an explicit
sequence, attempt, actor, label and purpose. No grouping is inferred from names.
The reporter owns the command's `run.json`: commit, dirty-checkout flag, selected
target names, timing and film mode. Each target owns its own `target.json` with
setup/cleanup state and process IDs, plus its own process logs. Target setup never
rewrites shared run metadata or another target's logs.
Skipped tests that never open a session have scenario metadata but no browser
artifacts. This unreleased format does not reconstruct parent test outcomes from
old session-only recordings; discard those runs and record new evidence.

Everything under `runs/` is gitignored. **Treat all evidence as sensitive**, even
with synthetic data: traces include requests, cookies, tokens and source; videos
may show one-time credentials; logs and screenshots can expose private content.
The viewer binds only to loopback, rejects unrelated Host headers and serves only
files inside the results root. Remote viewing requires the explicit origin setting
above and a reviewed tailnet access policy. Do not publish evidence without review.
Local evidence is not automatically pruned; delete old run directories when no
longer needed.

The opt-in **Reel** GitHub Actions workflow builds the real stack, runs the
harness self-check and records the selected suite. Its default is `main` on
SQLite; select Postgres to run the same journeys with that database. It uploads artifacts even after failures,
with seven-day retention, **only while the repository is private**. Public-repo
uploads are deliberately disabled; the final automatic CI matrix is later work.
No recordings or traces are published to Pages or a public trace viewer.

---

### Overview (`xtask/src/openapi/README.md`)

# OpenAPI hydration

`xtask openapi-hydrate` produces a self-contained JSON form of an OpenAPI
descriptor. It follows the reachable `$ref` graph, loads external YAML or JSON
documents, and dereferences the result.

## Usage

Run it from the repository root with a local descriptor:

```bash
cargo run --locked -p xtask -- openapi-hydrate path/to/openapi.yaml > openapi.json
```

The location may be a local path, a `file:` URI, or an HTTPS URL:

```bash
cargo run --locked -p xtask -- openapi-hydrate https://example.com/openapi.yaml > openapi.json
```

Successful output is pretty-printed JSON on standard output. Errors are
written to standard error and cause a non-zero exit status, so the command is
safe to use in scripts.

## What `openapi-hydrate` does

- Parses YAML or JSON descriptors.
- Loads reachable external `$ref` targets and follows nested references.
- Resolves JSON Pointer fragments, including percent-encoded pointer tokens.
- Dereferences the resulting document and prints it as JSON.
- Ignores unreferenced entries under the root document's `components`, so an
  unused broken external reference does not make hydration fail.

Only references reachable from the document are loaded. A missing document or
pointer target on a reachable path is an error.

## Fetch and file safety

`openapi-hydrate` is deliberately conservative when resolving references:

- Network documents and network references must use HTTPS. HTTP and other URI
  schemes are rejected, and redirects from HTTPS to another scheme are refused.
- An HTTPS root document cannot load `file:` references.
- A local root document can load local files only from its own directory tree;
  references that escape that tree are rejected.
- Symlinked local descriptors and references are rejected.
- Root and external documents are each limited to 16 MiB. HTTPS requests time
  out after 30 seconds, and up to 32 external documents are fetched at once.

## Development

Run the xtask tests with:

```bash
cargo test --locked -p xtask
```

---

### Overview (`apps/reef/public/fonts/README.md`)

# Font Assets

This directory contains the self-hosted font files used by Coral web.

- `encode-sans-semi-expanded/`: Encode Sans Semi Expanded from Google Fonts.
- `dm-mono/`: DM Mono from Google Fonts.

Both font families are distributed under the SIL Open Font License 1.1. The same
font families are used by the Wax UI theme.

---

### Overview (`crates/coral-app/src/auth/README.md`)

# Public Principal authentication

The Workspace MCP endpoint is exactly `/workspaces/{workspace_id}/mcp`.
Its URL fixes the Workspace, not the Principal or authority.

- Auth-disabled serving is loopback-only. Missing credentials map to the implicit
  local Organization Owner through normal domain authorization. A presented
  credential is never ignored: it must authenticate an Agent Registration.
- Remote User clients use Coral's OAuth authorization server, backed by the
  configured OIDC provider. Coral-issued User tokens are bound to the exact
  Workspace resource URL. Protected-resource metadata and the authentication
  challenge advertise that URL and the configured authorization server.
- Agents present their Registration bearer credential at the same endpoint.
  Agent-kind JWT claims are not Registration authentication.

`coral-auth::PrincipalAuthentication` owns credential verification into canonical
Principal evidence, including private JWT checks and current authority lookup.
`WorkspaceAuthentication` is the thin MCP Adapter: it supplies the exact resource
URL and projects safe errors and OAuth metadata. Bootstrap supplies resolved
signing configuration and authority Modules. Login HTTP orchestration remains here.

Every HTTP request verifies its credential again. MCP sessions are partitioned
by Workspace and canonical Principal; session IDs and `clientInfo` cannot select
another actor. Domain Modules read current Coral-owned Organization Membership
or Agent/Registration state rather than trusting a session's retained role.

## OAuth client registration: DCR and CIMD

An OAuth-capable User client can start with only a Workspace MCP URL. Protected-resource
metadata identifies Coral's authorization server. Authenticated deployments advertise
both `client_id_metadata_document_supported: true` and an absolute
`registration_endpoint` at `{listeners.auth.public_url}/oauth/register`.
Auth-disabled local mode serves neither User OAuth nor registration.

Two client identity paths share the same authorization flow:

- **CIMD:** the client supplies its canonical metadata-document URL as `client_id`.
  Coral retains hardened fetching, the operator's outbound destination policy,
  the five-second timeout, and the 5 KiB response bound. A document is published
  for every authorization server, so Coral drops redirect URIs it cannot use and
  fails only when none remain.
- **DCR (RFC 7591):** the client sends a JSON object to `POST /oauth/register` without an
  existing token, client ID, or initial access token. Coral validates and commits
  the registration before returning HTTP 201, an opaque `dcr_` client ID, and the
  actual accepted metadata. Responses use JSON and `Cache-Control: no-store`.
  Registration never fetches URLs. Only URL client IDs enter the CIMD fetcher;
  unknown or malformed opaque IDs fail closed.

OAuth client registrations describe deployment-level client software or instances.
They are not Users, Agents, Agent Registrations, Workspace Memberships, or access
credentials. They do not grant Workspace access. Store the returned client ID for
reuse; do not register again for each connection. Registrations survive Coral restart
on SQLite and Postgres and do not use the five-minute OAuth handshake lifetime.

### Accepted registration metadata

Coral supports only public clients and authorization-code grants with `code`
responses. Explicit confidential-client authentication methods fail. If the method
is omitted, Coral explicitly overrides RFC 7591's confidential default by returning
`token_endpoint_auth_method: "none"`. Omitted grants and response types use the RFC
`authorization_code` and `code` defaults. Requests containing those supported values
are accepted; the response contains only that supported subset. In particular,
Pi's `["authorization_code", "refresh_token"]` request is accepted without claiming
refresh-token support. A request with no usable supported flow fails.

- Supply 1–8 redirect URIs, each at most 2,048 UTF-8 bytes. Coral accepts HTTPS,
  explicit loopback HTTP, and native-app reverse-domain schemes such as
  `com.example.app:/oauth` (RFC 8252 §7.1). Other schemes, wildcards, credentials,
  fragments, control characters, and OAuth response-parameter collisions fail.
- Matching remains strict except for the existing HTTP loopback **port** variation.
  The actual requested redirect URI, including its port, binds the authorization code.
- An optional client name must be nonempty, trimmed, at most 255 UTF-8 bytes, and
  contain no control or bidirectional formatting characters. An absent name becomes
  `Unnamed OAuth client`. The consent page escapes names and identifies them as
  self-asserted. No supplied name, software identifier, metadata URL, or redirect
  origin acquires Reef's first-party identity or a trusted-client exception.
- Grant and response lists contain at most eight entries of at most 64 bytes each.
  Unrecognized metadata is ignored rather than stored. Coral does not fetch or render
  client logos, homepages, or other optional URLs. Requested scopes grant nothing;
  the accepted response explicitly returns an empty `scope`.
- No client secret, registration access token, access token, or refresh token is
  issued by registration. Malformed/unsupported requests return
  `invalid_client_metadata`; invalid redirect lists return `invalid_redirect_uri`.

Every new DCR client requires ordinary Coral consent, upstream OIDC sign-in, and
normal User/Workspace authorization. S256 PKCE remains mandatory. Code redemption
checks the exact client ID, actual redirect, verifier, and resource. A successful
exchange consumes the code once. Registration changes no access-token lifetime,
private first-party audience rule, or Agent Registration behavior.

### Anonymous admission and recovery

The registration body is limited to **5 KiB before parsing**, independently of
Content-Length. The deployment accepts at most **60 registrations per 60-second
fixed window** and retains at most **1,024 registrations**. One database admission
row serializes checks and inserts across concurrent requests and independent pools
on both supported databases. The rate clock and registry are durable; restarting
Coral or changing client identities cannot reset them. A backwards host clock does
not reopen the current window. There is no IP/header-derived exemption.

Throttling returns HTTP 429 with `temporarily_unavailable` and `Retry-After: 60`.
Wait before trying again. Full storage returns HTTP 503 with
`temporarily_unavailable`; it never silently evicts working registrations. Existing
IDs continue to resolve at capacity. Invalid requests and failed transactions leave
no partial registration. The global pending-consent budget remains 4,096, with a
64-per-client cap, independently of DCR admission. Handshake state remains local to
one Coral process; durable registration does not add multi-replica handshake routing.

At capacity, the operator must review registrations before reclaiming space. V1 has
no registration-management endpoint or UI. Stop every Coral process using the database,
back up the database, and remove only reviewed obsolete rows from `oauth_clients` by
exact `client_id` using the database's administration tools. Leave the singleton
`oauth_registration_admission` row intact. Capacity is counted from surviving rows;
restart does not require a counter repair. Removed clients must register and obtain
consent again. Do not delete User, Workspace, or Agent Registration records to recover
OAuth client capacity. Restoring the database also restores its registrations.

Keep registration bodies, callback queries, names, and credentials out of operational
logs. DCR emits no raw payload telemetry. Configure an ingress proxy's independent
connection limits for anonymous traffic without trusting arbitrary forwarding headers.

## Listener configuration

Normal runtime startup serves distinct MCP, auth-support, and private native
gRPC listeners. Reef runs as the separate Coral web HTTP process, not a route on
MCP or gRPC. Omitting the following sections selects ephemeral loopback binds;
it does not disable any listener. Startup failure tears down the already
started listeners rather than leaving a partial runtime. Local auth-support has
its own configurable loopback listener, with no User OAuth routes.

```toml
[listeners.grpc]
bind = "127.0.0.1:14555"

[listeners.mcp]
bind = "127.0.0.1:14556"

[listeners.auth]
bind = "127.0.0.1:9080"
```

With `[auth]`, both `listeners.mcp.public_url` and `listeners.auth.public_url`
are required. They name the public MCP origin and Coral OAuth issuer,
independently of the bind addresses. Without `[auth]`, auth-support must bind
loopback and cannot advertise a public OAuth URL. Authentication policy remains
under `[auth]`, `[auth.session]`, and `[auth.provider]`; manual client-consent
exceptions are `auth.trusted_clients`.

## Hosted web interface

Declare the operator-controlled web interface once in Coral's configuration:

```toml
[listeners.web]
public_url = "https://coral.example.com"
```

With `[auth]` configured, Coral registers this origin as a web resource and
trusts its exact `/.well-known/oauth-client` client ID when it returns to the
derived `/auth/callback` login redirect. Only that pairing skips Coral's client
consent screen, and it must use the web origin as its resource. The same client
ID returning to any other redirect its metadata document names is an ordinary
client request: Coral checks the redirect against the document and the resource
against the registered resources, and the user confirms it. Reef makes such a
request with its Provider Setup callback when a workspace adds another
workspace's MCP server on the same deployment. Listing the web client ID in
`auth.trusted_clients` would skip consent on every callback, so do not repeat
it there. Metadata validation, PKCE, upstream sign-in, Organization admission,
and normal domain authorization still run on both paths. Other clients,
including clients on the same host, still require consent unless independently
listed in `auth.trusted_clients`. Do not repeat the web origin in
`auth.allowed_audiences` either; both settings remain available for other
explicit registrations.

For hosted web-client registration, `public_url` must be an HTTPS origin with
no path, query, credentials, or fragment. It may use a private-network hostname.
The web process must configure `CORAL_WEB_PUBLIC_URL` to the same origin. This
setting does not launch a web process or change listener binds.

The same origin's `/.well-known/oauth-client` is also the Client ID Metadata
Document Coral presents to upstream Provider authorization servers. Whether a
new automatic MCP Provider sign-in uses that document or Dynamic Client
Registration is governed by `outbound.mcp_oauth_client_bootstrap`; by default Coral
prefers the document only when the hostname resolves to public addresses. See
[Provider OAuth client identity](../../../../docs/development.md#provider-oauth-client-identity)
for the values, the split-DNS limitation and troubleshooting. Web and MCP may
share a public origin behind a reverse proxy while retaining separate internal
listeners. MCP uses this explicit web destination for absolute human-review links,
never the incoming Host header or the MCP origin.

Every served deployment must declare a human-interface destination. Explicit
loopback HTTP origins are accepted for navigation, but do not register an OAuth
audience or grant web-client trust. Loopback OAuth development must register its
audience/client separately.

Local Desktop omits `[listeners.web]` and `[auth]`. Its launcher passes the actual
loopback navigation-bridge origin through `CORAL_LOCAL_WEB_URL` before starting
Coral. Local web launchers can supply their actual Reef origin the same way.
This bootstrap-only variable is rejected with `[auth]` or `[listeners.web]`;
missing or invalid destinations fail startup instead of producing relative links.
Desktop uses the implicit local User and needs no client consent. Desktop must
be running on the browser's machine for its loopback Run links to work.

### Private web origins

Each HTTPS origin in `auth.allowed_audiences` (including the derived web
resource) authorizes fetching that origin's exact `/.well-known/oauth-client` metadata document over private
networks, including a tailnet. This is an operator-selected destination, not
an origin-wide or network-wide SSRF exception. Other client IDs retain public
URL and DNS restrictions; Workspace MCP origins do not grant this permission.
The fetch still verifies TLS, rejects redirects, and enforces the five-second
request timeout and 5 KiB response limit. `trusted_clients` remains a separate
choice to skip the login approval page for other clients. An additional audience
alone does not imply client trust, and client trust grants no Principal authority.

## Organization admission and initial Owner

An eligible first OIDC login atomically provisions a stable Coral User and an
Organization Member. Set `auth.provider.bootstrap_owner_subject` to the exact
upstream `sub` **before that identity's first Coral login** to provision the
initial Organization Owner. The configured provider issuer must also match.
This is an operator selection, not first-login-wins or an email/client claim.

Subsequent logins do not overwrite roles or re-enable disabled Users, even if
the bootstrap setting names them. Role changes and disablement belong to
`coral_workspace::OrganizationModule`, including last-enabled-Owner protection
and atomic Administrative Lifecycle Events. Reef controls are follow-up work.

For Google Workspace, an example provider section is:

```toml
[auth.provider]
issuer = "https://accounts.google.com"
client_id = "your-google-client-id"
client_secret_env = "GOOGLE_CLIENT_SECRET"
bootstrap_owner_subject = "the-initial-owners-exact-google-sub"

[auth.provider.required_claims]
hd = "example.com"
```

### Trusted profiles and Workspace email lookup

`auth.provider.name_claim`, `email_claim`, and `email_verified_claim` default to
`name`, `email`, and `email_verified`. They are read separately from the verified
ID token. The older `display_name_claim` remains private authentication metadata
and is not assumed to be a person's name. Profile refresh preserves the stable
Coral User, roles, and disabled state; out-of-order logins cannot restore an older
profile. Names and emails are mutable and non-unique, never identity or linking keys.

Coral adds no email-verification ceremony. Google Workspace admission uses the
signed `hd` claim above, and email lookup uses the signed `email_verified` evidence.
Other providers' missing/false verification evidence prevents lookup, not otherwise
eligible login or existing access. Explicit operator-required admission claims
remain independent of this lookup policy.

Workspace Owners can see names and emails in People and retained Agent
creator attribution. Members can see themselves and the designated Owners only.
Exact email lookup is Owner-only in an active Workspace, trims surrounding whitespace,
and ignores ASCII case without alias rewriting. Multiple eligible matches return no
candidate list or grant. Confirmation rechecks the selected stable User and reviewed
profile evidence, ambiguity, eligibility, and current authority in the grant transaction.
A changed selection must be looked up and confirmed again; a committed request replay
never grants to a different User. Profiles can remain stale until the next login;
Coral does not synchronize an external directory or resolve account collisions.

### WorkOS and upstream access-token admission

WorkOS Connect returns identity claims in the ID token, but its selected
Organization is the `org_id` claim of the access token. Configure a confidential
Connect OAuth application (not the AuthKit environment's client ID) and opt in
to access-token admission to restrict the installation to one WorkOS Organization:

```toml
[auth.provider]
issuer = "https://your-environment.authkit.app"
client_id = "client_your_connect_application"
client_secret_env = "WORKOS_CONNECT_CLIENT_SECRET"
bootstrap_owner_subject = "user_the_initial_owner"

[auth.provider.access_token]
# With no resource indicator requested, WorkOS Connect uses the environment's
# client ID as its access-token audience, not the Connect application's ID.
audience = "client_your_authkit_environment"

[auth.provider.access_token.required_claims]
org_id = "org_your_organization"
```

This optional, provider-neutral policy requires an RS256 JWT access token from
that same code exchange. Coral verifies it with the configured provider's
JWKS, checks issuer, exact single audience, expiration/issuance/not-before times,
and the same subject as the independently verified ID token. Required
access-token claims use exact JSON equality. A missing, opaque, invalid, or
nonmatching access token fails login before User provisioning or code issuance.
The upstream access token is discarded, never persisted or forwarded to Reef or
MCP. Its roles do not become Coral roles. Without this policy, ordinary OIDC
sign-in still requires only a valid ID token; Google and other providers need
not issue JWT access tokens.

This is login admission, not external membership synchronization: removing a
WorkOS membership does not itself revoke an existing Coral session. Use Coral
Organization disablement to block current Coral authority.

Register `{listeners.auth.public_url}/auth/oidc/callback` as the callback
URL at the identity provider. Coral derives that URL; it is not a separate
`redirect_uri` setting. OIDC identity always uses the verified issuer and `sub`,
not a configurable Principal claim. Discovery metadata is cached for five minutes.

The remaining authorization-server, signing-key, and MCP listener settings are
still required. Google domain admission validates the signed `hd` claim; an
email suffix or login-screen hint is not sufficient. With Okta, configure its
OIDC issuer and application assignments, plus required claims when appropriate.

SAML and external membership synchronization are not implemented here. Removing
access at the identity provider does not immediately invalidate existing Coral
access. Coral-side disablement blocks subsequent authenticated requests and
later Run dispatches without deleting retained history or disabling Agents
merely because that User created or provisioned them. It does not retroactively
undo a fenced dispatch or introduce a new Run-termination policy.

## Listener health and private audiences

HTTP listeners expose unauthenticated `/healthz` (process liveness) and `/readyz`
(listener readiness). Private gRPC uses `grpc.health.v1.Health/Check`: the empty
service name is liveness and `coral.readiness` is private-listener readiness.
Readiness probes are bounded, cached, and coalesced before they reach the database.
Auth-support also requires composed login dependencies in authenticated mode.
Reef bounds its private gRPC readiness check and returns a safe HTTP 503 on failure;
its liveness stays healthy. Neither Workspace catalog state, upstream Provider
health, nor telemetry export decides listener readiness. Operators terminate public
TLS and isolate private gRPC on loopback or an internal network (optional operator
mTLS). No auth-disabled listener may bind off loopback.

A first-party User client redeems its normal OAuth authorization code and PKCE
verifier at `/oauth/token`, with the original `resource` and an optional
`coral_audience=mcp|reef` form parameter. This is a private first-party extension,
not token exchange or client-credentials authentication:

- Omit it for the unchanged public URL-audience token.
- `mcp` requires an exact resource in the configured Workspace MCP URL family.
  The signed token carries `aud=mcp` and that exact `workspace_id`.
- `reef` requires an explicitly registered resource in `auth.allowed_audiences`,
  not a Workspace MCP resource. The signed token carries `aud=reef`; each method
  still authorizes its requested Workspace. Configure only Reef resources in this
  explicit registry; MCP resources already belong to the Workspace URL family.
- An unrecognized or incompatible selection fails with `invalid_target`. The
  resource approved at login cannot be replaced at redemption. The code fixes
  the User and client identifier; a request cannot supply a Principal or role.

Private RPCs require `authorization: Bearer …` and `coral-audience: mcp|reef`.
The metadata selects the expected audience; it cannot override the signed one.
Public URL tokens do not authenticate private gRPC. MCP User tokens must contain
matching signed Workspace scope. Agents use their existing exact Workspace-bound
Registration credential, admitted only as `mcp`; Coral issues no second Agent or
workload credential. Local missing credentials select the implicit User, still
subject to method reachability and normal domain authorization. Malformed,
duplicated, or invalid credentials never fall back to local authority.

---

### Apps Docs Index (`apps/docs/index.mdx`)

---
title: "Introduction to Coral"
description: ""
---



Coral allows you to centralise access to <Tooltip tip="GitHub, Slack, Notion, and anything else behind an OpenAPI document or an MCP server.">providers</Tooltip>. It enables more fine-grained access control using policies you define.
It learns from your usage, and improves itself continuously.

You run Coral yourself. Your data, credentials and usage history stay on your
machine or on your own server.

## Get started

<Steps titleSize="h3">
  <Step title="Install Coral">
    <Tabs>
      <Tab title="Desktop app">
        Run Coral on your own machine, for yourself. Download the desktop app
        for macOS, Windows, or Linux from the
        [releases page](https://github.com/withcoral/coral-releases/releases).

        [Install the desktop app](/getting-started/install-locally)
      </Tab>

      <Tab title="Shared server">
        Run Coral as a server your team signs in to, using Coral's official container images.

        [Run a shared server](/getting-started/self-host)
      </Tab>
    </Tabs>

  </Step>

<Step title="Add your providers">
  Add GitHub, Slack, Datadog, and other providers to your workspace. Your agents
  can only call operations from providers you've added.

  [Add a provider](/getting-started/quickstart#add-a-provider)
</Step>

  <Step title="Connect your agents to Coral via MCP">
    Configure your MCP clients (e.g. Claude Code, Codex) to use the [Coral MCP server](/getting-started/quickstart#connect-an-mcp-client)
  </Step>
</Steps>

## Quick links

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/getting-started/quickstart">
    Add a provider, connect your agent, and ask your first question
  </Card>
  <Card title="Concepts" icon="book-open" href="/guides/concepts">
    Build the right mental model for Coral
  </Card>
</CardGroup>

---

### Apps Docs Getting Started Quickstart (`apps/docs/getting-started/quickstart.mdx`)

---
title: "Quickstart"
description: "Add a provider, connect your agent, and ask your first question."
---

Install the [desktop app](/getting-started/install-locally) or
[run a shared server](/getting-started/self-host) first.

<Steps>
  <Step title="Open the Coral UI">
    You set up everything in Coral from the Coral UI.

    - **Desktop app:** Open Coral. The app window is the Coral UI.
    - **Shared server:** Go to `https://<coral-host>` in your browser, then sign in.
  </Step>

  <Step title="Add a provider" id="add-a-provider">
    A [provider](/guides/concepts#providers) gives your agents access to a
    service, such as GitHub or Linear. Your agents can only call the providers
    in your workspace. Only [workspace owners](/guides/concepts#workspaces) can
    add a provider.

    Choose a provider from Coral's catalog, or add your own from an MCP server
    or an OpenAPI document.

    A new provider starts with a default [policy](/guides/concepts#operation-policy).
    Operations that read data run without approval. Other operations need
    approval. Workspace owners can change the policy.
  </Step>

  <Step title="Connect your agent" id="connect-an-mcp-client">
    Connect your agent to Coral once, and it can use every provider in your
    [workspace](/guides/concepts#workspaces). Copy the setup for your MCP client from the Coral UI. It already
    contains your workspace's URL.
  </Step>

  <Step title="Ask a question">
    Ask your agent something that needs your provider. For example, if you've
    connected GitHub, you could ask:

    > What are my open pull requests?

  </Step>
</Steps>

## Next steps

<CardGroup cols={2}>
  <Card title="Concepts" icon="book-open" href="/guides/concepts">
    Learn how workspaces, providers, and policies fit together.
  </Card>
  <Card title="Query databases" icon="database" href="/guides/query-databases">
    Give your agents read access to a database.
  </Card>
</CardGroup>

---

### Development (`docs/development.md`)

# Developing Coral and Reef

This is the authenticated development setup. It runs the Coral backend
and Reef from your checkout, using WorkOS for sign-in and Tailscale Serve for
private HTTPS by default. It does not install a service or enable Funnel, but
preserves matching Funnel mappings you have explicitly enabled.

## Prerequisites

On the machine that runs the checkout:

- Linux or macOS (use WSL for Windows).
- Node from `apps/reef/.node-version`, or a newer patch/minor of that major, and npm.
- Git and Rust via rustup; Cargo uses `rust-toolchain.toml`. Install the native
  build tools required by your platform. Linux uses clang and mold as specified
  in `.cargo/config.toml`.
- Tailscale installed, running, and signed into your tailnet. Your browser's
  machine must also have access to this tailnet device unless you explicitly enable
  Funnel for the surfaces it needs.
- Access to the team's WorkOS staging environment, and an enabled User membership
  in the Organization you will configure. You need permission to create a
  Connect application, or a teammate must create it for you.

Herdr is optional. Docker and a locally managed Postgres are not required; this
setup uses a separate SQLite database.

## First setup

```bash
./dev setup
```

The wizard installs Reef's pinned npm dependencies, builds Coral, and then walks
you through:

1. Confirming your machine's Tailscale hostname and the WorkOS environment.
   Enter its AuthKit issuer, environment client ID, and Organization ID. The
   repository holds no defaults for them; a team keeps its own in a password manager.
2. Creating a **separate confidential Connect OAuth application** for your
   development machine: choose **Managed by you**, keep client-secret
   authentication, and register the exact callback the wizard prints.
3. Entering its client secret (hidden input) and selecting your own WorkOS
   `user_…` ID as this checkout's initial Coral Organization Owner.
4. Generating fresh signing/session keys and configuring Tailscale Serve. Choose
   sudo only if your Tailscale installation requires it. If Tailscale prints an
   HTTPS-enablement link, follow it and retry `./dev proxy` or `./dev proxy --sudo`.

The environment client ID from **Developer → API Keys** and the OAuth client ID
from **Connect** are different values. Both are needed. Keep the client secret
in your password manager; never send it through chat or commit it.

The same private ports are used on every developer's own machine:

| Surface | Loopback address | Tailnet HTTPS |
| --- | --- | --- |
| Reef | `127.0.0.1:5173` | `https://<machine>.<tailnet>.ts.net` |
| Coral auth | `127.0.0.1:9080` | same hostname, port `8443` |
| Coral MCP | `127.0.0.1:14556` | same hostname, port `10000` |
| Private gRPC | `127.0.0.1:14555` | not exposed |

There can be one running stack on these ports per machine. The runner refuses
occupied ports rather than killing processes. Setup reuses matching Serve or
Funnel mappings without changing their visibility, but refuses to replace another
service, path handler, or incomplete configuration. Missing mappings are always
created with private Serve. Run/proxy/doctor warn when existing mappings use Funnel.
Tailnet access controls should normally restrict the development server to trusted
contributors: Vite serves development source code, not just authenticated pages.

### Optional public access with Funnel

Funnel removes the tailnet access boundary: anyone on the internet can reach the
exposed port. Coral's authentication still applies to protected application routes,
but it does not protect Vite's development source. Only enable public access when
you accept that exposure; expose only the surfaces external clients need.

To explicitly make all three surfaces public:

```bash
tailscale funnel --bg --https=443 http://127.0.0.1:5173
tailscale funnel --bg --https=8443 http://127.0.0.1:9080
tailscale funnel --bg --https=10000 http://127.0.0.1:14556
```

Follow any Tailscale enablement prompts. These public ports are supported by Funnel;
the backend loopback ports do not change. `./dev proxy` never enables or disables
Funnel itself. Check visibility with `tailscale funnel status` and test public
reachability from a device disconnected from Tailscale. Never expose private gRPC.

If upgrading an older setup that used MCP HTTPS port `8444`, run `./dev setup` to
regenerate the advertised URL and create the `10000` mapping, then restart Coral
and update MCP clients to the new URL. After verifying it, remove only your old
`8444` mapping using `tailscale serve --bg --https=8444 off` (or `tailscale funnel`
with the same flags if it used Funnel). Do not reset all Tailscale mappings.

## Run

```bash
./dev run
```

Coral rebuilds through Cargo; Reef uses Vite hot reload. Output from both
processes appears in the terminal. The runner waits up to five minutes for
readiness and prints the web URL. Ctrl-C stops both process groups, including
npm/Vite and Cargo children. If either server exits, the other is stopped too.
For Rust changes, stop and rerun the command. Alternatively, use Bacon to rebuild
and restart just Coral automatically, with Reef in a separate terminal:

```bash
bacon coral
./dev reef  # In another terminal
```

`bacon coral` wraps `./dev coral`, using the same setup, credentials, readiness
checks, and process cleanup. It watches backend sources and dev scripts, not
runtime state under `.dev/`.

For left/right Herdr panes, start in **one ordinary shell pane in a fresh
workspace**, not a coding-agent pane:

```bash
/path/to/checkout/dev run --herdr
```

This keeps Coral in the left pane and starts Reef on the right without changing
focus. Each pane is independent: stop each with Ctrl-C. Restart with `./dev coral`
or `./dev reef`. The same two commands work in ordinary separate terminals.

```bash
./dev doctor  # Check an already-running stack, including its HTTPS routes
./dev test    # Test the tools; requires Reef's npm dependencies, but no live IdP or tailnet
```

## Configuration and isolation

Everything private is under this checkout's Git-ignored `.dev/`:

- `setup.env`: saved wizard answers, including the Connect client secret.
- `state/config.toml` and `state/coral.db`: Coral configuration and isolated state.
- `secrets/`: per-checkout signing key and web session secret.
- `web.env`: server-only Reef configuration (`CORAL_WEB_*`).
- `settings.json`: the last applied non-secret setup identity.

`apps/reef/.env` is a relative symlink to `.dev/web.env`. Existing unrelated `.env`
files, development dotenv overrides, and unrecognized `.dev/` directories are
rejected rather than overwritten. If you already use a machine-local setup,
leave it alone or deliberately move its Reef env link aside before opting in.
These tools never read, migrate, or delete `~/.config/coral`.

Setup is rerunnable: Enter retains saved answers, and signing keys, session
secrets, and the database are preserved. Once the database exists, changing its
IdP, Organization, or initial Owner is refused. Use a fresh checkout for a fresh
installation; there is no automatic reset or migration. Edit settings through
`./dev setup`, then restart the servers.

`.dev/` is also excluded from Docker build contexts and explicitly denied by
Vite's filesystem server, including direct `@fs` requests. File permissions are
private to your OS user. Do not copy another developer's `.dev/` directory.

### Outbound provider access

Edit your deployment's `config.toml`, then restart Coral. Each field independently
accepts `deny`, `https`, or `http-and-https`:

```toml
[outbound]
loopback = "deny"
private_networks = "deny"
```

These are the uniform base defaults. The implicit-user CLI composition and Desktop
launcher supply `http-and-https` defaults for both categories. Explicit configuration
always wins, including `deny`. Omitted fields keep their composition defaults without
being saved as explicit selections. Ordinary server builders and authenticated
`./dev` deployments supply no permissive preset. Authentication never overwrites an
explicit outbound choice. Inbound auth-disabled listeners still require loopback.

```toml
# Authenticated HTTP localhost sidecar; private networks remain denied.
[outbound]
loopback = "http-and-https"
```

```toml
# Verified HTTPS internal providers; loopback remains denied.
[outbound]
private_networks = "https"
```

For an HTTP Docker-network sidecar such as `http://sidecar:5000`, select
`private_networks = "http-and-https"`. Private means RFC1918 IPv4 or unique-local IPv6,
including all DNS answers. Public addresses remain HTTPS-only. Metadata, link-local,
CGNAT (`100.64.0.0/10`) and other blocked special ranges stay blocked. Only explicit
localhost names and loopback literals can use loopback permission.

HTTP permission never disables HTTPS certificate verification. Enabling a category
trusts Provider administrators and imported Provider documents with that network
reachability, including unauthenticated internal services. Private HTTP can be
intercepted. This address policy is not network isolation or complete lateral-movement
prevention. The same resolved policy covers server-side setup, publication checks, HTTP/MCP
dispatch, redirects and OAuth fetches. There is no per-provider override or old-key alias.

Provider consent navigation is separate. Coral screens each initial HTTP consent URL
when it issues it, including reuse. Private HTTP hostnames must resolve exclusively to
private addresses in Coral's DNS lookup. Public HTTP destinations and blocked-special
literals are rejected; blocked-special literals are rejected under HTTPS too. Browser
DNS and later redirects can differ, so this is not connection-time enforcement.
`[outbound]` does not restrict browser consent transport: HTTP consent can coexist
with HTTPS-only discovery and token exchange. See
[ADR 0009](adr/0009-provider-consent-navigation.md) for the exact contract.

`./dev setup` regenerates `.dev/state/config.toml`. Reapply operator selections after
running it. See [ADR 0008](adr/0008-outbound-network-permissions.md) for the decision.

### Provider OAuth client identity

When a Provider's authorization server needs an OAuth client and nobody enters a
client ID, Coral establishes one automatically in one of two ways. This applies to
MCP providers only: MCP discovery is what reports document support and a registration
endpoint, so an OpenAPI provider needs a client ID you enter or its recipe pins. With
a Client ID Metadata Document, Coral's client ID is the URL `/.well-known/oauth-client` under
`listeners.web.public_url`, which the authorization server fetches. With Dynamic
Client Registration, Coral posts its details to the server's registration endpoint
and uses the client ID the server returns. Only an HTTPS `listeners.web.public_url`
on a deployment with `[auth]` configured publishes a document. Desktop and other local deployments publish none,
so they register dynamically when the server offers a registration endpoint and
otherwise need a client ID.

A server that advertises document support cannot necessarily fetch a document hosted
on a tailnet or an internal network. The consent then fails on the server's own page
after sign-in, where Coral cannot observe it. By default, when a new automatic
sign-in starts, Coral resolves the document's hostname once through the deployment's
normal resolver and prefers the document only when every returned address is public.
A private, loopback, CGNAT (`100.64.0.0/10`), link-local or other special-use answer
selects registration when the server offers it; so do an empty answer, a resolver
failure and a timeout, which Coral reports as unresolved rather than as private.
When the server offers only the document, the sign-in stops before the browser opens
and says why. The person can enter a client ID from their own registration, or you
can publish the document on a public address or select a policy below. An IP literal
or `localhost` origin is judged without DNS. Nothing is stored and nothing polls DNS.

Split DNS is the known limitation. A hostname can resolve to tailnet space on Coral's
host while public DNS, and the authorization server, see a public address, for
example a Tailscale Funnel origin that also resolves inside the tailnet. A non-public
answer therefore does not prove the server cannot fetch the document, and a public
answer does not prove it can, or that the document is valid. Edit your deployment's
`config.toml`, then restart Coral. The policy lives under `[outbound]` because it
shapes how Coral identifies itself to an upstream MCP authorization server. It does
not change what Coral may reach; `loopback` and `private_networks` govern that:

```toml
[outbound]
mcp_oauth_client_bootstrap = "prefer-client-id-metadata-document"
```

| Value                                           | New automatic clients                                                                                                                                                                                                                                       |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `automatic` (default)                           | The document when the server supports it and its hostname resolves to public addresses only; otherwise registration. Without registration, the sign-in stops with `CLIENT_METADATA_NOT_PUBLIC` or `CLIENT_METADATA_UNRESOLVED`. With neither a supported document nor a registration endpoint, the sign-in needs a client ID (`CLIENT_BOOTSTRAP_UNAVAILABLE`). |
| `prefer-client-id-metadata-document`            | The document whenever the server supports it, whatever the local resolver answers. For split DNS, and for servers with approved access to your internal network. Registration remains the choice for servers without document support.                   |
| `dynamic-client-registration-only`              | Registration or nothing. A server without a registration endpoint needs a client ID (`CLIENT_BOOTSTRAP_UNAVAILABLE`), and a failed registration is reported (`AUTHORIZATION_DISCOVERY_CLIENT_BOOTSTRAP_FAILED`) rather than replaced by the document. |

The policy governs only new automatic clients. A client ID someone enters, a client a
recipe or an OpenAPI document pins, and a registration Coral already holds for the
same callback are used as they are, and a working client is never rotated because a
DNS answer or a default changed. The policy does not relax URL validation, issuer
binding, callback checks, PKCE, consent, or the `[outbound]` destination policy.

Troubleshooting:

- The server shows an error such as "Unknown OAuth client" after consent and never
  returns to Coral: the server could not fetch the document. Confirm the sign-in used
  the document: the consent page's address carried `client_id=` with the document URL.
  Then publish the document on an address the server can reach, enter a client ID from
  a manual registration with the server, or rely on the default policy, which selects
  registration when the hostname resolves to non-public addresses.
- The sign-in stops with `CLIENT_METADATA_NOT_PUBLIC`: the hostname resolves to
  non-public addresses on Coral's host, or the origin is a non-public IP literal or
  `localhost`, which Coral judges without DNS, and the server offers no registration.
  If the server can reach the document anyway, select
  `prefer-client-id-metadata-document`.
- The sign-in stops with `CLIENT_METADATA_UNRESOLVED`: Coral's resolver could not
  resolve its own `public_url` hostname within a few seconds. Check the host's DNS
  configuration; `prefer-client-id-metadata-document` skips the lookup.

### Credential encryption key

Coral encrypts stored Provider Account credentials with one AES-256 key. By default
the runtime creates that key as `credentials.key` in its config directory on first
start and reads it from there afterwards. A deployment whose secrets live in a
secret manager names the environment variable that carries the key instead, so
the runtime never writes a key file. Edit your deployment's `config.toml`, then
restart Coral:

```toml
[credentials]
key_env = "CORAL_CREDENTIALS_KEY"
```

The variable holds the standard base64 encoding of 32 random bytes, the same
bytes a key file holds. Generate a new key with `openssl rand -base64 32`, or
move an existing deployment's key into the secret with `base64 < credentials.key`
and then remove the file. Coral refuses to start while a key file sits beside
`key_env`, because sealing new credentials under a different key from the one
the stored credentials need would only surface later as unreadable credentials.
A missing variable or one that does not decode to 32 bytes also refuses startup.
Losing the key makes every stored credential unreadable; back it up like the
database.

## Google sign-in through WorkOS

Google OAuth is configured **once per WorkOS environment**, not once per developer.
If Google displays `redirect_uri_mismatch`:

1. Open WorkOS **Authentication → OAuth providers → Google → Manage** and copy
   its Google **Redirect URI**.
2. Add that exact URI to the authorized redirects on the Google OAuth client
   configured in WorkOS.

That is **Google → WorkOS**, not the developer-specific **WorkOS → Coral** callback
ending in `/auth/oidc/callback`. Do not replace one with the other. See the
[WorkOS Google OAuth guide](https://workos.com/docs/integrations/google-oauth).

Successful sign-in still requires the configured WorkOS Organization and verified
identity. WorkOS roles do not become Coral roles. Coral's configured first-party
web client skips Coral's client-consent page when it signs a user in; its
Provider Setup flow and other MCP clients retain consent.

---

### Workflow (`docs/workflow.md`)

# Our development workflow

Agents make implementation faster. They do not remove the need for a process,
shared understanding, or engineering judgement.

**We use one team process, not a collection of individual processes.** You can
choose your model, reasoning level, and harness. The workflow and quality bar
stay the same. If the process needs to change, propose a change for everyone.

**You are responsible for the code you ship.** An agent writing it, reviewing
it, or passing tests does not transfer that responsibility. All of us own the
quality of the product.

## The short version

1. Check the proposed work against existing issues, the spec, and planned work.
2. Run `/grill-with-docs` to agree the problem, scope, and approach. Then ask the
   agent to create or update the source ticket. Share changes to product behaviour
   or shared contracts with the team before building. For larger efforts, share
   the map early and use `/to-spec` and `/to-tickets` to publish the build plan.
3. Run `/implement` on a working branch.
4. In a **new session**, run `/code-review` against `main`, with the source issue
   and any relevant additional context. Review the code yourself in parallel.
5. Evaluate the findings and the approach. Iterate on the implementation or
   restart with what you have learned.
6. Verify the result and create a PR. Link the source ticket and try adding a
   `/show-me` visual to explain the change. Attach a screen recording for UI
   features.

Large, uncertain efforts need `/wayfinder` before implementation. The sections
below explain when and why.

## 1. Check that this is work we need now

Before starting, read the existing issue or describe the problem if there is no
issue yet. Check whether later tickets already address it.

Give the agent the issue you are working on, if one exists, including relevant
comments.

## 2. Think before building

Settle the problem, expected behaviour, scope, and how you will verify it.
Participate in the discussion; the agent should not make product decisions on
your behalf. Scale the planning to the level of uncertainty:

| Where you are                                                                    | What to run                                                                                                                                                                                                      |
| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| You have a tiny, mechanical change with clear behaviour                          | Keep the scope check brief, then ask the agent to create or update the ticket. You do not need a full spec for a small fix.                                                                                      |
| You have a bounded bug, feature, or design you can reason through in one session | Run `/grill-with-docs`, then explicitly ask the agent to create or update the ticket with what you agreed.                                                                                                       |
| You expect implementation to need several sessions                               | Run `/grill-with-docs`, then `/to-spec` to publish the spec as a GitHub issue. Share it for team feedback, then run `/to-tickets` to propose a breakdown and create implementation tickets after you approve it. |
| You have a large, uncertain effort whose decisions cannot fit in one session     | Run `/wayfinder` to create the map and its decision issues. Once the route is clear, use `/to-spec` to publish the spec, get team feedback, then `/to-tickets` to create the approved implementation tickets.    |

`/grill-with-docs` updates `GLOSSARY.md` when terminology is settled and records
significant trade-offs in ADRs when warranted. It does **not** create a GitHub
issue; the explicit request in the table is a separate step.

Keep the discussion and ticket-writing steps in the same session. If an issue already
exists, update it rather than create a duplicate. Check that the tickets
capture what you agreed.

Scope each implementation ticket to fit in one fresh agent context window. If
it is too large, split it into smaller, independently verifiable pieces before
starting. Implement one ticket per fresh session for a multi-ticket build.

Wayfinder's decision issues are planning questions, not implementation tickets.
A long but well-understood build does not need Wayfinder just because it is long.
If scope expands or unresolved decisions emerge during implementation, stop and
return to planning.

### Share the thinking before the code

- **Bounded work that changes product behaviour or shared contracts:** share the
  source ticket with the proposed approach, scope, and open questions before
  implementation. A routine fix within an agreed spec does not need another
  team planning round.
- **Wayfinder work:** share the map early, once the destination, scope, and first
  decision questions are visible. Ask whether we are solving the right problem
  and which constraints are missing. Bring consequential decisions back to the
  team as the map develops; do not wait until every decision is settled.
- **Multi-session builds:** share the resulting spec before splitting it into
  implementation tickets. Ask for feedback on behaviour, boundaries, trade-offs,
  and what is out of scope. Share the ticket breakdown when dependencies or
  ownership need coordination.

Share widely, but always @mention the relevant people you want feedback from in Slack. Link the artifact and say what feedback you need, your recommendation,
and what is still undecided. Resolve feedback that affects the approach before building,
and record the outcome in the ticket or spec. A chat transcript is not the
handoff, and a PR should not be the team's first sight of a significant decision.

[Plannotator](https://plannotator.ai/) can help you respond to agent Q&A:
annotate the questions and recommendations with your answers or pushback, then
return that feedback to the agent.

## 3. Implement the agreed scope

Work on a branch for your PR. Pass the agreed issue and spec context to
`/implement`. Stay involved. Challenge speculative features, unnecessary
abstractions, and local patches that hide the underlying problem. Prefer the
smallest complete fix at the right boundary, not necessarily the smallest diff.

The skill includes testing, a review, and a commit. That review does **not**
replace the fresh-session review below. Follow the repository's validation
requirements rather than treating the skill's defaults as sufficient.

## 4. Review the implementation

**Review the code yourself.** Agent review does not replace reading and
understanding the implementation. You may only see that the approach is wrong
when you look at the code. Recognising that is your responsibility. [Plannotator's code review tool](https://docs.plannotator.ai/code-review)
can help you explore and annotate the diff.

Run `/code-review` in a **new session**, and use that time to review the code
yourself in parallel. Give the review agent a prompt such as:

```text
/code-review since main
Review against <source issue URL>.
Additional context, if relevant: <spec, architecture decision, or map links>.
Read relevant comments and amendments. Check both repo standards and spec fit.
Flag scope creep. Do not invent requirements for hypothetical future needs.
```

**Agent findings are input to your judgement, not a new backlog.** For each
finding, ask:

- Is it supported by the code and agreed requirements?
- Does it identify a real correctness, security, or maintainability problem?
- Is the proposed fix necessary now, and does it address the cause?
- Does it add behaviour or flexibility nobody has asked for?

Bias towards **YAGNI: you aren't gonna need it**. Push back on speculative
features and future-proofing. This is not an excuse to dismiss real bugs or skip
required safeguards.

Take the findings back to the implementation session with your decisions:
what to fix, what to reject, and why. Do not paste the report with “fix everything.”
Verify the fixes and re-review materially changed code as needed; do not chase
an endless cycle of agent-generated improvements.

### Decide whether to iterate or restart

Implementation teaches us things that planning did not. A poor approach often
means we left the work underspecified, with too many open decisions for the
agent to make itself.

An oversized ticket can also be the problem. Before retrying, consider splitting
it rather than asking another agent to tackle the same scope.

Use your judgement: can focused changes fix the approach, or should you discard
this implementation and start again? Implementation is cheap; existing code is
not a reason to keep the wrong design.

If you restart, explore the change with the implementation agent first. Identify
what you learned and which decisions need to be settled. Ask it to write a
`/handoff` for the next agent, capturing those lessons and linking existing
artifacts. Update the ticket if the learning changes or clarifies the agreed
work. Then start a fresh implementation session with the ticket and handoff.

## 5. Ship work you can stand behind

### Open a PR with a useful description

Write for future code archaeology. Someone returning to this change needs to
understand **why** it happened. Link the source ticket
for the problem, context, and agreed scope. Capture any new rationale there
rather than burying it in a generic agent-generated summary or file-by-file list.

Try running `/show-me` on the change and adding its output to the PR description:
a focused diagram, before-and-after sketch, or control flow can explain more
than a wall of prose. Check that it reflects the final implementation. Paste
Markdown-compatible output directly, or attach a screenshot or accessible link
for an HTML visual.

For UI features, attach a screen recording of the interaction so future readers
can see how the feature behaved.

### Assess feedback and merge

While Cubic's automated PR reviews are enabled, apply the same judgement as
for `/code-review`: assess each finding, address real problems, and push back
on speculative scope or unnecessary changes. Do not treat its findings as a
mandatory backlog. Cubic does not replace your own review or the fresh-session
review. Whether we keep Cubic enabled is still undecided.

If you want a second pair of eyes, assign the PR to the relevant teammates and
say what you want them to look at.

When the work is ready, you can merge the PR yourself.

## Ask for help, not a substitute owner

Ask teammates for input whenever it helps: an unclear requirement, a design
trade-off, an unfamiliar area, a disputed finding, or a second pair of human
eyes. You do not have to solve everything alone.

But asking someone else to ask their agent to review your agent's work is an
inefficient default. Run that review yourself and assess it first. Bring a
specific question, the relevant context, and your current judgement when you
need help.

---

This workflow uses the installed [AI Hero / Matt Pocock skills](https://github.com/mattpocock/skills).
See [the skill flow guide](../.agents/skills/ask-matt/SKILL.md) for the wider toolkit.

---

### Apps Docs AGENTS (`apps/docs/AGENTS.md`)

# Documentation guidance

- Public pages are MDX with YAML frontmatter. Navigation and Mintlify settings
  live in `docs.json`.
- Document public behavior and the minimum operational knowledge that readers
  need. Prefer the best existing page over a new page, and do not publish
  internal agent process.
- Use second person, root-relative internal links, language tags on code blocks,
  and alt text on images. Test code examples.

## Publishing

- The live site deploys from the long-lived `docs` branch, not from `main`.
- `main` still owns docs freshness. A merge to `main` does not publish the site.
- The release workflow advances `docs` only after it publishes the matching
  release. There is no separate immediate path for a docs-only release.

---

### Apps Docs Docs.Json (`apps/docs/docs.json`)

{
  "$schema": "https://mintlify.com/docs.json",
  "theme": "linden",
  "name": "Coral Docs",
  "description": "Install Coral, publish providers from OpenAPI documents and MCP servers, and let your agents discover and call their operations over MCP.",
  "logo": {
    "dark": "/images/coral-wordmark-dark.svg",
    "light": "/images/coral-wordmark-light.svg"
  },
  "favicon": {
    "dark": "/images/coralMark-dark.png",
    "light": "/images/coralMark-light.png"
  },
  "icons": {
    "library": "lucide"
  },
  "appearance": {
    "default": "system",
    "strict": true
  },
  "colors": {
    "primary": "#398125",
    "dark": "#398125",
    "light": "#B2EAA5"
  },
  "fonts": {
    "family": "Encode Sans Semi Expanded"
  },
  "styling": {
    "codeblocks": {
      "theme": {
        "light": "github-light",
        "dark": "github-dark"
      }
    }
  },
  "css": "/global.css",
  "navbar": {
    "links": [
      {
        "label": "Download",
        "href": "/getting-started/install-locally",
        "icon": "download"
      },
      {
        "type": "github",
        "href": "https://github.com/withcoral/coral"
      },
      {
        "type": "discord",
        "_comment": "Mintlify only supports discord.gg link, so we can't use the canonical discord link here https://withcoral.com/discord",
        "href": "https://discord.gg/h9aun8KpFF"
      }
    ]
  },
  "navigation": {
    "groups": [
      {
        "group": "Get started",
        "pages": [
          "index",
          "getting-started/quickstart",
          "getting-started/install-locally",
          {
            "group": "Shared server",
            "pages": [
              "getting-started/self-host",
              "getting-started/self-host/docker-compose",
              "getting-started/self-host/kubernetes"
            ]
          }
        ]
      },
      {
        "group": "Guides",
        "pages": [
          "guides/concepts",
          "guides/query-databases"
        ]
      },
      {
        "group": "Reference",
        "pages": [
          "reference/configuration"
        ]
      }
    ]
  },
  "redirects": [
    {
      "source": "/getting-started/installation",
      "destination": "/getting-started/install-locally",
      "permanent": true
    },
    {
      "source": "/guides/self-host-with-docker",
      "destination": "/getting-started/self-host",
      "permanent": true
    },
    {
      "source": "/guides/deploy-on-kubernetes",
      "destination": "/getting-started/self-host",
      "permanent": true
    }
  ],
  "errors": {
    "404": {
      "title": "Looks like this page got lost at sea",
      "description": "The route you tried does not exist. Swim back to the [docs home](/) and pick a guide from there."
    }
  },
  "integrations": {
    "posthog": {
      "apiKey": "phc_vgjDFiUbVgKPsKQL3wKPwsG3z7KnH4hgLi6n57PX7HEs",
      "apiHost": "https://eu.posthog.com",
      "sessionRecording": false
    },
    "ga4": {
      "measurementId": "G-MZ1LQREJTK"
    }
  },
  "thumbnails": {
    "background": "/images/og-image-dark.png",
    "appearance": "dark"
  },
  "seo": {
    "metatags": {
      "canonical": "https://withcoral.com/docs"
    }
  },
  "search": {
    "prompt": "Search Coral docs..."
  },
  "contextual": {
    "options": [
      "copy",
      "view",
      "claude",
      "chatgpt",
      "cursor",
      "vscode",
      "mcp",
      "add-mcp"
    ],
    "display": "header"
  }
}

---

### Apps Docs Legal (`apps/docs/legal.mdx`)

---
title: "Coral Legal Documents"
timestamp: true
---

* [Corporate Contributor License Agreement](/legal/cla-corporate)

---

### Adr 0001 Program Failure Diagnostics (`docs/adr/0001-program-failure-diagnostics.md`)

---
status: accepted
---

# Return the Program's existing failure through MCP

Replacing a thrown error with “Program rejected” prevents an agent from understanding why its Program failed.
Return the error that already caused the Program to fail through the existing Run and MCP surfaces. This is error
propagation, not a new diagnostic execution phase, additional instrumentation, or host investigation. Code already
executing as part of the Program remains ordinary Program execution.

Every failed Run should expose the available permitted error information so the agent can understand the cause and
determine the appropriate correction. Rich runtime diagnostics
must stay within what is visible inside the V8 isolate. Never expose unhandled Rust errors, host stacks, or internal
causes. Host-side failures retain safe public classifications rather than acquiring private error details. Operation
Errors retain their existing defined shape; this change does not expand the Operation error contract. ADR 0019
replaces that sentence: the Run diagnostic of an Operation Error that carries an upstream response exposes its HTTP
status, when it has one, and the first 1 KiB of its encoded data.

Authorized Run readers may receive bounded Program-controlled error messages, authored source locations, and a bounded
chain of causes. Include bounded `AggregateError` members separately from its cause chain. Cycles and truncation must
be handled explicitly, including omitted members. Arbitrary attached object properties are excluded by default.
Trusted Coral error fields remain distinguishable from Program-controlled text. Diagnostic content stays
out of telemetry, host internals and credentials remain private, and no diagnostic promises that repeating a Run is safe.

Expose structured error kinds, messages, authored source locations, and existing Operation error fields directly through
`exec` and `wait`, so an agent need not open Reef or parse prose to investigate a failure. This revises an earlier
MCP projection that reserved detailed Program stacks for Reef. Diagnostics still name only authored source.

The trade-off is that Program-controlled messages may retain upstream data, as explicit Program output can. Access to
that content follows Run visibility rather than a blanket rule suppressing thrown messages. This does not grant agents
new access to host information or other Principals' Runs.

## Representation and bounds

The Run failure keeps its safe `code` and compact `message`. Its optional `error` carries the existing error's
Program-controlled `name` and `message`, authored `frames`, optional `cause`, and `errors` for aggregate members.
The readable summary escapes NUL characters for portable text storage; the JSON error retains them.
Only host-issued Operation fields populate `operation`; assigning similar properties to a Program error does not make
them trusted. A `truncated` flag reports bounded content, `cause_omitted` and `omitted_errors` identify omitted
relationships, and `circular` marks a reference to an ancestor.

Every part is bounded: name and message length, frames per node, nesting depth, total nodes, aggregate members, and
the encoded size as a share of the Run's MCP response limit. The limits are constants in the code and not part of
this decision. Accessor and proxy properties are skipped; formatting uses data properties and V8's existing native exception frames without running custom stack hooks.

## Terminal outcome pagination

Run Updates report the current Run state on every page, but include the terminal outcome only on the final page.
`has_more` means that Events or terminal details remain. Clients continue with the returned cursor even when the
state is already terminal. If the complete outcome cannot fit alongside the remaining Events, those Events are
delivered first with `has_more: true`; the next observation returns the outcome on its own. An observation after
the completed cursor returns the retained outcome again, preserving later inspection without repeating it on
every historical Event page.

This replaces the earlier choice to include terminal data ahead of Event pagination. Repeating the outcome can
prevent an otherwise deliverable Event from fitting the response budget. Separate delivery preserves complete
Events and the same retained diagnostics without response-specific trimming. Each Event and the terminal outcome
must still fit a response individually. The same rule applies to a successful Run's Final Value.

---

### Adr 0002 Provider Recipe Credentials (`docs/adr/0002-provider-recipe-credentials.md`)

---
status: accepted
---

# Offer Provider Recipe authentication as named options with typed methods

A Provider Recipe describes authentication as `authentication.options`: a map of recipe-local choices, each with a
label and exactly one `method` from a fixed list. `default` may name the option to pre-select. The owner reviews the
options while configuring the Provider and confirms exactly one; that option's method and settings define the Provider
Credential Contract. Options are not a runtime fallback, a per-account choice, or a menu offered to Programs.

An earlier draft of this decision split authentication into `obtain`, describing how a person acquires a credential,
and `send`, describing how it reaches the Upstream Service. That split had three faults, and a later design proposed the shape
adopted here. It permitted combinations that cannot work, such as acquiring a credential by OAuth while sending it as an
HTTP Basic pair. It offered one `send` per Recipe, so a service accepting either a bearer token or a username and
password could describe only one of them. And it did not match how Coral itself models authentication: the server
describes each authentication choice by its method, and Reef renders the Add provider flow from those choices, so a
method-shaped Recipe option lines up with one of them, while an acquisition-and-injection pair would have to be
reassembled into a method before Reef could use it. ADR 0004 records how Reef applies a Recipe.

The previous source DSL shows what the method list replaces. It gave each surface its own auth grammar: an OpenAPI
surface accepted a twenty-variant tagged value type, an MCP surface accepted a single bearer token naming a declared
secret input, and MCP over stdio had no auth block at all. Those grammars were the Rust types the document
deserialized into, which is why a YAML file contained `type: HeaderAuth`. A survey of the 207 shipped v3 manifests
found that credential positions used only `template` (111), `input` (16), `literal` (1) and `bearer` (1). A Recipe
needs neither a value language nor templates over credentials; it needs a small set of named behaviors.

| Method | Behavior |
| --- | --- |
| `none` | No Provider Account. |
| `bearer` | One secret, sent as a bearer token. |
| `header_credentials` | One or more credential fields bound to headers, each with an optional literal `prefix`. |
| `basic_auth` | Username and password bindings, encoded by the host as HTTP Basic. |
| `query_credentials` | One or more credential fields bound to query parameters. |
| `oauth_authorization_code` | Authored authorization and token endpoints; bearer injection. |
| `oauth_device_code` | Authored device authorization and token endpoints; bearer injection. |
| `oauth_discovery` | MCP authorization metadata supplies the endpoints; the Recipe selects the flow. |

Until September 2026 this table also had `header_api_key` and `query_api_key`, for a single secret in one header or
query parameter. Each was only the one-field case of `header_credentials` or `query_credentials`.
Two ways to write the same thing made Recipes harder to write and review, and gave the server, the API and Reef an extra
method each. A single API key is now a `header_credentials` or `query_credentials` option with one field. `bearer` stays
separate even though it is a header with a `Bearer ` prefix, because MCP sign-in and every OAuth method send their token
that way.

`path_token`, for one secret between a literal path prefix and the Operation path, left the table in September 2026
before the server ever offered it. Telegram's Bot API is the only service we found that needs it, and Coral cannot
describe that API at all: Telegram publishes no OpenAPI document, and no current third-party one exists. A path
credential therefore unblocked nothing on its own, so the work stopped there. Meanwhile the Recipe format described
a method a recipe author could write and Reef could only skip, which is worse than not offering it. Coral adds a path
credential back when a service it can describe needs one.

Before this decision, the Coral server supported `none`, `bearer`, `header_api_key` and `oauth_authorization_code`. Its
`header_api_key` becomes a one-field `header_credentials`, the other methods follow, and ADR 0005 records how the API carries
them. SigV4 is deliberately absent from the list. Until the server offers a method, Reef cannot pre-fill a Recipe option
that uses it.

## Credential fields

A method that needs more than one value declares `fields`, each with a label, an optional description, and `sensitive`,
which defaults to true and controls only whether entry is masked. Fields describe one Provider Account's credential
record and never carry values or defaults. Methods bind them by reference, as `{field: api_token}`, or bind a protocol
constant, as `{literal: api}`. Every declared field must be referenced. This covers the twelve v3 manifests that
combine two secrets, the twenty-one using HTTP Basic, and the three sending more than one credential header.

Where a service pairs a secret with an account identifier, the identifier is a field and not an input. Jira
authenticates with an Atlassian account email and an API token, and the email is half of what authorizes the request;
making it a Provider-wide input would share one account's email with every account connecting through that Provider.
It is declared with `sensitive: false` so that it is visible while typed, and it remains private to that account.
Datadog's API key and application key are two labelled fields in one record, because a person pasting both needs to
know which box is which. Mailgun's Basic username is the constant `api`, so its Recipe binds a literal and collects one
field.

A header binding may carry a literal `prefix`. Twelve v3 manifests format their Authorization header with something
other than `Bearer`, such as PagerDuty's `Token token=`, and a literal prefix covers them without asking a person to
assemble a header value by hand, and without reintroducing a template language.

## Where a credential travels

The host owns every position a credential occupies. A Recipe never contains a `{{credential}}` token, and a credential
never becomes part of stored configuration, a URL preview, a diagnostic or telemetry. Query credentials occupy parameter
positions the host fills at dispatch and that ordinary Operation arguments cannot supply or override.

No credential occupies the URL path. Telegram's Bot API is the service that asks for one, embedding the token in the
path itself as `https://api.telegram.org/bot<token>/getUpdates`. An earlier draft allowed a credential inside
`base_url` to express this, which would have put a secret into the resolved destination Coral stores and shows for
review; `path_token` replaced it with a prefix the host rendered the token into at dispatch, and the method table
records why that method is gone too. Both rejections hold for the same reason: a general rule allowing credentials in
URLs would be a much larger surface than one API's path convention warrants.

A literal is a protocol constant, and Coral cannot prove that it is one. An owner types fixed values by hand, so a
literal can hold anything, and a check that guessed at secrets by their shape would refuse honest values while still
missing dishonest ones. Where a constant earns its place, Coral accepts what is typed and shows it back: Mailgun's Basic
username is `api`, a header may send a fixed API version, and PagerDuty's prefix is `Token token=`. Where a constant
earns nothing, Coral removes the choice rather than guess. An HTTP Basic password is a Credential Field or empty,
because Stripe sends a key with no password, no service we support sends a constant one, and a box for a fixed password
invites a secret into stored configuration, the difference list and the preview.

Every method a person types values into is for OpenAPI Providers only: `header_credentials`, `basic_auth` and
`query_credentials`. An MCP source authenticates with nothing, a bearer token, or OAuth, because none of the others has
an MCP injection recipe. The server offers no other method for such a source, and rejects a discovered contract or a
request that names one anyway, because Reef and the server can run different versions (ADR 0004).

## OAuth

An OAuth option may carry a `client` with a public `id` and an `authentication` of `none`, `client_secret_basic` or
`client_secret_post`. The public id is an editable default for an application Coral registered; the owner confirms it
into Provider Configuration. A client secret never appears in a Recipe: choosing a secret-bearing client authentication
makes Setup collect it through the host credential subsystem. The redirect URI is not authorable at all, because it
names where Coral listens for the callback and differs between a hosted deployment and a local one reading the same
Recipe.

A public client id is valid only for the authority it was registered with. A self-hosted service therefore gets its own
Recipe with no client id, and its OAuth endpoints derive from the instance input: GitLab.com and self-hosted GitLab are
separate Recipes so that a GitLab.com registration is never offered to another instance.

Authorization-code flows use S256 PKCE unless an option with a confidential client sets `pkce: disabled`. `scopes`
remains a logical list, and `scope_format` states the request and response separators for services such as Strava that
do not use spaces ([ADR 0010](0010-reported-grant-recorded-verbatim.md) supersedes the response separator: the token
endpoint's `scope` is recorded verbatim, so a Recipe states only the request separator). Non-secret
`authorization_parameters`, such as Google's `access_type: offline`, are literal and may not override host-controlled
parameters. An MCP Recipe cannot author endpoints, issuer or resource; `oauth_discovery` takes them from the server's
authorization metadata. That is why the GitHub and GitHub MCP Recipes offer the same sign-in with different options:
one authors its device-flow endpoints and the other discovers them.

In a device-code flow, the server starts device authorization and polls the token endpoint as part of the Provider
Account Authorization, and Reef only shows the code and waits. That keeps tokens out of Reef and does not depend on the
page staying open. A device-code option takes the same `client` settings as an authorization-code one. The server offers
`oauth_discovery` with `flow: device_code` only when the authorization server's metadata has a
`device_authorization_endpoint`.

## SigV4 is deferred

Four v3 manifests use AWS SigV4. Unlike every method above, it signs the method, URL, headers, payload and time of each
request, and temporary credentials add a session token with an expiry. That needs a host implementation and lifecycle
rules rather than a Recipe method name, so none is reserved until that work is designed.

---

### Adr 0003 Provider Recipes State Facts (`docs/adr/0003-provider-recipes-state-facts.md`)

---
status: accepted
---

# State facts about an Upstream Service in a Provider Recipe, not opinions about its use

Coral distributes Provider Recipes and presents them when someone adds a Provider, where a Recipe pre-fills the
Provider Configuration that the owner reviews and confirms. A Recipe therefore speaks with Coral's voice about somebody
else's service, and everything it contains reads to that person as a property of the Upstream Service rather than as a
choice Coral made. A Recipe records only what is true of the Upstream Service: where its descriptor lives, which
destinations its server declarations use, which headers it requires, and how a person authorizes against it. It
records nothing about which parts of that service are worth using.

The consequence that matters most is that a Recipe selects no Operations. GitHub's OpenAPI description carries roughly
nine hundred operations and a well-judged working set is a small fraction of that, so an `include` or `exclude` list is
the most tempting field the format could have. It is also the one that would do the most damage. A Recipe that ships
forty operations does not present as a curated subset; it presents as the whole of what GitHub offers through Coral,
and the person who needed the eight-hundred-and-sixty-first has no way to know it exists or that Coral decided against
it. Coral expects to offer deselection when a Provider is added, where the choice belongs to the person making it, is
visible as a choice, and is reversible per installation rather than baked into a file shipped to everyone.

The same reasoning removes renaming. A Recipe assigns no Operation names, because the Materialized Operation Catalog
already derives them and an authored name is Coral's opinion about what an Operation should be called. It also removes
pagination and response shaping. Those are facts, but they are facts about the descriptor rather than about the
service, and the descriptor is the thing that changes without warning. Inferring them keeps a Recipe correct when an
upstream edits its specification; authoring them means a Recipe rots silently and nobody learns of it until a query
returns the wrong rows.

An input may not supply a default value for an Operation parameter. Sentry motivates the idea — every path contains an
organization slug, and typing it on each call is tedious — and the previous DSL declared a `SENTRY_ORG` input that no
part of that manifest ever referenced. Binding an input to a parameter requires naming which parameter, on which
Operations, matched how, which is authored knowledge of the catalog arriving through a side door immediately after this
decision turned it away at the front. If the need is real it is better met where Coral already has the person present,
at Provider Setup or on the Provider Installation, than by a static file guessing which Operations take an organization.

Server destinations are facts, and a Recipe states them where the descriptor declares them. Datadog serves most of its
v1 API from `api.<site>` but its IP-ranges and log-intake Operations from other hosts, and its OpenAPI document declares
those servers on the Operations themselves. A Recipe configures each declaration by its document pointer rather than
replacing every destination with one base URL. That is not authored knowledge of the catalog: it selects no Operations
and names none, and a pointer that no longer matches the document is a review error rather than a silent fallback.

## What this removes from the previous format

Test queries are gone. They were SQL written against a projection, which is an opinion about how a service should be
queried, and they encoded a query surface Coral no longer presents. The honest replacement is a scheduled check that
each Recipe's descriptor URL still resolves and still parses, which tests a fact instead.

Request headers survive, narrowed to headers the Upstream Service requires. `X-GitHub-Api-Version: 2022-11-28` is a
fact about GitHub and belongs in the Recipe. The `User-Agent` the previous manifests carried is Coral identifying
itself, which is true of Coral rather than of GitHub, and the runtime sets it.

That distinction generalizes beyond opinions. A Recipe also excludes Coral's own configuration, which is neither a fact
about the Upstream Service nor an opinion about its use, and which the previous manifests carried without remarking on
it. The OAuth redirect URI is the clearest case: it names where Coral listens for a callback, and it differs between a
hosted deployment and a local one reading the same Recipe. An OAuth client secret is the same. A public client id is
narrower: a Recipe may carry one as an editable default for an application Coral registered, which the owner confirms
and which is never offered to another authority. ADR 0002 records the detail.

Rate-limit headers are gone too. They are facts about the service, but the format no longer carries anything Coral
does not yet act on, and a field nobody reads is a field nobody notices going stale.

Method labels survive; long hint prose does not. Most hints in the previous manifests explained how to configure Coral
— which input to set to use your own OAuth application, and what that input is called. That is documentation about
Coral wearing the costume of a fact about the provider. A short label naming the method, a sentence of genuine upstream
requirement, and a link to the provider's own documentation are enough.

---

### Adr 0004 Reef Owns Provider Recipes (`docs/adr/0004-reef-owns-provider-recipes.md`)

---
status: accepted
---

# Keep Provider Recipes in Reef and define their format in TypeScript

Reef owns Provider Recipes. It reads the checked-in library in `apps/reef/recipes/`, offers browse, search and lookup by
id within its own server, and defines the Recipe format as TypeScript types. No Rust code reads a Recipe, and the Coral
server exposes no API for them.

Recipes exist to help a person adding a Provider in the UI: they make well-known services discoverable and supply
sensible defaults. Nothing else needs them. Coral offers no way to add a Provider from a CLI, and an agent adding a
Provider can work out its settings without a Recipe's defaults. Storing Recipes in the Coral server would require an
API to browse, search and fetch them for the sole benefit of one client that can hold them itself. If organizations
later author their own Recipes, Reef's server can serve those too.

## Recipes pre-fill; the server decides

Reef renders the Add provider flow as it does without a Recipe, from the information the server sends, including the
authentication choices the server supports. A Recipe then pre-fills that flow. The server remains the authority on what
a Provider can use, and a Recipe is advice about how to fill in a form the server has already described.

That makes applying a Recipe best effort. Hosted Reef and the Coral server are built and deployed as separate images,
so they can run different versions, and a Recipe can name a method or setting the connected server does not offer.
When a Recipe does not fit what the server sent, Reef records the mismatch where someone can find it, and leaves the
affected fields empty. In the worst case nothing is pre-filled and the person enters every value by hand, which is the
experience without Recipes. A mismatched Recipe never blocks setup and never sends the server a setting it did not offer.

## The format stays separate from Reef's own types

The previous source DSL was a serialization of the Rust types the rest of the system used, which is why its YAML
contained `type: HeaderAuth` and why refactoring those types silently changed a file format people write by hand. The
language changes here but the lesson does not. The Recipe types exist only to describe the file format. They are not
Reef's form state, and not the types generated from the management API, and Reef maps a Recipe into form state in one
explicit step. A separate set of mirror types kept beside real ones is not the answer either, because nothing keeps a
mirror honest; the Recipe types are the only definition of the format.

The Recipe types are a zod schema in `apps/reef/app/lib/provider-recipes/recipe-format.ts`, so the schema is the one
definition and every object rejects unknown fields. `recipes.server.test.ts` beside it parses every file in
`apps/reef/recipes/` and checks the references a schema cannot express. It runs with Reef's tests; no CI job runs it yet.

`apps/reef/recipes/schema.json` is generated from the same zod schema, so an editor can complete and check a Recipe as
someone writes it. The schema's rules use zod checks that JSON Schema can express wherever zod has one, so each rule is
written once. zod has no such check for entry counts, unique items or excluded names, so those rules pair a check with
metadata taken from the same values. The generated schema is still a convenience, not the authority: whether a URL
parses, rules that depend on another field, and the references `parseRecipe` checks exist only at parse time. The recipe test fails when the checked-in file
no longer matches the schema.

## Versioning and strictness

A Recipe declares `schema_version`, starting at 1. The format is new, and continuing the previous DSL's numbering would
send future readers looking for earlier versions that never existed in this repository.

The checked-in library ships inside Reef with the code that parses it, so the two are always the same age, and parsing
can reject unknown fields. A library Recipe that fails to parse is a defect caught by tests, and at run time Reef skips
it rather than failing the page. Recipes supplied at run time, such as ones an organization writes, would not share
that guarantee, and supporting them has to come with a decision about how tolerant parsing becomes.

## A Recipe can bundle its OpenAPI document

A Recipe normally names its OpenAPI document by URL, so Coral reads what the Upstream Service publishes. Some services
publish no OpenAPI document at any URL. Google describes Gmail with a Discovery document, and the only public OpenAPI
conversion stopped at April 2023. For such a service, a Recipe can instead name an OpenAPI document that sits beside
`recipe.yaml` (`source.openapi.file`). Reef bundles the text into its server build, never its client assets. When
someone reads the Recipe, the browser posts only the Recipe's id, and Reef's server sends the bundled text to Coral as
an uploaded document. Coral needs no change. The alternatives were the stale public conversion and hosting the converted
document at a public URL, which would keep Recipes URL-only but make Coral run a document host for other companies'
APIs.

This is an exception to ADR 0003 and applies only where no usable URL exists. ADR 0003 treats the descriptor as the
thing that changes without warning and proposes a scheduled check that each descriptor URL still resolves and parses. A
bundled document is a snapshot, so it is only as current as its last regeneration, and no URL check sees it age. Each
bundled document therefore comes from a checked-in script that converts the upstream descriptor, such as
`npm run recipes:gmail --prefix apps/reef`, and its output changes only when the upstream descriptor does. The
conversion stays mechanical so the document still states facts about the service: it carries every method over and
selects no Operations. Where Coral's importer needs something the upstream descriptor does not state directly, such as
one credential requirement shared by every Operation, the script derives it from the descriptor and fails when the
answer is not unique.

---

### Adr 0005 Authentication Configuration Oneof (`docs/adr/0005-authentication-configuration-oneof.md`)

---
status: accepted
---

# Send each authentication method's settings as its own message

`ProviderConfiguration` in the management API carries authentication as a `oneof` with one message per method, the same
shape as the Rust `AuthenticationConfiguration` enum. We are replacing the flat message, where a `CredentialMethod`
value sat beside `header`, `authorization_url`, `token_url` and `scopes` fields that applied only to some methods.

The flat shape worked for four methods with at most one extra setting each. Coral now adds the rest of the methods in
ADR 0002, which bring Credential Fields, header and query bindings, prefixes and literals. As flat fields, most of those
would be meaningless for most methods, and neither the server nor Reef could tell from the type which ones a method
needs. The existing methods move into the `oneof` too, rather than keeping two shapes side by side, and `header_api_key`
becomes a one-field `header_credentials` (ADR 0002).

## Typed-in credentials are one map of field values

Every credential a person types travels as one map of Credential Field name to value, in place of the single `secret`
the API took before, and is sealed in that shape. A bearer token is the one field `token`, so nothing about it is a
special case between the entry form, the request, storage and dispatch.

The Provider Credential Contract holds only the field names and where each value is sent. How the owner asks for a
field, its label, description and whether entry is masked, sits beside the contract in Provider Configuration. An
account stays compatible while the contract is unchanged, so renaming a label or unmasking a field never asks people to
sign in again, and compatibility stays a plain comparison of the contract rather than one that has to strip
presentation out first (ADR 0002 and GLOSSARY.md).

## Fewer places that define the method list

Reef keeps its own method names for its views, because presentation modules may not import wire types
(`app/__tests__/architecture.test.ts`). Tables in Reef's server code tie the names together, and the compiler checks
them: one maps every generated method to Reef's name, so a new wire method fails to build until Reef names it, and one
maps every Recipe method to a Reef method or marks it not yet supported. On the server, one function builds the
methods offered for a source, and OpenAPI and MCP validation accept no other. The Recipe format stays a separate
definition, as ADR 0004 requires.

A Reef that meets a method it does not know leaves it out of Add provider rather than failing, because Reef and the
server can run different versions (ADR 0004).

---

### Adr 0006 Task Owned Orchestration (`docs/adr/0006-task-owned-orchestration.md`)

---
status: accepted
---

# Task coordinates attributed work above the operation domains

Task represents a goal and its history, not an execution prerequisite or a generic audit recorder. Task-attributed
orchestration sits above the operation domains, so Functions, Search, and Run do not need to understand the goal that
motivated their work.

This replaced an earlier design that pointed the dependency the other way. The operation domains recorded into Task,
every piece of work was assumed to belong to a Task, a Task accepted new work after it ended, and terminal history
expired by rules of its own.

## Ownership and interfaces

`coral-task` owns goals, individually identified child Interactions, snapshots, associations, and Task-attributed
command choreography. Functions, Search, and Run keep their behavior, authorization, schemas, and lifecycle decisions
without Task identifiers or recording dependencies, including in Run/Invocation execution context. They expose opaque
preparation and typed transaction-compatible writes, not Task callbacks or generic context fields that hide the same
coupling.

Task durably begins an authorized attempt before work, then commits the owning mutation, its exact association, and
attempt completion in one transaction. Preparation occurs outside the final write transaction; owners recheck their
authority and invariants inside it. A staged write result is not proof of commit. Standalone methods may own their own
transaction while reusing those same domain implementations.

Run owns post-commit launch, claims, deadlines, execution, dispatch, and recovery. Task commits acceptance and its
association before handing the accepted Run to that machinery. Response or host loss preserves accepted evidence
without request replay, invented rollback, or a new scheduling or recovery policy.

Rejected: a separate activity orchestration and store module. This coordination belongs to the goal, while
operation-specific behavior stays below it. Task must not absorb compilation, execution, or policy to simplify its
command methods.

## Caller workflows and history

MCP keeps mandatory Task and per-call intent attribution for its goal-oriented tools. Ordinary human work in Reef has
no goal or intent form and creates no synthetic Task. It can start a normal standalone Run through the same core
admission and execution behavior. Observing or canceling an authorized Run does not require a Task lookup.

Standalone Reef discovery and admission rejection create no additional history record or snapshot. Reef shows safe
admission errors immediately; accepted Runs, including runtime failures, retain normal history. Saved Function changes
retain their Administrative Lifecycle Events. MCP's Task-attributed attempts retain complete discovery snapshots and
authorized failure evidence. In Reef's activity pages, Run/Invocation views include both sources, while the Tasks view
contains actual goals with optional navigation from execution detail.

Ending a Task rejects new attempts without canceling attempts durably begun earlier or accepted Runs. The first durable
completion wins its outcome, optional feedback, and terminal time together; repeated ending cannot change any of them
or extend retention. Goal success is never inferred from a Run or attempt result.

## Completion feedback

Feedback was once a report submitted on its own. It is now part of ending a Task. `end_task` accepts optional free text
for success or failure: at most 4,096 Unicode scalar values, nonblank, with no null character. Omission is durable
absence; a retry cannot add feedback later.

Feedback belongs to the Task, not to an independently submitted report or Interaction. It shares Task visibility and
grouped retention. Effective Owners can inspect Agent Task feedback but never another User's private feedback. The
wider report-inspection exception, report identity, report transports, standalone retention, and hosted forwarding are
removed. Agents gain no history access. Authorized exact Task detail may expose the text; list summaries, telemetry,
and Administrative Lifecycle Events may not.

## Retention consequences

- A Task and its associated execution history expire together after 30 days without qualifying activity, provided no
  associated Run is nonterminal. An unfinished but idle goal can expire too; unknown attempts alone do not preserve it
  indefinitely or acquire invented outcomes on deletion.
- Accepted, executing, and waiting nonterminal Runs protect the group. After the last Run ends, the group receives at
  least 30 days, extended by later qualifying activity.
- A standalone Run and its execution-dependent history expire 30 days after termination.
- Genuine attempts, completion, and lifecycle, execution and human-action transitions count as activity. Reads,
  polling, repeated no-op decisions, and unrelated configuration changes do not. Run-related activity comes from
  Run-owned evidence, not callbacks into Task.
- Associations expire with their Task group. Current Saved Functions, configuration, Administrative Lifecycle Events,
  and independent or shared identity workflows keep their own lifecycles and protections. Grouped cleanup must not
  delete another Run's work.

This decision sets what expires together and what counts as activity. Scheduling cleanup across a deployment is a
separate concern and is not decided here.

---

### Adr 0007 Defer Organization Providers (`docs/adr/0007-defer-organization-providers.md`)

---
status: accepted
---

# Use Workspace-local Providers and Accounts in v1

V1 has one independently identified, Workspace-owned Provider with its own immutable namespace, configured catalog, account setting, Operation policy, and lifecycle. Central catalog distribution and shared organization credentials reduce repeated setup, but their ownership, availability, account-selection, update, and administration concepts impose implementation and UX costs that are not justified for v1. Accept separate setup and maintenance in each Workspace.

This removed Organization Providers, availability and distribution, the separate Provider Installation resource, independently selectable Provider Identities, and Provider Identity Bindings. No dormant ownership variants, compatibility interfaces, flags, or future-library scaffolding remain. Organization membership, inherited Workspace Owner authority, and private-work visibility were left unchanged. [ADR 0015](0015-workspace-authority-from-membership.md) later removed inherited Workspace Owner authority; it supersedes that clause and the inherited-access exception under Removal and metadata.

## Account locality instead of reuse

A Provider uses no account, one shared Provider Account, or one Provider Account per caller User or Agent. The Account is a local authorization record with protected credentials, not a verified upstream identity or selectable inventory. An additional simultaneous account for the same User or Agent—or a second shared account—requires another Provider. The immutable Provider namespace identifies its Program call paths; a mutable display name supplies presentation. Removing and recreating a namespace transfers neither identity, policy, nor accounts.

Effective Workspace Owners manage shared and Agent accounts; Users manage their own caller accounts. An owner completes OAuth or secret entry for an explicitly selected Agent without impersonating a Coral User or copying that User's stored credentials. Reef presents "One shared account" or "Separate accounts" and makes Agent detail the primary provisioning surface. The provisioning human's departure does not disable shared or Agent accounts.

## Separate discovery authorization from runtime use

Add first discovers operations, authenticating only if discovery requires it, and then asks how callers authenticate for execution. Discovery authentication is temporary setup material, not a permanent discovery Account. Explicitly retaining compatible discovery credentials as the shared or creating User's Account avoids signing in twice; it never silently provisions an Agent or turns discovery into shared runtime authority.

Shared-account creation must connect the shared account before publication. Caller-mode creation need not connect the creator's personal account, because that User may be configuring the Provider for others or an Agent. One owner-reviewed catalog is shared within the Workspace; upstream permission differences do not create per-caller catalogs or promise every caller can execute every published Operation.

Update may explicitly borrow the existing shared Account or the updating owner's own caller Account when compatible, but never another User's or an Agent's Account. New temporary grants are scrubbed after use; existing borrowed runtime accounts are not temporary material to delete.

## Atomic changes, not hidden account switching

Owners may change account settings without replacing the Provider or its policy. Commit a switch to shared only after compatible authorization succeeds, then clear credentials belonging to the previous setting with an explicit warning. Retaining dormant accounts for a later switch back was rejected: it recreates hidden credential inventory.

Reconnect preserves current credentials until a successful atomic replacement; failure leaves them unchanged from Coral's perspective. Disconnect is immediate and distinct. Concurrent flows complete only against their expected account/configuration evidence; first successful replacement wins and late callbacks cannot reverse Disconnect. Upstream services may independently affect older grants during authorization, so Coral does not promise upstream isolation or verified account continuity.

An authentication-changing shared-account update publishes its replacement Account and configuration atomically. Separate-account updates warn and require affected Users and Agents to reconnect individually. Changed authentication requirements end obsolete connection requests and fail their waiters rather than storing an immediately incompatible Account. Already-authorized Invocations retain their selected Generation and normal live authority checks.

Routine OAuth refresh stays within the same authorization and does not invalidate pending Invocations or approvals, including the Invocation that triggered refresh. This replaced a generation fence that failed the triggering Invocation after a successful refresh. Explicit reconnect, Disconnect, and account-setting changes still fence affected pending work, and conditional refresh completion cannot resurrect removed credentials. No already-dispatched Operation is retried.

## Removal and metadata

When membership removal ends effective Workspace access, clear that User's stored Provider Account credentials there; inherited Organization Owner access prevents treating an explicit membership removal as loss of access. Temporary disablement retains credentials but blocks use. Provider or Agent removal clears its account credentials while preserving historical attribution.

`coral-administration::AccessLifecycleModule` owns Workspace membership removal/leave, Organization membership removal, and Agent deletion above Workspace, Provider Accounts, and Functions. Each is one domain-owned transaction. It replaced Agent deletion inside Run, a Workspace membership removal that committed on its own, and a separate Saved Function membership cascade. Workspace supplies opaque prepared authority/effective-access evidence; Accounts and Functions join its transaction for cleanup and lifecycle events, then Workspace applies the prepared authority removal before Administration commits. Lower modules do not depend on Administration, and adapters call one command. This places the cross-domain rule in one module instead of creating separate account and Saved Function offboarding paths.

Record latest successful connection time and permitted configuring-User attribution without calling that User the upstream account. Incorporate upstream account ID/label only when an existing trustworthy source makes it straightforward. The current OAuth result carries no upstream identity, so provider-specific lookups and identity verification remain follow-up work.

---

### Adr 0008 Outbound Network Permissions (`docs/adr/0008-outbound-network-permissions.md`)

---
status: accepted
---

# Separate outbound permissions from identity and listener exposure

Authenticated Coral must be able to call operator-approved HTTP sidecars without changing the Invoking Principal.
Resolve two independent settings, `outbound.loopback` and `outbound.private_networks`, each accepting exactly `deny`,
`https`, or `http-and-https`. Both base defaults are `deny`. Explicit operator fields override composition defaults
independently; omitted fields are not persisted as operator choices. HTTPS always verifies certificates.

The runtime's implicit-user composition and Desktop launcher supply `http-and-https` defaults for both categories.
Ordinary `ServerBuilder` instances supply no override, even without authentication. The authenticated `./dev` launcher
also supplies no override. Tests that need fixture reachability select it explicitly. This keeps local use convenient
without making authentication a hidden outbound policy switch. Explicit restrictive settings win in every composition.
Incoming auth-disabled listeners remain loopback-only; the implicit Organization Owner and normal authorization remain.
Operator-configured sign-in issuer handling retains its separate endpoint contract.

For **Coral server requests**, public destinations remain HTTPS-only, enforced at connection time. Private means
RFC1918 IPv4 and unique-local IPv6, not CGNAT, metadata, link-local, or other special ranges. Loopback access remains limited to explicit localhost/literals, not arbitrary DNS names that
resolve to loopback. HTTP private hostnames are provisional at publication: every actual DNS answer must satisfy the
request's scheme/category permissions at connection time. Server-request redirects revalidate destinations and isolate credentials.

Provider consent navigation has independent permissions and issuance-time screening, not a guarded browser connection.
[ADR 0009](0009-provider-consent-navigation.md) supersedes the earlier unqualified public-HTTPS statement for consent.
It preserves intentional local/private HTTP navigation while rejecting known public HTTP destinations at issuance.
Browser DNS and subsequent redirects remain outside Coral's control.

Enabling a category trusts Provider administrators and imported Provider documents with that network reachability.
Internal services can be unauthenticated; private HTTP can be intercepted. Address classification is not network
isolation or complete lateral-movement prevention: a public IP can host operationally internal services.

## Superseded decisions

This decision supersedes the local-only loopback/public-HTTPS restriction and the boolean private-network
interface that preceded it, not their SSRF, DNS-rebinding, blocked-address or cross-origin credential protections. No old keys,
aliases or compatibility readers remain.

It also supersedes the earlier native host alias and persistent trust prerequisite for E2E. Browser and server E2E now
runs inside a disposable Linux container. [Reel](../../apps/reef/e2e/README.md) records that topology and how to remove
the previous host setup. See [development configuration](../development.md#outbound-provider-access) for operator
examples.

---

### Adr 0009 Provider Consent Navigation (`docs/adr/0009-provider-consent-navigation.md`)

---
status: accepted
---

# Screen Provider consent separately from server requests

Provider authorization endpoints are browser destinations, not Coral fetch targets. A browser can intentionally visit
an HTTP consent page while Coral requires HTTPS for discovery and token exchange; those endpoints need not share a
transport. No new configuration switches are introduced. `[outbound]` governs server requests only.

This clarifies and supersedes ADR 0008's unqualified “public destinations remain HTTPS-only” statement:

- **Server requests:** public HTTPS-only is enforced at connection time by the guarded client, including DNS answers.
- **Consent navigation:** Coral screens each initial URL at issuance time. This does not guarantee the browser's
  resolution, its eventual connection address, or a provider's subsequent redirects.

## Initial consent navigation contract

The endpoint comes from confirmed Provider configuration and an explicitly initiated authorization flow.

| Destination | Issuance rule |
| --- | --- |
| HTTPS | Accept safe URL shapes, subject to the blocked-literal rule below. No Coral-side DNS prerequisite. The browser retains normal certificate verification. |
| HTTP loopback literal | Accept. |
| HTTP explicit `localhost` | Require a fresh lookup whose answers are all loopback. |
| HTTP RFC1918 IPv4 or unique-local IPv6 literal | Accept. |
| Other HTTP hostname, including `sidecar` | Require a fresh Coral-side lookup whose answers are exclusively private. |
| HTTP public literal or public/mixed DNS answers | Reject. |
| Blocked-special-address literal | Reject **regardless of scheme**, including HTTPS. |
| Credentials, fragments, unsupported schemes or malformed URLs | Reject. |

The shared address classifier continues to block metadata/link-local, CGNAT, multicast and other special ranges.
Arbitrary names resolving to loopback receive no localhost exception. HTTP lookups have a three-second deadline and
at most 1,024 answers; failures, empty answers and oversized answers fail closed. No lookup result is retained as
navigation authority. HTTPS names are not classified by Coral DNS; blocked literal rejection is not a claim to
control what an HTTPS hostname resolves to in the browser.

Private HTTP consent can be intercepted and can reach services in the browser's network. It is an intentional
transport choice, not a network-isolation guarantee.

Coral DNS is evidence for issuance, not proof of browser reachability. Split DNS, rebinding, later record changes and
subsequent redirects can differ. Preserving private HTTP hostnames means accepting that limitation, not disguising a
preflight lookup as connection enforcement. Coral does not proxy consent or rewrite hostnames to IP addresses.
Registered OAuth callback URI rules and operator-configured sign-in endpoints retain their separate contracts.

## Enforceable issuance interface

Provider authorization produces an opaque `ConsentNavigation` only after screening. It has no raw URL constructor or
deserializer. Repository rows retain untrusted strings, never reusable checked authority. Pending Setup and Account
reads, starts and management-request replays screen the stored URL again before returning navigation evidence. DNS
runs outside database transactions. Terminal projections contain no consent navigation.

The private gRPC adapter can project navigation only from that domain type. The wire `ProviderConsentNavigation`
message is distinct from an authorization endpoint in configuration. Reef's reference-based redirect interface fetches
fresh authorization state and returns an HTTP response; it neither accepts nor exports a navigation URL string.
An architecture test confines wire consent consumption to that adapter, with only boolean availability permitted in
account presentation. Setup callback cookies and Desktop's external-browser return marker are attached after checked
issuance. Reuse follows the same path; the Desktop host does not evaluate a second destination policy.

A previous response is evidence of a check at that time, not permanent permission. Neither the domain type nor the
wire message claims that Coral controls a URL after it reaches the browser. A failed check returns refused issuance
and no navigation URL. Progress remains readable, so the User can edit, retry or cancel; refusal is not persisted as
an OAuth failure. The redirect adapter rejects that navigation with a safe error. Neither refusal nor successful
issuance relaxes server-egress permissions or changes the Invoking Principal.

---

### Adr 0010 Reported Grant Recorded Verbatim (`docs/adr/0010-reported-grant-recorded-verbatim.md`)

---
status: accepted
---

# Record the reported OAuth grant verbatim

The Provider Credential Contract pins the exact scopes Coral requests. Until now the token endpoint's `scope` value was
split on spaces and had to contain every requested scope, or the authorization failed with `insufficient_scope`; the
rule came from the credential refresh and reconnect work so that a partial consent surfaced before dispatch rather than as a 403 in a Run.

That comparison cannot deliver what it promises and breaks real services. Scope names are not permissions: a provider
still rejects calls for plan tier, organization policy or resource access that no token response shows, and an omitted
`scope` was already accepted as a full grant. The comparison also forced Coral to parse a value whose delimiter is
service-specific — GitHub and Slack return commas — and for MCP servers the request is the whole `scopes_supported`
advertisement, which the MCP specification treats as a requirement list to request, not an exclusive grant to verify.
Slack's MCP server therefore could never connect, and the message told people to approve permissions they had already
approved. The reference MCP SDKs record what was granted and react only to an upstream `insufficient_scope` challenge.

Coral now keeps the token endpoint's `scope` value as the **Reported Grant**: one string, recorded verbatim, shown to
people, never compared with the request and never parsed. An omitted value records the requested scopes, as RFC 6749
§5.1 defines. Authorization succeeds on any well-formed bearer token. A grant the service later finds insufficient
surfaces as that Operation's upstream failure, and a person resolves it by reconnecting the Account; Coral does not
react to it automatically. The discovery-time `insufficient_scope` challenge from an MCP server keeps escalating the
requested scopes as before.

This supersedes the response half of [ADR 0002](0002-provider-recipe-credentials.md)'s `scope_format`: a Recipe
states only the request separator, because Coral never parses the token endpoint's `scope`, and Reef no longer
leaves a Recipe's scopes out of the Add provider pre-fill over a response separator.

Rejected: parsing with a per-service delimiter map, and comparing only for Recipe-authored scope lists. Both keep a
check that cannot be right for every service and would still reject a grant the service itself honours.

---

### Adr 0011 Universal Search Engine And Retrievers (`docs/adr/0011-universal-search-engine-and-retrievers.md`)

---
status: accepted
---

# Split Universal Search into an engine and one retriever per Search Entity kind

Universal Search in `crates/coral-search` has one engine and one retriever per Search Entity kind: Operations and
Saved Functions. The engine normalizes the query once, asks every retriever, fuses their ranked lists by position,
pages, and encodes cursors. It never branches on kind. A retriever owns everything about its kind: applying the owners' visibility rules, matching, its own
ranking over its own indexed entries, evidence, hydration into hits, and exact resolution. The rules themselves stay
with coral-workspace, coral-provider, coral-policy, and coral-functions; search stores no visibility and decides none. Only positions and evidence cross from a retriever into the engine; raw scores stay inside and are
assumed incomparable across retrievers. `describe` is a separate exact path that asks the retrievers in turn and never
touches relevance.

Universal Search also owns its index end to end. The workflow that commits a Provider Generation or a Saved Function notifies Universal
Search afterward with the entries the owner prepared, the Task command for Task-attributed work; an `EntityIndexer` per kind, the write-side sibling of `EntityRetriever` over the
same identity, stores them in one table that search owns; a periodic reconcile task converges what a lost notification missed.
Publication and save keep deciding what may be searched. Retrieval and ranking run inside the database on both backends, Postgres full-text search with an
approximate BM25 in SQL and SQLite FTS5, with the same field weights; the app loads only a window per retriever
and computes evidence over it. ADR 0017 replaces the sentence on the entries the owner prepared: the decision is
that search prepares its entries from the stored Generation after the commit (#2463 carries it out), and no search
bound changes an Operation's availability.

## Why a seam per kind, not branches

Before the split, the module loaded two lanes of material for every visible Provider on every query, ranked each lane
across all kinds, and fused the lanes by weighted reciprocal rank; Saved Functions were a second branch inside the same
loops. Every new kind, and every kind-specific rule about visibility or evidence, would have added another branch to
each loop. Behind the retriever's four methods, everything a kind knows lives in one place. Rejected:

- **By-hand branches per kind** in the engine: the shape we had, with the cost above.
- **A lanes-only trait**, one supplier of metadata and declaration lanes per kind with the engine still ranking: it
  keeps kind knowledge in the engine and forbids a kind from ranking its own way, for example inside the database later.
- **A two-phase retriever with a per-request prepared object**: an extra type per kind that carried resolved
  visibility from a prepare call into a query call. The same resolution now happens once inside `query`, and a
  retriever may hold its own resources between calls.
- **One ranking per lane across kinds**, the previous model: it assumes lane scores are comparable across kinds, which
  stops being true once each kind ranks its own way.

## Why the engine fuses by rank, and what that changes

Each retriever returns one ranked list. The engine fuses by position with equal weights, a Saved Function before an
Operation at equal rank, then Call Path; Saved Functions were written by people for this Workspace and no model has
them memorised. On a query where both retrievers answer, results come out in a different order than before. That is
deliberate. The guardrail is the mixed-query test in `crates/coral-search`. Two further order changes ride
along: declaration paths rank inside the Operation's entry at the lowest field preference instead of in a separate
lane, and the deprecation flag and the Provider's access mode left searchable text.

## Why search owns its index

The two JSON lane columns on the Generation row put search material under coral-catalog's schema and made every query
re-read and decode it. Rejected: **leaving index storage on the Generation row** and putting an indexer trait over it.
Instead the owner prepares entries, the committing workflow hands them to search after its commit, and search writes them into its
own table, where the Provider's active-Generation pointer decides which rows are live with no flag of their own.
Indexing is no longer atomic with the Generation: between the pointer moving and the rows landing, that Provider is
absent from relevance and the page reports partial. A Saved Function's row carries a cascading foreign key to the
function record, so its removal needs no notification.

---

### Adr 0012 Desktop Hosts Reef As A Loopback Server (`docs/adr/0012-desktop-hosts-reef-as-a-loopback-server.md`)

---
status: accepted
---

# Desktop hosts Reef as a loopback server

Desktop runs Reef's bundled `server.mjs` in an Electron utility process, listening on `127.0.0.1` with auth disabled,
and its window loads that URL. Reef then runs as it does in Docker and in local mode: the same bundled server entry, the
same request path and the same Origin check. Every deployment ships the same Reef build, with no Desktop marker. The
window has no preload and no bridge. Desktop is a supervisor and holds no product logic.

Rejected: keep the earlier `coral-app://` scheme, which rendered Reef in the Electron main process. Its scheme handler
rewrote CSP, set `process.env` on every request and passed `x-coral-desktop-*` headers to Reef. Reef exempted POSTs
without an Origin header from the Origin check. Desktop copied Reef's production dependencies by hand. Every one of
those was a Desktop-only request path or packaging step that the other deployments never tested.

## Consequences

- **Any local process can reach Reef.** With auth disabled, Reef serves the implicit local User, the Organization
  Owner, to every loopback caller, as local mode does. React Router derives the request origin from `Host`, so a
  DNS-rebinding page would pass the Origin check. Reef therefore answers 421 with no body unless `Host` names its own
  loopback listener (`<address>:<port>` or `localhost:<port>`) and the target is origin-form. Do not remove this check
  while auth can be disabled.
- **The Run-navigation bridge is gone on purpose.** The earlier Desktop ran a separate loopback server that turned
  Run Status Page links from agents into navigation inside its window. The runtime now builds those links on Reef's own
  URL (`CORAL_LOCAL_WEB_URL`), so they open in the system browser against the same Reef. Desktop keeps Reef's port
  across launches, so the links stay valid. Do not reintroduce a bridge to bring links into the window.
- **Provider consent leaves the window.** Chromium applies `form-action` to redirects, so Desktop hands a consent
  redirect marked under [ADR 0009](0009-provider-consent-navigation.md) to the system browser and keeps the window on
  Reef. A device sign-in form targets a new tab, as it does in a browser. Desktop submits that form in a hidden
  window, so its consent redirect reaches the same hand-off.

---

### Adr 0013 Desktop Release Channel (`docs/adr/0013-desktop-release-channel.md`)

---
status: accepted
---

# Desktop releases through a v1 feed of its own

Release builds of Desktop update from GitHub releases in `withcoral/coral-releases`, a public repository that holds
only release assets. The release workflow builds every platform, uploads the verified assets to a draft release there
and then publishes the draft, so an updater never sees a partial release. Only a release package names the feed. Every
other package has no `app-update.yml` and no update menu item.

The feed is separate because v1's code replaces the 0.x code in `withcoral/coral`, while 0.x installs keep
polling that repository's releases. A v1 feed there would update those installs into v1, which has another
app id and another data folder. A repository of its own has the same name before and after that replacement,
so the release path the betas tested is the one that ships.

Rejected: v1 feed files with another channel name in `withcoral/coral`'s releases. electron-updater's GitHub provider
reads whichever release is latest there, so the two apps' releases would steer each other. Rejected: a generic HTTPS
bucket. Coral has no bucket infrastructure, and GitHub drafts already give atomic promotion.

## Transition

v1 never takes over the 0.x identity (`com.withcoral.desktop`). It keeps `com.withcoral.app`, its data folder and
its updater cache (`coral-app-updater`). The deprecated app's last release, cut from `withcoral/coral` before v1 lands there,
tells its users to download v1 and stops checking for updates. After that, releases in `withcoral/coral` carry no
`latest*.yml`, so a deprecated install that still checks finds no update rather than v1.

## Consequences

- **v1 starts at 1.0.0.** The 0.x app's tags in `withcoral/coral` reach 0.15, so v1's 0.14 development versions would
  collide with them. release-please moves the runtime and Desktop together. Its first v1 release, or a beta,
  comes from a `Release-As: 1.0.0-beta.1` commit footer. The release workflow requires the tag, `Cargo.toml` and
  `apps/desktop/package.json` to agree.
- **Prereleases test the real path.** Release a version such as `1.0.0-beta.1`. The release workflow
  publishes a GitHub prerelease. Builds that are prereleases follow prereleases; stable builds never see them. The feed
  repository is public, so those assets are public but not linked from anywhere.
- **Every release ships every platform.** Betas could leave out Windows while its signing was not ready. That
  opt-out is gone: a release without a signed Windows installer fails.
- **Updates download in the background and install on restart or quit.** The release brief asked for user-initiated downloads. We
  changed that once the betas ran: a check runs at launch and every four hours and downloads what it finds. Installing
  waits for the person: Restart to Update, or their next quit. Desktop has no bridge into Reef, so a notification, a
  menu item and a Dock badge say an update is ready; a more visible indicator is an open decision.
- **The runtime gets JIT on macOS.** The release brief asked for JIT on Electron only, but v1's runtime runs Programs in V8. Under
  the hardened runtime, V8 needs `MAP_JIT` and aborts at startup without `allow-jit`, as `1.0.0-beta.2` did. Every
  binary therefore shares Electron's one entitlement, and xtask checks it on the app and the runtime.
- **Windows signature checks depend on the publisher name.** electron-builder writes the Azure Trusted Signing
  publisher into `app-update.yml`, and the updater installs only an installer signed by that publisher. Changing the
  signing certificate's subject strands installed apps on their version.

---

### Adr 0014 Desktop Serves In The Background (`docs/adr/0014-desktop-serves-in-the-background.md`)

---
status: accepted
---

# Desktop keeps serving in the background; the runtime is not an OS service

Agent clients store Coral's MCP URL and fail when Desktop is not running. Coral therefore keeps serving after the
window closes, and a person can choose to start it at login without a window. Desktop itself stays running and keeps
supervising the runtime and Reef, as it does with a window open. Closing the window does not stop Coral; Quit does.
Keeping Coral running is not a setting: a person who wants Coral stopped quits it. Desktop says so the first time the
last window closes. Start at login is off until the person turns it on.

Rejected: install the runtime as an OS service (launchd, `systemd --user`, Task Scheduler) that Desktop attaches to.
That fits a single binary that serves MCP and its own web interface. Coral's differs:

- **MCP without Reef is half a product.** Approval Requests, Account Connection Requests and Run Status Page links all
  resolve on Reef, and Reef runs on Electron's Node ([ADR 0012](0012-desktop-hosts-reef-as-a-loopback-server.md)). A
  runtime-only service would give agents links that do not load.
- **The service binary would live inside the package that updates replace.** An AppImage runs from a temporary mount,
  the Windows installer cannot overwrite a running executable and macOS swaps the whole app. Each needs a copy of the
  runtime outside the package, and logic to detect and replace a stale one.
- **Desktop would need a second way to run Coral.** Today the runtime never outlives Desktop, Desktop writes its
  configuration at every launch and picks the gRPC port. An attached runtime that Desktop does not own reverses each
  of those, and adds three OS backends to maintain.

## Consequences

- **The settings are native.** Desktop has no bridge into Reef, so a tray or menu bar item and the application menu
  carry Open Coral, the Start at login setting and Quit. The operating system keeps the login item, and Desktop reads
  it from there, so the checkbox shows what the system will do.
- **Desktop tells, and does not ask.** A question at the first close made the person decide before they knew what
  the choice meant. A notice says that Coral still runs and where to quit it.
- **No setting makes the window's close stop Coral.** 1.0.0-beta.8 had one. It only made closing the window, a habit,
  cut off the person's agents, and each platform already has a Quit that stops Coral.
- **On macOS, Command+Q does not stop Coral.** Quit in the Coral menu only closes the windows, so a habitual Command+Q
  does not cut off the person's agents. Quit in the Dock and Q in the app switcher do the same: a person uses them as
  they use Command+Q. macOS sends those and a logout or shutdown as the same quit request, so Desktop listens for the
  system's power-off notice and lets that one stop Coral. Quit Coral in the menu bar item stops Coral, and so does
  Quit Completely in the Coral menu and the Dock menu. On Windows and Linux, Quit in the File menu stops Coral: stock
  GNOME shows no tray icon, so that menu must keep a way to quit.
- **On macOS, a Coral with no window leaves the Dock and the app switcher.** A person reads a Dock icon as an open
  app. The menu bar item remains, as it does for other menu bar apps. The Dock
  icon returns with the window.
- **A waiting update needs a signal that works with no window.** A background Desktop rarely quits, so a downloaded
  update can wait for days ([ADR 0013](0013-desktop-release-channel.md)). Desktop still installs only on Restart to
  Update or on quit, and never restarts Coral by itself: a restart interrupts agents that are mid-task. The tray or
  menu bar item shows that an update waits and offers the restart.
- **Linux differs.** Electron's login item API does not cover Linux, so Desktop writes an XDG autostart entry that
  names the AppImage. Stock GNOME shows no tray icon; launching Coral again opens the window.
- **Nothing restarts Desktop when its main process crashes.** The runtime stops with it. An OS agent that launches
  Desktop without a window could add that later and would not change this decision.
- **The loopback listeners stay open for the whole login session.** They accept any local process as the local User
  (ADR 0012). Background serving lengthens that exposure and does not widen it.
- **Coral does not serve before login or on a machine with no desktop session.** Use the Docker image or a local
  `coral` runtime there. If Desktop must cover that case, first make Reef run outside Electron.

---

### Adr 0015 Workspace Authority From Membership (`docs/adr/0015-workspace-authority-from-membership.md`)

---
status: accepted
---

# Workspace authority comes only from a Workspace Membership

An Organization Owner used to hold Owner authority in every Workspace without a Membership. We wanted an IT admin who
can see every Workspace and recover one, and that answer was much bigger than the need: the Organization Owner became a
working Owner everywhere and turned up in Workspaces nobody had invited them to. Authority inside a Workspace now comes
only from a Workspace Membership. An Organization Owner keeps a small governance surface over Workspaces they are not
in: list them with their state and designated Owners, assign a designated Owner (including themselves), archive, and
restore. Assigning themselves is the explicit, recorded way to do setup in a Workspace.

This supersedes two statements in [ADR 0007](0007-defer-organization-providers.md): that inherited Workspace Owner
authority remains unchanged, and that inherited Organization Owner access prevents treating a Membership removal as
loss of access. Removing a Workspace Membership now always ends access there and always clears the User's stored
Provider Account credentials and current Saved Functions. It also reverses an earlier
resolution that Coral need not preserve a designated Workspace Owner.

## Every Workspace keeps a designated Owner

Leave, removal, and demotion of the last designated Owner are denied. Removing an Organization Membership needs a
transfer target when the User is the sole designated Owner of a Workspace; the target becomes Owner of each such
Workspace in the same transaction as the removal and its cleanup.

The rule counts Owners on record and ignores whether their Organization Membership is enabled. The last-enabled
Organization Owner guard counts enabled Owners because nothing sits above the Organization to recover it. A Workspace
has the Organization Owner above it, who can always assign another Owner, so one count is enough. Suspending a person
never changes ownership and only deleting them forces a transfer. This removes a second notion of "ownerless" (an Owner
on record who cannot act) and with it any "needs an Owner" state. The cost is one case we accept: an enabled Owner can
leave a Workspace whose only other Owner is disabled, and it then has nobody to administer it until an Organization
Owner assigns someone. The Organization's Workspace list shows that Owner as disabled.

## Considered options

- **A separate IT admin or auditor role, or Organization-level Provider setup.** Rejected for now: ADR 0007 already
  accepts separate setup in each Workspace, and self-assignment covers the need without a new role.
- **Count only enabled Owners.** Rejected for the reason above. It also made the transfer rule and the Owner listings
  disagree about who the Owner is.
- **Automatic archiving when the last Owner leaves.** Rejected: archiving stops every Agent and every other Member.
- **No backfill at the cutover.** Rejected: every Organization Owner would lose access on upgrade and leave stored
  credentials and Saved Functions in Workspaces they could no longer open.

## Consequences

- **The cutover backfills.** One migration makes every enabled Organization Owner a designated Owner of every existing
  Workspace and promotes one who is already a Member there. Those Owners hold that authority today; the backfill makes
  it visible and removable and grants nothing new. Its Administrative Lifecycle Events carry each Workspace's last
  update time and a fixed trace identifier, because a migration has no clock or trace. A User who becomes an
  Organization Owner later gets no Workspace Membership from that role.
- **Organization Owner governance is a recorded authority source.** Owner assignment, archive, restore, and the transfer
  on Organization Membership removal record `organization_owner`. No other runtime action does; the backfill's events carry it too.
- **Agent credentials outlive their creator.** Disabling or removing a User does not rotate the bearer credential of an
  Agent they created or saw. The Workspace owns the Agent, and another Owner rotates the credential when needed.
- **Assignment needs an active Workspace.** An Organization Owner restores an archived Workspace before assigning an
  Owner. The transfer on Organization Membership removal still applies to archived Workspaces.

## Automated deprovisioning

Coral has no SCIM or other identity-provider deprovisioning today. When it is added, it follows this rule:

- Deprovisioning disables the User. It never removes the Organization Membership, including when the identity provider
  sends a hard delete. Removal deletes stored Provider Account credentials and Saved Functions and can need a transfer
  target, so a person must decide it.
- A manual disablement by an Organization Owner stays in force when the identity provider later reports the User as
  active. Track the two reasons separately.
- Deprovisioning the last enabled Organization Owner fails with a clear error.

---

### Adr 0016 Safe Http Methods Enabled By Default (`docs/adr/0016-safe-http-methods-enabled-by-default.md`)

---
status: accepted
---

# Safe HTTP methods are enabled by default

OpenAPI/HTTP-sourced Operations declare no Operation Hints, so every one of them needed approval until a Workspace Owner
enabled it. Most of those Operations only read, and approving each read buried the approvals that matter. An Operation
with no exact rule now defaults to `enabled` when it declares itself read-only or when its HTTP method is safe in the
RFC 9110 sense: GET, HEAD, OPTIONS, or TRACE. Every other Operation still defaults to `approval_required`.

The method is a protocol fact, not a hint. Operation Hints remain provider claims, and Coral still never assumes one,
so a GET Operation shows no "Read-only" label. We rejected synthesizing a read-only hint from the method because it
would present Coral's guess as the Provider's claim, everywhere hints appear.

The default applies to every Operation without an exact rule, including those of existing Providers, with no migration.
On upgrade, those Operations stop asking for approval, and waiting Approval Requests for them proceed as if an Owner had
enabled them. We rejected writing explicit `approval_required` rules to keep the old behavior: they would appear as
Owner decisions that no Owner made. An upstream that changes state on a safe method needs an exact rule from its
Workspace Owner; there is no per-Provider opt-out.

---

### Adr 0017 Universal Search Never Gates The Catalog (`docs/adr/0017-universal-search-never-gates-the-catalog.md`)

---
status: accepted
---

# Universal Search never gates the catalog

Universal Search is a projection of the catalog. It never changes an Operation's availability, and no Setup or
Publication call waits for it. The index logs a bound or an indexing failure when it writes the Generation; neither is
ever a reason to refuse a call. How Reef shows them is a separate decision (#2469).

This replaces one sentence of ADR 0011, "the entries the owner prepared". The decision: the workflow that commits a
Provider Generation tells search that the Generation is active, and search prepares the entries from the stored
Generation after the commit, the way the reconcile task already does. Publication still decides what may be
searched, through availability. Search decides nothing about availability.

Issue numbers prefixed `lagoon` are in withcoral/lagoon, Coral's previous repository; bare numbers are in this one.

## Why

Until lagoon #366, one Operation over the search bound rejected the whole catalog; Slack and Xero both hit it. #366
kept a remnant: a declaration field over 256 KiB still marked its Operation Unavailable, so Coral refused to invoke
it. That bound was a copy of the 256 KiB Authoring Description budget with no reason of its own, and the stored lane
column it once protected went in lagoon #430. Lagoon #116 and #186 framed an index failure as "that Operation's
diagnostic and availability". That framing is withdrawn. An agent that cannot find an Operation through search can
still call it, and an Operation the catalog accepts is callable whatever search makes of it.

The walk that prepares search material also runs inside `materialize`, so the Setup preview and Publication both pay
for it, and the preview drops the result. On Microsoft Graph that is about 13 s per call against Reef's 10 s deadline
(#2344).

## Consequences

- Done with this record: `prepare_operation_declaration_fields` cannot fail. It truncates breadth-first at the field
  bound and warns, and a schema it cannot read stops it with a warning.
- Done in #2463: the walk leaves `materialize`, and search prepares its entries from the stored Generation after the
  commit. The walk and its field and depth bounds stay in coral-catalog, which owns the schema projection, but only
  search runs them, so they bound the index and never the catalog. Their warnings leave the Operation; the index logs
  them when it writes the Generation, and #2469 decides how Reef shows them.
- The Setup preview then shows availability from structural diagnostics only. Search coverage is reported after
  indexing.
- Rejected: keeping the walk in Publication so the index activates atomically with the Generation. The reconcile task
  already tolerates the gap, and retrieval reads only each Provider's active Generation.

---

### Adr 0018 Run Owner Coordinates Each Run (`docs/adr/0018-run-owner-coordinates-each-run.md`)

---
status: accepted
---

# One Run Owner per Run writes the Run Ledger; the database holds no Run work queue

A Coral Program Run is coordinated by one in-process **Run Owner**: an actor that exists from Run acceptance until the
Run is terminal and is the only writer of that Run's **Run Ledger**. Every Run-scoped request reaches its mailbox as a
**Run Request** or a wake-up; the owner applies them in arrival order in memory, builds chunks, and commits each chunk
in one short transaction whose predicate is the Run's execution claim id and the ledger head it was built on. Once a Run
is claimed, only two transitions wait for the database: the chunk commit and the **Dispatch Fence**, which rides inside
a chunk commit; the claim itself is written in the acceptance transaction, renewed by every commit, and taken over only
by recovery: the expired-lease sweep or the owner's supervisor, both below. Admit, wait, settle, decide and cancel are
owner memory. Nothing holds a connection while a Program waits for a human, a credential refresh, or an upstream call.

This replaces the shape where a `hold_run` row lock on the Run row served as the Run's work queue, serial Event ledger
and live authority fence at once, with compare-and-set counters (`next_event_sequence`, `accumulated_output_bytes`) as
the real invariant underneath. The decision was charted on
[map #2389](https://github.com/withcoral/coral/issues/2389), whose decision records hold the reasoning and rejected
alternatives for each part; this record states the shape and the contract every implementation slice keeps.

## Why

The incident that motivated this decision was an admission burst amplified by round-trip latency, not steady-state wait or human decision
volume: one Run's parallel Invocations queued pooled
connections behind `hold_run`, and sign-in, readiness and other Workspaces waited in the same pool. After the incident mitigation the
start burst remained, scaling with calls × round trip and nothing else
(30 approval-gated calls take 0.7 s to become visible at
+0 ms, 13.3 s at +5 ms, 45.3 s at +20 ms; 101 calls at +20 ms self-interrupt on the literal pool timeout). Two surveys found that every system with prefix-complete per-entity order
has one logical history writer per entity and fences a stale writer in the predicate of the history write itself; a
lock without a write fence is the shape the evidence rejects.

## The shape

- **Run Owner**. One per nonterminal Run, created in the acceptance transaction, which also writes its claim, and
  retired at termination. It safeguards Event persistence and the Run's lifecycle: the fence, the pre-send terminal
  check, deadlines, wait prechecks and every outcome decision. Decryption and the upstream call run in the dispatch it
  starts and report back through the mailbox. Nothing outside the owner writes its memory. The claim is the only
  compare-and-set outside a chunk commit; every chunk commit carries the claim-and-head predicate, every re-claim mints
  a new claim id, and the stale-owner fence is that id in every commit predicate.
- **Run Request**. Admit, Emit, Log and Finish come from the sandbox host; Decide Approval and Cancel from outside.
  Stages: Applied (decided in memory), Recorded (chunk committed), Settled (what it started has ended). Shared fate
  decides who may act on Applied: the sandbox dies with the owner and answers at Applied, because every later effect of
  the Run is a later owner commit that carries everything applied before it; outside requesters answer at Recorded. No
  caller tokens: a request that decides something is identified by the entity it decides (Approval Request, Run,
  Invocation), first wins, a repeat gets the recorded answer; Emit and Log have no identity and no repeat, since the
  Program shares the owner's fate. `OwnerLost` means outcome unknown, reread. Timers and coalesced wake-ups share the
  mailbox. The clock never decides state outside the owner.
- **Run Ledger**. The Run row's `chunk_sequence` is the head; every chunk that writes increments it, and Event numbering
  stays data. Commit-when-free after draining the mailbox, no timer; a chunk closes at 1000 Events or 1 MiB of output;
  the Applied cap on unrecorded output bytes (default 8 MiB, bootstrap-configured, validated at ≥ 2 × the chunk bytes
  cap) makes Program requests answer at Recorded only while it is exceeded, so the cap bounds loss and memory, never the
  admission burst. Every chunk commit renews the worker lease; an idle tick renews it at half a lease. A fenced owner
  stops on its commit's evidence, by failure class: claim mismatch is definitive; head mismatch under a matching claim
  means the chunk landed; no answer means reread the head under the claim and resolve to landed, recommit as built, or
  corruption. Suspect after one lease without a commit; self-interrupt after three; retries end only on success or claim
  mismatch.
- **Run Output Budget**. Exact, zero overshoot, decided at
  apply time against the owner's in-memory total; `accumulated_output_bytes` is written as a plain value per chunk under
  the claim-id fence. Evidence on exceed keeps today's shape and both failure codes.
- **Dispatch Fence**. Still a held transaction, reshaped: one fence per batch of
  ready Invocations, merged into the next chunk commit, which writes no Event and increments the head. `FOR SHARE` on
  Postgres so fences share and an authority writer waits for in-flight fences; `BEGIN IMMEDIATE` on SQLite. Run-scoped
  facts (claim, termination, deadline, Approval state, connection waits) come from owner memory with one clock sample;
  the database predicate keeps only the claim id and the Invocation's `pending` state. One fact loader that takes the
  lock mode and one evaluator serve both the fence and the parked-wait precheck. The fence reads credential evidence
  under its locks and parks a refresh wait there; decryption runs outside, per dispatch, after the pre-send check and
  never re-fences. The terminal chunk waits for a fence in flight, so no Event follows `run.ended`.
- **Fan-out Limit**. At most 32 Invocations of one Run
  `dispatching` at once, deployment-wide and bootstrap-configured. Admission stays eager; excess ready calls wait as
  `pending` and dispatch in readiness order; approval and account waits hold no slot, so every Approval Request is
  visible at once. No admitted-count cap, rate, or per-Workspace limit.
- **Wake-ups**. The change signal stays content-free. An owner
  subscribes only while an Invocation is parked on a connection request and, per coalesced wake after a 1 s gap, reads
  just its own pending requests. A 15 s periodic snapshot precheck per owner with parked Invocations is the ceiling for
  everything else and replaces the 5 s poll and the 30 s held recheck. A wake-up is a hint, never evidence.
- **Recovery**. The worker lease is ownership: a Run belongs to the process whose owner holds its live lease, and no
  process touches a Run with a live lease it does not hold. An expired-lease sweep recovers the rest: every `running`
  Run with no local owner whose lease has lapsed, or that has no claim and was accepted more than one lease ago (the
  previous release claims a moment after acceptance; the new one claims in the acceptance transaction, so a fresh
  unclaimed Run is the hot process's, not abandoned), gets an owner with passed timers queued in deadline order, then
  "finish the recorded termination" or "interrupt"; that owner writes one chunk and retires. The sweep runs first at
  boot, before any listener binds, and then every half lease for the life of the process, so a Run whose process died
  ends within one lease and one sweep. Coral never resumes a Run. A local owner whose lease lapsed is not swept: it
  continues when its next commit shows a matching claim, and a takeover by another process reaches it as a claim
  mismatch. An owner task that ends without retiring while the process lives (a panic unwinds; nothing aborts the
  process) is caught by its supervisor through the task's join handle, which takes over the claim and writes the same
  one-chunk interruption.
- **Rollout**. A deploy may run one hot process and one cold process until the routing flip, with no routing drain.
  The cold process's boot sweep leaves the hot process's Runs alone. On shutdown the hot process reports not ready,
  refuses new Runs, and waits the **Shutdown Drain** (bootstrap-configured) for its live Runs to finish on their own;
  at the end of the drain it sends interrupt to every owner still live and waits a bounded grace for their commits.
  Whatever does not commit keeps its lease and the cold process's sweep ends it after expiry. A Run Request that
  reaches a process with no local owner for a nonterminal Run is answered `OwnedElsewhere`, a retryable answer, until
  the Run is terminal; then it is answered from the durable record. Reads are unaffected: they read Recorded state.
  Concurrent processes in regular operation are out of scope.
- **Pool**. One pool, no connection classes, no reserved
  capacity. A Run holds at most one connection at a time, for one chunk commit.

## The contract every slice keeps

1. Events are prefix-complete per Run: once a sequence is visible, every lower one is committed. One writer, serial
   chunk commits, snapshot reads. Gapless numbering is not required.
2. The Run Output Budget is exact. A Run never exceeds it by a byte.
3. Dispatch starts only after one authoritative Dispatch Fence against current authority, policy, account and approval
   facts. A revocation that commits first always wins; a change
   that commits after the recorded dispatch start does not stop that dispatch. A cached decision never authorizes.
4. No Event after `run.ended`. No writer other than the owner touches Run-scoped rows while the Run is nonterminal.
5. Every commit that writes the Run Ledger is predicated on the claim id and the head. Never the clock.
6. An Approval binds one exact Invocation; approvals stay independent decisions. The first durable decision wins.
7. SQLite and Postgres parity: the owner code is identical on both backends; backend-specific idioms live in `coral-db`.
8. Nothing decided here needs reversal for multi-process Coral: the fence is in the write predicate, the owner is found
   by the claim, in-process mailboxes become a durable inbox behind the same seam, and recovery adds a holder-instance
   column on the claim then, not now.

## Rejected

- A durable head as a shared allocator with owner renumbering, and two streams merged at read time: optimistic
  appends degrade to one winner per round trip under the burst; no surveyed system merges at read time.
- A versioned decision in place of a held fence: a snapshot compare plus a separate compare-and-set is unsafe
  under READ COMMITTED, so the write predicate was the blocker, not version coverage. No new version columns.
- A separate fence transaction, an overlap fence beside the chunk commit, and decryption inside the fence.
- Lazy admission and an admitted-count cap. Approximate output accounting with bounded overshoot.
- Resuming a Run after restart; a lazy on-read recovery rule; taking over a live lease at boot (the cold process
  would interrupt the hot process's Runs); stop-before-start as a deployment requirement; takeover on demand, where
  the process that receives a request steals the Run's claim (a decision would interrupt instead of decide).
- A scope-keyed change subscription demultiplexed in `coral-db`: the upgrade path if the harness shows the
  content-free signal ringing too often; the owner's contract would not change.
- Connection classes, a second pool, or reserved permits.

## Consequences

- **Request work shares the pool with Run Ledger work.** Reconsider reserved capacity (permits in the one pool; a
  second pool stays rejected on SQLite parity and readiness honesty) if the owner-shape harness shows the `sign_in` or
  `ready` probes exceeding their budgets at the default pool while every Run transaction is short. The harness cell with
  more concurrent Runs than pool connections exists so that evidence can exist.
- **Freshness is not bounded.** A consumer reading Recorded state can be behind a flooding Program by what the
  database can write, in order and never wrong. A time bound would reintroduce the per-Event wait.
- **The loss set of an owner crash** is Applied-but-unrecorded output and results (at most the Applied cap plus one
  chunk in flight) plus upstream effects of recorded dispatches whose outcome becomes Indeterminate. Same loss set as
  today's crash mid-dispatch; a lost Run is never resumed, so bytes can be lost but never duplicated.
- **Readers never read owner memory.** `wait`, Reef, the approval page, required actions and the activity stream read
  Recorded state from a snapshot, as today, and show a countdown beside a recorded deadline rather than deriving state
  from `now`.
- **Measurement before and after.** The load harness (PR #2392) is the comparison tool: the admission burst, `begin`
  and readiness during the burst, admit p95 and the lock-wait ceiling should move; the emit-to-visible numbers from
  the cadence spike must hold behind `RunModule` with V8, the fence and the probes.
- **Rolling updates are supported; scale-out is not.** A rollout must not crash or lose information; it may degrade.
  What degrades: a Run that does not finish within the Shutdown Drain ends `interrupted` at the hot process's shutdown;
  a cancel or decision for such a Run that reaches the cold process answers retry until the Run is terminal, so a Run
  waiting on a human during the drain ends interrupted unless the decision reaches the hot process; a Run whose process
  crashed takes up to one lease and one sweep to end. The orchestrator's termination grace must exceed the Shutdown
  Drain plus the commit grace; the hosted deployment and the self-host guides must say so before boot recovery ships.
- **Migrations are expand-only across one release.** The cold process migrates at boot while the hot process still
  serves, so a migration must keep the previous release's queries valid: add columns and tables, keep constraints the
  old code satisfies, and drop in a later release. The Run Ledger head is added beside `next_event_sequence`, which
  stays written at acceptance until the release after the one that stops reading it.
- **Every observable commit notifies.** On Postgres each chunk commit calls `pg_notify` and holds the notify lock
  through commit, as every Event write does today; chunking reduces the count, and the fence slice measures what
  remains beside the `FOR SHARE` delta.
- **Out of this decision.** The first-time `operation_access_rules` row race (an absent row locks nothing);
  sign-in's own serialisation on the Organization authority guard; pool size, acquire timeout and pool metrics
  (#2391); Reef's activity stream and `wait` subscriptions.

## Evidence

Throwaway prototypes on the `wayfinder/447-assets` branch: the ledger-writers state model, the owner-loop state
model with its burst table, and the chunk-cadence Rust spike. The load harness and its baseline on
today's shape are in PR #2392. None of them prove integration with the real engine, authorization or both
backends; the implementation slices under #2378 do.

---

### Adr 0019 Operation Calls Return Only Declared Successes (`docs/adr/0019-operation-calls-return-only-declared-successes.md`)

---
status: accepted
---

# Operation calls return only Declared Successes

Coral v1 did not classify upstream outcomes. Every complete response, whatever its status or whether its body decoded,
returned to the Program as an Operation Result, and `coral.OperationError` meant only that Coral received no complete
response. The declarations followed: each HTTP Operation returned a union of every declared response plus
`HttpUnknownResult`, with the status inside `metadata`. TypeScript cannot narrow a union on a nested property, and the
catch-all's `status: number` overlaps every literal status, so `result.data` stayed `unknown` after any status check.
Agents cast results to `any`, which turned checking off. Coral's own compiler types `coral.providers` as `any`, so
nothing showed this until an outside type check did (#2508).

An Operation call now returns only a Declared Success: for HTTP, a 2xx response that selects a declared 2xx code or
range, or `default` when the Operation declares no 2xx response; for MCP, a tool result without `isError: true`. One
exception returns untyped: the final 2xx after a followed redirect, when the Operation declares no success to describe
it. Every other outcome rejects the call with
`coral.OperationError`: a non-2xx status, a 2xx status or media type the Operation does not declare as a success, a
body that does not decode, and an MCP tool result with `isError: true`. An HTTP Operation Result is
`{ status, data, metadata }`, so checking `status` narrows `data` when an Operation declares several successes.
Declared object types list only documented fields, so a misspelled read is an error; undocumented fields still arrive
at runtime. This reverses the v1 rule and the sentence of ADR 0001 that kept the Operation Error contract unchanged.

## Considered options

- **Keep every response, fix only the shape.** Put `status` beside `data` and give the catch-all a separate marker. It
  keeps Coral out of deciding success, but every call needs two checks or a guard before reading `data`. Agents skip
  them and return to `as any`.
- **A separate error class for upstream errors.** It keeps `OperationError` meaning "no response", but agents learn two
  names and catch the wrong one. One class with fields carries the same distinction: when the upstream answered,
  `data` is present and the outcome is known; without `data`, there is no response to read, and the call may or may not
  have taken effect. The codes stay distinct: `error_response`, `undeclared_response` and `undecodable_response` never
  name a Coral Failure. An MCP `isError` result takes `error_response`, as an HTTP error status does; `metadata.kind`
  and the absence of `status` tell them apart.
- **Throw on a schema mismatch, or on a body for a success that declares no content.** Types would never lie, but an
  inaccurate OpenAPI document would make the Operation unusable until someone fixed it. Both stay Declared Successes,
  with their decode diagnostics in `metadata`, and a success that declares no content types `data` as `unknown`.

## Consequences

- **The Invocation record is unchanged.** It retains the complete Upstream Outcome and counts it toward the Run Output
  Budget whether the Program received it or was rejected with it. Classification happens when the outcome reaches the
  Program, and no Program is ever replayed.
- **An uncaught upstream error fails the Run.** Its diagnostic carries the code, the HTTP status, and the first 1 KiB
  of the encoded `data`, read from host-only state that the Program cannot redefine.
- **Stored Generations keep their declarations.** A Provider shows the new declarations once it is regenerated. Until
  then its declarations still list error responses that now throw, and its search signature lines can show their data.
  Coral reads only the current shape and keeps no compatibility path for the earlier one.

---

### Adr 0020 Saved Functions Are Typed From The Callers Declarations (`docs/adr/0020-saved-functions-are-typed-from-the-callers-declarations.md`)

---
status: accepted
---

# Saved Functions are typed from the caller's declarations, and type errors stay advisory

Saved Function Save types each Call Path under `coral.providers`, `coral.workspace` and `coral.functions` that a source
names from the declarations the caller can discover. Before this, those roots were `any`, so a wrong input or a
misspelled result field produced no diagnostic, and a Saved Function that returned an Operation's result was declared
`Promise<any>` (#2426). The diagnostics are advisory: a save with type errors still succeeds.

## Considered options

- **Type `exec` Programs too.** We built it and ran two agent benchmarks against `main`. It changed no outcome. Its
  only true catches were Call Paths that name no Operation, which the Run's own error reports at the same moment,
  because the Program has already run. Most of what remained came from inaccurate Provider schemas, such as a
  timestamp declared as a number that the upstream returns as a string. An agent that "fixes" such a call breaks
  working code. It also needed diagnostics stored with each Run and returned on every `exec` update. A Saved Function
  is different: callers read its declaration long after the save, and the save has no Run to report errors.
- **Reject a save with type errors.** A gate would teach agents to cast to `any` first, which turns checking off. It
  would also make an inaccurate Provider schema block a Saved Function that runs correctly.
- **Type the whole catalog.** The full surface is specific to the Workspace and the caller, and too large for the
  two-second deadline. Coral resolves only the literal Call Paths that the source names, as it already does for Saved
  Functions. An Operation path uses the Operation's own declaration plus every named type it reaches, for an
  Operation that `describe` would show the caller. A Saved Function path uses its stored Saved Function Declaration.

## Consequences

- **Typing reveals nothing that discovery conceals.** The save command reads the declarations with `describe`'s
  visibility rules. A path that names no Operation the caller can call is typed `any`, with one Coral diagnostic that
  reads the same whether the Operation is hidden, disabled, or absent. The diagnostic replaces the cascade of
  TypeScript errors that an unknown path would cause. A chain the source does not call, such as an alias of a
  namespace, is `any` without a diagnostic, because an alias may reach a real Operation. A source that names more
  than 128 candidate Call Paths, or whose declarations total more than 1 MiB, leaves Operations untyped without a
  diagnostic. That bounds how much a typed compile must check.
- **Typing never fails a save.** A Saved Function Declaration must stay self-contained, so an Operation result type is
  inlined into it. A recursive or branded type cannot be inlined, and an inlined type can exceed the 256 KiB
  declaration limit. Then the declaration is emitted with the `coral` namespace untyped, as before, and a diagnostic
  says why. A source that Coral cannot analyze, or a declaration read that fails or runs out of time, leaves
  Operations untyped and is logged.
- **Typing fits the existing deadline.** Finding the Call Paths and reading their declarations share at
  most 500 ms of the two-second compilation deadline, and always leave one second to compile. A save that would
  compile untyped never times out because of typing. A source that names an Operation with 4,000 result fields saves
  in about 12 ms, against 5 ms for one that names no Operation. Every compilation shares one parsed copy of the
  TypeScript libraries.
- **Diagnostics follow the declarations at save time.** A stored Saved Function's diagnostics and declaration reflect
  the Provider Generation it was saved against, and the save response adds no note about it. A Provider published
  before #2508 keeps its older result shape until it is regenerated.

---

### Adr 0021 Admin Operations As A Built In Provider (`docs/adr/0021-admin-operations-as-a-built-in-provider.md`)

---
status: accepted
---

# Admin operations are a Built-in Provider

Agent onboarding needs an agent to add a Provider. Earlier designs gave `coral.admin` a separate surface with its own
catalog, policy, approval and records. Coral instead gives every Workspace one **Built-in Provider**: a real Provider
row with a real Materialized Operation Catalog whose Operations Coral defines in Rust and performs itself. Search,
describe, Operation Access State, Approval Requests and Operation Invocation records therefore apply unchanged, and
Coral grows the catalog one Operation at a time, starting with `createProvider`. This supersedes those designs.

Each Provider row stores its full **Provider Call Root** (`coral.providers.github`, or `coral.workspace` for the Built-in
Provider) instead of a short namespace that code prefixes with `coral.providers.`. Call Paths come straight from the
row, Workspace uniqueness applies to the Call Root, and a Provider named `workspace` cannot clash with the Built-in
Provider. Migration `0028` prefixes the namespace of every existing row, so Providers from an earlier release keep their
Call Paths.

The catalog tables only accept the known input kinds and protocols, and the Provider lifecycle table only accepts the
known actions. Migration `0026` widens the catalog rules to accept `builtin`. Migration `0027` adds the
`provider.builtin_installed` action, so installing the Built-in Provider records its own action instead of a
Publication that never happened. Postgres can replace a CHECK constraint and SQLite cannot, so each of the two has one
file per database: Postgres replaces the constraint, and SQLite rebuilds the table. A database from an earlier release
upgrades in place.

The Built-in Provider is immutable apart from its access state. The server rejects rename, reconfiguration,
regeneration, account work and removal for it, and an Owner can only disable or enable it and set its Operations'
policy.

Amended by [ADR 0022](0022-coral-updates-the-builtin-catalog-at-boot.md): when Coral boots, it installs the Built-in
Provider in each Workspace that has none and updates it to the catalog it serves. An Owner still cannot regenerate or
update it.

Rejected: a separate admin surface, which duplicates catalog, policy and approval; a reserved `workspace` namespace under
`coral.providers`, which special-cases the call path; and editing `0003` and `0004` in place, which makes Coral refuse
every database from an earlier release.

---

### Adr 0022 Coral Updates The Builtin Catalog At Boot (`docs/adr/0022-coral-updates-the-builtin-catalog-at-boot.md`)

---
status: accepted
---

# Coral updates the Built-in Provider's catalog at boot

Each Workspace has its own Generation of the Built-in Provider's catalog. Coral writes it when it creates the Workspace.
A Workspace from a release before the Built-in Provider has none.

When a Coral process boots, before it takes requests, it first installs the Built-in Provider in each Workspace that
has none, in any Workspace state. Each install has its own transaction, the served catalog, Coral as the recorded actor
and one `provider.builtin_installed` event. Then the process finds every Workspace's Built-in Provider whose Generation
pins a catalog identity other than the one the process serves. It updates each in its own transaction: it publishes a
Generation of the served catalog and activates it, with Coral as the recorded actor and one `provider.updated` event
with the changed field paths `provider.active_generation` and `catalog`. The Provider keeps its state, and an archived
Workspace updates too. Coral asks and tells nobody; the new Operations simply appear. A Workspace whose update fails
keeps its catalog until the next boot, and the other Workspaces still update.

Processes that boot together each try the install and the update. The first to hold the Workspace row installs the
Provider, and the others then find it installed. The first to hold the Provider row replaces the Generation it listed.
The others then find another Generation active and skip the Provider, even when that Generation pins another catalog;
their next boot moves it to their own. Coral reads one row per Workspace to find what to install or update, so a boot
with nothing to do stays cheap.

Catalogs are not ordered. A Coral older than the one that booted last, after a rollback or when an old process restarts
during a rolling deploy, moves Workspaces back to its own catalog, and the next newer boot moves them forward again. An
older Coral that reads a newer catalog in the meantime still searches and describes its Operations, and fails an
Invocation of one it does not perform with `operation_unavailable`.

A new Operation starts at the Access State its hints give, and Owners change it with the existing Operation policy.
Exact rules are keyed by Operation Key, so a rule set on an Operation applies again when the Operation returns.

This amends [ADR 0021](0021-admin-operations-as-a-built-in-provider.md), which says the server rejects regeneration and
the Built-in Provider is immutable apart from its access state. An Owner still cannot regenerate, rename, reconfigure,
update or remove it. Coral updates it at boot.

Rejected: an Owner reviewing and applying each update on the providers page, which leaves Workspaces on an old catalog
until someone acts and makes every Owner repeat the same step; and ordering catalogs by a version number so that an
older Coral never moves a Workspace back, which needs a manual bump, release parsing or build changes, and leaves a
rolled-back Coral serving Operations it cannot perform.

---

### Agents Domain (`docs/agents/domain.md`)

# Domain Docs

This repository uses a single-context domain model.

## Before code exploration

Read:

- `GLOSSARY.md` at the repository root.
- Relevant ADRs under `docs/adr/`.

If a file or directory does not exist, continue without reporting its absence.

## Use the glossary vocabulary

Use terms as `GLOSSARY.md` defines them. Do not use synonyms that the glossary
explicitly rejects.

If a required concept is missing, reconsider the term or record the gap for the
domain-modeling workflow.

## ADR conflicts

If proposed work conflicts with an ADR, state the conflict explicitly. Do not
silently replace the decision.

---

### Agents Issue Tracker (`docs/agents/issue-tracker.md`)

# Issue tracker: GitHub

Issues and specs for this repository live in GitHub Issues in
`withcoral/coral`. Use the `gh` CLI for all operations.

Always specify `--repo withcoral/coral`. This clone also has an `upstream`
remote for `withcoral/coral`, so implicit repository selection is not safe.

## Conventions

- Create: `gh issue create --repo withcoral/coral --title "..." --body "..."`
- Read: `gh issue view <number> --repo withcoral/coral --json title,body,comments --jq '.title, .body, (.comments[].body)'`.
  Outside a terminal, `--comments` prints the comments without the body.
- List: `gh issue list --repo withcoral/coral`
- Comment: `gh issue comment <number> --repo withcoral/coral --body "..."`
- Add or remove labels: `gh issue edit <number> --repo withcoral/coral`
- Close: `gh issue close <number> --repo withcoral/coral`

Use a heredoc for multiline issue bodies.

## Pull requests as a triage surface

**PRs as a request surface: no.**

## When a skill says "publish to the issue tracker"

Create a GitHub issue in `withcoral/coral`.

## When a skill says "fetch the relevant ticket"

Run:

```sh
gh issue view <number> --repo withcoral/coral --json title,body,comments \
  --jq '.title, .body, (.comments[].body)'
```

## Wayfinding operations

The map is one issue with child issues as tickets.

- Label a map `wayfinder:map`.
- Label child issues `wayfinder:research`, `wayfinder:prototype`,
  `wayfinder:grilling`, or `wayfinder:task`.
- Use GitHub sub-issues for map membership.
- Use GitHub issue dependencies for blocking relationships.
- If these features are not available, use a task list in the map and a
  `Blocked by: #<number>` line in each child issue.
- Claim a ticket by assigning it to yourself.
- Resolve a ticket by adding its answer as a comment and closing the issue.
- Add the resolved decision to the map's Decisions-so-far section.

---

### Agents Triage Labels (`docs/agents/triage-labels.md`)

# Triage Labels

| Label in mattpocock/skills | Label in this repository | Meaning                                |
| -------------------------- | ------------------------ | -------------------------------------- |
| `needs-triage`             | `needs-triage`           | Maintainer must evaluate this issue    |
| `needs-info`               | `needs-info`             | Waiting for more information           |
| `ready-for-agent`          | `ready-for-agent`        | Fully specified and ready for an agent |
| `ready-for-human`          | `ready-for-human`        | Requires human implementation          |
| `wontfix`                  | `wontfix`                | Will not be done                       |

When a skill names a canonical triage role, use its mapped label from this
table.

---

### Apps Docs Getting Started Install Locally (`apps/docs/getting-started/install-locally.mdx`)

---
title: "Install the desktop app"
sidebarTitle: "Desktop app"
description: "Run Coral on your own machine, for yourself."
---

Coral ships as a desktop app on macOS, Linux, and Windows.

## Install Coral

Download Coral from the [releases page](https://github.com/withcoral/coral-releases/releases). The newest release is at the top. Open its **Assets** list and download the file for your platform.

<Tabs>
<Tab title="macOS">

The macOS app runs on Apple silicon only.

1. Download `coral-app-mac-arm64.dmg` from the [newest release](https://github.com/withcoral/coral-releases/releases).
2. Open the DMG and drag **Coral** into your Applications folder.
3. Launch Coral and add your first providers from the app.

</Tab>

<Tab title="Linux">

On Linux, Coral is an x86_64 AppImage, so there's nothing to install.

1. Download `coral-app-linux-x86_64.AppImage` from the [newest release](https://github.com/withcoral/coral-releases/releases).
2. Make it executable and run it:

   ```shellscript
   chmod +x coral-app-linux-x86_64.AppImage
   ./coral-app-linux-x86_64.AppImage
   ```

3. Add your first providers from the app.

</Tab>

<Tab title="Windows">

The Windows app is x64 only.

1. Download `coral-app-win-x64.exe` from the [newest release](https://github.com/withcoral/coral-releases/releases).
2. Run the installer. It doesn't need administrator rights.
3. Launch **Coral** from the Start menu and add your first providers from the app.

</Tab>
</Tabs>

Coral keeps running in the background after you close the window, so your agents can still reach it.

## Upgrade

The desktop app updates itself. It downloads updates in the background.

When an update is ready, Coral lets you know. Select **Restart to Update** to install it right away, or it installs the next time you quit.

To check yourself, select **Check for Updates…** in the **Coral** menu on macOS, or in the **Help** menu on Linux and Windows.

On Linux, Coral replaces the AppImage file in place, so keep it somewhere you can write to.

### From Coral 0.x

Coral 1.0 is a new app with its own data folder. It doesn't update the 0.x app, and it doesn't read or change that app's data. Install Coral 1.0 as described above, then add your providers again. Uninstall the 0.x app when you no longer need it.

If you run the `coral` runtime yourself instead of the desktop app, see [Upgrade from Coral 0.x](/getting-started/self-host#upgrade-from-coral-0-x).

## Uninstall

<Tabs>
<Tab title="macOS">

Select **Coral** > **Quit Completely**, then move Coral to the trash. **Quit Coral** only closes the window.

</Tab>

<Tab title="Linux">

Quit Coral and delete the AppImage file.

</Tab>

<Tab title="Windows">

Quit Coral, then remove it from **Settings** > **Apps** > **Installed apps**. You
can also run `Uninstall Coral.exe` in the installation directory.

</Tab>
</Tabs>

Uninstalling keeps your data, so your providers and accounts are still there if you reinstall.

---

### Apps Docs Getting Started Self Host (`apps/docs/getting-started/self-host.mdx`)

---
title: "Run Coral as a shared server"
sidebarTitle: "Overview"
description: "Self-host Coral for your team, in containers, behind your own TLS."
---

Coral can be run as a shared self-hosted server, allowing you and your team to share workspaces, provider and operation policies. You can also choose to share provider credentials with your team members on a given workspace if this is part of your workflow.

Self-hosted Coral has two major components:

- `coral`: Docker image with the Coral server. This includes the runtime, provider configuration, policies, authentication server and MCP server.
- `coral-web`: The UI you and your team can use to execute Coral management actions.

In this guide, we will walk you through setting up Coral with either Docker Compose or Kubernetes.

## Before you start

1. Make sure you have an OIDC compatible identity provider: Self-hosted Coral requires that you have your own IdP that Coral can use to authenticate users. Any OIDC compatible IdP will work, so if you use Okta, Google Workspace or WorkOS you're already good to go. You will need to configure an application on your IdP for Coral.
2. Choose your backing DB: Coral supports both SQLite and Postgres, with SQLite as the default. Choose what's appropriate for your setup.
3. TLS termination: Coral requires the usage of TLS for user-facing connections, but Coral itself does not provide TLS support. You can use your preferred reverse proxy (e.g. nginx, envoy) or use Caddy if you don't have any.
4. Public endpoints: Coral uses three endpoints that must be visible to the user's system. We will use `<coral-host>` as the placeholder for the root domain of your Coral deployment:

- `<coral-host>`: URL for the `coral-web` UI.
- `auth.<coral-host>`: Auth endpoint, used by both `coral-web` and the MCP server to authenticate users.
- `mcp.<coral-host>`: MCP server endpoint.

Make sure you can configure the names to point to your Coral deployment (details in the deployment guides) and that you can create valid TLS certificates for these domains.

## Upgrade from Coral 0.x

Coral 1.0 is a ground-up rewrite. Your agents now write TypeScript programs that call your providers, where 0.x ran SQL queries over sources. You manage Coral in the Coral UI instead of with `coral` commands, and your agents connect to the MCP server over HTTP instead of stdio.

That makes 1.0 a breaking change. It starts from fresh state and doesn't convert what 0.x stored, so your 0.x setup stays as it is until you're ready to switch.

When the Coral 1.0 runtime starts on a configuration folder or a database that an earlier version wrote, it stops and reports an error such as:

```text
pre-v1 Coral state detected at '<configuration folder>'; move or remove it before starting this version of Coral
pre-v1 Coral database state detected; move or remove it, or configure a fresh database before starting this version of Coral
```

Coral never deletes, converts or archives old state. Don't start a Coral 1.0 container on a 0.x volume, because the container overwrites `config.toml` there.

To start Coral 1.0:

1. Stop the old Coral. Copy its configuration folder, or its volume, and keep the copy for as long as you need it.
2. Give Coral 1.0 fresh state. Set `CORAL_CONFIG_DIR` to an empty folder or, in containers, mount a new volume. With Postgres, point Coral at an empty database.
3. Write a new configuration from the [configuration reference](/reference/configuration), or follow one of the guides below. Coral 1.0 rejects a 0.x configuration file.
4. Start Coral, then add your providers and connect your accounts again.
5. Point your agents' MCP clients at the new MCP endpoint.

To go back, stop Coral 1.0 and start the old version on the copy you kept.

## Detailed guides

<CardGroup cols={2}>
  <Card title="Deploy with Docker Compose" icon="container" href="/getting-started/self-host/docker-compose" />
  <Card title="Deploy with Kubernetes" icon="server" href="/getting-started/self-host/kubernetes" />
</CardGroup>

---

### Apps Docs Guides Concepts (`apps/docs/guides/concepts.mdx`)

---
title: "Concepts"
description: "Build the right mental model for Coral"
---

## Organizations

Each Coral deployment contains exactly one **organization**.

An organization is the top level container for [workspaces](#workspaces). Any organization member can create a workspace.

Organizations have owners. They can:

- Manage organization membership
- See every workspace and its owners
- Assign a workspace owner, including themselves
- Archive and restore workspaces

Organization owners have no access inside a workspace. To work in one, they need a workspace membership like anyone else.

The [desktop app](/getting-started/install-locally) has one user, who doesn't sign in. That user is the organization owner. Membership and sign-in apply to a [shared server](/getting-started/self-host).

## Workspaces

A **workspace** is a container for providers, the people and agents that use them, and everything they run.

Workspaces have owners. They can perform some operations at the workspace level:

- Manage [members](#principals)
- Manage [providers](#providers) and [accounts](#account-setting)
- Manage [policy](#operation-policy)

Workspace [members](#principals) share its [providers](#providers) and policy.

## Providers

A **provider** is a workspace-owned set of [operations](#operations) for an upstream service such as GitHub or Linear. Add one from Coral's provider catalog, or from your own OpenAPI document or MCP server.

Each provider you add carries:

- an immutable **namespace**, unique in the workspace, that prefixes every [call path](#operations)
- an [**account setting**](#account-setting)
- per-operation [**policy**](#operation-policy)

You can add a provider multiple times, each under its own namespace, to give the same operations a different account setting and policy.

### Built-in provider

Every [workspace](#workspaces) also has one built-in provider, **Coral**. Coral defines its operations and performs them itself, without an upstream service. It is listed first under **Providers** in the [Coral UI](#coral-ui).

- Its call paths begin with `coral.workspace`, not with a namespace.
- Only workspace owners can find and call its operations. Members and agents cannot see them.
- Workspace owners can only disable it, enable it, and set its [policy](#operation-policy). Nobody can rename or remove it. It has no [accounts](#account-setting).

## Operations

An **operation** is a single action exposed by a [provider](#providers). It has named inputs and returns one result.

[Programs](#programs) call an operation by its **call path**, which begins with `coral.providers` and the [provider](#providers) namespace:

```ts
await coral.providers.github.actions.addRepoAccess({ repo, user });
```

Call paths of the [built-in provider](#built-in-provider) begin with `coral.workspace` instead.

## Account setting

An **account** holds the credentials that Coral sends to the upstream service. It belongs to one [provider](#providers), and Coral never reuses it for another provider.

An **account setting** decides whose account a provider uses.

- **No account**, the service doesn't require credentials.
- **Individual accounts**, each user connects their own account. For agents, the workspace owner will have to connect an account.
  - Choose this setting when you want each user to access the provider as themselves. For example, GMail should be set up this way: every user should only be able to access their own emails.
- **One shared account**, all principals (users and agents) use the provider's single shared account.
  - Choose this setting when the provider is something everyone is allowed to access. For example, Datadog: everyone should see the same dashboards and alerts, and the account is owned by the team rather than by any one person.

You can add the same provider multiple times, with different account settings. For example, you could have one GitHub provider with a shared readonly account used by every user, and another GitHub provider with individual accounts, so that users can create PRs as themselves through Coral.

## Operation policy

**Policy** is set by workspace owners on a [provider](#providers), and decides whether an [operation](#operations) is:

- Always allow, the operation runs
- Needs approval, the call pauses until a person decides. A user approves their own calls. A [workspace](#workspaces) owner approves an agent's calls.
- Disabled, the operation cannot be called, and is hidden from the agent altogether

Until you set a policy, an operation is Always allow when it declares itself read-only or uses the HTTP method GET, HEAD, OPTIONS, or TRACE. Every other operation needs approval. Set Needs approval on an operation that changes data through one of these methods.

## Principals

A **principal** is an authenticated actor that Coral attributes work to. It is a human user or an autonomous agent.

- A **user** signs in through OAuth and can be part of many workspaces.
- An **agent** belongs to one [workspace](#workspaces) only, and uses its own API key.

Coral attributes a [run](#runs) to the principal that submitted it, even when a workspace owner approves an operation or the provider uses a [shared account](#account-setting).

## Programs

A **program** is TypeScript that a [principal](#principals) submits for Coral to execute.

A program reaches upstream services (e.g. GitHub, Linear) only through [operations](#operations):

```ts
const issues = await coral.providers.github.issues.list({ state: "open" });
coral.emit({ open: issues.data.length });
```

It can also call the principal's [saved functions](#saved-functions) under `coral.functions`.

It never sees your credentials.

Submitting a program creates a [run](#runs).

## Runs

A **run** is one execution of a [program](#programs).

A run executes on the Coral server, not in your agent. If your agent disconnects, the run continues, and its operations still take effect.

Coral never retries an [operation](#operations).

## Coral UI

The **Coral UI** is Coral's web interface. The desktop app and a shared server both include it.

Use the Coral UI to add [providers](#providers), connect [accounts](#account-setting), set [policy](#operation-policy), approve operations, and inspect [runs](#runs). Your agents do not use the Coral UI. They reach a workspace through its MCP endpoint.

## Saved functions

A **saved function** is reusable TypeScript that a [principal](#principals) saves at a path under `coral.functions` in one [workspace](#workspaces).

```ts
await coral.functions.triage.staleIssues({ days: 30 });
```

A [program](#programs) calls one the same way it calls an [operation](#operations). It can hold local types, helpers, and top-level setup, but no packages and no Node APIs.

A user's saved functions are private to that user.

---

### Apps Docs Guides Query Databases (`apps/docs/guides/query-databases.mdx`)

---
title: "Query databases"
description: "Connect your database to Coral through MCP Toolbox for Databases."
---

[MCP Toolbox for Databases](https://mcp-toolbox.dev/documentation/introduction/) exposes database tools through MCP.
Add Toolbox as a Coral provider so your agents can query your database.
You need a running Coral instance and a workspace that you own.

Supported databases and query engines include:

- **Relational:** [PostgreSQL](https://mcp-toolbox.dev/integrations/postgres/), [MySQL](https://mcp-toolbox.dev/integrations/mysql/), [MariaDB](https://mcp-toolbox.dev/integrations/mariadb/), [SQL Server](https://mcp-toolbox.dev/integrations/mssql/), [SQLite](https://mcp-toolbox.dev/integrations/sqlite/), [Oracle](https://mcp-toolbox.dev/integrations/oracle/), and [Firebird](https://mcp-toolbox.dev/integrations/firebird/).
- **Distributed SQL:** [CockroachDB](https://mcp-toolbox.dev/integrations/cockroachdb/), [OceanBase](https://mcp-toolbox.dev/integrations/oceanbase/), [TiDB](https://mcp-toolbox.dev/integrations/tidb/), and [YugabyteDB](https://mcp-toolbox.dev/integrations/yuagbytedb/).
- **NoSQL:** [MongoDB](https://mcp-toolbox.dev/integrations/mongodb/), [Couchbase](https://mcp-toolbox.dev/integrations/couchbase/), [Cassandra](https://mcp-toolbox.dev/integrations/cassandra/), [ScyllaDB](https://mcp-toolbox.dev/integrations/scylladb/), [Redis](https://mcp-toolbox.dev/integrations/redis/), and [Valkey](https://mcp-toolbox.dev/integrations/valkey/).
- **Graph:** [Neo4j](https://mcp-toolbox.dev/integrations/neo4j/), [Dgraph](https://mcp-toolbox.dev/integrations/dgraph/), [FalkorDB](https://mcp-toolbox.dev/integrations/falkordb/), and [ArcadeDB](https://mcp-toolbox.dev/integrations/arcadedb/).
- **Analytics and search:** [ClickHouse](https://mcp-toolbox.dev/integrations/clickhouse/), [Snowflake](https://mcp-toolbox.dev/integrations/snowflake/), [SingleStore](https://mcp-toolbox.dev/integrations/singlestore/), [Elasticsearch](https://mcp-toolbox.dev/integrations/elasticsearch/), [Trino](https://mcp-toolbox.dev/integrations/trino/), and [MindsDB](https://mcp-toolbox.dev/integrations/mindsdb/).
- **Google Cloud:** [AlloyDB](https://mcp-toolbox.dev/integrations/alloydb/), Cloud SQL for [PostgreSQL](https://mcp-toolbox.dev/integrations/cloud-sql-pg/), [MySQL](https://mcp-toolbox.dev/integrations/cloud-sql-mysql/), and [SQL Server](https://mcp-toolbox.dev/integrations/cloud-sql-mssql/), [BigQuery](https://mcp-toolbox.dev/integrations/bigquery/), [Bigtable](https://mcp-toolbox.dev/integrations/bigtable/), [Firestore](https://mcp-toolbox.dev/integrations/firestore/), and [Spanner](https://mcp-toolbox.dev/integrations/spanner/).

See [Toolbox's integration catalog](https://mcp-toolbox.dev/integrations/) for the full list and available tools.

## Run Toolbox

1. [Install Toolbox](https://mcp-toolbox.dev/documentation/introduction/) on a host that can reach your database.
2. For trusted development, choose a [prebuilt configuration](https://mcp-toolbox.dev/documentation/configuration/prebuilt-configs/) for your database.
3. Set the connection and credential environment variables listed in that configuration's documentation.

For example, after setting the [PostgreSQL environment variables](https://mcp-toolbox.dev/integrations/postgres/prebuilt-configs/postgresql/), start a local server:

```shellscript
toolbox --prebuilt postgres --address 127.0.0.1 --port 5000
```

This loads Toolbox's built-in PostgreSQL tools without a `tools.yaml` file.
For custom tools or databases without a prebuilt configuration, [define `tools.yaml`](https://mcp-toolbox.dev/documentation/configuration/) and use `--config tools.yaml` instead.

Coral recommends exposing only an `execute_sql` tool, using a database account with the minimum permissions required for your agent's work.
See [Toolbox's security guidance](https://mcp-toolbox.dev/documentation/configuration/prebuilt-configs/#security-for-dynamic-sql-tools).

Its MCP endpoint is `http://127.0.0.1:5000/mcp`.
To expose a specific toolset, use `/mcp/<toolset-name>` instead.
For containers or a remote server, use an address reachable from the Coral runtime.
See [Deploy with Coral](#deploy-with-coral) below.

Configure database credentials in Toolbox.
If Toolbox requires client authentication, follow its [authentication documentation](https://mcp-toolbox.dev/documentation/configuration/authentication/) and configure the corresponding credentials in Coral.

## Add Toolbox to Coral

1. Open your workspace and select **Providers**, then **Add provider**.
2. Select **Add custom** and enter the Toolbox endpoint in **MCP server URL**.
3. Select **Connect**.
4. Set the display name and namespace, then choose the sign-in method that matches your Toolbox configuration.
5. Select **Next**. Authenticate if prompted, then review the discovered operations with **Expand all**.
6. Select **Next**, choose the account setting, and select **Add provider**.

For a server without authentication, select **No authentication** and confirm **No account needed**.
Review the provider's [operation policy](/guides/concepts#operation-policy) to choose which operations can run or require approval.
The database account's permissions still apply to every query.

## Query through Coral

[Connect your MCP client to Coral](/getting-started/quickstart#connect-an-mcp-client).
Give your client the Coral workspace URL, and keep the Toolbox URL in the provider configuration.
Ask your agent to query the database using the provider's namespace.

## Deploy with Coral

Without Toolbox client authentication, allow only Coral to reach its MCP endpoint.
Direct access bypasses Coral's operation policy.

### Local

Run Toolbox on the same host as Coral with `--address 127.0.0.1`, as shown above.
Use `http://127.0.0.1:5000/mcp` as the provider URL.
Loopback binding blocks remote connections, but other processes on the host can still access Toolbox.

### Docker Compose

Follow [Toolbox's Docker Compose guide](https://mcp-toolbox.dev/documentation/deploy-to/docker/) to add it to your [Coral deployment](/getting-started/self-host/docker-compose).
Connect Coral and Toolbox through a private network shared only by these services.
Omit Toolbox's `ports` mapping to keep its MCP port unpublished.
Keep Toolbox connected to a network that can reach your database.
For a service named `toolbox` on port `5000`, use `http://toolbox:5000/mcp` as the provider URL.

### Kubernetes

Follow [Toolbox's Kubernetes guide](https://mcp-toolbox.dev/documentation/deploy-to/kubernetes/) alongside [Coral's Kubernetes guide](/getting-started/self-host/kubernetes).
For a sidecar, run Toolbox in Coral's Pod with `--address 127.0.0.1`.
Use `http://127.0.0.1:5000/mcp` as the provider URL, without a Service for Toolbox.

For a separate Deployment, use an internal ClusterIP Service and a NetworkPolicy that permits inbound connections only from Coral.
For a Service named `toolbox` in the `coral` namespace, use `http://toolbox.coral.svc.cluster.local:5000/mcp`.
Adjust the namespace, port, and cluster domain to match your deployment.

For each deployment, include the provider URL's hostname and port in Toolbox's `--allowed-hosts`.
Configure Coral's [outbound permissions](/reference/configuration#outbound) for the endpoint in its `[outbound]` configuration:

- For loopback HTTP endpoints, including `127.0.0.1` sidecars, set `loopback = "http-and-https"`.
- For private network HTTP endpoints, including Compose services and Kubernetes ClusterIP Services, set `private_networks = "http-and-https"`.

These permissions are independent and default to `deny` in server deployments.
Desktop and implicit-user local deployments allow both by default, unless you explicitly override them.

---

### Apps Docs Legal Cla Corporate (`apps/docs/legal/cla-corporate.mdx`)

---
title: Coral Software Grant and Corporate Contributor License Agreement ("Agreement")
sidebarTitle: Corporate Contributor License Agreement
---

Thank you for your interest in Coral agent data software ("Coral"). To clarify the intellectual property licence granted with Contributions from any person or entity, Coral must have on file a signed Contributor Licence Agreement ("CLA") from each Contributor, indicating agreement with the licence terms below. This agreement is for your protection as a Contributor as well as the protection of Coral and its users. It does not change your rights to use your own Contributions for any other purpose.

This version of the Agreement allows an entity (the "Corporation") to submit Contributions to Coral, to authorize Contributions submitted by its designated employees to Coral, and to grant copyright and patent licenses thereto.

Please print, complete, and sign this form, then email a scanned copy to [legal@withcoral.com](mailto:legal@withcoral.com).

Read this document carefully before signing and keep a copy for your records.

Corporation name: ___________________________________________________________________

Corporation address: _________________________________________________________________

___________________________________________________________________________________

Point of Contact: ____________________________________________________________________

E-Mail: _____________________________________________________________________________

Telephone: _________________________________________________________________________

You accept and agree to the following terms and conditions for Your present and future Contributions submitted to Coral. Except for the license granted herein to Coral and recipients of software distributed by Coral, You reserve all right, title, and interest in and to Your Contributions.

1. **Definitions.**

    "Coral" means the Coral agent data software project managed by Phoebe Technology Limited.

    "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Coral. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.

    "Contribution" shall mean the code, documentation or other original works of authorship expressly identified in Schedule A, as well as any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Coral for inclusion in, or documentation of, any of the products owned or managed by Coral (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Coral or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Coral for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."

2. **Grant of Copyright License.** Subject to the terms and conditions of this Agreement, You hereby grant to Coral and to recipients of software distributed by Coral a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
3. **Grant of Patent License.** Subject to the terms and conditions of this Agreement, You hereby grant to Coral and to recipients of software distributed by Coral a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) were submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
4. **Authorised Contributors.** You represent that You are legally entitled to grant the above license. You represent further that all employees, contractors, and agents of the Corporation who submit Contributions on the Corporation's behalf are authorized to do so. The Corporation is solely responsible for ensuring that any individual submitting Contributions under this Agreement is authorized to do so, and for revoking that authority when appropriate (for example, upon termination of employment). Coral is entitled to assume that any Contribution submitted by an individual who identifies themselves as acting on behalf of the Corporation is authorised.
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others).
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
7. Should You wish to submit work that is not Your original creation, You may submit it to Coral separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".

Please sign: ___________________________________

Date: ________________________________________

Name: _______________________________________

Title: _________________________________________

Schedule A

[Identification of optional concurrent software grant. Would be left blank or omitted if there is no concurrent software grant.]

___________________________________________________________________________________

___________________________________________________________________________________

___________________________________________________________________________________

___________________________________________________________________________________

___________________________________________________________________________________

___________________________________________________________________________________

___________________________________________________________________________________

---

### Apps Docs Reference Configuration (`apps/docs/reference/configuration.mdx`)

---
title: "Configuration Reference"
sidebarTitle: "Configuration"
---

Your configuration lives in the `config.toml` file of Coral's configuration folder. By default the folder is located in:

- macOS: `~/Library/Application Support/com.withcoral.coral`
- Linux: `~/.config/coral`
- Windows: `%APPDATA%\withcoral\coral\config`

You can override the location of the config folder with the `CORAL_CONFIG_DIR` environment variable.

## General

### `[listeners.grpc]`

| Key    | Description                                | Required | Default       |
| ------ | ------------------------------------------ | -------- | ------------- |
| `bind` | Bind address and port for Coral's gRPC API | No       | `127.0.0.1:0` |

### `[listeners.mcp]`

| Key          | Description                                                                                                | Required                  | Default       |
| ------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------- | ------------- |
| `bind`       | Bind address and port for Coral's MCP server                                                               | No                        | `127.0.0.1:0` |
| `public_url` | Public URL of the MCP Server. Must be https or loopback http. May include a port; must not include a path. | Yes, when auth is enabled | N/A           |

### `[listeners.auth]`

| Key          | Description                                                                                                 | Required                  | Default                                                                    |
| ------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------- |
| `bind`       | Bind address and port for Coral's auth server                                                               | No                        | `127.0.0.1:0` for a shared server and `127.0.0.1:8779` for the desktop app |
| `public_url` | Public URL of the Auth Server. Must be https or loopback http. May include a port; must not include a path. | Yes, when auth is enabled | N/A                                                                        |

### `[listeners.web]`

| Key          | Description                                                                                                  | Required                                 | Default |
| ------------ | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | ------- |
| `public_url` | Public URL of the coral-web UI. Must be https or loopback http. May include a port; must not include a path. | Not if `CORAL_LOCAL_WEB_URL` is provided | N/A     |

### `[credentials]`

| Key       | Description                                                                                                                         | Required                                                                                    | Default |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------- |
| `key_env` | Name of the environment variable that holds the encryption key for provider credentials. Generate it with `openssl rand -base64 32` | No. If not present Coral will autogenerate an encryption key and save it to its config dir. | N/A     |

### `[outbound]`

Controls the policy for outbound calls from Coral to providers.
Policies can have one of the following values:

- `deny`: Denies all requests to the policy target
- `https`: Allows only https requests, no plain http.
- `http-and-https`: Allows both http and https requests.

All provider calls not covered by an explicit policy in this section are treated under the `https` policy.

| Key                | Description                                                   | Required | Default |
| ------------------ | ------------------------------------------------------------- | -------- | ------- |
| `loopback`         | Policy for provider calls to loopback addresses.              | No       | `deny`  |
| `private_networks` | Policy for provider calls to addresses in non-public networks | No       | `deny`  |

### `[runtime]`

| Key                    | Description                                                        | Required | Default |
| ---------------------- | ------------------------------------------------------------------ | -------- | ------- |
| `execution_heap_bytes` | Maximum heap bytes allowed for a single V8 isolate                 | No       | 128MiB  |
| `compiler_heap_bytes`  | Maximum heap bytes allowed for a single Typescript compilation job | No       | 512MiB  |

### `[search]`

| Key                          | Description                                                                         | Required | Default |
| ---------------------------- | ----------------------------------------------------------------------------------- | -------- | ------- |
| `reconcile_interval_seconds` | Interval on which provider operation and saved function search indexing is executed | No       | `300`   |

### `[otel]`

Controls telemetry export to an OpenTelemetry endpoint.

| Key            | Description                                                                        | Required | Default                                                                                                                                 |
| -------------- | ---------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `endpoint`     | URL of the http otel endpoint to which telemetry will be sent.                     | No       | N/A                                                                                                                                     |
| `headers`      | Additional headers sent to the otel endpoint, formatted as `name=value,name=value` | No       | None                                                                                                                                    |
| `log_filter`   | Filters to apply to logs before otel export                                        | No       | `coral_app=info,coral_grpc=info,coral_mcp=info,coral_search=info,coral_administration=info`                                             |
| `trace_filter` | Filters to apply to traces before otel export                                      | No       | `coral_app=trace,coral_grpc=trace,coral_mcp=trace,coral_search=trace,coral_administration=trace,coral.http.body=off,coral.mcp.body=off` |
| `service_name` | Name of the service when exporting OTEL data                                       | No       | `coral`                                                                                                                                 |

## Auth

### `[auth]`

| Key                 | Description                                                                            | Required | Default |
| ------------------- | -------------------------------------------------------------------------------------- | -------- | ------- |
| `allowed_audiences` | Array. Additional audiences for which Coral's auth server can mint tokens              | No       | `[]`    |
| `trusted_clients`   | Array with client ids for which Coral's auth server will not display a consent screen. | No       | `[]`    |

### `[auth.session]`

| Key                        | Description                                                                                                                  | Required                                                                                                   | Default                     |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------- |
| `signing_key_env`          | Name of the environment variable holding the key used to sign Coral's auth server tokens. Must be a base64 PKCS#8 P-256 key. | No, but if `[auth]` is present the environment variable must exist or `signing_key_file` must be defined   | `CORAL_SESSION_SIGNING_KEY` |
| `signing_key_file`         | File path containing the key used to sign Coral's auth server tokens. Must be a PKCS#8 P-256 key in DER format.              | Yes, if `[auth]` section is present and if the environment variable defined in `signing_key_env` is absent | N/A                         |
| `access_token_ttl_seconds` | Duration of Coral's access tokens, in seconds.                                                                               | No                                                                                                         | `2592000` (720h)            |

### `[auth.provider]`

| Key                       | Description                                                                                                                | Required                                                                                                                  | Default                          |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `issuer`                  | URL of the OIDC issuer. Must be https.                                                                                     | Yes, if `[auth]` section is present                                                                                       | N/A                              |
| `client_id`               | Client ID used to connect to the OIDC issuer.                                                                              | Yes, if `[auth]` section is present                                                                                       | N/A                              |
| `client_secret`           | Client secret used to connect to the OIDC issuer.                                                                          | Yes, if `[auth]` section is present and if `client_secret_env` is absent                                                  | N/A                              |
| `client_secret_env`       | Name of the environment variable holding the client secret used to connect to the OIDC issuer                              | Yes, if `[auth]` section is present and if `client_secret` is absent                                                      | N/A                              |
| `scopes`                  | Array of scopes to request to the OIDC Issuer. Must include `openid`                                                       | No                                                                                                                        | `["openid", "email", "profile"]` |
| `display_name_claim`      | Name of the claim from the OIDC token used for user display names                                                          | No                                                                                                                        | `email`                          |
| `name_claim`              | Name of the claim from the OIDC token used for user names                                                                  | No                                                                                                                        | `name`                           |
| `email_claim`             | Name of the claim from the OIDC token used for user emails                                                                 | No                                                                                                                        | `email`                          |
| `email_verified_claim`    | Name of the claim from the OIDC token used to check if user email is verified                                              | No                                                                                                                        | `email_verified`                 |
| `bootstrap_owner_subject` | Value of the `sub` claim of the user that will have Coral's organization owner role. Applied at that user's first sign-in. | No, but Org owner admin operations will be disabled if left undefined. Omit the key rather than set it to an empty string | N/A                              |
| `auth_params`             | Extra parameters that will be passed in the OIDC authorization request                                                     | No                                                                                                                        | `{}`                             |
| `required_claims`         | Claim values that must be present on the OIDC ID token to allow login into Coral.                                          | No                                                                                                                        | `{}`                             |

Register `<listeners.auth.public_url>/auth/oidc/callback` as the redirect URI at the OIDC issuer.

### `[auth.provider.access_token]`

Optional section that can be used to require certain values on the OIDC's access token.

| Key               | Description                                                                           | Required | Default |
| ----------------- | ------------------------------------------------------------------------------------- | -------- | ------- |
| `audience`        | `aud` claim that must be present on the OIDC access token                             | Yes      | N/A     |
| `required_claims` | Claim values that must be present on the OIDC access token to allow login into Coral. | No       | `{}`    |

## Database

### `[database]`

| Key       | Description                                                                            | Required                             | Default                 |
| --------- | -------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
| `backend` | DB backend Coral will use. Either `sqlite` or `postgres`                               | No when the config section is absent | `sqlite`                |
| `path`    | SQLite only. Path to the db file                                                       | No                                   | `<config dir>/coral.db` |
| `url_env` | Postgres only. Name of the environment variable that has the connection URL to the DB. | Yes, when `backend` is `postgres`    | N/A                     |

---

### Apps Docs Getting Started Self Host Docker Compose (`apps/docs/getting-started/self-host/docker-compose.mdx`)

---
title: "Deploying Coral with Docker Compose"
sidebarTitle: "Docker Compose"
---

This guide will explain how to set up Coral using Docker Compose with your choice of SQLite or Postgres as the backing DB.

We will use `<coral-host>` as the placeholder for the domain of your Coral deployment.

## Before you start

- **Hostnames.** Coral serves the UI, its auth server and its MCP server on three public HTTPS URLs. This guide uses `<coral-host>`, `auth.<coral-host>` and `mcp.<coral-host>`. If you only have one hostname, see [Using a single hostname](#using-a-single-hostname).
- **OIDC client.** Create an OAuth/OIDC client at your identity provider and register this redirect URI:

  ```text
  https://auth.<coral-host>/auth/oidc/callback
  ```

  Coral derives the redirect URI from `listeners.auth.public_url`; there is no separate setting for it.

- **Images.** `latest` is the newest stable release. To pin a version, use a tag from the [releases page](https://github.com/withcoral/coral/releases) without the leading `v`, such as `ghcr.io/withcoral/coral:1.0.0`. The images are built for `linux/amd64` only. On an arm64 host, such as an Apple Silicon Mac, add `platform: linux/amd64` to both services to run them under emulation.

## Deploy

1. Create a compose file with the following contents on your working directory:

- See the [Configuration Reference](/reference/configuration) for more details on the configuration options.
- This compose file exposes no ports. Depending on how you intend to do TLS termination, either add the TLS termination container to this compose file (e.g. Caddy) or expose the ports 14556 (mcp), 8779 (auth) and 3000 (UI) to your reverse proxy so it can forward calls to Coral.
- Coral's gRPC should not be exposed outside the compose network. Be mindful the server is plaintext with no encryption.

<Tabs>
<Tab title="With SQLite">

```
/* Detailed source-code truncated for AI context efficiency. */
```

<Note>
  Coral will save both the SQLite DB files and the encryption key for sensitive
  data in the `coral-state` volume.
</Note>

</Tab>
<Tab title="With Postgres">

```
/* Detailed source-code truncated for AI context efficiency. */
```

If your Postgres server uses a custom CA for TLS, add the CA bundle to the Coral container so Coral knows it can trust the certificate. For example, if you're using AWS RDS:

- Download the CA bundle:
  ```bash
  curl -sSo rds-ca.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
  ```
- Mount it on the Coral container in the compose file:

  ```yaml
  coral:
    image: ghcr.io/withcoral/coral:latest
    volumes:
      - ./rds-ca.pem:/etc/coral/rds-ca.pem:ro
    environment: # unchanged
  ```

- Refer to the CA bundle in your `.env` file:
  ```dotenv
  CORAL_DATABASE_URL=postgres://coral:PASSWORD@<rds-endpoint>:5432/coral?sslmode=verify-full&sslrootcert=/etc/coral/rds-ca.pem
  ```

</Tab>
</Tabs>

2. Create an .env file that will hold the credentials used by Coral

```dotenv
# Coral session signing key - create with:
# openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -outform DER | base64 -w0
CORAL_SESSION_SIGNING_KEY=...

# Client secret from your OIDC provider's application settings.
CORAL_OIDC_CLIENT_SECRET=...

# Coral web session secret, at least 32 characters.
# openssl rand -hex 32
CORAL_WEB_SESSION_SECRET=...

# The following are only needed in the Postgres example

# Credential encryption key - create with: openssl rand -base64 32
CORAL_CREDENTIALS_KEY=...

# Postgres connection URL
CORAL_DATABASE_URL=postgres://coral:PASSWORD@db.example.com:5432/coral?sslmode=verify-full
```

3. Start Coral with `docker compose up -d`

   Both images have a built-in health check, which is what `condition: service_healthy` waits for. If `coral-web` reports `dependency coral failed to start`, the cause is in the `coral` container: run `docker compose logs coral`.

4. Start your reverse proxy for TLS termination. If you don't have one, you can use Caddy by merging it into the compose file:

```yaml
services:
  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      coral:
        condition: service_healthy
      coral-web:
        condition: service_healthy

volumes:
  caddy-data:
  caddy-config:
```

Caddyfile:

```caddyfile
{
	email ops@example.com
}
<coral-host> {
	reverse_proxy coral-web:3000
}
auth.<coral-host> {
	reverse_proxy coral:8779
}
mcp.<coral-host> {
	reverse_proxy coral:14556
}
```

5. You should be able to open the UI in your browser in `https://<coral-host>`. After you create your first workspace you can use the UI instructions to connect an MCP client. Each workspace has its own MCP endpoint:

   ```text
   https://mcp.<coral-host>/workspaces/<workspace-id>/mcp
   ```

## Choosing the organization owner

`bootstrap_owner_subject` is the `sub` claim that your identity provider issues for the user who becomes the organization owner. It is not an email address. With Google it is a numeric account ID; other providers usually show it as the user ID in their admin console. You can also read it from the `sub` claim of an ID token issued to that user.

- Set it before that user signs in to Coral for the first time. Coral assigns the role at a user's first sign-in and does not change the role of a user who has already signed in.
- If you do not know the value yet, remove the line. An empty string is not valid and stops Coral from starting. Without an owner, organization admin operations are disabled.

## Using a single hostname

Each `public_url` is an origin: a scheme, a host and an optional port, with no path. Coral cannot be served under a path prefix. If you only have one hostname, as with Tailscale Serve, give each listener its own HTTPS port:

| Setting                                              | Example value                |
| ---------------------------------------------------- | ---------------------------- |
| `listeners.web.public_url`, `CORAL_WEB_PUBLIC_URL`   | `https://<coral-host>`       |
| `listeners.auth.public_url`, `CORAL_WEB_AUTH_ISSUER` | `https://<coral-host>:8443`  |
| `listeners.mcp.public_url`                           | `https://<coral-host>:10000` |

The redirect URI to register is then `https://<coral-host>:8443/auth/oidc/callback`.

## Troubleshooting

- **`coral-web` cannot reach the auth server.** `coral-web` calls the auth server at `CORAL_WEB_AUTH_ISSUER`, its public URL, from inside the compose network. That hostname must resolve and route from the containers, not only from your browser.
- **`/.well-known/openid-configuration` returns 404 on the auth server.** This is expected. Coral's auth server is an OAuth authorization server for the UI and MCP clients, not an OpenID provider. Its metadata is at `/.well-known/oauth-authorization-server`.

---

### Apps Docs Getting Started Self Host Kubernetes (`apps/docs/getting-started/self-host/kubernetes.mdx`)

---
title: "Deploying Coral with Kubernetes"
sidebarTitle: "Kubernetes"
---

This guide will explain how to deploy Coral in your Kubernetes cluster. We assume that you have an ingress controller installed and that you have a cert-manager installed or equivalent to issue TLS certificates. You may choose to do your TLS termination differently according to your needs.

We will use `<coral-host>` as the placeholder for the domain of your Coral deployment.

- See the [Configuration Reference](/reference/configuration) for more details on the configuration options.

## Common setup

1. Create the namespace, secrets, services and coral-web deployment. Save this to `common.yaml`. The gRPC server of the Coral deployment is plaintext and not exposed outside the cluster.

```
/* Detailed source-code truncated for AI context efficiency. */
```

## Coral deployment

Choose whether you want to use SQLite or Postgres as the backing storage.

<Tabs>
<Tab title="With SQLite">

2. Create the config and coral server deployment. A PVC will be used to store the SQLite data and the credential encryption key. Save this to `coral.yaml`.

```
/* Detailed source-code truncated for AI context efficiency. */
```

</Tab>
<Tab title="With Postgres">

2. Create the config and coral server deployment. Save this to `coral.yaml`.

```
/* Detailed source-code truncated for AI context efficiency. */
```

If your Postgres server uses a custom CA for TLS, add the CA bundle to the Coral deployment so Coral knows it can trust the certificate. For example, if you're using AWS RDS:

- Download the CA bundle and put it in a ConfigMap:
  ```bash
  curl -sSo rds-ca.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
  kubectl -n coral create configmap rds-ca --from-file=rds-ca.pem
  ```
- Mount it on the Coral pod:

  ```yaml
          volumeMounts:
            - name: state
              mountPath: /var/lib/coral
            - name: rds-ca
              mountPath: /etc/coral/rds-ca.pem
              subPath: rds-ca.pem
              readOnly: true
      volumes:
        - name: state
          emptyDir: {}
        - name: rds-ca
          configMap:
            name: rds-ca
  ```

- Refer to the CA bundle on Coral's DB secret:
  ```yaml
  CORAL_DATABASE_URL: "postgres://coral:PASSWORD@<rds-endpoint>:5432/coral?sslmode=verify-full&sslrootcert=/etc/coral/rds-ca.pem"
  ```

</Tab>
</Tabs>

### Apply

3. Apply your Kubernetes manifests

```shell
kubectl apply -f common.yaml
kubectl apply -f coral.yaml
```

4. Wait for the deployments to stabilize. You should be able to open the UI in your browser in `https://<coral-host>`. After you create your first workspace you can use the UI instructions to connect an MCP client via `https://mcp.<coral-host>`.

---

--- METRICS ---
- Files Extracted: 49
- Estimated Token Budget: ~74683 tokens
- Canonical Reference: https://codewiki.google/github.com/withcoral/coral
