A disaster-risk geospatial agent for Southeast Asia. It answers natural-language questions — "how many km of road are at risk from flooding in Battambang?" — by overlaying OpenStreetMap assets against hazard/risk rasters and returning a grounded number: the LLM extracts intent and phrases the result, but every number is computed from real data, never generated by the model.
Monorepo, managed with uv (backend) and npm (frontend):
.
├── apps/
│ ├── api/ # FastAPI + LangGraph agent — the spatial engine
│ └── web/ # React + OpenLayers map UI
├── conf/ # hazard catalog, grounding prompt, raster schema, Layer-2 weights
└── pyproject.toml # uv workspace root
Source code was migrated here separately
question → route → resolve → fetch → operate → finalize → grounded answer + map layers
The LLM only routes (extracts hazard, asset, place) and finalizes (phrases the result). In between, the agent resolves how to answer — and because a "flood question" can mean different things, it asks rather than guesses:
- Exposure — which assets sit in the raw hazard footprint.
- Risk (precomputed) — the agency's
risk_<hazard>layer (Hazard × Vulnerability). - Risk (recomputed) — the same risk rebuilt here from reclassed inputs with tunable weights.
Only the paths whose data exists are offered; the pick is resumed on the same thread_id.
The numbers themselves come from a deterministic spatial overlay (graph/geo/store.py) — the
single place a number is ever produced.
resolve sits on a ladder of how risk data is obtained, cheapest first:
| Layer | Source |
|---|---|
| L1 | sample a precomputed risk_<hazard> raster |
| L2 | recompute risk = Hazard × weighted Vulnerability from reclassed 1–5 layers |
| L3 | reclass a raw/continuous source into 1–5 on the fly (planned) |
| L4 | fetch from an upstream provider when nothing local fits (planned) |
L1, L2, and the resolver are implemented; L3–L4 are the remaining work. The rationale is in
SOURCE_DATA_APPROACH.md.
Users can also upload their own hazard GeoTIFF (POST /api/tiffs). It passes a verification
gate before anything else can touch it — single-band, georeferenced, values within the
declared 0–5/1–5 severity scale — and only then is registered (per conversation, in-memory)
so the agent can answer against it like a built-in layer. The curated catalog is never touched.
Two processes. Backend (from the repo root):
uv sync
cp apps/api/.env.example apps/api/.env # set DEFAULT_PROVIDER + the matching API key
uv run uvicorn app.main:app --reload --app-dir apps/api/src --port 8001Frontend (separate terminal):
cd apps/web && npm install && npm run dev # http://localhost:5173Vite proxies /api to the backend on :8001, so the UI works with no CORS setup.
Both run with authentication off by default — no login is asked for, and the MCP transport serves anonymous tool calls. Enabling AuthKit is per-deployment; see Access & authentication, which also covers the auth-on local-dev shapes.
Authentication is AuthKit (OAuth 2.1), switched on per deployment. With it off (the default), the whole platform is open and anonymous — how local dev and the test suite run. With it on, visitors sign in to the web app, and MCP clients authenticate against the same AuthKit accounts.
Access is granted by whoever runs the deployment: you are invited by email, and only invited emails can create an account (Sign-up is off).
The web platform. Open the URL you were given.
- The page shows a public Sign in to continue screen — click Sign in.
- AuthKit opens in the same tab. The first time, set up your account with the invited email (via the invitation link); later, sign in with your password, Google, or your organization's SSO.
- You land back on the platform, signed in. Your email shows in the bar top-right; that menu has Sign out and Switch account.
Receipt links, health checks, map tiles and public embeds need no sign-in. If you cannot get in (expired invitation, forgotten password), ask the operator to resend the invitation or reset the password.
Claude Code (terminal). One command, nothing to paste:
claude mcp add --transport http servirplatform https://<deployment-url>/mcpThe first tool call opens your browser for the same AuthKit sign-in; afterwards the client authenticates itself. Useful commands:
claude mcp list # servers + auth state
claude mcp logout servirplatform && claude mcp login servirplatform # re-test login
claude mcp remove servirplatform # disconnectClaude Desktop. Add the MCP server by URL — Settings → Developer → MCP
servers → Add MCP server, entering https://<deployment-url>/mcp — or add
it to claude_desktop_config.json under mcpServers:
{
"mcpServers": {
"servirplatform": {
"type": "http", "url": "https://<deployment-url>/mcp"
}
}
}The first tool call opens your browser for the same AuthKit sign-in. To sign in as someone else, sign out of Claude Desktop (or remove and re-add the server) and go through the login again.
Signing out vs switching accounts. In the web app, Sign out ends the app's session and lands on the public page; AuthKit's own session survives, so a fresh Sign in resumes in one click. Switch account forces the AuthKit login screen instead. Sessions are per browser profile — two people on one machine should use separate profiles.
Set them in apps/api/.env for local runs (the app reads that file) or in the
deployed service's environment. All are listed in apps/api/.env.example.
| Variable | Meaning |
|---|---|
GRP_OAUTH_ENABLED=1 |
Master switch. With it on, the MCP transport requires OAuth; with a client id also set, web login is mounted and the app is gated. Unset = open. |
GRP_PUBLIC_URL |
The origin clients reach. Load-bearing: it becomes the /auth/callback origin and the MCP token audience, so it must equal the origin registered in WorkOS or logins fail with a redirect/audience mismatch. |
GRP_AUTHKIT_DOMAIN |
The AuthKit tenant domain (e.g. https://<tenant>.authkit.app). |
GRP_AUTHKIT_CLIENT_ID |
The Connect OAuth application's client id — the web login client (PKCE public client, no secret). Empty = MCP-only: no login routes, app open. |
GRP_ALLOW_ANONYMOUS=1 |
Deploy-only escape hatch: lets the container boot with auth off. Never set in normal operation. |
WORKOS_API_KEY |
Admin-only, local secret for the script used to add, revoke, or refresh email invitations; never set on the deployed service. |
AuthKit settings are per environment (staging and production are separate), so repeat this in each. Two different "application" objects exist in the dashboard and are NOT linked — each governs a different flow:
| Object | Where | What it is for | Settings that live here |
|---|---|---|---|
| Connect → OAuth application | Connect → Applications | The OAuth client your raw login flow uses | Client ID → GRP_AUTHKIT_CLIENT_ID; Redirect URI https://<public_url>/auth/callback |
| AuthKit → default application | AuthKit → Applications | The environment's app object; hosted-UI flows that carry no OAuth redirect (invitation acceptance, password resets) | You set the redirect URI that your browser redirects to following login here — otherwise, accepted invites end on AuthKit's broken /default-redirect page |
Environment checklist (per staging/prod):
- Authentication → Sign up: off. Only invited emails can create accounts (the whitelist).
- Connect → OAuth application: copy the client id into
GRP_AUTHKIT_CLIENT_ID; set the redirect URIhttps://<public_url>/auth/callback. - AuthKit → default application: add
https://<public_url>/and mark it default (hosted invite-accept and password-reset flows land there). - Connect → Configuration: the MCP resource indicator
https://<public_url>/mcpand the DCR toggle, for the MCP transport.
The golden rule: GRP_PUBLIC_URL must equal the origin registered on both
objects and in the resource indicator.
Moving a deployment to a new origin touches the same value in five places.
Change GRP_PUBLIC_URL and re-deploy, then update the three WorkOS
registrations (same environment) to the new origin:
- Connect → Applications → the OAuth application → Redirect URI:
https://<new-url>/auth/callback(its Client ID does not change). - AuthKit → Applications → the default application → add
https://<new-url>/and mark it default. - Connect → Configuration → the MCP resource indicator:
https://<new-url>/mcp.
A mismatch shows up as a login that bounces, or an MCP client refused with an audience error.
- Enable: set
GRP_OAUTH_ENABLED=1and, for web login,GRP_AUTHKIT_CLIENT_ID(plus the domain and public URL). The deployment scripts (deploy/entrypoint.sh,deploy/deploy.sh) refuse to start or deploy otherwise. - Disable (roll back to open): unset
GRP_OAUTH_ENABLED; to drop web login alone, unsetGRP_AUTHKIT_CLIENT_ID. The auth routes are not mounted,/auth/meanswers 404, and the app serves open — the local and test default. The container's fail-closed check is bypassed only by the explicitGRP_ALLOW_ANONYMOUS=1.
With Sign-up off, an invitation is the whitelist: only an invited email can create an account. Accepting an invitation proves the inbox, so a first-time sign-up with the invited email is auto-verified (no separate email OTP) while the invitation is fresh; resending restarts that window.
To invite someone to your application and not your organization, this is the process: Dashboard: Users (in the sidebar) → Invites → create an invite → leave the organization unset, set the user role as "Member".
A script also exists to individually or batch add, revoke, or refresh email invites:
cd apps/api
uv run python scripts/manage_workos_users.py add alice@example.com --file more.csv
uv run python scripts/manage_workos_users.py refresh alice@example.com
uv run python scripts/manage_workos_users.py revoke bob@example.com
uv run python scripts/manage_workos_users.py listaddsends a new invitation;refreshresends a pending one (or adds one if none is pending);revokewithdraws a pending invitation;listshows pending/accepted/revoked. Emails come as arguments or a--file(one per line, or a CSV with anemailcolumn);--dry-runpreviews without calling WorkOS.
The script needs
WORKOS_API_KEY=sk_...inapps/api/.env— from Developer → API keys in the same environment whose users you manage (a staging key manages staging users). The key is a local admin secret; never deploy it. Removing someone who already accepted is done in the dashboard (Users), not the script:revokeonly withdraws a still-pending invitation.
The server plays two roles, each with its own small module:
- Resource server for
/mcp—apps/api/src/app/mcp/auth.py. It never issues tokens; it publishes discovery and verifies the JWTs clients bring. AResourceServer(AuthKitProvider)subclass pins the audience to<GRP_PUBLIC_URL>/mcpand holds the AuthKit domain as a plain string (no trailing-slash normalization), so the token audience, the discovery document and the 401 challenge all agree. Keys are fetched from<GRP_AUTHKIT_DOMAIN>/oauth2/jwks. - OAuth client for the standard app —
apps/api/src/app/web_auth.py. It sends people to AuthKit and keeps a browser session: an authorization-code + PKCE flow (public client, no secret) with an in-memory session store behind an HttpOnlygrp_sessioncookie, refreshing the access token while alive.
What is gated, and how. With web login on, SessionGate
(apps/api/src/app/main.py) wraps the app. Always open: the public API set
(/api/health, /api/resolve/*, /api/raster/*,
/api/food-security/rag/document/*), the SPA's static code and host
(/assets/*, /favicon.ico, / — the shell and its signed-out landing page
carry no data), /auth/* and /.well-known/* (how a login starts), and /mcp
(which has its own transport-level OAuth). Gated: /api data answers
401 {"detail":"login required"}; other UI paths redirect to
/auth/login?next=…. The MCP transport independently refuses anonymous calls
with a 401 + WWW-Authenticate pointing at its discovery document.
How the web UI decides. On load the SPA probes GET /auth/me: 200 →
signed in; 401 → signed out (renders the landing page); 404 (routes not
mounted) → auth off (renders normally, no auth chrome). Auth state lives in a
useAuth hook (apps/web/src/hooks/useAuth.ts) over a single auth module
(apps/web/src/lib/auth.ts); /api calls send credentials: 'include', and a
401 from one starts a fresh login.
Running without auth. The commands in Run already are: the app only gates when the env vars above are set. Local test commands:
GRP_OAUTH_ENABLED=0 GRP_AUTHKIT_CLIENT_ID= uv run pytest # backend tests (auth must be off)
cd apps/web && npm test # frontend tests (auth helpers)Auth-on local development. The login round-trip and the grp_session
cookie live on the backend origin — GRP_PUBLIC_URL (default
http://127.0.0.1:8001) decides that host, not VITE_API_BASE_URL. Two shapes:
- Same-origin (simplest). Build the frontend and let the backend serve it:
cd apps/web && npm run build, then run the backend withGRP_WEB_DIST=apps/web/distand browsehttp://127.0.0.1:8001. Identical to production. - Vite dev (cross-origin). Set the
VITE_API_BASE_URLin the frontend's .env file. to the backend url, eg:VITE_API_BASE_URL=http://127.0.0.1:8001. Both sides must be127.0.0.1(never mix inlocalhost): the cookie is host-scoped to 127.0.0.1, and CORS already allows the Vite origin.
Either shape needs the backend origin registered in WorkOS for local testing:
add http://127.0.0.1:8001/auth/callback as a redirect URI and
http://127.0.0.1:8001/ as the default redirect on the environment's
applications (see the operator section above). This is because OAuth is a managed service here.
Design constraints to keep — no WorkOS API key or client secret on the
server (the web login client is a PKCE public client; MCP clients self-register
via DCR). The frontend keeps no provider values either: /auth/* and the
session live on the backend only.
Backend (fast suite; stubs, no network — must run with auth off, the default):
GRP_OAUTH_ENABLED=0 GRP_AUTHKIT_CLIENT_ID= uv run pytest # with it on, the app is gated
GRP_RUN_SLOW=1 uv run pytest # + slow real-data / Drive testsFrontend (auth helpers unit tests, then lint and build):
cd apps/web
npm test
npm run lint
npm run buildTwo paired Claude Code skills live in .claude/skills/ for adding and viewing execution traces — a step-by-step record of what the agent actually did to produce an answer, not just the final result. To load these skills in, either restart your Claude Code session or run the /reload-skills command
| Skill | What it does |
|---|---|
trace-emit |
Instruments a backend so every response can carry a structured trace: which steps ran, how long each took, which external services/caches were hit, and what each step decided (including LLM token/cost detail). |
trace-visualize |
Turns a trace a backend already emits into something readable — a step list, timeline, flow diagram, CLI output, or printable report. |
trace-visualize depends on trace-emit — you can't render a trace that isn't being produced yet, so the two are always run in that order, never in the same pass.
Both follow the same working style: survey the codebase first, then propose options (capture scope, delivery surface, view shape) with a recommendation and pause for approval at a few key checkpoints, then build one real, working slice before instrumenting or rendering the rest.
apps/api/README.md— backend architecture, the graph, the module map.apps/api/API_EXAMPLES.md— the/api/chatrequest/response contract (frontend source of truth).apps/api/DEMO.md— an exhaustive set of example queries.SOURCE_DATA_APPROACH.md— the four-layer risk model and design rationale.apps/web/README.md— the frontend: dev server, how it talks to the API.
Licensed under the Eclipse Public License 2.0 (EPL-2.0) license. See LICENSE for the full text.
git clone git@github.com:SERVIR-AI/global-platform.git && cd global-platform
# backend (uv installs everything, including Python)
cd apps/api && uv sync && cd ../..
# web app + runbook (optional — skip for API-only)
cd apps/web && npm install && npm run build && cd ../..
# minimal config: nothing is required for local dev — the app runs open
# (or set GRP_OAUTH_ENABLED=1 with the AuthKit settings to gate it)
cd apps/api
# start
GRP_WEB_DIST=$PWD/../web/dist GRP_PUBLIC_BASE=http://127.0.0.1:8000 \
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000Point any MCP client at http://127.0.0.1:8000/mcp; the runbook serves at
http://127.0.0.1:8000/runbook/.
Local dev runs open by default — set GRP_OAUTH_ENABLED=1 (with the AuthKit
settings in apps/api/.env.example) to gate the app behind a SERVIR login; the
deployed entrypoint refuses to boot without OAuth on or an explicit
GRP_ALLOW_ANONYMOUS=1. No LLM keys are required: keyless you still get the
platform map, all live feeds, and the complete risk-pack loop — deterministic
geo evidence through the groundedness gate to a minted receipt. Add provider
keys (see apps/api/.env.example) to unlock the food-security corpus and the
LLM drafting paths.