Self-hosting Personal Agent¶
Personal Agent is domain-agnostic: one built image serves every deployment, and
the operator points it at their own domain through a handful of environment
variables. Throughout this guide, app.example.com and id.example.com are
placeholders — substitute your own hostnames.
This guide covers the single-host Docker Compose path
(deploy/compose/docker-compose.prod.yml). The scaled-out Kubernetes path uses
the same knobs via the Helm umbrella chart (deploy/charts/personal-agent/); see
that chart's values.yaml and README.md.
1. Prerequisites¶
- Docker + Compose v2 (
docker compose). - A reverse proxy terminating TLS in front of the stack (Caddy, nginx, Traefik, …). It must:
- route the app origin to the
frontendcontainer (port 80) and/api,/webhooks, SSE and WebSocket paths to thebackendcontainer (port 8000); - not buffer SSE responses and allow WebSocket upgrades. The reverse proxy lives outside this repo — Compose only ships the app and its data services.
- A reachable Keycloak instance (the OIDC provider). You can run your own; the realm is imported for you (see §5).
- Public DNS for your app and Keycloak hostnames.
The Postgres+pgvector, Redis, and a single-host Temporal dev server are bundled in the Compose file — you don't provide those yourself.
LLM provider credentials are not environment variables. They are admin-managed "platform keys" entered in the admin UI (Settings → Providers), stored envelope-encrypted in the database using
BYOK_MASTER_KEY.
2. Canonical environment knobs¶
Everything domain-specific derives from a few top-level variables. They live in
deploy/compose/.env (copied from deploy/compose/.env.example):
| Variable | Example | Meaning |
|---|---|---|
APP_ORIGIN |
https://app.example.com |
The app's public origin (SPA + API as seen by a browser / device). Sets PERSONAL_AGENT__PUBLIC_BASE_URL. |
KEYCLOAK_ORIGIN |
https://id.example.com |
Keycloak base URL. |
REALM |
personal-agent |
Keycloak realm name. |
OIDC_ISSUER |
https://id.example.com/realms/personal-agent |
The OIDC issuer = ${KEYCLOAK_ORIGIN}/realms/${REALM}. Compose does not expand variables inside .env, so write the full value. Sets PERSONAL_AGENT__OIDC__ISSUER (backend) and PA_OIDC_AUTHORITY (SPA). |
CORS_ORIGINS |
["https://app.example.com"] |
JSON array of allowed browser origins. Usually just the app origin. Sets PERSONAL_AGENT__SECURITY__CORS_ORIGINS. |
Secrets (generate strong, unique values, e.g. openssl rand -base64 32):
| Variable | Meaning |
|---|---|
POSTGRES_PASSWORD |
Application database password. |
BYOK_MASTER_KEY |
Master key for envelope-encrypting admin-managed provider keys. Required to store/use any provider credential. |
WHATSAPP_WEBHOOK_SECRET |
Shared HMAC secret for the optional WhatsApp bridge webhook. |
Same-origin is the default. If the SPA, API, SSE and WebSocket are served from
the same origin (the usual single-host reverse-proxy setup), you only need to set
the OIDC issuer and the secrets — the browser derives API/SSE/WS bases from
window.location at runtime. Only set the PA_* overrides below if the API is on
a different origin than the SPA.
| Variable | Default | When to set |
|---|---|---|
PA_API_BASE |
<origin>/api/v1 |
API on a different origin. |
PA_SSE_BASE |
<origin>/api/v1 |
SSE on a different origin. |
PA_WS_BASE |
ws(s)://<origin>/api/v1 |
WS on a different origin. |
PA_APP_ORIGIN |
<origin> |
Override the app origin used to build OIDC redirect URIs. |
PA_OIDC_CLIENT_ID |
personal-agent-spa |
The SPA's Keycloak public client id. |
The backend config defaults are env-driven and need no changes — only the .env
edge values above.
3. Compose quickstart¶
cp deploy/compose/.env.example deploy/compose/.env
# edit deploy/compose/.env: set APP_ORIGIN, KEYCLOAK_ORIGIN, REALM, OIDC_ISSUER,
# CORS_ORIGINS, and the secrets.
docker compose -f deploy/compose/docker-compose.prod.yml up -d --build
This builds the backend, worker, and frontend images, runs the Alembic
migrate job (ordered before the API/worker start), and brings up Postgres,
Redis, and the Temporal dev server. Point your reverse proxy at the frontend
(:80) and backend (:8000) services and you're up.
Health endpoints on the backend: GET /healthz (liveness), GET /readyz
(DB + Redis), GET /health/deps (soft deps: Temporal, JWKS).
4. How the SPA is configured at container start¶
The frontend is one static image for every deployment. Runtime config is
rendered at container start by deploy/compose/frontend-entrypoint.sh, which
writes /config.js (window.__APRIL_CONFIG__) from the environment and then runs
nginx. The image needs no rebuild per environment.
The only value the browser cannot derive on its own is the OIDC authority, so
PA_OIDC_AUTHORITY (wired to OIDC_ISSUER in the Compose file) is required.
When the PA_API_BASE / PA_SSE_BASE / PA_WS_BASE / PA_APP_ORIGIN overrides
are empty, the entrypoint emits JavaScript that derives same-origin values from
window.location in the browser; the OIDC redirect URIs (/auth/callback, logout
/) are derived the same way. So a single-host reverse-proxy deployment only needs
the issuer.
5. How the Keycloak realm is imported with your domain¶
The realm definition lives at keycloak/realm-personal-agent.json and is imported
idempotently — you don't click through the Keycloak admin UI.
- Compose (dev / single-host with bundled Keycloak): the
keycloakservice runsstart-dev --import-realmwith thekeycloak/directory mounted into/opt/keycloak/data/import. The realm is created (or overwritten) on start. - Kubernetes (Helm): a pre-install/pre-upgrade
realm-importJob runskeycloak-config-cliagainst your Keycloak (templates/jobs/realm-import.yaml), fed by a ConfigMap built fromfiles/realm-*.json. It setsIMPORT_VARSUBSTITUTION_ENABLED=true, so the realm JSON may contain${VAR}placeholders that are substituted from the Job's environment, and it waits for Keycloak readiness via an init container before importing.
When you adapt the realm to your domain, set these per OIDC client so logins and redirects work for your hostnames:
- the SPA client (
personal-agent-spa):rootUrl,redirectUris(https://app.example.com/*,https://app.example.com/auth/callback),webOrigins, andpost.logout.redirect.uris— point them at yourAPP_ORIGIN; - the API resource server audience (
personal-agent-api) — keep it matchingPERSONAL_AGENT__OIDC__AUDIENCE; - the device client (
personal-agent) and the browser-extension client (personal-agent-browser) — see §7.
The shipped realm file carries example clients for adjacent standalone apps
(a VPN UI, an Open WebUI) that are not part of Personal Agent; remove or
re-point them to your own domains as needed. Either edit the JSON to your
hostnames, or (on Helm) parameterize them with ${VAR} placeholders and supply
the values to the import Job.
6. Remote sandbox + public URL note¶
PERSONAL_AGENT__PUBLIC_BASE_URL (set from APP_ORIGIN) is the origin that
on-demand cloud coding/browser sandboxes and device agents use to dial
back to the backend (and the SPA / browser-extension OIDC bootstrap read it).
It must be the externally reachable origin, not an internal service name. If it is
unset, the backend falls back to the first CORS_ORIGINS entry.
Inside the Compose network, spawned sandbox containers reach the backend by service
name via PERSONAL_AGENT__SANDBOX_BACKEND_URL (http://backend:8000 by default)
on the PERSONAL_AGENT__SANDBOX_NETWORK network — that internal URL is separate
from the public origin above and normally needs no change.
The bundled web tools' outbound User-Agent and the Met.no weather integration's
contact string default to a generic project URL; configure a real contact on the
weather integration if you use it heavily (Met.no's ToS asks for one).
7. Building the client apps for your instance¶
Desktop app (Tauri)¶
The desktop shell wraps your live SPA and is configured at build time
(personal-agent-desktop/). Build with:
docker build personal-agent-desktop \
--build-arg PA_APP_URL=https://app.example.com \
--build-arg TAURI_IDENTIFIER=com.example.personalagent.desktop \
-t personal-agent-desktop
PA_APP_URL (defaults to http://localhost:9000) is your SPA's public origin;
the navigation allowlist and the Tauri remote.urls capability derive from it.
See personal-agent-desktop/README.md for the full build-arg table.
Browser extension (Chrome MV3)¶
The extension is a kind=browser device that acts in your logged-in browser
session. At runtime it asks for your Server URL (your APP_ORIGIN) and
Keycloak issuer (your OIDC_ISSUER), so the same build works against any
instance. Two things must match your Keycloak realm:
- a public client
personal-agent-browser(auth-code + PKCE, no secret) with audiencepersonal-agent-api; - the extension's OAuth redirect URI, which is keyed to its extension ID.
Keycloak rejects mid-host wildcards, so pin the extension ID (a packed
extension has a stable ID) and add its exact
chrome.identity.getRedirectURL()value to the client's redirect URIs.
See browser-extension/README.md for packaging and the redirect-URI details.
Android app¶
The Android WebView shell (personal-agent-android/) bakes its instance config
at build time via Gradle properties (example.com defaults). Copy
personal-agent-android/gradle.properties.example and set your values, or pass
them on the command line:
./gradlew assembleMinimalRelease \
-PpaBaseUrl=https://app.example.com \
-PpaOidcIssuer=https://id.example.com/realms/personal-agent \
-PpaOidcClientId=personal-agent-app
The app's OIDC redirect URI is <applicationId>:/oauth/callback
(applicationId defaults to dev.luebke.personalagent); register that exact
value on the personal-agent-app Keycloak client (change applicationId in
app/build.gradle.kts if you fork the package name).
assembleMinimalRelease (the default flavor) uses the foreground-WebSocket push
path and needs no Google services. To wake a backgrounded phone via Firebase
Cloud Messaging — and to reliably deliver the agent's device commands (alarms,
timers) when the screen is off — build the full flavor and configure the
server side: see FCM push setup.