Skip to content

Deployment

Kwasi runs on Railway using Docker. The bot starts automatically on deploy.


How it Deploys

flowchart LR
    A[Push to main] --> B[Railway detects push]
    B --> C[Build Docker image\nDockerfile]
    C --> D[Start container\npython -m app.main]
    D --> E[FastAPI starts\nlifespan begins]
    E --> F[Telegram bot starts\nlong-polling]
    E --> G[Background loops start\nreminders · briefing · scheduled tasks · reflection]

Railway config (railway.json):

{
  "build": { "builder": "DOCKERFILE" },
  "deploy": {
    "startCommand": "python -m app.main",
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10
  }
}

Environment Variables

.env.example is the complete list. It carries every one of the 108 Settings fields, grouped, with the code default beside each. This page is the prose reference — what each variable does, and which loop or tool it gates. If the two ever disagree, app/config.py wins.

Required

Variable Description
TELEGRAM_TOKEN Bot token from @BotFather
ALLOWED_TELEGRAM_USER_IDS Comma-separated list of your Telegram user IDs. If unset, all messages are blocked.
GOOGLE_API_KEY Gemini API key — used for LLM inference, Vision, STT, and text embeddings (semantic search)
STORAGE_BACKEND postgres in production
DATABASE_URL PostgreSQL connection string (set automatically by Railway Postgres plugin)

LLM Provider (configure at least one)

Variable Description Default
GOOGLE_API_KEY For Gemini models
ANTHROPIC_API_KEY For Claude models
OPENAI_API_KEY For GPT models
MODEL_NAME Primary model for all interactive responses google-gla:gemini-2.5-flash
MINI_MODEL_NAME Fast/cheap model for skill synthesis (CV extraction, research, read-later summaries, post-conversation fact extraction) google-gla:gemini-3-1-flash-lite-preview
REFLECTION_MODEL_NAME Optional override for the nightly reflection agent. If empty, falls back to MODEL_NAME. Set to a larger model for deeper nightly analysis without affecting interactive latency. (empty)

The three model vars are runtime-overridable — Kwasi can switch its model instantly via set_runtime_config (no redeploy), and the override survives restarts. The env var is only the boot default. See Self-management.

Tools (optional)

Variable Description
TAVILY_API_KEY Web search — agent degrades gracefully without it
WEATHERAPI_KEY Weather — agent degrades gracefully without it
GITHUB_TOKEN GitHub Personal Access Token with repo + notifications scope
GOOGLE_MAPS_API_KEY Google Maps Platform key (Places, Directions, Geocoding APIs)
SLACK_BOT_TOKEN Slack Bot User OAuth Token (xoxb-...) — bot must be invited to channels it reads
IDFM_API_KEY Île-de-France Mobilités Prim' key for Paris transit status (free at prim.iledefrance-mobilites.fr)
JIRA_BASE_URL Jira Cloud instance URL, e.g. https://yourteam.atlassian.net
JIRA_EMAIL Atlassian account email address
JIRA_API_TOKEN Jira API token from id.atlassian.com/manage-api-tokens
YOUVERSION_APP_KEY YouVersion API key — enables get_verse_of_the_day + step 9 of the morning briefing
YOUVERSION_BIBLE_ID YouVersion bible ID (integer)
E2B_API_KEY Enables execute_python, delegate_to_coding_agent, and delegate_web_task (e2b.dev sandboxes)
DELEGATION_BACKEND Default coding-agent backend: opencode (default) or claude_code. The web_task backend is reached via the dedicated delegate_web_task tool, not this setting
ANTHROPIC_API_KEY (delegation) Required for backend="claude_code". Subscription OAuth (CLAUDE_CODE_OAUTH_TOKEN) is not used — removed for compliance; a leftover Railway var is ignored (Settings.extra="ignore")
DELEGATION_OPENCODE_MODEL Model passed to OpenCode (default google/gemini-2.5-pro)
WEB_TASK_TEMPLATE / WEB_TASK_MODEL delegate_web_task browser backend: prebuilt browser-use E2B template (default browser-use) + model (default gemini-2.5-flash). Inert until the template is built externally
DELEGATION_MAX_MINUTES / DELEGATION_MAX_PER_DAY / DELEGATION_MAX_COST_USD Sandbox wall-clock cap (10), per-day rate limit (5), soft cost cap ($5 — Claude Code path only)
DELEGATION_MAX_FILE_BYTES Largest generated file inlined into the delegation result (default 1000000)
BROWSER_ALLOWED_DOMAINS Comma-separated domain allowlist for browse_web and delegate_web_task. Read straight from the environment (os.getenv), not a Settings field — so it is absent from app/config.py
NOTION_API_KEY Notion internal-integration token (Spec 015). Both Notion vars are required; with either unset the Notion tools offer a normal Kwasi note instead
NOTION_NOTES_DATABASE_ID ID of the connected "Notes" database. The integration can only reach pages explicitly connected to it

Semantic search is activated automatically when GOOGLE_API_KEY is set. No additional env var is needed. The same key used for Gemini inference is used for gemini-embedding-001 embeddings.

Postgres: Requires pgvector ≥0.7 on your PostgreSQL instance. Railway's managed Postgres includes a recent pgvector — Kwasi runs CREATE EXTENSION IF NOT EXISTS vector on startup, then ensures embedding columns are halfvec(3072) (auto-migrating any legacy vector(3072) columns in place) and creates HNSW indexes with halfvec_cosine_ops. The 16-bit halfvec type is required because pgvector's HNSW caps at 2000 dims for full-precision vector — Gemini's 3072-dim output exceeds that, and without an index semantic search degrades to a sequential scan. If the extension is unavailable, semantic search falls back silently to keyword-only.

Optional retrieval tuning — message-history mode and thresholds are controlled via env vars (all default to safe values; see app/config.py for the exhaustive list):

Var Default What it does
ENABLE_SEMANTIC_HISTORY false When true, fetch_message_history() returns SEMANTIC_HISTORY_RECENT_COUNT recent verbatim turns + up to SEMANTIC_HISTORY_SEMANTIC_COUNT semantically-relevant older interactions instead of the chronological last-10. Falls back to chronological on any failure.
SEMANTIC_HISTORY_RECENT_COUNT 3 How many recent turns kept verbatim when semantic mode is on.
SEMANTIC_HISTORY_SEMANTIC_COUNT 3 How many semantic hits added when semantic mode is on.
SEMANTIC_HISTORY_THRESHOLD 0.6 Minimum cosine similarity for a semantic-history hit.

After first deploy: existing records have no embeddings. Backfill them with:

curl -X POST https://your-app.railway.app/embed-backfill \
  -H "X-Reflection-Secret: your-reflection-secret"

Returns {"notes": N, "interactions": N, "read_later": N} — the count of newly embedded rows.

Browser Automation (optional)

Variable Description
BROWSER_ALLOWED_DOMAINS Comma-separated list of domains the browser tools may visit, e.g. bbc.com,github.com. If unset, all domains are allowed. localhost and private IPs are always blocked.

Credential Vault (optional)

Lets delegate_web_task(credential="<name>") log into the user's real accounts on no-API sites without the LLM ever seeing the plaintext (Fernet-encrypted at rest; see app/vault.py).

Variable Description
VAULT_MASTER_KEY Fernet key (mint with scripts/vault_genkey.py). Railway env only — never in the DB, never agent-settable (the KEY deny-substring blocks railway_set_env).

Enroll credentials out of band (never via Telegram): railway run python scripts/vault_add.py. The cred's domains must be in BROWSER_ALLOWED_DOMAINS. db_query refuses the vault_credentials table.

Captured sessions — the way past reCAPTCHA

railway run --service personal-assistant .venv/bin/python \
    scripts/vault_capture_session.py <service>            # capture
railway run --service personal-assistant .venv/bin/python \
    scripts/vault_capture_session.py <service> --clear    # revoke

Both flags are needed: the project has several services, and railway run does not find a bare python on PATH.

A stored username and password is often not enough to sign in. Measured against a live site on 2026-08-23, delegate_web_task reached the login form and filled it correctly from the vault, and was then stopped by a reCAPTCHA challenge — identically from an E2B datacenter IP and from a residential connection. The challenge is triggered by an automated browser submitting a login form, not by where the request comes from, so neither a better model nor a cleaner egress IP gets past it.

reCAPTCHA guards the login, not the session. vault_capture_session.py opens a visible browser on your own machine, waits while you log in by hand, and encrypts the resulting cookies into the credential's row. Subsequent web tasks start already authenticated and never touch the login form. Nothing is automated, spoofed or solved — a person signs in, and the agent reuses what that person's browser was given.

Session cookies are bearer credentials for the account: they are encrypted with the same key as the password and never leave the vault in plaintext. Sessions expire; when a task reports that it landed on a login page, capture again. The password stays enrolled as the fallback, so a missing or corrupt session degrades to the old behaviour rather than failing.

Voice & TTS (optional)

Variable Description Default
TTS_VOICE Microsoft Neural TTS voice name for voice replies en-GB-RyanNeural

Background Loops

Variable Description Default
BRIEFING_CHAT_ID Your Telegram chat ID — activates morning briefing, task notifications, and user scheduled tasks
BRIEFING_TIME UTC time for morning briefing (HH:MM) 08:00
BRIEFING_WHATSAPP_NUMBER WhatsApp phone number to also receive briefings (E.164 without +, e.g. 33612345678)
REFLECTION_SECRET Any secret string — activates nightly reflection loop and POST /reflect endpoint
USER_TIMEZONE IANA timezone for natural language time parsing and scheduled times — e.g. Africa/Accra, Europe/Paris UTC
EVENING_RECAP_TIME Local time for evening recap (HH:MM) 21:00
WEEKLY_RECAP_DAY Day of week for weekly recap (mondaysunday) friday
WEEKLY_RECAP_TIME Local time for weekly recap (HH:MM) 18:00
WEEKLY_PREP_DAY Day of week for weekly prep (mondaysunday) sunday
WEEKLY_PREP_TIME Local time for weekly prep (HH:MM) 18:00
READ_LATER_DIGEST_DAY Day of week for read-later digest (mondaysunday) saturday
READ_LATER_DIGEST_TIME Local time for read-later digest (HH:MM) 09:00
JOURNAL_DIGEST_DAY Day of week for weekly journal digest (mondaysunday) sunday
JOURNAL_DIGEST_TIME Local time for journal digest (HH:MM) 19:00
EMAIL_INTEL_TIME Local time for daily email intelligence triage (HH:MM). Set to "" to disable. 09:30
MEETING_PREP_LEAD_MINUTES How many minutes before a meeting to send the prep brief 30
MEETING_PREP_MIN_DURATION_MINUTES Minimum meeting duration (minutes) to trigger a prep brief — skips short calls 10
MEETING_PREP_REQUIRE_ATTENDEES Only prep events with at least one attendee besides you — suppresses prep briefs for solo calendar blocks ("Laundry", "Family devotion"). Runtime-settable via set_runtime_config true
PHONE_DIGEST_ENABLED / PHONE_DIGEST_TIME Nightly Phone Awareness screen-time digest — see Phone Awareness below true / 21:30
MEETING_FOLLOWUP_ENABLED Polls work Gmail for gemini-notes@google.com emails and surfaces a Telegram approval card with your action items. Requires GMAIL_WORK_REFRESH_TOKEN. true
MEETING_FOLLOWUP_POLL_MINUTES How often to check Gmail for new Gemini Notes 30
USER_FIRST_NAME First name used to filter meeting action items to those owned by you (case-insensitive prefix match) Lawrence
PR_REVIEW_TIME Local time for the daily inbound PR-review sweep (HH:MM). Set to "" to disable. 09:00
PR_REVIEW_ORGS Comma-separated GitHub orgs to allow for inbound PR review. Fail-closed when empty (loop does nothing).
PR_REVIEW_LOOKBACK_DAYS How far back the Gmail-discovery half of the sweep looks 7

Format validated at startup. All HH:MM fields must be in 24-hour format (e.g. 08:00, not 8am). Day fields must be lowercase English day names. USER_TIMEZONE must be a valid IANA timezone. Invalid values cause the app to refuse to start with a clear error message identifying the offending variable.

Email & Calendar (optional)

Variable Description
GMAIL_CLIENT_ID Google OAuth2 client ID
GMAIL_CLIENT_SECRET Google OAuth2 client secret
GMAIL_REFRESH_TOKEN Gmail refresh token for personal account (from scripts/get_gmail_token.py)
GMAIL_WORK_REFRESH_TOKEN Gmail refresh token for work account — enables a second set of email tools for the work inbox
OUTLOOK_CLIENT_ID Azure app client ID
OUTLOOK_CLIENT_SECRET Azure app client secret
OUTLOOK_TENANT_ID Azure tenant ID (default: common)
OUTLOOK_REFRESH_TOKEN Outlook refresh token (from scripts/auth_outlook.py). Requires Tasks.ReadWrite scope for Microsoft To Do tools.
GOOGLE_DRIVE_REFRESH_TOKEN Same token as GMAIL_REFRESH_TOKENscripts/get_gmail_token.py already requests drive.readonly scope. Just copy the same value.
GOOGLE_DRIVE_WORK_REFRESH_TOKEN Same token as GMAIL_WORK_REFRESH_TOKEN (run scripts/get_gmail_token.py --work). Copy the same value.

If an account starts failing with invalid_grant, re-auth the env var

The Google credential broker persists rotated refresh tokens to the context table and prefers the stored value. A stored token that later goes invalid used to permanently shadow a freshly re-authed env var. It now self-heals: on a refresh error, if the env token differs from the stored one, the broker retries with it and adopts it. So updating GMAIL_*_REFRESH_TOKEN and restarting is the fix — no DB surgery. See Architecture → Google credential broker.

Microsoft To Do ↔ tasks sync (optional — Spec 013)

Inert without Outlook credentials regardless of the flag (settings.todo_sync_active requires both).

Variable Description Default
TODO_SYNC_ENABLED Mirror the tasks table to and from the To Do default list. Push is inline on every task write; pull is the reconcile loop true
TODO_SYNC_INTERVAL_SECONDS Reconcile interval (floored at 60) 300

See Background Loops → To Do Sync Loop for the conflict, echo-guard, and guarded-delete rules.

WhatsApp (optional)

Variable Description
WHATSAPP_VERIFY_TOKEN Webhook verification token (set in Meta developer portal)
WHATSAPP_ACCESS_TOKEN Meta Cloud API access token
WHATSAPP_PHONE_NUMBER_ID Phone number ID from the Meta Cloud API
WHATSAPP_APP_SECRET App secret for webhook signature verification
ALLOWED_WHATSAPP_NUMBERS Comma-separated phone numbers allowed to message (E.164 without +, e.g. 33612345678). If unset, all senders are allowed.

Android / External API (optional)

Variable Description
API_TOKEN If set, enables POST /message for Android HTTP Shortcuts and other external clients. Pass via X-API-Token header.

Using POST /message

Accepts either multipart/form-data (file uploads) or application/json (base64 images).

Text message (multipart):

curl -X POST https://your-app.railway.app/message \
  -H "X-API-Token: your_token" \
  -F "text=What's on my calendar today?"

Text message (JSON):

curl -X POST https://your-app.railway.app/message \
  -H "X-API-Token: your_token" \
  -H "Content-Type: application/json" \
  -d '{"text": "What'\''s on my calendar today?"}'

Image (multipart file upload):

curl -X POST https://your-app.railway.app/message \
  -H "X-API-Token: your_token" \
  -F "text=What does this say?" \
  -F "file=@screenshot.png"

Image (JSON with base64):

curl -X POST https://your-app.railway.app/message \
  -H "X-API-Token: your_token" \
  -H "Content-Type: application/json" \
  -d "{\"text\": \"Analyse this\", \"image_b64\": \"$(base64 -i screenshot.png)\", \"mime_type\": \"image/png\"}"

Response format:

{"response": "Here's what I found...", "status": "ok"}

The response is also sent to Telegram (BRIEFING_CHAT_ID). Conversation history is shared with the Telegram interface — both use the same user ID so Kwasi has full context across surfaces.

Dashboard (optional)

Variable Description
DASHBOARD_SECRET If set, enables /dashboard/ and uses this value as the auth token. Pass via X-Dashboard-Secret header or ?token= query param.
MINIAPP_BASE_URL Public HTTPS origin of this service, e.g. https://personal-assistant-production-c5ec.up.railway.app. Installs the chat menu button pointing at /miniapp/ at startup. Unset ⇒ no button, page unreachable in practice. Telegram rejects http:// and localhost.
MINIAPP_BUTTON_TEXT Label on the chat menu button (default Memory).

Mini App auth is not DASHBOARD_SECRET. The Mini App (Spec 016) authenticates on the initData blob Telegram signs for the page — verified server-side on every request against HMAC_SHA256(key="WebAppData", msg=<bot token>), with an auth_date freshness window, and then checked against ALLOWED_TELEGRAM_USER_IDS. A secret in a URL is fine for a bookmark and wrong for a page that can be handed to anyone. The browser dashboard is untouched; this is a second scheme for its own routes.

GET /miniapp/ itself is unauthenticated on purpose — it is a static shell containing no memory. Every byte of data arrives through /miniapp/api/*, which is not.

Views. Two tabs, one entry point:

  • Memory — every user_facts row, newest first, searchable, with edit and one-tap delete. The only surface that shows them; /dashboard/?view=memory renders just the reflection profile.

    Correcting a fact marks it source="user", and a user-asserted fact outranks an extracted one. save_user_fact carries WHERE user_facts.source <> 'user' OR EXCLUDED.source = 'user' on its ON CONFLICT, so reflection and the post-conversation extractor cannot overwrite a correction. Without that rule the edit button would be a lie: a value fixed in the evening was reverted by the 2 AM reflection re-extracting the same key, and because source was overwritten too the correction erased its own evidence. Prod shows the shape of it — 541 reflection facts updated this week against 2 user facts, both untouched since March. To hand a fact back to extraction, delete it rather than editing it; a deleted fact is simply re-derived. - Approve — pending approvals with their payloads in full, and Approve/Decline. This is the one thing the Telegram card cannot do: it shows a preview that truncates hard (execute_python cuts code at 400 characters, delegate_to_coding_agent cuts the task at 300), so an approval given there is for something you cannot finish reading. The full payload was always stored; nothing ever displayed it. Resolving goes through app.approval.confirm_action / cancel_action — the same functions the inline keyboard uses — so execution, the audit entry and the Langfuse user_approval score cannot differ between surfaces. The audit row records channel="miniapp" so you can tell where an action was authorised. The Telegram card is edited closed afterwards, so its buttons stop being live.

    Edit revises a pending action's payload — the code, the SQL, the delegation task — and does not run it; the revised action stays pending so approving it is a separate, deliberate tap. Three rules make that safe: only keys already in the payload may change (the payload's keys are the executor's keyword arguments, so a new one would raise TypeError at approve time, after you had committed); types are preserved (a form posts strings, timeout is an int); and the edited payload is re-validated by the originating tool's own checks via app.approval.validate_payload. That last one is the point — db_execute blocks DDL, refuses audit_log/pending_actions/vault_credentials, demands a reason and dry-runs, all before the approval gate, so an edit path that skipped them would be worse than offering no edit at all. Editing also rewrites the Telegram card, since leaving the old preview above a live Confirm button is a way to approve something you never read. - Status — the Command Centre on a phone: Railway deploy state, loop heartbeats, recent exceptions, console deep links, and a confirmed Redeploy button. It reads the same sources as diagnose_self and the browser Command Centre (HEARTBEAT_KEYS from app.background, railway.latest_deployment, logfire_exceptions), because those two had already drifted apart once.

Logfire exceptions are parsed server-side into type/message/time rather than dumped raw. The browser Command Centre prints the query API's columnar JSON verbatim, which on a phone is a screen-filling wall of escaped JSON and stack traces. Stack traces are dropped deliberately — they are a tap away in Logfire.

The page lives at app/static/miniapp.html, read once at import. A missing file degrades to a stub rather than failing the boot: a packaging slip in a secondary surface must not take Telegram and every loop down with it. tests/test_miniapp.py asserts it ships.

Self-management — runtime config & Railway control (optional)

Kwasi can change select settings about itself on request (e.g. "switch to claude sonnet"). There are two mechanisms:

The set of changeable settings is a single source of truth — CONFIG_REGISTRY in app/tools/config_registry.py — from which both allowlists below are derived, so they can never drift.

Runtime overrides (no redeploy). set_runtime_config(key, value) changes the live setting in memory and persists it to the context table (key system:config:<field>), so it takes effect on the next message and survives restarts — no Railway change, no downtime. Runtime-settable keys include the model trio (model_name, mini_model_name, reflection_model_name), the *_time schedule fields, and numeric/bool operational knobs (semantic_history_threshold, delegation_max_*, delegation_backend, meeting prep/followup, enable_semantic_history, …) — call list_runtime_config for the live set. Model values use provider:model form (the relevant provider API key must already be set). get_runtime_config / list_runtime_config read current values; clear_runtime_config reverts to the boot value. No extra env vars needed. Models are runtime-overridable — you do not need a Railway redeploy to switch the model.

Railway control (env var + redeploy). railway_set_env(name, value, redeploy=True) and railway_redeploy() use Railway's GraphQL API to change the real env var and restart the service. Use this only to make a change permanent in the deployment or to set a boot-only var (e.g. WEAVE_ENABLED). Requires:

Variable Description
RAILWAY_API_TOKEN Account/team token with deploy perms (a project token can't redeploy). Sent as Authorization: Bearer.
RAILWAY_PROJECT_ID Railway project id.
RAILWAY_ENVIRONMENT_ID Target environment id (e.g. production).
RAILWAY_SERVICE_ID This service's id.

Get the three ids from the project/service URLs or railway status --json. Only a curated, non-secret allowlist of variable names is settable (RAILWAY_ENV_ALLOWLIST, derived from CONFIG_REGISTRY); any name containing TOKEN/SECRET/KEY/PASSWORD/URL/CREDENTIAL is refused even if allowlisted. railway_set_env validates the value against the field's registry validator before writing, so a bad MODEL_NAME can't bake in and crash-loop the redeploy.

Read-only self-diagnosis (no approval). railway_deployment_status (latest deploy state), railway_deploy_logs(limit) (build logs for a failed redeploy), and diagnose_self (effective config + recent errors + loop heartbeats + deploy status) let Kwasi inspect its own health.

Post-redeploy confirmation. When the agent triggers a redeploy it restarts mid-conversation. A system:pending_self_redeploy marker is written after the successful Railway call; on the next boot the lifespan posts a proactive ✅ (or ⚠️ on mismatch / failed build) to the user and consumes the marker — so a self-triggered redeploy always reports back. See Architecture → Self-Management Subsystem.

Caveats. A redeploy costs ~1–2 min of downtime and interrupts in-flight delegated tasks (orphan recovery marks them failed). Mutating tools are approval-gated on Telegram. Precedence: after a redeploy, a persisted runtime override is re-applied on top of the boot env var — so a stale override can mask a Railway change. diagnose_self marks overridden fields ("shadows env"); when setting a Railway var that's also runtime-overridable, clear the runtime override too.

Health Data Ingest (optional — Spec 010)

Required only if you sideload the bridge-android/ app to ingest Samsung Watch / Health Connect data.

Variable Description Default
HEALTH_INGEST_SECRET Shared secret for POST /health/ingest and POST /health/backfill. The router refuses to mount if unset (returns 503), so the endpoint is locked down by default. Generate with openssl rand -hex 32.
HEALTH_BASELINE_DAYS Rolling window (in days) used by the agent's health read tools to compute HRV/RHR baselines. 30

Building the bridge: Open bridge-android/ in Android Studio (or run ./gradlew assembleDebug from the directory once the wrapper is generated). Sideload the resulting APK, paste your Kwasi base URL + HEALTH_INGEST_SECRET into the app's settings, grant Health Connect permissions, and tap "Sync now". The bridge runs every 15 min via WorkManager from then on. Full setup notes in bridge-android/README.md.

Backfilling history: If you have a Samsung Health "Download Personal Data" CSV/JSON archive from before the bridge went live, upload it once via:

curl -X POST https://your-app.railway.app/health/backfill \
  -H "X-Health-Secret: $HEALTH_INGEST_SECRET" \
  -F "file=@samsung_health_export.json"

Phone Awareness (optional)

Ambient app-usage (screen-time) context from the same bridge-android/ app. The whole feature is inert unless PHONE_INGEST_SECRET is set — the endpoint returns 503. Notification ingest was removed on 2026-08-22. Full reference: Phone Awareness.

Variable Description Default
PHONE_INGEST_SECRET Shared secret for POST /phone/usage/ingest. Generate with openssl rand -hex 32
PHONE_DIGEST_ENABLED Nightly deterministic digest of yesterday's screen time true
PHONE_DIGEST_TIME Local time for the digest (HH:MM) — after the daily usage rollup lands 21:30

TTL pruning rides the hourly cleanup pass inside the approval-expiry loop; there is no dedicated pruning loop.

Bridge setup friction (Galaxy S26 / Android 16 / One UI 8): notification access is a "Restricted setting" needing a one-time manual unlock, and the app must be added to Samsung's "Never sleeping apps" or the listener service is killed.

Operational limits (optional)

Rarely changed. Listed so the full surface is documented.

Variable Description Default
TELEGRAM_MAX_MESSAGE_LENGTH Split threshold for outbound Telegram messages 4096
TELEGRAM_PROGRESS_ENABLED Narrate tool calls ("⏳ Searching notes…") onto the placeholder message while the agent runs, instead of a bare for the whole turn. Cosmetic only; runtime-settable via set_runtime_config true
MAX_USER_MESSAGE_LENGTH Longest inbound user message accepted 10000
INTENTION_FOLLOWUP_DAILY_CAP Maximum proactive intention follow-ups per day 2
INTENTION_MAX_FOLLOWUPS Nudges before an intention is marked expired and stops being followed up 3
LEARNING_SIMILARITY_THRESHOLD Cosine similarity above which a newly-extracted behavioural rule reinforces an existing one instead of being inserted as a near duplicate 0.80
LEARNING_PROMOTE_AT Sightings before a candidate rule becomes an injected guideline 2
LEARNING_CANDIDATE_TTL_DAYS Days after which an unreinforced candidate rule is marked expired 45
LEARNING_ACTIVE_TTL_DAYS Days after which an unreinforced active rule is demoted back to candidate 90
ROUTING_ENABLED false skips intent classification and always uses the full agent (experiment switch for the routing layer) true
DRIFT_SCRUB_ENABLED false keeps persona-drift detection and logging but stops rewriting message history (experiment switch for the scrubber) true
SCHEDULED_TASK_AGE_TOLERANCE_SECONDS How late a cron-scheduled task may fire and still count as due 120
PLAN_MAX_TOTAL_TOKENS Abort a multi-step plan past this cumulative token count. 0 = uncapped 0
PLAN_MAX_REQUESTS Abort a plan past this many cumulative model requests. 0 = uncapped 0
SELF_REPO owner/repo for self-persistence PRs (e.g. activated skills). Empty = disabled. Also drives the GitHub deep link on the Command Centre (empty)
TTS_VOICE Microsoft Neural TTS voice for voice replies en-GB-RyanNeural

Observability (optional)

Up to three stacks (Logfire, Langfuse, and optional W&B Weave) share one OpenTelemetry tracer provider — see Architecture → Observability. Any one can be disabled independently.

Variable Description
LOGFIRE_TOKEN Pydantic Logfire token for infra tracing (FastAPI routes, background loops, exceptions). Activates logfire.configure() and logfire.instrument_fastapi(app).
LOGFIRE_READ_TOKEN Logfire read-only token — enables self-diagnosis tools (what errors did you have this week?, which tools are slowest?) via the diagnostics_agent.
LANGFUSE_PUBLIC_KEY Langfuse public key — activates LLM-layer tracing (per-generation token usage + cost, sessions, user grouping, trace scores). Pair with the secret key below.
LANGFUSE_SECRET_KEY Langfuse secret key.
LANGFUSE_BASE_URL Langfuse endpoint (default https://cloud.langfuse.com). The legacy LANGFUSE_HOST name is still read as a fallback so an existing deployment keeps working, but it is deprecated in the v4 SDK — prefer LANGFUSE_BASE_URL.
LANGFUSE_TRACING_ENVIRONMENT Tags every trace with the environment it came from. Set automatically — production when RAILWAY_ENVIRONMENT_NAME is present (i.e. a deployed container), development otherwise. Set it explicitly only for a third name such as staging.
LANGFUSE_RELEASE Tags every trace with the build it came from. Set automatically from RAILWAY_GIT_COMMIT_SHA (short SHA) in a deployed container; unset locally, deliberately — a wrong release label silently merges two builds into one bucket, which is worse than none. environment says where a trace came from; this says which build, which is what any "did that regress?" comparison needs.

Filter to production when reading the Langfuse project

An audit on 2026-08-26 found ~1,100 traces over eight days of which 85 were real Telegram turns. The largest group was agent run tagged interface:cli — local CLI sessions writing into the production project, because a developer's .env carries the same keys. Tagging the environment does not stop that (it is the developer's own .env); it makes it filterable, which is what restores the production view.

| WEAVE_ENABLED | Set true to also ship traces to W&B Weave (off by default). Requires the two vars below. | | WEAVE_API_KEY | W&B API key. _init_weave() attaches an OTLP exporter to the shared provider + calls weave.init(); fails soft if unset. | | WEAVE_PROJECT | W&B project as <entity>/<project>, e.g. lawrence/kwasi. | | OBSERVABILITY_ALERT_SECRET | Gates POST /observability/alert, which forwards Langfuse Monitor and Logfire alerts to Telegram. Unset ⇒ 503, so an unconfigured deployment cannot be used to push text to the owner's phone. |

Trace scoring. When Langfuse is enabled, four trace-level scores are emitted: user_approval (Confirm 1.0 / Cancel 0.0 on a PendingAction), user_edit (Edit tap), agent_error (in-flight exception during a turn), and user_feedback (a 👍/👎 reaction on any Kwasi message — BOOLEAN). All four can fire minutes after the original turn closed, so each captures its trace ID at send/gate time: PendingAction.trace_id for the approval scores, and a system:msgtrace:<chat_id>:<message_id> row in the context KV for reactions.

Reactions as feedback. user_feedback covers the ~95% of messages that never reach the approval gate. It needs no configuration beyond Langfuse itself, but it does depend on the bot requesting message_reaction in allowed_updates (it does — Update.ALL_TYPES); Telegram withholds that update type by default. The mapping row also records the message's genre (chat, or the loop label — morning_briefing, weekly_recap, observability_alert, …), which is what makes "which proactive genres earn any engagement" answerable:

SELECT content::json->>'genre' AS genre,
       count(*) AS sent,
       count(content::json->>'reaction') AS reacted
FROM context WHERE user_id LIKE 'system:msgtrace:%' GROUP BY 1 ORDER BY 2 DESC;

Rows are pruned after 30 days by the hourly cleanup in _approval_expiry_loop.

Verified end-to-end in production on 2026-08-23: a 👍 on a Kwasi reply produced user_feedback / value: 1 / dataType: BOOLEAN / comment: "reaction:👍 genre:chat" on the originating trace, six seconds after the reply. Two things that were genuine open risks and are now settled: Telegram does deliver message_reaction in a private chat without the bot being an administrator (the "must be an administrator" clause in the Bot API docs applies to groups), and a reaction to a message sent before this feature deployed correctly scores nothing rather than erroring — there is no mapping row for it, which is the same path as a pruned row.

Score API deprecation

GET /api/public/scores returns a deprecation notice: on Langfuse Cloud it is removed 2026-11-16 — the same date as the v4 cutover — in favour of GET /api/public/v3/scores. Anything written to read scores back out (an engagement report, a CI check) should target v3 from the start.

Alert webhook — runbook

POST /observability/alert is the push counterpart to diagnose_self: it forwards Langfuse Monitors and Logfire alerts to Telegram. diagnose_self answers "how are you?" when asked; this is the half that speaks up unprompted. Judges and monitors are useless in a dashboard nobody opens.

Live since 2026-08-23

Configured on the production service and verified end to end: an HMAC-signed alert arrived in Telegram, an identical repeat was suppressed, and an unsigned request and a wrong signature were both rejected. You do not need to set this up again — the notes below are for adding a sender, or for rebuilding after a service migration.

Endpoint: https://personal-assistant-production-c5ec.up.railway.app/observability/alert

The secret lives in Railway as OBSERVABILITY_ALERT_SECRET and nowhere else — not in this repo, not in the database. To read it back when configuring a sender:

railway variables --service personal-assistant --kv | grep OBSERVABILITY_ALERT_SECRET

To rotate it, set a new value and update every configured sender; a redeploy is required either way, because Settings is read once at boot:

railway variables --service personal-assistant \
  --set "OBSERVABILITY_ALERT_SECRET=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"

Authenticating a sender

Two options, because Langfuse and Logfire do not offer the same one. Either is sufficient.

A custom header — simplest, if the sender lets you set arbitrary headers:

curl -X POST "$BASE_URL/observability/alert" \
  -H "X-Alert-Secret: $OBSERVABILITY_ALERT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"title":"agent_error above threshold","message":"12 errors in 1h","url":"https://cloud.langfuse.com/..."}'

An HMAC signature over the raw body, in X-Signature-256, X-Langfuse-Signature or X-Hub-Signature-256, as sha256=<hex> or bare hex:

BODY='{"title":"heartbeat missing","message":"reflection has not run in 26h"}'
SIG="sha256=$(python3 -c "
import hashlib, hmac, os, sys
print(hmac.new(os.environ['OBSERVABILITY_ALERT_SECRET'].encode(), sys.argv[1].encode(), hashlib.sha256).hexdigest())" "$BODY")"

curl -X POST "$BASE_URL/observability/alert" \
  -H "X-Signature-256: $SIG" -H "Content-Type: application/json" -d "$BODY"

The signature covers the bytes as sent. Re-serialising parsed JSON produces a different digest and will be rejected — which is the intended behaviour, not a bug to work around.

Responses

Status Meaning
200 {"status":"delivered"} Forwarded to Telegram
200 {"status":"suppressed"} Identical alert already sent inside the 15-minute dedup window
200 {"status":"accepted","delivered":false} Verified, but Telegram is not configured
401 Missing, malformed or wrong signature/secret
503 OBSERVABILITY_ALERT_SECRET is unset — the endpoint fails closed rather than accepting anything
400 / 502 Body was not JSON / delivery to Telegram failed

What to point at it

Two monitors fit the free tier and are the ones worth having first:

  • agent_error count above zero in an hour — catches a turn failing outright.
  • A 1-day average judge score below threshold — catches quality degrading without anything erroring. Requires the online judges in Evaluation → Online signals, which are UI-config and must be observation-level (trace-level evaluators stop producing results at the Langfuse v4 Cloud cutover, 2026-11-16).

Logfire alerts are SQL-defined, so the natural ones are a missing loop heartbeat and an exception burst. Logfire's Issues (fingerprint-grouped, stateful open/resolved) are a better source than raw exception reads.

Design notes

  • Fails closed. No secret means 503, never "accept anything" — the endpoint forwards sender-chosen text to the owner's phone, so an open one is a nuisance vector.
  • Schema-tolerant. The payload is not validated. It looks for plausible title / message / severity / url keys one level deep and falls back to rendering the raw JSON, because an alert arriving in an unexpected shape is still worth delivering; the alternative is silence at exactly the wrong moment. Langfuse and Logfire disagree about field names and both will change them.
  • Deduplicated. Identical alerts inside 15 minutes are counted and dropped, so a flapping monitor costs one message rather than fifty. This is not cosmetic: the measured problem this whole area exists to address is a 9.19:1 proactive-to-user message ratio, and an alerting loop is the obvious way to make it worse.
  • Alerts are a proactive genre. Each delivered alert records genre="observability_alert" in the reaction-feedback mapping, so "does anyone ever react to these" is answerable by the same query as every other proactive genre. An alert stream nobody acknowledges is evidence, not noise to tolerate.

Prompt-version attribution. Generations carry the managed prompt that produced them (promptName / promptVersion), so Langfuse's per-version metrics answer "did morning_briefing v3 score better than v2" without extra code.

That link only works on a GENERATION observation — the same attribute on a SPAN is accepted and silently ignored. get_prompt() runs while instructions are assembled, when the active span is agent run (a SPAN), so for a long time the link was written one level above the only observation that could show it: 0 of 5,414 generations over 30 days carried a prompt, while morning_briefing accumulated five indistinguishable versions. _PromptLinkSpanProcessor now copies it onto the generation as that span starts.

Managed prompts. Nine system prompts can be tuned in the Langfuse UI without redeploying — full list and behaviour in Tools → Langfuse-managed Prompts. Sync workflow:

uv run python scripts/sync_prompts.py --check    # CI gate; exit 1 on drift
uv run python scripts/sync_prompts.py --push     # code → Langfuse + bump lock
uv run python scripts/sync_prompts.py --pull     # Langfuse → code + bump lock
uv run python scripts/sync_prompts.py --pull --dry-run

scripts/langfuse_cleanup.py is a separate operator tool for pruning unused prompts/traces — run interactively when needed.

prompts.lock.json (repo root) pins each managed prompt's sha256. check_drift() runs at startup and logs a warning if a code constant has been edited without --push first — keeping the deployed code and the Langfuse-published version reconciled.

Retention and tail sampling

Logfire's span allowance is finite, and every successful request costs the same as the handful of traces anyone will ever read. Measured on 2026-08-23 the project was retaining a rolling eleven minutes — 35 records, zero exceptions, an empty issue list — which makes diagnose_self, the Mini App's exception panel and logfire_query read from a store with nothing in it.

Excluding the bridge's ingest routes (August) bought a reprieve and did not hold: it addressed one source rather than the shape of the problem, and new surfaces keep arriving — 13 of those 35 surviving records were GET /miniapp/*, which did not exist when the exclusion was written.

init_observability now passes a tail-sampling policy (SamplingOptions.level_or_duration), deciding after a trace completes:

rule value why
level_threshold warn any trace containing a warning or worse is kept in full — errors can never be sampled away, which is what makes the rest safe
duration_threshold 10s p50 is 2.78s and p95 is 43.7s, so this keeps the latency tail (the actual open problem) and drops the fast majority
background_rate 0.05 5% of ordinary successes survive. Not zero — with zero you can never see what healthy looks like

If the sampling API is ever unavailable the policy degrades to None, which is "keep everything" — the behaviour that preceded this, never worse.

When LOGFIRE_TOKEN is set, the following spans are recorded:

Span Attributes What it captures
telegram/message user_id, chars, categories, agent, type, est_tokens_history, est_tokens_context, est_tokens_user_turn, context_layers, history_interactions Full message handling — text, voice, photo, document; includes per-request token breakdown
whatsapp/message user_id, chars, categories Full WhatsApp message handling
api/message chars, has_image POST /message endpoint (Android HTTP Shortcuts)
embedding/embed_text chars Every Gemini embedding API call
loop/briefing Morning briefing execution
loop/evening_recap Evening recap execution
loop/weekly_recap Weekly recap execution
loop/weekly_prep Weekly prep execution
loop/read_later_digest Read-later digest execution
loop/journal_digest Weekly journal digest execution
loop/email_intel Daily email intelligence triage
loop/reflection Nightly reflection run
loop/alert_fired rule When a proactive alert rule fires
loop/meeting_prep event When a meeting prep brief is sent
loop/pr_review Daily inbound PR review sweep

Token breakdown attributes on telegram/message spans:

Attribute What it measures
est_tokens_history Estimated tokens from message_history (conversation context)
est_tokens_context Estimated tokens from XML context blocks (notes/summaries/read-later)
est_tokens_user_turn Estimated tokens for the full enriched user turn (datetime + context + message)
context_layers Number of context injection layers that contributed (0–3)
history_interactions Number of past interactions kept after token-budget trimming

Pydantic AI's instrument_pydantic_ai() nests tool calls and model calls as child spans under each message span, giving a complete end-to-end trace tree for every interaction.


Local Development

# Install dependencies
uv sync
# pgvector is required for production Postgres semantic search
# It is installed automatically via uv sync (listed in pyproject.toml)
# Tests and local dev need a pgvector container — see below

# Run in server mode (Telegram + WhatsApp webhook)
uv run python -m app.main

# Run in CLI mode (no Telegram needed)
uv run python -m app.main --cli

# Run tests
uv run pytest

# Lint
uv run ruff check .

Storage is Postgres only. STORAGE_BACKEND rejects any other value, and a leftover sqlite:// DATABASE_URL is a hard config error rather than a silent fallback — so local dev and the test suite both need a pgvector container:

docker run -d --name kwasi-test-pg -e POSTGRES_PASSWORD=test \
  -p 55432:5432 pgvector/pgvector:pg17

Finding Your Telegram Chat ID

Your Telegram chat ID is the same as your Telegram user ID for direct (non-group) conversations. You can find it by:

  1. Sending a message to @userinfobot on Telegram
  2. Or checking the ALLOWED_TELEGRAM_USER_IDS value you already have — for a personal bot, these are the same

Set both:

ALLOWED_TELEGRAM_USER_IDS=your_id
BRIEFING_CHAT_ID=your_id

Web Dashboard

If DASHBOARD_SECRET is set, the dashboard is available at /dashboard/. It has four views:

  • /dashboard/ — Recent interactions (last 50)
  • /dashboard/tools — Audit log (tool calls with sanitised arguments)
  • /dashboard/tasks — Scheduled tasks (view + toggle enabled)
  • /dashboard/memory — Current user context profile from the Reflection Engine

Authenticate by passing the secret as a header or query param:

# Header
curl -H "X-Dashboard-Secret: your_secret" https://your-app.railway.app/dashboard/

# Query param (browser friendly)
https://your-app.railway.app/dashboard/?token=your_secret

GitHub Token Setup

  1. Go to GitHub → Settings → Developer settings → Personal access tokens (classic)
  2. Generate a new token with scopes: repo, notifications, read:user
  3. Set GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx in Railway

Hosting Docs Locally

pip install mkdocs-material
mkdocs serve

Then open http://localhost:8000.

The docs are deployed automatically to GitHub Pages on every push to main that touches docs/ or mkdocs.yml.