coral Documentation

The execution layer for AI agents. Act across more of your systems with precise controls: safer, faster and more efficient than MCP tools and gateways.

4,938 stars Rust Rust 49 files · ~74,762 tokens
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy coral on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name coral --restart always withcoral/coral:latest

Technical Documentation: withcoral/coral

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

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.




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,
    or run a shared server
    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.

  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.

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.

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.

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.

Development

To run Coral from a checkout, follow the
development guide.

Community

Ask questions, share ideas, and show what you built in our
Discord or on
GitHub issues.

Security

Do not report security issues in public issues or pull requests. See
SECURITY.md.

License

Coral is licensed under the Apache License 2.0. See 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
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 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 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 [email protected]. 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.

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 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, Background and
    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:[email protected]: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.

Local Development

To preview the documentation locally:

  1. Run the Mintlify dev server: npx mint dev
  2. Open http://localhost:3000

Authoring Guidelines

If you are an agent or a human contributing to these docs, please read 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 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 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-; 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
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"

Coral allows you to centralise access to providers. 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

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).
text
[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>
Add GitHub, Slack, Datadog, and other providers to your workspace. Your agents can only call operations from providers you've added.

Add a provider

Configure your MCP clients (e.g. Claude Code, Codex) to use the [Coral MCP server](/getting-started/quickstart#connect-an-mcp-client)
Add a provider, connect your agent, and ask your first question Build the right mental model for Coral

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


title: "Quickstart"

Install the desktop app or
run a shared server first.

You set up everything in Coral from the Coral UI.
text
- **Desktop app:** Open Coral. The app window is the Coral UI.
- **Shared server:** Go to `https://<coral-host>` in your browser, then sign in.
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.
text
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.
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. Ask your agent something that needs your provider. For example, if you've connected GitHub, you could ask:
text
> What are my open pull requests?

Next steps

Learn how workspaces, providers, and policies fit together. Give your agents read access to a database.

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 for the exact contract.

./dev setup regenerates .dev/state/config.toml. Reapply operator selections after
running it. See ADR 0008 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.

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 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
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.
See the skill flow guide 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"
}
}




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 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 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 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 records that topology and how to remove
the previous host setup. See development configuration for operator
examples.



status: accepted

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.

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'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>: or localhost:) 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 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). 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). 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: 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, 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: 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, 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"

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

Install Coral

Download Coral from the releases page. The newest release is at the top. Open its Assets list and download the file for your platform.

The macOS app runs on Apple silicon only.

  1. Download coral-app-mac-arm64.dmg from the newest release.
  2. Open the DMG and drag Coral into your Applications folder.
  3. Launch Coral and add your first providers from the app.

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.

  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.

The Windows app is x64 only.

  1. Download coral-app-win-x64.exe from the newest release.
  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.

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.

Uninstall

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

Quit Coral and delete the AppImage file.

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

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"

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, 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


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


title: "Concepts"

Organizations

Each Coral deployment contains exactly one organization.

An organization is the top level container for 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 has one user, who doesn't sign in. That user is the organization owner. Membership and sign-in apply to a shared server.

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:

Workspace members share its providers and policy.

Providers

A provider is a workspace-owned set of 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:

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 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.

  • 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. Nobody can rename or remove it. It has no accounts.

Operations

An operation is a single action exposed by a provider. It has named inputs and returns one result.

Programs call an operation by its call path, which begins with coral.providers and the provider namespace:

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

Call paths of the 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, 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, and decides whether an operation is:

  • Always allow, the operation runs
  • Needs approval, the call pauses until a person decides. A user approves their own calls. A workspace 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 only, and uses its own API key.

Coral attributes a run to the principal that submitted it, even when a workspace owner approves an operation or the provider uses a shared account.

Programs

A program is TypeScript that a principal submits for Coral to execute.

A program reaches upstream services (e.g. GitHub, Linear) only through 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 under coral.functions.

It never sees your credentials.

Submitting a program creates a run.

Runs

A run is one execution of a program.

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.

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, connect accounts, set policy, approve operations, and inspect 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 saves at a path under coral.functions in one workspace.

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

A program calls one the same way it calls an operation. 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"

MCP Toolbox for Databases 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:

See Toolbox's integration catalog for the full list and available tools.

Run Toolbox

  1. Install Toolbox on a host that can reach your database.
  2. For trusted development, choose a prebuilt configuration 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, 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 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.

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 below.

Configure database credentials in Toolbox.
If Toolbox requires client authentication, follow its authentication documentation 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 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.
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 to add it to your Coral deployment.
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 alongside Coral's Kubernetes guide.
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 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.



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 [email protected].

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
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.

  • 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 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 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.
text
/* Detailed source-code truncated for AI context efficiency. */
Coral will save both the SQLite DB files and the encryption key for sensitive data in the `coral-state` volume.
text
/* 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
  1. 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:[email protected]:5432/coral?sslmode=verify-full
  1. 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.

  2. 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 [email protected]
}
<coral-host> {
	reverse_proxy coral-web:3000
}
auth.<coral-host> {
	reverse_proxy coral:8779
}
mcp.<coral-host> {
	reverse_proxy coral:14556
}
  1. 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.

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.
text
/* Detailed source-code truncated for AI context efficiency. */

Coral deployment

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

  1. 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.
text
/* Detailed source-code truncated for AI context efficiency. */
  1. Create the config and coral server deployment. Save this to coral.yaml.
text
/* 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"

Apply

  1. Apply your Kubernetes manifests
shell
kubectl apply -f common.yaml
kubectl apply -f coral.yaml
  1. 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 ---