<!-- OpenSmartRoute: osr-platform. Source https://github.com/isathish/OpenSmartRoute/blob/main/.claude/skills/osr-platform/SKILL.md; HTML https://opensmartroute.ai/docs/skills/osr-platform -->
---
name: osr-platform
description: Work on the hosted OpenSmartRoute platform under platform/ - the FastAPI platform API (osr_platform, accounts, API keys, plans, metered /api/v1/route, OpenAI-compatible /v1 proxy, admin, editions, the committed openapi.json snapshot) and the Next.js 16 web app (landing, models catalogue, rankings, playground, pricing, dashboard, route-handler proxies). Add or change a page of the end-user documentation site (/docs) rendered from the repository Markdown via sync-content.mjs, lib/docs/catalogue.ts and lib/docs/render.ts, the generated REST API reference (/docs/api) or the version menu; publish a new docs file or skill; fix the docs build, search index or sitemap. Build and deploy both containers to Azure Container Apps with azd (azure.yaml, infra/, the platform GitHub workflow). Use when editing platform/api, platform/web, the documentation site, azure.yaml or infra/.
license: Apache-2.0
compatibility: OpenSmartRoute repository, Python >= 3.11, Node >= 22, Azure Developer CLI
metadata:
  author: opensmartroute
  osr-domains: "coding general"
  osr-tags: "opensmartroute platform nextjs fastapi docs azure azd"
  osr-quality-prior: "0.85"
  osr-primary: "false"
---

# OpenSmartRoute hosted platform

Two deployables under `platform/`, one repository - deployed as three services: the API (`osr-api`), the
public website (`osr-web`, `opensmartroute.ai/`, `/docs`) and the platform (`osr-platform`, everything under
`opensmartroute.ai/platform/...`; the web image with the website forwarding `/platform` to it). Read
`platform/README.md` first (developer
documentation, repository only); the end-user guide is `docs/PLATFORM.md`, published at `/docs/PLATFORM`.

| Directory | Service | Stack | Checks |
|---|---|---|---|
| `platform/api` | `osr-platform-api` | FastAPI, SQLite, `osr_platform` package | `ruff format --check platform; ruff check platform; mypy platform/api/osr_platform; pytest platform/api/tests -q` |
| `platform/web` | `osr-platform-web` | Next.js 16 App Router, React 19, Tailwind 4 | `npm run check && npm run build` (in `platform/web`; `check` = eslint + tsc + `vitest run` over `tests/*.test.ts`), then `npm run e2e` (Playwright: customer journey + operator console against the standalone build and a throw-away API bootstrapped with the `OPERATOR` from `e2e/global-setup.ts`; `OSR_E2E_WEB` / `OSR_E2E_API` / `OSR_E2E_OPERATOR_USERNAME` / `_PASSWORD` point it at servers you already run, e.g. the dev stack with `admin` / `admin-dev-only`) |

Run the whole platform in Docker with live updates: `docker compose up --watch` (root `compose.yaml`, project
`opensmartroute-dev`; dev images `platform/api/Dockerfile.dev` and `platform/web/Dockerfile.dev`, context = repository
root with their own `Dockerfile.dev.dockerignore`; `--profile llm` adds Ollama wired through
`deploy/compose/providers.ollama.yaml`; knobs `OSR_API_PORT`, `OSR_WEB_PORT`, `OSR_OLLAMA_PORT`, `OSR_OLLAMA_MODEL`).
Compose Watch syncs saves into the containers - new source folders or content sources need a `develop.watch`
entry there. The self-hosted production stack (PostgreSQL, Redis, Kafka) is `platform/docker-compose.yml`.
Or without Docker: `$env:PYTHONPATH="src;platform/api"; python -m osr_platform.app` (env `OSR_PLATFORM_*`,
port 8080) and `OSR_API_URL=http://localhost:8080 npm run dev` in `platform/web` (port 3000). The browser
only talks to the web origin: `src/app/api/[[...path]]`, `v1/[[...path]]` and `openapi.json` route
handlers stream to `OSR_API_URL`. A new API response header must be added to
`FORWARD_RESPONSE_HEADERS` in `src/lib/api/proxy.ts` or the browser never sees it.

## Platform API (`platform/api/osr_platform`)

- `app.py` builds the FastAPI app (`create_app(settings)`), `settings.py` maps `OSR_PLATFORM_<FIELD>`;
  `api.py` public/account routes, `proxy.py` OpenAI-compatible `/v1`, `admin.py`, `billing.py`, `docs.py`
  (JSON mirror of the Markdown at `/api/v1/docs`; the web site does not use it).
- Two editions: `OSR_PLATFORM_EDITION=community` (core `Router`) or `enterprise` (`RouterBuilder` with
  auto-learning, health, guard, metrics, hash-chained audit; enables tenants, stats, audit). Both are built
  in `editions.py` behind `Engine` (`controls()`, `health_snapshot()`, `observability_snapshot()`); the
  guard with PII redaction and `MetricsTelemetry` run in both.
- Observability (`observability.py`): `RequestContextMiddleware` (echo/generate `X-Request-Id`,
  `Server-Timing`, JSON access log `osr.platform.access`, `HttpMetrics` per route template), `/healthz`,
  `/readyz` (`readiness()` checks database, catalogue, router, audit file), `/metrics` (`prometheus_text`;
  `OSR_PLATFORM_METRICS_PUBLIC=false` gates it behind `X-Admin-Token`). Public catalogue GETs return
  through `api.cacheable(request, payload, max_age_s)` (ETag, `Cache-Control`, 304 on `If-None-Match`;
  `OSR_PLATFORM_HTTP_CACHE_MAX_AGE_S`). `OSR_PLATFORM_ROUTE_CACHE_TTL_S` adds `CacheMiddleware`;
  `OSR_PLATFORM_RETENTION_DAYS` drives `db.purge_usage` from a background sweep in `app.py`.
- Governance (`api.py`): `POLICY_KEYS` = tenant keys + `daily_budget_usd` / `monthly_budget_usd`;
  `validate_policy(config, registry)` (400 on unknown keys, unknown targets, allow/deny overlap) is used by
  both `PUT /tenants` and `PUT /policy`; `apply_policy(constraints, cfg)` merges (fill boundaries, stricter
  caps, union deny, intersect allow). `metered()` calls `check_budget` only when `meta["spends"]` (execute /
  chat) and records `tenant` on usage rows (`db.spend`, `db.spend_by_tenant`); exhaustion = 429 +
  `Retry-After` + `X-Budget-Limit|Used|Period`. `GET /governance` -> `PlatformAPI.governance(p)`. Web pages
  `app/platform/dashboard/governance/page.tsx` (all editions) and `app/platform/dashboard/health/page.tsx` (enterprise, `stats`).
  New response headers must be added to `EXPOSED_HEADERS` in `app.py` and `FORWARD_RESPONSE_HEADERS`.
- Traces and events (`api.py`): `event_buffer()` finds the tracer's `MemorySink`; `trace_payload(p, rid)`
  (`GET /trace/{request_id}`, 404 unless `db.usage_by_request(account, rid)` finds the row - always
  account-scope trace reads), `events_payload(...)` (`GET /events`, joined to the account through
  `db.request_ids(account, since)`; request-less events only with the `stats` feature), `status_payload()`
  (public `GET /status`, 503 when `readiness()` fails), `mark_traced(items)` on `/activity`,
  `trace_id_of(rid)` -> `trace_id` + `X-OSR-Trace-Id` on `/route`. Feedback is persisted per request
  (`db.record_feedback` / `db.feedback_for`, table `feedback`); `audit_records(p, rid)` scans the audit file
  for the request (plans with `audit`). Web: `components/observability/
  trace-view.tsx` (`TraceView`, `TraceLoader`, `stageTone`, `summarise`) used by `app/platform/dashboard/events/
  page.tsx`, the activity drawer and the playground `DecisionPanel`; `system-status.tsx` (`useStatus`,
  `StatusPill`, `SystemStatusCard`). Event attributes never carry prompt text - keep it that way.
- Learning (`editions.py`): `PlatformSettings.slm` -> `RouterSLM.from_file` + `builder.with_strategy(SLMStrategy)`;
  `PlatformSettings.autopilot` -> `_build_autopilot` (mirror of `cli._build_autopilot`: `SelfImprover` on
  `engine.core.feedback` / `.memory`, `Autopilot(tracer=engine.tracer)` so cycles land in the platform's tracer,
  `engine.core.observers.append(pilot.observe)`; `with_router_options(remember_requests=...)` keeps prompts in
  memory for feedback rows). A promoted model is saved to `settings.autopilot_dir / "slm.json"` and preferred
  over the mounted file on restart. `Engine.learning_snapshot()` (`GET /api/v1/learning`, any plan),
  `Engine.feedback_snapshot()` (NaN -> None via `_finite`), `slm_info(model)`, `Engine.start()/stop()` wired
  as FastAPI startup/shutdown handlers in `app.py`. Admin `POST /admin/autopilot/cycle` -> `pilot.trigger("api")`.
  Web: `app/platform/dashboard/learning/page.tsx` (nav icon `brain`), types `LearningSnapshot`, `SlmInfo`,
  `AutopilotStatus`, `ImprovementReport`. SDK side: `EnterpriseRouter._bookkeep` runs `Router.observers`;
  `SelfImprover` emits `learn.improve` / `learn.promote`. Tests `platform/api/tests/test_learning.py`.
- Marketplace import (`harvest.py`, `docs/MARKETPLACE.md` "Imported catalogue"): `SkillsShSource` (skills.sh
  sitemaps -> every `SKILL.md` per repo through `GitHub.tree/raw`), `GitHubSearchSource` (code search, token
  required, size buckets <= 1000 hits), `MCPRegistrySource` (stream `?version=latest`, active only -> `tool`
  rows). `listing_for(h)` builds the OCM manifest (`metadata.source = {provider, repository, path, ref, url}`,
  slug `owner-repo-skill` / `mcp-<name>`, <= 63 chars + hash), `import_rows(store, rows, refresh=)` writes in
  one transaction per batch, `Harvester(work_dir)` keeps a resumable JSONL cache per source. Admin
  `GET|POST /api/v1/admin/registry/import` (max 500 items) and `scripts/harvest_skills.py collect|stats|publish`
  drive it. `RegistryStore.facets()` is cached (`FACETS_TTL_S`, `invalidate()` on write); `sources()` counts
  listings per provider. Seeded manifests have `metadata.source` as a *string* - always `isinstance` guard.
  Tests swap the transport: `monkeypatch.setattr(harvest, "fetch", fake)`. Never `git add` the work dirs
  (`.osr-harvest*/`). Whole-catalogue publishes go OFFLINE (`publish --data-dir`, stop the API, replace
  `platform.sqlite3` on the Azure Files share, start it): sustained bulk POSTs over SMB corrupted the listings
  btree once ("database disk image is malformed"). The endpoint is for incremental batches. Keeping current:
  `MarketplaceRefresher` (harvest.py) runs in the process owning `marketplace` when
  `OSR_PLATFORM_MARKETPLACE_REFRESH_S > 0` (Bicep param `marketplaceRefreshS`, weekly) - cache under
  `<data>/harvest/`, `existing_slugs()` filter, batches of 100; `POST /admin/registry/import/refresh` = run now.
  Big catalogues need the covering indexes (`ix_listings_browse`, `ix_listings_popular`) BUILT OFFLINE and the
  file swapped: a cold `SELECT tags FROM listings` over 100k wide rows on Azure Files took 14 minutes.
- Signup `POST /api/v1/signup {email,name[,password]}` (5/IP/day). Route body: `text`, `objective` (Objective field
  names `quality/cost/latency`), `constraints`, `kinds`, `plan` (Pro-gated), `execute`.
- Operator console (`/platform/admin`, `api.admin_router()`): operators (`db.operators`, scrypt hashes via `auth.hash_password`
  / `verify_password`, `osr_op_` sessions 12 h, roles `operator` / `superadmin`) sign in at `POST /admin/auth/login`;
  the `self.admin` dependency yields an `OperatorPrincipal` from a Bearer operator token *or* `X-Admin-Token`
  (counts as superadmin) - keep every `/api/v1/admin/*` route on it, call `require_superadmin` for operator
  management and `audit(op, "<noun>.<verb>", ...)` for mutations (an `admin` event). `bootstrap_operator()` runs
  at start-up from `OSR_PLATFORM_ADMIN_USERNAME/_PASSWORD`. Users: `users.password_hash`, `POST /auth/password/login`,
  `POST|DELETE /auth/password`, `settings.password_login`. Web: `lib/auth/operator.tsx` (`OperatorProvider`,
  `useOperator`, `useAdminApi`; token in localStorage `osr.platform.operatorToken`), `components/admin/admin-shell.tsx`,
  pages under `app/platform/admin/(console)/`, `app/platform/admin/login`; `robots.ts` disallows `/platform/admin`.
- Shared state (`cluster.py`, `db.py` backends): `Database(path_or_url)` picks `SqliteBackend` or `PostgresBackend`
  (`to_postgres()` rewrites `?` -> `%s`, `INSERT OR IGNORE`, `REAL` -> `DOUBLE PRECISION`, `AUTOINCREMENT` -> identity;
  qualify columns in `ON CONFLICT ... DO UPDATE SET x=table.x+1`). Run the suite on PostgreSQL with
  `OSR_TEST_DATABASE_URL` (one schema per test). `make_limiter` -> `RedisRateLimiter` (Lua sliding window) when
  `OSR_PLATFORM_REDIS_URL`; `make_event_bus` -> `KafkaEventBus` when `OSR_PLATFORM_KAFKA_BOOTSTRAP`
  (`api.emit(topic, payload, key)`; topics `usage`, `feedback`, `admin`; `MemoryEventBus` in tests via
  `c.app.state.api.bus = ...`). `/readyz` reports `redis`, `events`, `database_dialect` without failing readiness.
  `platform/docker-compose.yml` = production stack (PostgreSQL, Redis, KRaft Kafka, API, web); `infra/`
  `clusterMode` adds `osr-postgres` / `osr-redis` / `osr-kafka` container apps (internal TCP ingress, one share
  each, `nobrl` mounts) and multi-replica `osr-api`. Kafka image: `CONTROLLER://localhost:9093` (0.0.0.0 fails
  validation), `CLUSTER_ID` must be 22 base64url chars; kafka-python `bootstrap_connected()` goes false after
  metadata - use `_metadata.brokers()` for health.
- SLM service (`slm_service.py`, entry `osr-platform-slm`): `SLMWorker` (cold-starts an untrained bundle to
  `autopilot_dir/slm.json`, `ingest(topic, payload)` for `training` {request_id,text} into `RequestMemory` and
  `feedback` into a `FeedbackStore`, `cycle()` = `SelfImprover.cycle`, Kafka consumer group `osr-slm`), FastAPI
  `/healthz /model /predict /reports /cycle` on `settings.slm_port` (8090). API side: `settings.slm_url`,
  `training_events` (`api.note_training` next to `note_decision` in /route and /v1 chat), `slm_reload_s`
  (`api.maybe_reload_slm()` swaps core/calibrator/targets/meta in place from the promoted file's mtime);
  `build_engine` serves the promoted file when `slm_url` is set even without `OSR_PLATFORM_SLM`. Admin proxies
  `/api/v1/admin/slm*` use urllib (404 without `slm_url`, 503 unreachable). Web `app/platform/admin/(console)/slm/page.tsx`.
  Tests `test_slm_service.py` (in-process TestClient + a stdlib HTTP relay for the proxy).
- Domain services (`services.py`): `SERVICES` specs (name, gateway prefixes, entry `osr-platform-<name>`), ports
  in `settings.SERVICE_PORTS` (docs 8091 ... admin 8098, mcp 8099, openai 8100, routing 8101, slm 8090),
  `create_service_app(name)` = `PlatformContext.routers(name)` + `/healthz /readyz /metrics`; the API includes
  `gateway_router` for a domain when `OSR_PLATFORM_<NAME>_URL` is set (`X-OSR-Service` on the answer,
  `text/event-stream` relayed chunk by chunk through `forward_async`). Include order in `create_platform_app`:
  rankings (`/stats/public`) before routing (`/stats`), routing (`/admin/autopilot`) + marketplace + providers
  before the `admin` catch-all, onboarding before accounts (`/auth`). `ctx.owns(name)` decides background work
  (owner of `routing` runs the autopilot and attaches the telemetry store). Adding a service = SERVICE_PORTS +
  SERVICES + `main_<name>` + pyproject script + `routers()` + loop tuple + both compose files + bicep
  `domainServices`/`domainServiceEnv` + platform/README table + `test_services.py`.
- Notifications (`notifications.py`): `NotificationStore` (tables `notification_channels`, `notifications` =
  one episode per alert id with `state` firing/resolved and `read_at`, `notification_deliveries`),
  `Notifier(api, interval_s)` = daemon loop started by the `api` role (`PlatformContext.start`), leader through
  `SET osr:alerts:leader NX EX` in Redis; `tick()` retries last round's failures, re-evaluates
  `api.alerts_payload(p)` for every active workspace and `alerts.deployment_alerts(...)` for the deployment,
  `_reconcile` opens/refreshes/resolves episodes, emits Kafka topic `alerts`, delivers `firing` / `escalated`
  / `resolved` through `send(ch, n, event, context)` (email via `api.mail`, webhook JSON + `X-OSR-Event`,
  `X-OSR-Delivery`, `X-OSR-Signature` = `sign(secret, body, ts)`, Slack Block Kit, Teams MessageCard; `https`
  only, `_post_json` never raises). `notifications_router` (workspace, `accounts` domain, tag `account`) and
  `admin_notifications_router` (deployment scope, `admin` domain) - add new alert rule names to the `rules`
  list in each router. Settings `alerts_interval_s` (`OSR_PLATFORM_ALERTS_INTERVAL_S`; tests pass `0.0` and call
  `notifier.tick(force=True)`). Web: `components/notifications/notifications-center.tsx` (shared by
  `app/platform/dashboard/notifications/page.tsx` with `useApi` and `app/platform/admin/(console)/notifications/page.tsx` with
  `useAdminApi`), `notifications-bell.tsx` in the dashboard header. Tests `test_notifications.py` (a stdlib
  `Sink` HTTP server receives the webhooks; header names arrive title-cased - compare lower-cased).
- Console extras (`admin_console.py`): `admin_console_router` (`admin` domain) serves `/analytics` (SQL grouped by
  the portable `usage.day` column; signups bucketed in Python), `/activity` (cross-workspace request log, cursor
  `before`), `/lookup?q=`, `/settings` (`settings_payload`: dataclass fields -> `OSR_PLATFORM_<NAME>`, `_mask` /
  `_scrub` hide secret-looking names and nested `client_secret` / `api_key` keys - add new secret markers to
  `_SECRET_MARKERS`, new groups to `_GROUPS`), `/retention` + `/retention/purge`; `admin_console_local_router`
  serves `/health` from the routing process (register it with the other local admin routers before the gateway).
  Web: `app/platform/admin/(console)/{analytics,health,activity,leads,plans,marketplace,settings,search}/page.tsx`;
  `ADMIN_NAV` in `components/admin/admin-shell.tsx` (also the header search box -> `/platform/admin/search?q=`).
  Tests `platform/api/tests/test_admin_extras.py`.
- CLI sign-in (`osr login`) is the RFC 8628 device grant in `auth_router()`: `POST /auth/device/code`
  (anonymous, `device:{ip}` limiter 10/min + 30/h, returns `user_code` `XXXX-XXXX` from
  `auth.generate_user_code`, `verification_uri = <web_url>/platform/cli/authorize`), `GET /auth/device/{user_code}`
  and `POST /auth/device/approve|deny` (signed in; approve needs `admin` and a free key slot; `db.decide_device_code`),
  `POST /auth/device/token` (`authorization_pending` / `slow_down` / `access_denied` / `expired_token` as
  HTTP 400 JSON; success mints a key via `create_key` and `consume_device_code`). Table `device_codes`
  stores only the SHA-256 of the device code (`DEVICE_CODE_TTL_S` 15 min). Web page:
  `app/platform/(auth)/cli/authorize/page.tsx` + `components/auth/device-authorize.tsx`. SDK side lives in
  `src/opensmartroute/credentials.py` (`device_login`, `CredentialStore`). Tests: `tests/test_device_flow.py`.
- The installer scripts `install.sh` / `install.ps1` at the repository root are copied by `sync-content.mjs`
  and served verbatim by `app/install.sh/route.ts` and `app/install.ps1/route.ts` (`lib/installers.ts`);
  install one-liners for the site come from `install` in `lib/config/site.ts`.
- Chat proxy metadata key is `opensmartroute`; `models[]` = candidate list with fallbacks; `stream` = SSE.
- Tests use `platform/api/tests/conftest.py` fixtures (temp data dir, `examples/targets.yaml`).
  Smoke a deployment with `python platform/api/scripts/smoke.py <base-url>`.
- `platform/api/openapi.json` is a committed snapshot of the enterprise-edition OpenAPI document
  (`OPENAPI_TAGS`, `SECURITY_SCHEMES` and `openapi_snapshot()` in `app.py`). After changing any route,
  model or docstring run `python -X utf8 platform/api/scripts/export_openapi.py`;
  `test_openapi_snapshot_is_current` and the CI `--check` step fail on drift. Tag descriptions become
  the intro of each `/docs/api/<tag>` page; the `admin` and `docs` tags and every `/api/v1/admin/*`
  path (`HIDDEN_TAGS`, `HIDDEN_PATH` in `lib/docs/openapi.ts`) are hidden from the site; give a new tag
  a title in `TAG_TITLES`.

## Web app (`platform/web`)

- Route groups: `(marketing)` (landing, marketplace, models, rankings, estimate, pricing, roi,
  compare, plus the prose pages `support`, `terms`, `privacy` built on
  `components/marketing/prose-page.tsx`), `(docs)`, and everything signed-in under `platform/` - the
  **platform** half of the site, served at `/platform/...`: `platform/(auth)` (login, signup, password
  recovery, `auth/callback`, `invite/[token]`, `cli/authorize`), `platform/dashboard/`, `platform/admin/`
  (operator console) and `platform/(marketing)` (playground, `marketplace/publish` - public-site chrome,
  the layout re-exports `(marketing)/layout.tsx`). `/platform` itself redirects to the dashboard. Write
  page paths with the prefix (`/platform/dashboard/keys`, `/platform/login`); `src/lib/routes.ts` has
  `PLATFORM_PREFIX`, `platformPath()` and `LEGACY_PLATFORM_PATH` (the pre-split paths that `src/proxy.ts`
  308-redirects). In production the website and the platform are two container apps running the same
  image: `osr-web` has `OSR_WEB_PLATFORM_URL` and forwards `/platform` requests to `osr-platform`
  (`NextResponse.rewrite`); a single process (`next dev`, Compose) serves both. Site-wide names and nav live
  in `src/lib/config/site.ts`: `headerNav` (`groups` render as navigation-menu panels on desktop and
  sections in the mobile sheet, `links` are top-level; icon names resolve in
  `components/layout/nav-icons.tsx`), `footerNav` (Product / Developers / Resources / Company groups)
  and `legalNav` (footer bottom bar). Every layout
  renders `<main id="main">` - the header's skip link targets it. New public pages also go into
  `SearchCommand`'s `PAGES`, `app/sitemap.ts` and the container smoke step of `.github/workflows/platform.yml`.
  Server-side API reads in `src/lib/api/server.ts` (`serverApi`, memoised); client calls in `src/lib/api/client.ts`.
- UI toolkit: `src/components/ui` is the shadcn/ui `new-york` kit (`components.json`: rsc, slate,
  CSS variables, lucide icons; aliases `utils -> @/lib/utils/format`, `ui -> @/components/ui`,
  `hooks -> @/hooks`). Add a component with `npx shadcn@latest add <name>`, then fix the generator
  output: it writes `import { cn } from "cn"` (must be `@/lib/utils/format`), it may scaffold
  `next-themes`/dark-mode code (remove it - the dark surface is the `theme-dark` class, not a
  theme provider) and its `Math.random`/`setState`-in-effect patterns fail the React Compiler lint.
  Radix comes from the `radix-ui` umbrella package only - never add `@radix-ui/react-*`.
  Brand tokens map onto the kit variables in `globals.css` (`--background`, `--muted-foreground`,
  ...): use `text-muted-foreground`, `bg-muted`, `border-input`, `ring-ring` rather than legacy
  `text-muted`. Legacy-named wrappers kept for call sites: `Badge`/`Card`/`Alert`/`EmptyState`/
  `StatCard` in `ui/card.tsx`, `Button`/`ButtonLink` (`external`) in `ui/button.tsx`, `Input`/
  `Textarea`/`SearchInput`/`Select`/`Switch`/`Field` in `ui/field.tsx`, `Avatar`/`Progress`/
  `Separator` in `ui/primitives.tsx`, `DataTable` + `Table*` (`TableRow interactive`, `TableCell
  numeric`) in `ui/table.tsx`, `FilterChip` in `ui/toggle.tsx`, `SegmentedControl` in `ui/tabs.tsx`.
  `Select` renders a sizing wrapper around the native `<select>`: width/height/text-size classes go
  on `className` (e.g. `className="h-8 w-52 text-xs"`), never padding. No hand-rolled `<table>`,
  `<select>`, `<details>`, chip buttons or dropdowns in pages - reach for the kit component.
- Design system (light, editorial; see the website sections of `docs/BRAND.md`): fonts come from
  `next/font` in `app/layout.tsx` - Inter (`--font-sans`, also `font-display`), Instrument Serif
  (`--font-serif`, weight 400 only, never `font-bold`), Geist Mono (`--font-mono`), Quicksand only
  for the wordmark (`font-wordmark`). Utilities in `globals.css`: `heading-serif` for display titles
  with `text-display-sm/md/lg/xl`, `eyebrow` (mono uppercase muted label), `container-x` (76rem).
  Marketing sections are `<section className="border-b border-line py-20 sm:py-28">` with
  `SectionHeading` (`eyebrow`, serif `title` with an `<em className="text-slate-ink">` second clause,
  `description`; left-aligned by default, `as="h1"` for the page title, `size` sm/md/lg) and
  `divide-y divide-line` ledgers with `font-mono text-sm text-muted-foreground tabular-nums`
  numerals - not icon cards. Alternate bands use `bg-snow`. Buttons: `primary`/`default` = ink fill,
  `accent` = brand-blue (rare), `secondary` = mist, `outline`, `inverse` on dark panes. Inline links
  are `text-ink underline decoration-ink/30 underline-offset-4 hover:decoration-ink`, highlighted
  cards use `border-ink` (no coloured rings), hover borders `hover:border-ink/30`, shadows hairline
  (`shadow-card`) or none. The header is always `h-16`; the docs topbar sticks at `top-16` and the
  docs sidebar at `top-[6.75rem]`. Dashboard page titles are `text-xl font-semibold` (`PageHeader`);
  the dashboard sidebar is `bg-sidebar` (snow) and the content area white. Docs prose stays plain.
- Next 16 lint rules (react-hooks v7): no `setState` directly in a `useEffect` body, no ref writes
  during render, use `<Link>` for internal paths (even `/api/...`).
- SEO: every public page exports `metadata = pageMetadata({ title, description, path, keywords?, type? })`
  from `src/lib/seo.ts` (canonical, Open Graph, Twitter card, merged `seo.keywords` from
  `lib/config/site.ts`; `noIndex` for one-time links) - dynamic routes call it from `generateMetadata`
  and return `robots: { index: false }` for unknown ids. Structured data goes through
  `<JsonLd data={...}>` (`components/seo/json-ld.tsx`, escapes `<`) with the builders in `lib/seo.ts`
  (`organizationJsonLd`/`websiteJsonLd` in the root layout, `softwareJsonLd` on the landing page,
  `breadcrumbJsonLd` + `articleJsonLd` on docs/compare pages, `offersJsonLd` + `faqJsonLd` on pricing,
  a `SoftwareApplication` per marketplace listing, `itemListJsonLd` on `/vendors`, `/vendors/[vendor]`
  and `/compare`). The default `og:image` is the generated card `app/og/route.tsx`
  (`/og?title&subtitle&kicker`, `next/og` + Poppins read from `public/brand/fonts` with `fs` - the
  standalone server has no asset `fetch`; clamped text; `ogImageUrl()` builds the
  URL, `absoluteTitle` pages keep the static social card). `robots.ts` disallows `/platform/dashboard`, `/api/`, `/v1/`,
  `/mcp`, `/auth/`, `/cli/`, `/platform/invite/` and `?view=`/`?plan=`/`?next=` variants; `sitemap.ts` lists
  canonical paths only (no query strings) including `/vendors/<vendor>` and `/compare`. Programmatic hubs:
  `(marketing)/vendors` (from `serverApi.llms().vendors`) and `(marketing)/vendors/[vendor]`
  (models table + FAQ, `vendorHref()` in `components/models/reference-model.tsx`); `(marketing)/compare`
  indexes `COMPARISONS`. Machine feeds: `app/llms.txt/route.ts` (llmstxt.org map built from
  `docSections()`), `app/feed.xml/route.ts` (Atom from `releases()`, advertised through
  `ALTERNATE_TYPES` in `pageMetadata` and the root layout - a page-level `alternates` replaces the
  layout's), `app/indexnow.txt/route.ts` (serves runtime `INDEXNOW_KEY`, 404 when unset) with
  `scripts/indexnow.mjs` submitting the sitemap after deploy (workflow step, `OSR_INDEXNOW_KEY`).
- Analytics: `components/analytics/google-analytics.tsx` (rendered once in the root layout) loads the
  Google tag with Consent Mode v2 defaults denied, replays the stored choice, sends `page_view` per
  client navigation and mounts `ConsentBanner`; it renders nothing without
  `NEXT_PUBLIC_GA_MEASUREMENT_ID` or under `/platform/dashboard`. `lib/analytics.ts` exposes `track(event,
  params)` (used for the GA4 `sign_up` / `login` conversions in `components/auth/*`; when
  `analytics.googleAdsConversions[event]` is set it also fires the Ads `conversion` hit), `trackCta`,
  `trackOutbound`, `trackWebVital` (`useReportWebVitals` -> LCP/INP/CLS events), `writeConsent`
  and `subscribeConsent` (`useSyncExternalStore`, never setState-in-effect). Server components mark
  CTAs with `data-track="<name>" data-track-location="<where>"`; one delegated listener in
  `GoogleAnalytics` turns those and every external `<a>` into events, and `CopyButton` sends
  `copy_code`. Ids and verification
  tokens are build args (`azure.yaml` `OSR_GA_MEASUREMENT_ID`, `OSR_GOOGLE_ADS_ID`,
  `OSR_GOOGLE_ADS_CONVERSIONS`, `OSR_GOOGLE_SITE_VERIFICATION`, `OSR_BING_SITE_VERIFICATION`); the
  privacy page documents the tag and renders `ConsentControls`.
- `output: "standalone"`; the image is `platform/web/Dockerfile` (context = `platform/web`). A running
  standalone server (`node .next/standalone/server.js`) holds `.next/standalone` locked on Windows and
  makes `next build` fail with EBUSY - stop it first.

## Documentation site (`/docs`)

The site is **end-user documentation** for the hosted platform, the Python SDK and the pip package.
It renders the repository Markdown itself at build time - no API call, fully static. Internal material
(`docs/PLATFORM_PLAN.md`, `docs/GO_TO_MARKET.md`, `docs/BRAND.md`, `docs/sales/`, `platform/README.md`)
stays in the repository and is deliberately not in the catalogue; Markdown links to it fall back to
GitHub.

1. **Content snapshot** - `node scripts/sync-content.mjs` copies `README.md`, `CHANGELOG.md`,
   `CONTRIBUTING.md`, `SECURITY.md`, `docs/**` (minus `docs/sales`), `deploy/README.md`,
   `spec/ocm/README.md`, `examples/leaderboard/README.md`, `platform/api/openapi.json` and
   `.claude/skills/*/SKILL.md` into
   `platform/web/content/` (git-ignored, same relative layout as the repo). npm `predev`/`prebuild`,
   the azd `prepackage` hook and the CI container job all run it; the Dockerfile copies `content/`
   into the runtime image.
2. **Catalogue** - `src/lib/docs/catalogue.ts` is the single source of truth for sections, order,
   slugs, titles, one-line summaries and icons. Sections: Start, Hosted platform (`PLATFORM`,
   `MARKETPLACE`, the `api` overview and its discovered `api/<tag>` children), Python SDK (`SDK`, `ENTERPRISE`, `REFERENCE`),
   Self-hosting (`deploy`), Concepts, Security, Agent skills (discovered) and Releases (`changelog`,
   `ROADMAP`, `contributing`). `SECTION_TABS` drives the top bar. Slugs: file stem for `docs/*.md`
   (`/docs/GUIDE`), plus `readme`, `changelog`, `contributing`, `security-policy`, `deploy`, `ocm`.
3. **Renderer** - `src/lib/docs/render.ts`: remark-parse + GFM -> rehype-raw -> strip the first H1
   (page header renders it) -> `rehype-slug` ids (GitHub-compatible; `README` anchors such as
   `#10-routing-latency` keep working) -> TOC (h2/h3) -> autolinked headings -> link rewriting
   (relative `.md` to `/docs/<slug>`, other repo paths to GitHub blob/tree, images to raw GitHub) ->
   Shiki (`github-dark-default`, languages listed in `CODE_LANGS`) -> HTML. ```` ```mermaid ```` fences
   become `<pre class="mermaid">` and render client-side in `components/docs/docs-article.tsx`.
4. **REST API reference** - `src/lib/docs/openapi.ts` reads the snapshot and groups operations by
   tag; `components/docs/api-reference.tsx` renders the overview (`/docs/api`: auth schemes, base URL,
   conventions, groups) and one page per tag (parameters, request body, responses, curl and JSON
   examples). Example bodies come from `exampleBodyFor()`; add a hint there for a new request model.
5. **Versions** - `src/lib/docs/versions.ts` parses `## [x.y.z] - date` headings from `CHANGELOG.md`;
   `components/docs/docs-topbar.tsx` shows the current release and a menu that links each release (and
   the unreleased section) to `/docs/changelog#<anchor>`. A release needs no code change here.
6. **Pages** - `src/app/(docs)/docs/page.tsx` (index: intro, install, entry points per audience, one
   list per section), `[...slug]/page.tsx`
   (`generateStaticParams` from the catalogue, `dynamicParams = false`, breadcrumb, reading time, edit
   link, prev/next; API pages branch on `kind === "api"`), `search.json/route.ts` (static index; API
   pages index their operations) consumed by `components/layout/search-command.tsx`. `sitemap.ts`
   lists every catalogue entry.

To publish a new user-facing document: add the Markdown under `docs/`, add a `STATIC_SECTIONS` entry
in `catalogue.ts` (slug, file, title, description, icon), link it from `README.md`
(`tests/test_docs.py` requires every `docs/*.md` to be linked), run `npm run build` and open
`/docs/<slug>`. Internal documents are linked from `README.md` only. A new
`.claude/skills/<name>/SKILL.md` needs no code change. When a heading is renamed, search for the old
anchor across `docs/`, `README.md` and `platform/web/src`.

Style: the docs pages are plain reference pages - headings, paragraphs, tables and code blocks. No
icon cards, badges, pills, uppercase eyebrow labels, hover animations or drop shadows; no reading
time or page counts; copy states what an endpoint or option does, without marketing adjectives.

## Deploy

```
azd provision            # infra/main.bicep: Container Apps osr-api + osr-web, Azure OpenAI, ACR, Files share, DNS zone
azd deploy api           # platform/api/Dockerfile, context = repo root
azd deploy web           # platform/web/Dockerfile, context = platform/web (prepackage hook syncs content/)
azd deploy platform      # same Dockerfile -> osr-platform (serves /platform; osr-web forwards to it)
.\scripts\domains.ps1    # custom domain: dns (zone + name servers) -> bind (certs) -> verify; -Phase clean retires osr-platform
.\scripts\sso.ps1        # sign-in providers: microsoft (az app registration + secret) | google | github | gitlab | show | push | apply
```

JSON-valued deployment inputs (`OSR_PLATFORM_SSO_PROVIDERS_B64`, `OSR_PLATFORM_STRIPE_PRICES_B64`) travel
base64-encoded: azd substitutes `${VAR}` textually into `infra/main.parameters.json`, so raw JSON breaks every
provision ("invalid character after object key:value pair"); `infra/main.bicep` decodes with `base64ToString`.
`sso.ps1` is the only writer of the SSO map (local azd env -> `gh secret set` -> `azd provision`); every provider
uses the single redirect `<WEB_URL>/auth/callback` (kept without the `/platform` prefix on purpose - the web app
308s it to `/platform/auth/callback`, so the URI registered at every provider never changes). Google and GitHub have no API for OAuth clients, so those
commands print the console steps and prompt for the id and hidden secret.

`.github/workflows/platform.yml` runs API tests and the OpenAPI snapshot check, `npm run check && npm
run build`, a two-container smoke (`/`, `/docs`, `/docs/GUIDE`, `/docs/PLATFORM`, `/docs/api`,
`/docs/api/routing`, `/docs/skills/osr-contributing`, `/docs/search.json`, `/pricing`, `/api/v1/info`;
`/docs/sales/*` must 404) and then `azd up` on `main`. After deploying, verify `<WEB>/docs/api` and
`<WEB>/api/v1/info` (`edition`, `sdk_version`). `NEXT_PUBLIC_SITE_URL` is baked at build time via
`buildArgs` in `azure.yaml`; SQLite on Azure Files needs `nobrl` mount options and a single API replica.
The public domain is an Azure DNS zone (`infra/dns.bicep`, azd `OSR_DNS_ZONE=opensmartroute.ai`): apex A to the
environment static IP, `www`/`api` CNAMEs, `asuid.*` TXT; the registrar delegates to the zone's name servers.
`www` redirects come from `platform/web/src/proxy.ts` (`OSR_WEB_REDIRECT_HOSTS`), not from an edge proxy.
Apex managed certificates use `HTTP` domain-control validation, subdomains `CNAME` (`TXT` never completes for an
apex). The `azd up` job signs in with an OIDC federated credential whose subject is
`repo:isathish/OpenSmartRoute:environment:production` (repository variables `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`,
`AZURE_SUBSCRIPTION_ID`, custom-domain `OSR_*` vars; secret `OSR_PLATFORM_ADMIN_TOKEN`) and seeds
`SERVICE_*_IMAGE_NAME` from the running apps before `azd provision`; setup in `platform/README.md`
("Continuous deployment").
