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.exampleis the complete list. It carries every one of the 108Settingsfields, 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.pywins.
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 (optional, but recommended)¶
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 (monday–sunday) |
friday |
WEEKLY_RECAP_TIME |
Local time for weekly recap (HH:MM) |
18:00 |
WEEKLY_PREP_DAY |
Day of week for weekly prep (monday–sunday) |
sunday |
WEEKLY_PREP_TIME |
Local time for weekly prep (HH:MM) |
18:00 |
READ_LATER_DIGEST_DAY |
Day of week for read-later digest (monday–sunday) |
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 (monday–sunday) |
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:MMfields must be in 24-hour format (e.g.08:00, not8am). Day fields must be lowercase English day names.USER_TIMEZONEmust 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_TOKEN — scripts/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:
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_factsrow, newest first, searchable, with edit and one-tap delete. The only surface that shows them;/dashboard/?view=memoryrenders just the reflection profile.Correcting a fact marks it
source="user", and a user-asserted fact outranks an extracted one.save_user_factcarriesWHERE user_facts.source <> 'user' OR EXCLUDED.source = 'user'on itsON 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 becausesourcewas 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_pythoncuts code at 400 characters,delegate_to_coding_agentcuts 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 throughapp.approval.confirm_action/cancel_action— the same functions the inline keyboard uses — so execution, the audit entry and the Langfuseuser_approvalscore cannot differ between surfaces. The audit row recordschannel="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
TypeErrorat approve time, after you had committed); types are preserved (a form posts strings,timeoutis an int); and the edited payload is re-validated by the originating tool's own checks viaapp.approval.validate_payload. That last one is the point —db_executeblocks DDL, refusesaudit_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 asdiagnose_selfand the browser Command Centre (HEARTBEAT_KEYSfromapp.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_selfmarks 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:
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_errorcount 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:
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:
- Sending a message to @userinfobot on Telegram
- Or checking the
ALLOWED_TELEGRAM_USER_IDSvalue you already have — for a personal bot, these are the same
Set both:
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¶
- Go to GitHub → Settings → Developer settings → Personal access tokens (classic)
- Generate a new token with scopes:
repo,notifications,read:user - Set
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxin Railway
Hosting Docs Locally¶
Then open http://localhost:8000.
The docs are deployed automatically to GitHub Pages on every push to main that touches docs/ or mkdocs.yml.