README.md

September 6, 2026 · View on GitHub

Dravr Logo

Dravr Platform

Multi-tenant fitness coaching platform — same agent, every surface (web, mobile, MCP, messaging), with sports-science guardrails on every claim.

CI: Backend (Rust) CI: Backend (PostgreSQL) CI: Web Frontend CI: TypeScript SDK CI: MCP Compliance CI: Integration (HTTP/MCP) CI: Mobile E2E (iOS)


What this is

Dravr is the platform behind dravr.ai. It runs an AI fitness agent that lives wherever the user already is — chat apps, a mobile app, a web dashboard, or any AI assistant that speaks the Model Context Protocol.

The interesting bit isn't "an LLM that talks fitness." It's the architecture that turns a chat turn into a verifiable, tenant-scoped, provider-grounded coaching answer:

  • An agent is a tenant-scoped persona — a system prompt plus a category, plus tier-specific behaviour rules — not a free-form chat session.
  • Every agent turn is decomposed into atomic claims and run through a layered verifier (heuristic + LLM-as-judge) before it reaches the user. False or unsupported physiological / nutrition / training-prescription claims are flagged, scored, and stored against the conversation turn.
  • Every agent turn is grounded in real provider data — Strava activities, WHOOP sleep, Garmin HRV — fetched through tool calls, not hallucinated from an activity title.
  • Every agent turn reaches every surface through one envelope — a single ordered block list that web, mobile and the five chat channels all render from, with each surface receiving the blocks its capabilities permit. Progressive token-by-token deltas are narrower than that: TurnEvent::ProseDelta has one producer, the Copilot ACP branch, so on a function-calling provider an in-app turn arrives as the terminal frame rather than streaming (ledgered as progressive-text-deltas-acp-only).

The system, end to end

                ┌─────────── surfaces ───────────┐
   web (Vite)   mobile (Expo)   MCP (stdio/HTTP)   messaging (TG/Slack/Discord/WA/FB)


            ┌─────────────────────────────────────┐
            │  Transport layer (HTTP/SSE/WS/stdio)│
            │  Protocol adapters (REST/MCP/A2A)   │
            └─────────────────────────────────────┘
                            │  (auth, tenant resolution, CSRF, rate limit)

            ┌─────────────────────────────────────┐
            │   Chat orchestration (one path)     │
            │  ┌────────────────────────────────┐ │
            │  │ Coaching harness pipeline:     │ │
            │  │  1. memory recall + compaction │ │
            │  │  2. persona + agent prompt     │ │
            │  │  3. tool registry dispatch     │ │
            │  │  4. LLM tool-loop              │ │
            │  │  5. claim extraction           │ │
            │  │  6. claim verification         │ │
            │  │  7. agent notes + followups    │ │
            │  └────────────────────────────────┘ │
            │   AG-UI events fan out at every step│
            └─────────────────────────────────────┘
                │                  │             │
                ▼                  ▼             ▼
       Provider abstraction   LLM abstraction   Repository abstraction
        Strava, Garmin,        Gemini, Groq,    SQLite | PostgreSQL
        WHOOP                  OpenAI, Ollama,  every query is
        (via Sciotte +         Copilot ACP      tenant_id-scoped
         OAuth)
                │                  │
                └─── activity ─────┘
                     streams,
                     sleep, HRV

Every surface lands on the same chat orchestration. There is no "mobile pipeline" vs "web pipeline" — the channel adapter is responsible only for transport and rendering; the coaching logic is invariant.

Architectural pillars

Surface convergence

The web app, mobile app, MCP clients, and messaging adapters all hit the same orchestration. Surfaces differ only in:

  • Auth shape — JWT (web/mobile), MCP token (CLI assistants), per-channel signed webhooks (Telegram/Slack/Discord/WhatsApp/Messenger).
  • Rendering — markdown blocks for chat surfaces, AG-UI step events for streaming surfaces, plain-text fallbacks for SMS-grade channels.
  • Latency budget — chat surfaces tolerate streaming with progressive AG-UI events; webhook channels render a "thinking…" message that gets edited in place as STEP_FINISHED events arrive.

An agent exists once. Its behaviour is identical on every surface.

The coaching harness pipeline

An "agent turn" is a deterministic state machine, not a single LLM call. Each tier is independently observable and independently testable.

TierResponsibilityWhat it produces
0 — PersonaRender base persona (casual / professional / supportive / direct)System prompt header
1 — AgentInject tenant-scoped agent prompt from a hot-reload registryAgent domain expertise
2 — MemoryRecall extracted user facts (goals, equipment, injuries) + agent-authored notesUser context
3 — CompactionSummarise oldest N turns when window crosses warning thresholdBounded context window
4 — Tool dispatchCapability-filtered MCP tool registry runs the LLM tool-loopTool calls + provider data
5 — GuardrailsToken caps, blocked-topic filtering, disclaimer injectionBounded output
5.5 — Claim verifierDecompose response → atomic claims → heuristic + judge verdictVerdict store, audit trail
6 — Memory write-backExtract new facts from the turn, persist agent notes / follow-upsUpdated user model

Every tier emits AG-UI STEP_STARTED / STEP_FINISHED events so subscribers can show real-time progress instead of a spinner. Every tier is reconfigurable at runtime via the admin "Harness Config" panel — no redeploy.

Provider abstraction

Fitness providers are behind a single Provider trait. Adding a provider means implementing the trait and gating a feature flag — the rest of the system is provider-agnostic.

ConcernHow it's solved
OAuth lifecyclePer-tenant token store; transparent token refresh inside tool execution.
Activity normalizationProvider-specific responses are normalised through dravr-cageux / dravr-riviere / dravr-equilibre into a canonical activity / sleep / recovery model.
Syncdravr-enforme runs scheduled background sync per provider per tenant.
Restricted jurisdictionsSciotte (dravr-sciotte) ships a Strava mirror via headless Chrome where Strava's OAuth API isn't an option.

Agents see a &dyn Provider. They never know whether the data came from Strava OAuth, a Garmin webhook, or a Sciotte scrape.

LLM abstraction

LLM backends are interchangeable per tenant. Tool-loop dispatch picks one of three execution strategies based on the model's declared capabilities:

  • API tool-loop — for vendors with native function-calling (OpenAI-compatible, Gemini).
  • Headless tool-loop — for transports where tool calls go over Agent Client Protocol (Copilot via dravr-embacle).
  • CLI tool-loop — for local models without protocol support; uses text-based tool emission.

A single LlmProvider trait abstracts all three; orchestration code is identical regardless of which backend is configured.

Multi-tenant by construction

Tenant isolation is a CI-enforced invariant, not a runtime convention.

  • Every database query includes tenant_id in the WHERE clause. An architectural-validation script fails CI on missing scoping.
  • OAuth tokens, API keys, LLM credentials, cache keys are all per-tenant. There is no global / shared store.
  • Admin operations that touch an agent or a user must verify the target's tenant matches the caller's, including system agents (which are pinned to the seed tenant but accepted unconditionally for read/use, only).
  • The frontend admin console runs under super-admin impersonation, but every route still goes through the same tenant resolver — there's no impersonation-only data path.

External dravr-* modules — embedded today, extractable as services

The platform is composed against a set of independently versioned dravr-* modules. They split cleanly along an axis the architecture has been designed around: stateless / CPU-bound libraries that don't benefit from RPC vs. stateful / I/O-bound subsystems that are natural service boundaries.

ModuleRoleTodayExtractable as service?
dravr-cageuxSports-science formulas (training load, race predictions, fitness scoring)Static linkNo — pure CPU, RPC overhead dwarfs the work
dravr-riviereTime-series primitives for activity streamsStatic linkNo — same reason
dravr-equilibreHealth / recovery domain models (sleep, HRV, strain)Static linkNo — same reason
dravr-enformeProvider sync harness (Strava / Garmin / WHOOP)In-process workerYes — natural sync worker / Cloud Run job
dravr-embacleLLM runner (Copilot ACP, OpenAI, Gemini transports)Static link + child process for ACPYes — natural inference proxy
dravr-canotMessaging gateway (Telegram / Slack / Discord / WA / Messenger)In-process webhook handlersYes — extracts as a webhook receiver service
dravr-commerePush-notification service (APNs, FCM)In-processYes — natural push gateway
dravr-troncMCP protocol engine — tool/schema/transport/host traits the stdio and HTTP servers are built on; the notify layer is one small part of itStatic linkNo — protocol plumbing the server links directly
dravr-meteoWeather lookupsIn-process HTTP clientYes — small caching service
dravr-sciotteHeadless-Chrome Strava mirror scraperAlready a service — separate Cloud Run(already extracted)
dravr-contremaitreSystem prompts + agent definitionsAlready external — separate GitHub repo, hot-reloaded over webhook(already extracted)

Today — embedded composition:

graph LR
    subgraph platform["Dravr Platform (pierre-mcp-server)"]
        ORCH[Chat Orchestration]
        TOOLS[Tool Registry]
        REPO[(Repository Layer<br/>SQLite / PostgreSQL)]
    end

    subgraph libs["Statically linked libraries — no RPC"]
        CAGEUX[dravr-cageux]
        RIVIERE[dravr-riviere]
        EQUILIBRE[dravr-equilibre]
        TRONC[dravr-tronc<br/>MCP protocol engine]
    end

    subgraph embedded["I/O subsystems — embedded in-process today"]
        ENFORME[dravr-enforme<br/>provider sync]
        EMBACLE[dravr-embacle<br/>LLM runner]
        CANOT[dravr-canot<br/>messaging]
        COMMERE[dravr-commere<br/>push]
        METEO[dravr-meteo<br/>weather]
    end

    subgraph external["Already external services"]
        SCIOTTE[dravr-sciotte<br/>Cloud Run<br/>headless Chrome]
        CONTREMAITRE[(dravr-contremaitre<br/>GitHub repo<br/>hot-reload via webhook)]
    end

    ORCH --> TOOLS
    ORCH --> REPO
    ORCH --> CAGEUX
    ORCH --> RIVIERE
    ORCH --> EQUILIBRE
    ORCH --> ENFORME
    ORCH --> EMBACLE
    ORCH --> CANOT
    ORCH --> COMMERE
    ORCH --> TRONC
    ORCH --> METEO
    ORCH -.HTTP.-> SCIOTTE
    ORCH -.git pull + webhook.-> CONTREMAITRE

Tomorrow — service extraction path (incremental, per-module):

graph LR
    subgraph platform["Dravr Platform (pierre-mcp-server)"]
        ORCH[Chat Orchestration]
    end

    subgraph libs["Statically linked libraries — still linked"]
        CAGEUX[dravr-cageux]
        RIVIERE[dravr-riviere]
        EQUILIBRE[dravr-equilibre]
        TRONC[dravr-tronc<br/>MCP protocol engine]
    end

    subgraph mesh["Service mesh — each container scales independently"]
        ENFORME_SVC[dravr-enforme-svc<br/>provider sync worker<br/>Cloud Run job]
        EMBACLE_SVC[dravr-embacle-svc<br/>LLM proxy<br/>Cloud Run]
        CANOT_SVC[dravr-canot-svc<br/>webhook receiver<br/>Cloud Run]
        COMMERE_SVC[dravr-commere-svc<br/>push gateway<br/>Cloud Run]
        METEO_SVC[dravr-meteo-svc<br/>weather cache<br/>Cloud Run]
        SCIOTTE[dravr-sciotte<br/>scraper<br/>Cloud Run]
    end

    ORCH --> CAGEUX
    ORCH --> RIVIERE
    ORCH --> EQUILIBRE
    ORCH --> TRONC
    ORCH -.HTTP/gRPC.-> ENFORME_SVC
    ORCH -.HTTP/gRPC.-> EMBACLE_SVC
    ORCH -.HTTP/gRPC.-> CANOT_SVC
    ORCH -.HTTP/gRPC.-> COMMERE_SVC
    ORCH -.HTTP/gRPC.-> METEO_SVC
    ORCH -.HTTP/gRPC.-> SCIOTTE

Why the embedded-first design. The crates expose clean library APIs (no &self.db, no global state) so wrapping them in a thin HTTP/gRPC server is a mechanical change, not a refactor. We keep them in-process until a real signal demands extraction:

  • Independent scaling — when scraper or LLM-proxy load patterns diverge from chat-orchestration load patterns.
  • Blast-radius isolation — a Chrome OOM in sciotte already takes down its own pod, not the orchestrator. Same model extends to the others.
  • Polyglot deployments — Telegram-only edge nodes that need just dravr-canot and a tiny embacle.
  • Cost control — push and messaging can run on cheaper instance shapes than the orchestrator.

Adding a service for any of the extractable modules is: ship a thin binary that exposes the library trait over HTTP/gRPC, swap the in-process call for a client behind the same Rust trait the orchestrator already uses. Call sites do not change.

Hot-reloadable prompts

System prompts and agent personas don't ship in the binary. They live in dravr-contremaitre under prompts/coaches/<category>/<slug>/<locale>.md, and the server hot-reloads them on startup and on webhook from the contremaitre repo. Editing an agent prompt is a content change, not a deploy.

Streaming via AG-UI

Real-time progress on every surface is delivered through the AG-UI protocol. The orchestrator emits RUN_STARTED / STEP_STARTED / STEP_FINISHED / RUN_FINISHED / RUN_ERROR events into a per-run sink; SSE subscribers fan them out to whichever surface is rendering. Telegram-style webhooks consume the same stream and edit a "thinking…" message in place as steps complete.

Dual storage backend

The repository layer abstracts SQLite and PostgreSQL behind the same trait set. SQLite is the default for local dev and single-machine deployments; PostgreSQL is what production runs on Cloud SQL. Migrations are maintained in parallel directories (migrations/ and migrations_pg/), and CI exercises both: every push runs the backend suite on a real PostgreSQL service (ci-postgres.yml), where each test opens a private PostgreSQL database through pierre_database::database::test_utils and the lane refuses to fall back to SQLite; the daily SQLite shards in ci-backend.yml run the same files on SQLite. Tests never spell a database URL — the factory honours DATABASE_URL, so a plain local cargo test stays on in-memory SQLite. Adding a feature without a PostgreSQL backend is a CI failure.

Quick start

Requires Rust (stable), Bun, direnv, macOS or Linux.

git clone https://github.com/dravr-ai/dravr-platform.git
cd dravr-platform

cp .envrc.example .envrc      # API keys, OAuth client secrets
direnv allow

# Resets DB → runs migrations → seeds admin/coaches/demo/social/mobility →
# starts backend (8081), Vite frontend (5173), Expo mobile (8082).
./bin/setup-db-with-seeds-and-oauth-and-start-servers.sh

Default credentials seeded by the script:

RoleEmailPassword
Super adminadmin@example.comAdminPassword123
Web testerwebtest@pierre.devWebTest123!
Mobile testermobiletest@pierre.devMobileTest1234
Demo useralice@acme.comDemoUser123!

Admin API token written to logs/admin-token.txt for tooling.

These are local credentials, created by the setup script on your machine. The deployed dev environment seeds a different admin — admin@dravr.ai, set in infra/environments/dev/main.tf — so a login that works locally will not work against dev, and vice versa. Conflating the two costs an afternoon; they are separate databases with separate seeders.

For exercising the agent on either environment, sign in as a regular user (webtest@pierre.dev) rather than the admin: the admin account has no provider connections and the onboarding gate rejects its chat requests.

Connecting an AI assistant

The same backend that serves web/mobile is an MCP server. Add to Claude Desktop's claude_desktop_config.json:

{
  "mcpServers": {
    "dravr": {
      "command": "npx",
      "args": ["-y", "pierre-mcp-client@next", "--server", "http://localhost:8081"]
    }
  }
}

The TypeScript SDK handles OAuth 2.0 PKCE end-to-end. See sdk/README.md.

Build profiles

Each profile is a deployment shape, not just a feature subset.

ProfileDeployment shape
server-fullLocal dev / single-machine — every protocol, transport, provider, channel. SQLite.
server-productionCloud Run target — REST + MCP + A2A, all production providers, PostgreSQL, no synthetic.
# Exactly what docker/images/server/Dockerfile ships, and what CI checks
cargo build --release \
  --no-default-features \
  --features "server-production,postgresql,sqlx/migrate,telemetry,gcp-kms"

# A narrower shape: REST only, Strava, core tools
cargo build --release \
  --no-default-features \
  --features "sqlite,protocol-rest,transport-http,client-web,provider-strava,tools-fitness-core"

The full feature matrix (protocols × transports × clients × tools × providers × channels) is documented in book/src/build.md.

Repo layout

.
├── crates/                # Rust workspace — backend, organised by bounded context
├── frontend/              # React + Vite — web admin + user dashboard
├── frontend-mobile/       # Expo + React Native — iOS + Android consumer app
├── sdk/                   # pierre-mcp-client npm package (MCP bridge for AI assistants)
├── packages/              # Bun workspace — shared TS code (api-client, types, ui-logic, i18n)
├── migrations/            # SQLite migrations
├── migrations_pg/         # PostgreSQL migrations (kept in parity)
├── infra/                 # Terraform — GCP Cloud Run, Cloud SQL, Memorystore, Secret Manager
├── book/                  # mdBook documentation source
├── scripts/               # CI helpers, validation gates, generators
├── bin/                   # Dev scripts (start/stop/setup/tunnel)
├── templates/             # OAuth login/success/error HTML, brand assets
└── website/               # Marketing site (Astro) deployed to GitHub Pages

Development discipline

Pre-push validation

The repo uses a marker-based pre-push gate so the full test matrix doesn't run on every push.

# Once per clone — wire the canonical hooks dir
git submodule update --init --recursive
git config core.hooksPath .build/hooks

# Before pushing, generate the validation marker (~2 min)
./scripts/ci/pre-push-validate.sh

# The pre-push hook checks the marker is fresh (15 min TTL) and matches HEAD
git push

Test gates

cargo test runs everything in crates/pierre-server/tests/ by default. Four env vars opt-in to tests that hit live external services (local Ollama/vLLM, OpenWeather, USDA): RUN_LOCAL_LLM_TESTS, RUN_VLLM_TESTS, RUN_NETWORK_TESTS, USDA_API_KEY. See book/src/environment.md#test-gates for the full table and per-test commands. There are no #[ignore] attributes — every other test runs every time.

Architectural CI gates

These run as the code-quality job and block every other CI job. Run them locally when touching auth / OAuth / admin / database / tenant code:

GateWhat it enforces
security-review.shTenant scoping, log redaction, SQL injection, XSS, OWASP Top 10.
check-input-validation.shDivision-by-zero, pagination bounds, cache key completeness, numeric ranges.
architectural-validation.shNo placeholder code, structured errors only (no anyhow!), tenant scoping, no unsafe outside the FFI allowlist, secret-pattern detection.

See book/src/development.md and book/src/testing.md.

Deployment

Production runs on Google Cloud Run with Cloud SQL (PostgreSQL), Memorystore (Redis), and Secret Manager — all Terraform-managed in infra/. The deploy pipeline is Deploy: Build & Ship (dev) (.github/workflows/publish-images.yml); hotfixes go via Deploy: Hotfix (skip CI).

The frontend and backend ship in the same Cloud Run service, joined by an nginx sidecar that proxies /__/ to the Firebase auth handler and everything else to the Rust backend. The browser sees one origin — no CORS gymnastics.

A separate Contremaitre: Sync Prompts workflow pushes prompt edits from dravr-contremaitre into the running Cloud Run instances by triggering the in-process hot-reloader. Agent prompt edits are zero-redeploy.

Documentation

mdBook source in book/. Architectural reading order:

Build locally:

mdbook serve book/

Per-component READMEs: sdk/README.md, frontend/README.md, frontend-mobile/README.md, scripts/README.md, bin/README.md.

Contributing

See CONTRIBUTING.md. Architectural ground rules:

  • No PRs. Feature branches are squash-merged locally onto main.
  • Bun only for JS/TS workspaces.
  • Structured errors only in src/ — no anyhow!, all errors flow through AppError / DatabaseError / ProviderError.
  • Tenant scoping is non-negotiable. Every query carries tenant_id; CI fails otherwise.
  • No SQLite-only features. PostgreSQL backend ships in the same change.

License

Dual-licensed under Apache 2.0 or MIT — pick whichever fits your downstream use.