Skip to content

About

AI-assisted geospatial platform for environmental decision support (source-available, non-commercial)

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

Global Risk Platform

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

How it answers a question

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.

The four-layer data model

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.

Bring your own data

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.

Run

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 8001

Frontend (separate terminal):

cd apps/web && npm install && npm run dev   # http://localhost:5173

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

Access & authentication

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.

For end users

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.

  1. The page shows a public Sign in to continue screen — click Sign in.
  2. 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.
  3. 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>/mcp

The 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                         # disconnect

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

For operators: enabling and configuring a deployment

Environment variables

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.

The two WorkOS application objects

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):

  1. Authentication → Sign up: off. Only invited emails can create accounts (the whitelist).
  2. Connect → OAuth application: copy the client id into GRP_AUTHKIT_CLIENT_ID; set the redirect URI https://<public_url>/auth/callback.
  3. AuthKit → default application: add https://<public_url>/ and mark it default (hosted invite-accept and password-reset flows land there).
  4. Connect → Configuration: the MCP resource indicator https://<public_url>/mcp and 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.

Changing the deployment URL

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.

Enabling and disabling auth

  • Enable: set GRP_OAUTH_ENABLED=1 and, 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, unset GRP_AUTHKIT_CLIENT_ID. The auth routes are not mounted, /auth/me answers 404, and the app serves open — the local and test default. The container's fail-closed check is bypassed only by the explicit GRP_ALLOW_ANONYMOUS=1.

Managing users (add / remove)

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 list
  • add sends a new invitation;
  • refresh resends a pending one (or adds one if none is pending);
  • revoke withdraws a pending invitation;
  • list shows pending/accepted/revoked. Emails come as arguments or a --file (one per line, or a CSV with an email column);
  • --dry-run previews without calling WorkOS.

The script needs WORKOS_API_KEY=sk_... in apps/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: revoke only withdraws a still-pending invitation.

For developers: where the auth lives

The server plays two roles, each with its own small module:

  1. Resource server for /mcp — apps/api/src/app/mcp/auth.py. It never issues tokens; it publishes discovery and verifies the JWTs clients bring. A ResourceServer(AuthKitProvider) subclass pins the audience to <GRP_PUBLIC_URL>/mcp and 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.
  2. 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 HttpOnly grp_session cookie, 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 with GRP_WEB_DIST=apps/web/dist and browse http://127.0.0.1:8001. Identical to production.
  • Vite dev (cross-origin). Set the VITE_API_BASE_URL in the frontend's .env file. to the backend url, eg: VITE_API_BASE_URL=http://127.0.0.1:8001. Both sides must be 127.0.0.1 (never mix in localhost): 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.

Test

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 tests

Frontend (auth helpers unit tests, then lint and build):

cd apps/web
npm test
npm run lint
npm run build

Tracing skills

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

More

License

Licensed under the Eclipse Public License 2.0 (EPL-2.0) license. See LICENSE for the full text.

Run it locally

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 8000

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

About

AI-assisted geospatial platform for environmental decision support (source-available, non-commercial)

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages