Architecture & auth
Two services, one protocol
Section titled “Two services, one protocol”The pack is a client and a server that agree on AG-UI. The frontend has no idea which models or agents exist; the backend has no idea what the UI looks like. Everything specific to a deployment lives in configuration on one side or the other.
Two NebariApp resources — one per service — are all the pack tells the platform. The
nebari-operator turns each into an HTTPRoute, a
certificate, Keycloak clients, and (for the UI) a landing-page tile. The chart also renders the
release namespace with nebari.dev/managed=true; without that label the operator ignores both
resources and nothing is routed.
The request path
Section titled “The request path”A message travels:
- The browser POSTs
/api/threads/{id}/runsto its own origin, with the Keycloak access token attached. - nginx in the frontend container proxies
/api/to the backend Service, forwarding theAuthorizationheader and disabling buffering so the SSE stream flows through unbroken. - Ravnar validates the token, resolves the caller’s permissions, and hands the run to the configured agent.
- The agent streams AG-UI events back — text deltas, tool calls, activity snapshots — which the proxy passes through to the browser as they are produced.
- If the agent called a browser-side tool, the run ends without a result for it; the frontend executes the tool and submits a follow-up run. See the run loop.
Because the UI proxies same-origin, the browser never makes a cross-origin request and CORS never enters the picture. The API’s own hostname exists for direct API clients.
Authentication
Section titled “Authentication”Both halves point at the same realm — the chart derives them from the single top-level
keycloak.* values, so they cannot drift apart.
The UI — app-native OIDC
Section titled “The UI — app-native OIDC”The frontend’s NebariApp sets auth.enforceAtGateway: false and provisions a public SPA
client (auth.spaClient). The gateway therefore does not intercept the request; the app logs
the user in itself:
- Before React mounts, the app fetches
/config.jsonand reads the Keycloakurl,realm, andclientIdfrom it. Initializing eagerly, rather than lazily inside a login call, is what keeps post-login redirects from looping. keycloak-jsruns the PKCE login flow against the realm.- Every API call goes through a fetch wrapper that refreshes the token if needed and attaches
Authorization: Bearer <token>. The wrapper captures the nativefetchat module load, so code that later monkey-patcheswindow.fetchcannot observe the token.
Since the client id comes from a runtime file rather than the bundle, the same image works against any realm.
The backend — bearer tokens, verified in-process
Section titled “The backend — bearer tokens, verified in-process”security.authenticator in the backend config builds a Ravnar BearerTokenAuthenticator over an
OIDC validator for {keycloak.url}/realms/{keycloak.realm}. Every request is validated against
the realm before it reaches an agent, and a validated caller is granted the full Ravnar
permission set. Narrower authorization means supplying your own authenticator — see
Agents & models.
The backend’s NebariApp carries no auth block, so the API hostname is not gated at the
gateway: the token check inside Ravnar is what protects it. Anything you place in front of that
hostname (network policy, additional gateway auth) is a deployment choice, not something the
chart assumes.
Local development
Section titled “Local development”Building the frontend with VITE_AUTH_ENABLED=false removes keycloak-js from the picture
entirely — no login, no header — and a Ravnar with no configured authenticator treats the caller
as a local user with every permission. Convenient locally; never how you deploy.
Configuration flow
Section titled “Configuration flow”Two files decide how a deployment behaves, and neither requires an image rebuild:
| File | Consumed by | Rendered from |
|---|---|---|
/config.json | The browser, before React mounts | The chart’s frontend ConfigMap (keycloak.* + frontend.branding), or environment variables at container start outside Kubernetes. See Branding & configuration. |
/var/ravnar/helm/config.yaml | The Ravnar process, at boot | config.inline deep-merged with the chart’s security.authenticator block. See Agents & models. |
nginx additionally renders ${API_URL} into its config at container start, so the same image can
point at any backend.
- PostgreSQL (a StatefulSet from the
ravnarsubchart) stores threads, runs, and messages. The chart generates and persists a password unless you supply one, and injects the DSN asRAVNAR_STORAGE__DATABASE__DSN. Setravnar.postgres.enabled=falseto bring your own. - A PersistentVolumeClaim holds uploaded files at
/var/ravnar/files. - The frontend is stateless — it is nginx serving a static bundle plus a proxy. Scale it freely.
Thread history is server-side, so a user’s conversations follow them across browsers and devices.
If storage.enabled is false, the UI refuses to load rather than pretending to remember.
Security posture
Section titled “Security posture”- Both containers run as non-root (uid 1000). The backend runs with a read-only root filesystem;
the frontend disables privilege escalation and writes only to
/tmp/nginx. - Config is mounted read-only. The frontend’s entrypoint only generates
/config.jsonwhen the file is writable, so a ConfigMap ordocker -v ...:romount can never be overwritten by environment variables. - Branding values are sanitized before they touch the DOM: logo and favicon URLs must be
root-relative,
http(s), or a base64 imagedata:URI, and theme tokens containing CSS injection vectors (;,{,}, quotes,url(,expression(,javascript:) are dropped. - The access token lives only in the
keycloak-jsinstance and is attached by a fetch wrapper that closed over the nativefetch. - Agent tools run with whatever access you give them. The demo database tools execute model-written SQL, so connect them with a read-only role — the read-only guarantee belongs to the database grant, not the tool. See Tools.