Personal assistant · Pydantic AI · Railway · Telegram-first
The anatomy of Kwasi, at four altitudes.
Every diagram answers one question, uses one shared color language, and was verified against the deployed code — including the paths the existing docs don't draw. Tap any diagram to zoom, pan and pinch. Sections descend from the world outside the container to the internals of each subsystem.
158tools on full agent
13+1domain + briefing agents
46MCP tool wrappers
38approval-gated entries
21postgres tables
17background loops
9managed prompts
ALTITUDE 1 · WORLD
System context
Kwasi is one FastAPI process on Railway. Everything on the left is Lawrence's world pushing in; everything on the right is a service Kwasi reaches out to. Postgres is the only other Railway service.
Who talks to Kwasi, and what does Kwasi talk to?#hand-drawn context map
Field notes
The Mini App page is served by Kwasi (GET /miniapp/, an unauthenticated shell) but opened inside Telegram — data calls authenticate with Telegram-signed initData, never DASHBOARD_SECRET.
All inbound push surfaces are secret-gated and fail closed when unconfigured: health/phone ingest, alert webhook (503 without its secret), /message, /reflect.
Anthropic is reached only from inside an E2B delegation sandbox with a metered API key — never from the main agent.
ALTITUDE 2 · STRUCTURE
Inside the container
One process, read top to bottom: interfaces feed a per-turn pipeline, the pipeline picks an agent, agents call tools, tools cross the approval gate before touching the world, and everything persists through one StoragePort. Two rails run beside every layer: observability watches it, background loops act without being asked.
What are the layers, and which rail sees which?#band map
Observability rail
one OTEL TracerProvider
telegram.turn root span per turn
Logfire → infra + loops
Langfuse → LLM traces + scores
Weave → optional mirror
heartbeats per loop
👍/👎 → user_feedback
Interfaces
Telegram bot — dispatch, media, commands, callbacks, reactionsMini App — 14 API routesWhatsApp webhookCLI REPLRouters: dashboard · health · phone · observability · api (/message, /reflect)
26 agent_tools modules → implementations in app/tools/46 MCP wrappers → gmail ×2 · outlook/To Do · gcal · gdrive · logfire (all sync, via to_thread)6 skills — read_later · travel · cv · research · meeting_notes · podcast syncdelegation → E2B (3 backends)introspection — read own source, query own DB, diagnose self
▼
Approval gate — Telegram only
38 tools intercepted → PendingAction + sentinel, human confirms on phone17 action typesone shared resolve path for keyboard + Mini Appfail-closed: only the CLI skips approval
▼
Memory & storage
StoragePort → single PostgresAdapter (SQLite deleted 2026-08)21 tables — conversation · knowledge · proactive state · safety ledger · ingest · vaultcontext KV doubles as system state: dedup keys, OAuth tokens, runtime config, msgtraceembeddings: row first, vector best-effort
Proactive rail — 17 loops
reflection (2 AM)
reminders · approval expiry
briefing · recaps · digests
meeting prep + follow-up
alerts + intentions
To Do sync · PR review
scheduled tasks (cron)
7 currently silenced (Phase 0)
Field notes
Domain agents are compositions, not silos: every one carries the _UTILITY_FNS baseline (db, introspection, search, self-diagnosis), so "focused" means a focused prompt, not exclusive tool access.
One live Phase 0 experiment is left in this picture: ROUTING_ENABLED=false still sends every turn to the full agent, and decides whether router.py + routing/agents.py (~1,600 lines) get deleted.
The other read out, and the code is gone (#132, 2026-08-30). Replaying both drift detectors over 2,000 stored interactions beat waiting on logs: the semantic half had fired 0 times ever — nine anchors embedded at every boot, earning nothing — the regex half not since 2026-06-08, and most of its 31 historical matches were Kwasi being correct. PR #72 (instructions vs system_prompt) was the real cause of that drift; this had only ever been the compensation.
How does the process boot, and in what order?#flowchart
flowchart TD
A["create_app()"]:::iface --> B["Settings() + export AI keys to os.environ"]:::ext
B --> C["init_observability — one OTEL provider,<br/>Logfire + Langfuse + Weave attach here"]:::obs
C --> D["include 7 routers + GET /health"]:::iface
D --> E["lifespan: build_deps → PostgresAdapter<br/>(table DDL runs lazily on first pool connect)"]:::mem
E --> F["load_overrides — re-apply persisted<br/>runtime config from context KV"]:::safe
F --> G["pre-warm 13 routing anchor sets<br/>(deliberately NOT inside a span)"]:::agent
G --> H["wire persistence: Outlook MSAL cache ·<br/>Google OAuth broker → context KV"]:::ext
H --> I["start_telegram_bot<br/>(allowed_updates = ALL_TYPES — reactions need it)"]:::iface
I --> J["recover_orphan_delegations →<br/>'your delegation was interrupted'"]:::safe
J --> K["confirm_pending_self_redeploy → ✅ / ⚠️"]:::safe
K --> L["spawn 17 background loops (each env-gated)"]:::loopc
L --> M["serve · on shutdown: cancel loops, stop bot,<br/>close pool, flush Langfuse"]:::ext
Field notes
The anchor pre-warm is deliberately not wrapped in a span. logfire.span writes to Logfire’s own tracer, which Langfuse never sees — but it is a valid OTEL span, so has_active_span() reported a parent and let each of the 59 anchor embeddings trace. Langfuse received 59 spans whose parent it had no record of and promoted every one to a root trace: 151 of 226 root traces over seven days were these, renewed on every deploy.
Table DDL is not a boot step — it runs lazily on the first pool connect, which is why no explicit storage bootstrap appears in the lifespan.
flowchart LR
UF["_UTILITY_FNS baseline<br/>db_query · db_execute · read_source_file ·<br/>grep_source · diagnose_self · search_history ·<br/>semantic_search · attention tools · verse …"]:::tool --> BUILD
DOM["domain fn list<br/>(e.g. _GITHUB_FNS, _MEMORY_FNS)"]:::tool --> BUILD
MCPS["MCP set<br/>(e.g. _EMAIL_MCP, _CALENDAR_MCP, _TODO_MCP)"]:::ext --> BUILD
SK["6 skills via @skill registry"]:::tool -->|"include_skills"| BUILD
BUILD["_build_domain_agent()<br/>dedups the lists — a duplicate once<br/>silently ate the tool it duplicated"]:::agent
BUILD --> DA["13 prebuilt domain agents"]:::agent
DA --> SEL{"select_agent(categories)"}
SEL -->|"none"| FULL["full_agent — 158 tools"]:::agent
SEL -->|"only utility"| UA["utility_agent"]:::agent
SEL -->|"one domain"| ONE["that domain agent"]:::agent
SEL -->|"two or more"| COMP["composed agent<br/>lru_cache(32) on the frozenset"]:::agent
ALTITUDE 3 · RUNTIME
One turn, end to end
A Telegram text message crosses seven phases. Two short-circuits (edit corrections, bare URLs) never reach the agent at all, and a multi-step plan stops the turn at an approval card before the agent runs.
What actually happens between "message sent" and "reply delivered"?#sequence · code-verified
sequenceDiagram
autonumber
actor L as Lawrence
participant TG as Telegram
participant B as bot.handle_message
participant R as router
participant PL as planner
participant AG as agent.run (Gemini)
participant GT as approval_gate
participant DB as Postgres
participant LF as Langfuse / OTEL
L->>TG: message
TG->>B: update (long poll)
rect rgb(237,240,242)
note over B: PHASE 1 · gates — before any span exists
B->>B: allowlist check
B->>B: pending edit? → revise PendingAction, return
B->>DB: bare URL? → save to read_later, return<br/>(no agent, no trace)
end
rect rgb(246,230,240)
note over B,LF: PHASE 2 · open telemetry
B->>LF: langfuse_root_span("telegram.turn") + turn_span
end
rect rgb(227,236,251)
note over B,DB: PHASE 3 · enrich
B->>DB: fetch_message_history — 10 chrono,<br/>or 3 recent + 3 semantic
B->>R: classify_intent(text, prev-turn hint)
R-->>B: categories → select_agent (full / domain / composed)
B->>DB: inject_context — notes, summaries, read-later<br/>under a 1000-token budget
B->>B: prepend datetime line to the user turn
end
rect rgb(248,231,225)
note over B,PL: PHASE 4 · planning gate
B->>PL: classify_complexity(text)
alt plan with 2+ steps
PL-->>B: ExecutionPlan
B->>DB: PendingAction("plan") + trace_id
B-->>TG: plan preview + Confirm/Cancel — turn ends,<br/>agent never runs
end
end
rect rgb(238,232,250)
note over B,AG: PHASE 5 · run
B-->>TG: "…" placeholder — ProgressReporter narrates tool calls onto it
B->>AG: run_with_resilience(primary, fallback=mini)
loop tool loop
AG->>GT: consequential tool call
alt gated (38 tools, telegram)
GT->>DB: save PendingAction + trace_id
GT-->>AG: sentinel [APPROVAL_PENDING:uuid]
else ungated or non-telegram
GT->>GT: execute immediately
end
end
AG-->>B: response text
end
rect rgb(227,236,251)
note over B,TG: PHASE 6 · deliver
alt voice trigger matched
B-->>TG: voice note (edge-tts)
else
B-->>TG: edit placeholder — markdown, split at 4096
end
B->>DB: record_message_trace (genre=chat) — before keyboards
B->>TG: scan tool calls for sentinels → attach approval keyboard(s)
B->>LF: root span output = response
end
rect rgb(233,241,228)
note over B,DB: PHASE 7 · post-turn, spawn_with_otel_context
B->>DB: log_interaction (+ best-effort embedding) · request entry · audit
B--)DB: extract_facts_from_exchange (mini model → fact-quality gate)
B--)DB: detect_and_save_capability_gap
end
What no doc diagram showed until now
Bare URLs never reach the agent. A message that is only a URL is saved to read-later and returns before the root span even opens — it is invisible to Langfuse.
record_message_trace fires before the approval keyboards attach, not after (the docs draw it the other way).
Post-turn work uses spawn_with_otel_context, not bare create_task — that's why fact extraction nests under the turn's trace instead of orphaning.
The placeholder message is a live surface: ProgressReporter edits tool-call narration onto it, which is why the final markdown edit is the one most likely to hit Telegram's edit throttle.
Which gate can end the turn early, and in what order?#decision flow
flowchart TD
M["incoming text"]:::iface --> A{"allowlisted?"}
A -->|no| X1["ignored"]:::ext
A -->|yes| E{"edit<br/>pending?"}
E -->|yes| X2["revise the PendingAction<br/>via mini model — stays pending"]:::safe
E -->|no| U{"bare<br/>URL?"}
U -->|yes| X3["auto-save to read_later<br/>no agent · no span"]:::mem
U -->|no| SP["open telegram.turn root span"]:::obs
SP --> RT["classify_intent → select_agent"]:::agent
RT --> CX["inject_context + datetime line"]:::mem
CX --> PG{"multi-step?"}
PG -->|"plan ≥ 2 steps"| X4["plan approval card —<br/>agent does not run yet"]:::safe
PG -->|no| RUN["run_with_resilience → agent.run"]:::agent
RUN --> DL["deliver · keyboards · post-turn"]:::iface
ALTITUDE 3 · RUNTIME
Intent routing
Zero-cost keywords first, then embeddings against 13 semantic anchors with three confidence bands. The whole layer is behind a kill switch — and the switch is currently off, deciding whether ~1,600 lines get deleted.
flowchart TD
T["message text"]:::iface --> KS{"ROUTING_ENABLED?"}
KS -->|"false — Phase 0 experiment, live in prod"| FA["full_agent, 158 tools<br/>(every turn since 2026-08-21)"]:::agent
KS -->|true| KW{"keyword<br/>hit?"}
KW -->|"yes — substring over 13 domains"| CAT["matched categories + utility<br/>(method: keyword, zero cost)"]:::tool
KW -->|no| SEM["embed message vs 13 anchor sets"]:::agent
SEM --> B1{"best cosine<br/>score"}
B1 -->|"≥ 0.65"| ONE["single best domain"]:::tool
B1 -->|"0.45 – 0.65"| TOPK["top-K ≤ 3 within 0.10 of best →<br/>composed agent, LLM picks"]:::tool
B1 -->|"< 0.45"| HINT{"one-domain<br/>prior hint?"}
HINT -->|yes| INH["inherit that domain"]:::tool
HINT -->|no| NONE["no categories"]:::ext
CAT --> SEL
ONE --> SEL
TOPK --> SEL
INH --> SEL
NONE --> SEL
SEL["select_agent"]:::agent
SEL --> O1["none → full_agent"]:::agent
SEL --> O2["only utility → utility_agent"]:::agent
SEL --> O3["1 domain → prebuilt agent"]:::agent
SEL --> O4["≥2 → composed (lru 32)"]:::agent
SEL -.->|"keyword_hits + semantic_top3<br/>logged per decision"| LFX["Langfuse span"]:::obs
Field notes
The 13 domains: email · calendar · github · memory · news · slack · jira · drive · meetings · health · database · diagnostics · utility. Keywords and anchors use the identical key set.
The middle band exists because binary cutoffs misrouted borderline scores — attaching the wrong tool set is worse than letting the LLM choose among three.
An offline eval harness (eval/) gates routing in CI on the free keyword path, and a trace-miner promotes real prod misroutes into the dataset.
ALTITUDE 3 · RUNTIME
Approval gate
The safety spine: 38 consequential tools don't execute — they park a PendingAction and hand the agent a sentinel. A human on a phone is the executor. Both resolve surfaces (inline keyboard, Mini App) share one code path, so they cannot diverge.
What states can a pending action move through?#state machine
stateDiagram-v2
[*] --> pending : approval_gate saves PendingAction,<br/>returns sentinel to the LLM
pending --> pending : Edit — mini-model revises payload,<br/>re-validated by the originating tool's own guards<br/>(scores user_edit)
pending --> confirmed : Confirm — keyboard or Mini App
confirmed --> executed : executor runs, audited<br/>(scores user_approval 1.0)
confirmed --> failed : executor raised<br/>(scores user_approval 0.0)
pending --> cancelled : Cancel<br/>(scores user_approval 0.0)
pending --> expired : expiry loop — 30 min default,<br/>24 h for meeting follow-up cards
executed --> [*]
failed --> [*]
cancelled --> [*]
expired --> [*]
How do two different surfaces resolve one action identically?#sequence
sequenceDiagram
participant K as Inline keyboard
participant M as Mini App · Approve tab
participant AP as app.approval
participant EX as executor (ACTION_REGISTRY)
participant DB as Postgres
participant LF as Langfuse
K->>AP: callback approve:<id>
note over M: database · deployment · config · delegation ask for<br/>fingerprint or face first (BiometricManager, Bot API 7.2)
M->>AP: POST /approvals/<id>/confirm
note over AP: one shared confirm_action / cancel_action —<br/>the third time this repo paid for duplicated logic
AP->>DB: get_pending_action (pending, unexpired)
AP->>EX: execute_approved_action(tool_name)
alt executor succeeds
EX-->>AP: result
AP->>DB: status=confirmed · audit entry with channel
AP->>LF: score user_approval = 1.0<br/>(trace_id was captured at gate time)
else executor raises
AP->>DB: status=failed
AP->>LF: score user_approval = 0.0
end
note over M: Mini App edits only revise — validate_payload runs the<br/>originating tool's guards (e.g. db_execute's DDL + table bans),<br/>and the action stays pending
Field notes
Only the CLI skips the gate. Loops and /message get a card in the briefing chat; WhatsApp and unknown interfaces are refused — AgentDeps.interface decides, and its default is "system".
The gate protects itself: db_execute refuses SQL touching audit_log, pending_actions, vault_credentials — the tables that make approval mean anything.
Approval cards show live consequences before Confirm: a transactional dry-run prints "will affect N rows" for raw SQL; delegation cards name the resolved backend.
ALTITUDE 4 · SUBSYSTEM
Memory & storage
Three write cadences (per exchange, per save, nightly) and four read paths into every prompt. The hard-won rule of this subsystem: a prompt cannot enforce a filter; code can — quality gates and overwrite protection live in Python, not instructions.
What writes memory, what gates it, and what reads it back?#data flow
flowchart TD
subgraph W["WRITE PATHS"]
X["every exchange"]:::iface --> FE["extract_facts_from_exchange<br/>mini model, fire-and-forget"]:::agent
FE --> FQ["fact_quality gate — code, not prompt:<br/>rejects kwasi_*/railway_* self-state,<br/>jira mirrors"]:::safe
FQ --> UFACTS["user_facts upsert —<br/>source='user' rows cannot be<br/>overwritten by extraction"]:::mem
R2["2 AM reflection (LLM)<br/>wall-clock anchored"]:::loopc --> RO["profile · facts · intentions (≤3 follow-ups) ·<br/>learnings + topic-summary notes"]:::mem
SV["save_note · save_interaction ·<br/>save_read_later"]:::tool --> EMB["row INSERT first, then embed —<br/>gemini-embedding-001, halfvec(3072),<br/>vector UPDATE is best-effort"]:::mem
end
subgraph RD["READ PATHS"]
SPR["build_system_prompt — profile +<br/>top-10 learnings by recurrence +<br/>live DB schema + vault meta"]:::agent
HIS["fetch_message_history —<br/>last 10 chrono, or 3 recent + 3 semantic"]:::agent
INJ["inject_context, 1000-token budget:<br/>notes ≥0.6 → summaries ≥0.6 →<br/>read-later tag overlap, XML-wrapped"]:::agent
REC["recall_facts on demand — cap 60<br/>(once returned all 760, ~45k chars)"]:::tool
end
UFACTS --> REC
RO --> SPR
EMB --> INJ
EMB --> HIS
Field notes
The source='user' guard exists because prod showed a correction made in the evening being silently reverted by the 2 AM reflection — 541 reflection-updated facts in a week vs 2 user facts.
Learnings rank by recurrence_count then recency, capped at 10 — 160 one-off incidents once steered every turn as policy.
Embedding is awaited inline but after the user's reply is sent, so durability costs the user nothing.
flowchart LR
H["hourly cleanup"]:::loopc --> MT["msgtrace rows past TTL —<br/>prefix-scoped delete (starts_with,<br/>not LIKE; empty prefix raises)"]:::mem
N["nightly reflection"]:::loopc --> EF["*_context facts unseen 90 d<br/>(EPISODIC_FACT_TTL_DAYS — suffix-scoped:<br/>a broader sweep once matched a real phone number)"]:::mem
L5["5-min loop"]:::loopc --> PX["pending approvals past expiry"]:::safe
MAN["scripts/memory_cleanup.py<br/>dry-run by default"]:::ext --> BL["the backlog no automatic<br/>retention path reaches"]:::mem
ALTITUDE 4 · SUBSYSTEM
Background loops
The proactive half of the system: 17 asyncio loops spawned at boot. Seven are deliberately silenced since the Phase 0 reduction (measured 9.19:1 proactive-to-user ratio); each loop's 👍/👎 engagement is now measurable per genre, which decides who comes back.
Every loop, by cadence — who's on, who's silenced, and what gates it?#cadence board
Loop
When
Gate
Dedup / guard
Status
Continuous — every minute or few
User scheduled tasks
every 60 s (croniter)
briefing chat
per-task cron state
on
Reminders
every 5 min
Telegram token
due-time check
on
Approval expiry
every 5 min
Telegram token
expires_at
on
Alert rules + intentions
every 5 min
briefing chat
rule state · ≤3 follow-ups per intention
on
Meeting prep
every 5 min, fires ~30 min ahead
briefing chat
system:meeting_prep:<event> · skips solo blocks (no attendees)
on
Meeting follow-up
every 30 min
work Gmail token
system:meeting_followup:gmail:<msg>
on
To Do sync (reconcile)
every 300 s
flag + Outlook creds
last_synced watermark
on
Daily
Reflection
02:00 UTC
REFLECTION_SECRET
wall-clock re-anchored (drifted 02:00→08:25 once)
on
Morning briefing
08:00 UTC
briefing chat
system:briefing (today's date)
on
Email intelligence
09:30 local
briefing chat + email creds
empty time = disabled
silenced
PR review
09:00 local
GitHub token + org allowlist
system:pr_review:*, 18 h TTL
silenced
Evening recap
21:00 local
briefing chat
date key
silenced
Phone digest
21:30 local
briefing chat + flag
system:phone_digest
silenced
Weekly
Weekly recap
Fri 18:00 local
briefing chat
week key
on
Weekly prep
Sun 18:00 local
briefing chat
week key
silenced
Read-later digest
Sat 09:00 local
briefing chat
week key
silenced
Journal digest
Sun 19:00 local
briefing chat
week key
silenced
Field notes
Expected proactive volume with the current on-set is ~5.1 messages/day — meeting prep (2.8/day) and alerts (1.2/day) dominate, not the briefings.
An empty *_TIME is a first-class "disabled" value, so any silence is reversible from Telegram via set_runtime_config without a redeploy.
flowchart LR
WC["re-anchor to wall clock<br/>(never 'sleep 24h' — that drifts)"]:::loopc --> GATE["env gate — empty *_TIME<br/>means disabled"]:::safe
GATE --> DEDUP["context-KV dedup key —<br/>survives container restarts"]:::mem
DEDUP --> WORK["do the work<br/>(often the briefing_agent — a real LLM run)"]:::agent
WORK --> SEND["send via Telegram"]:::iface
SEND --> GEN["record_message_trace(genre) —<br/>👍/👎 engagement measurable per loop"]:::obs
SEND --> HB["write heartbeat — read by diagnose_self,<br/>Command Centre, Mini App Status"]:::obs
ALTITUDE 4 · SUBSYSTEM
Integrations & delegation
Four distinct coupling styles, chosen deliberately: MCP wrappers for mail/calendar/drive, a bidirectional sync engine for To Do, source-of-truth reads for Notion and Calendar, and sandboxed delegation for work no tool covers.
How do 46 agent tools reach Google and Microsoft without ever holding a password?#component flow
How does a dead refresh token heal itself?#sequence
sequenceDiagram
participant W as MCP wrapper
participant BR as credential_broker
participant G as Google OAuth
participant KV as context KV
W->>BR: get_access_token(provider, account, scopes)
BR->>BR: under lock — cached token with >60 s left? return it
BR->>G: refresh(stored token — DB-seeded beats env)
alt invalid_grant, and the env token differs from stored
BR->>G: retry with the env refresh token
G-->>BR: fresh access token
BR->>KV: adopt + persist the env token (system:oauth:…)
note over BR: re-authing the env var now self-heals —<br/>this failure bit twice and used to need manual SQL
else invalid_grant, env token is the same
BR-->>W: raise — account genuinely needs re-auth
end
BR-->>W: access token
What happens when Kwasi hires help it doesn't have a tool for?#delegation pipeline
flowchart TD
T1["delegate_to_coding_agent(task, backend?)"]:::tool --> PC
T2["delegate_web_task(task) — a dedicated tool,<br/>so browser intents route correctly"]:::tool --> PC
PC["pre-checks: backend credentials ·<br/>daily cap (delegation_max_per_day)"]:::safe --> AGATE["approval gate — the card names<br/>the resolved backend"]:::safe
AGATE --> ROW["Delegation row + spawn run_delegation —<br/>agent replies 'working on it' immediately"]:::mem
ROW --> SBX["E2B sandbox — template per backend"]:::ext
SBX --> B1["opencode — Gemini,<br/>plain stdout answer, cheap default"]:::ext
SBX --> B2["claude_code — metered ANTHROPIC_API_KEY only,<br/>stream-json: cost cap, tool telemetry,<br/>progress ≤1 msg / 30 s"]:::ext
SBX --> B3["web_task — browser-use + Gemini,<br/>runner injected via setup_files,<br/>task via WEB_TASK_PROMPT env,<br/>headless, BROWSER_ALLOWED_DOMAINS"]:::ext
V["vault (Fernet) — decrypted only at run time,<br/>into browser-use sensitive_data placeholders;<br/>the LLM never sees plaintext"]:::safe -.-> B3
B1 --> RES
B2 --> RES
B3 --> RES
RES["result → separate Telegram message<br/>+ Note tagged #delegation"]:::iface
BOOT["container restart → recover_orphan_delegations<br/>marks running→failed, notifies"]:::loopc -.-> ROW
How do two task lists stay one task list?#sync sequence
sequenceDiagram
participant EX as task executors
participant DB as tasks table
participant SY as _todo_sync_loop (300 s)
participant GR as Microsoft Graph · defaultList
note over EX,GR: PUSH — inline, best-effort (same posture as embeddings)
EX->>DB: create / update / complete / delete
EX--)GR: push_created / push_updated / push_deleted
note over SY,GR: PULL — reconcile every interval · conflict rule: To Do wins
SY->>GR: fetch all items
alt fetch fails
SY->>SY: bail — a failed fetch never deletes locals
end
SY->>DB: linked item strictly newer than last_synced → apply remote
SY->>SY: item at the watermark → skip (echo guard —<br/>our own push never round-trips)
SY->>DB: linked but absent from a SUCCESSFUL fetch → delete local
SY->>DB: unmatched remote → adopt same-title local, else create
SY--)GR: remaining unlinked locals → push up
flowchart LR
W["Galaxy Watch"]:::ext --> HC["Google Health Connect"]:::ext
HC --> BR["bridge-android — sideloaded Kotlin,<br/>WorkManager ~15 min"]:::ext
US["UsageStatsManager —<br/>screen time · unlocks"]:::ext --> BR
BR -->|"POST /health/ingest<br/>X-Health-Secret"| HI["health router"]:::iface
BR -->|"POST /phone/usage/ingest<br/>X-Phone-Secret"| PI["phone router"]:::iface
HI --> HS["health_samples — 11 metric types,<br/>idempotent on (type, start, device)"]:::mem
PI --> AU["app_usage_daily —<br/>per (date, package, device) upsert"]:::mem
HS --> HT["4 read tools · briefing snapshot ·<br/>reflection health block · health_metric alerts<br/>(resting HR derived: 5th percentile of ~1/min stream)"]:::tool
AU --> AT["attention tools · nightly phone digest ·<br/>reflection attention block"]:::tool
Field notes
Push model only — the server never polls the phone. Unset secrets make each ingest surface inert.
The notification half was removed (#104) after 15 days of real data: 83% was one repeating "Data warning", and messaging rows carried counters, not content. What's worth capturing next, if ever: notification dwell time.
Notion is the deliberate contrast to To Do sync: no mirror, no loop — Notion is the source of truth, read live.
ALTITUDE 4 · SUBSYSTEM
Observability & feedback
One tracer provider, three clouds watching the same spans — and a set of taps that turn user behavior into scores on the exact trace that earned them, even when the signal arrives minutes later.
flowchart TD
FA["FastAPI spans<br/>(bridge ingest routes excluded —<br/>they once burned retention to ~15 min)"]:::iface --> TP
PA["Pydantic AI agent spans<br/>Agent.instrument_all()"]:::agent --> TP
LP["turn spans + background loops"]:::loopc --> TP
RES["OTEL_RESOURCE_ATTRIBUTES — set before any<br/>provider exists, so langfuse.environment + release<br/>land on the Resource every span inherits"]:::ext --> TP
TP["one shared OTEL TracerProvider<br/>(init_observability)"]:::obs
TP --> LG["Logfire — infra, loops, exceptions,<br/>tail-sampled"]:::obs
TP --> LFU["Langfuse — LLM traces, sessions,<br/>prompts, scores, experiments"]:::obs
TP --> WV["W&B Weave — optional OTLP mirror,<br/>off by default"]:::obs
Field notes
Setting LANGFUSE_TRACING_ENVIRONMENT / LANGFUSE_RELEASE is not enough. The Langfuse SDK builds a Resource carrying both, but only applies it when it creates the tracer provider; Logfire configures first here, so the Resource is silently dropped and only SDK-created spans get stamped.
The split was exact inside one verified turn: telegram.turn and every @observe span carried environment=production, while the agent run, 4 generations and 11 tools carried environment=default. Cost lives entirely on the generations — so filtering to environment = production returned $0.00, silently.
OTEL_RESOURCE_ATTRIBUTES is the provider-agnostic fix: Resource.create() merges it whoever calls it, so it must be set before any provider exists. Found by reading the live project after the v4 migration, not by a failing test.
telegram.turn — root span · session=telegram:<chat> · input/output └─ telegram/message — stage span ├─ message_history_retrieval (@observe) ├─ telegram/routing — keyword_hits · semantic_top3 ├─ agent run — model request(s), tool spans, usage rollup └─ post-turn fact extraction + capability gap — still nested, via spawn_with_otel_context
What measures whether any of this is worth it to the user?#signal taps
flowchart LR
A1["Confirm / Cancel at the gate"]:::safe --> S1["user_approval 1.0 / 0.0"]:::obs
A2["Edit at the gate"]:::safe --> S2["user_edit"]:::obs
A3["exception during a turn"]:::safe --> S3["agent_error"]:::obs
A4["👍 / 👎 reaction on ANY message —<br/>the only signal covering turns<br/>that never hit the gate"]:::iface --> MT["system:msgtrace:<chat>:<msg> —<br/>written at send time, carries the genre<br/>(chat, briefing, alert, digest …)"]:::mem
MT --> S4["user_feedback (boolean) +<br/>per-genre engagement — the query that<br/>decides which silenced loops return"]:::obs
S1 --> LF
S2 --> LF
S3 --> LF
S4 --> LF
LF["Langfuse scores, attached to the trace_id<br/>captured when the signal was armed"]:::obs
PR["10 managed prompts — Langfuse UI ·<br/>code-constant fallbacks · prompts.lock.json<br/>sha256 pins · check_drift() at boot ·<br/>sync_prompts.py is the only mutator"]:::obs
Field notes
Reactions only arrive because polling passes allowed_updates=Update.ALL_TYPES — Telegram excludes message_reaction from the default set, and naming any type switches the default off entirely. A test asserts the polling call itself.
Delayed signals share one shape: capture trace_id at arming time (PendingAction.trace_id, msgtrace rows), score whenever the human gets around to it.
ALTITUDE 4 · SUBSYSTEM
Self-management
Kwasi reconfigures and redeploys itself from a chat message — safely, because one registry derives every allowlist, secrets are deny-listed by shape, and a redeploy must prove it survived.
Two control planes: instant vs baked-in?#flowchart
flowchart TD
REG["CONFIG_REGISTRY — single source of truth:<br/>ConfigField(field, env, validator, coerce,<br/>runtime_settable, env_settable)"]:::safe
REG --> AK["ALLOWED_CONFIG_KEYS<br/>(runtime allowlist — derived)"]:::safe
REG --> RK["RAILWAY_ENV_ALLOWLIST<br/>(env allowlist — derived)"]:::safe
DENY["independent deny-substring gate:<br/>TOKEN · SECRET · KEY · PASSWORD ·<br/>DATABASE_URL · CREDENTIAL"]:::safe -.-> RK
subgraph P1["PLANE A — instant, survives restart"]
RC["set_runtime_config (approval-gated)"]:::tool --> MEM1["live Settings in memory —<br/>effective on the very next message<br/>(model is bound per request)"]:::agent
RC --> KV1["context KV system:config:*"]:::mem
KV1 --> LO["load_overrides at boot"]:::loopc
end
subgraph P2["PLANE B — baked into the Railway env"]
SE["railway_set_env (approval-gated) —<br/>validates via the registry BEFORE upsert,<br/>so a bad MODEL_NAME can't crash-loop"]:::tool --> GQ["Railway GraphQL upsert"]:::ext
GQ --> RD["railway_redeploy"]:::ext
RD --> MK["write system:pending_self_redeploy<br/>(chat, expected config, prev deploy id)"]:::mem
MK --> CF["next boot: confirm_pending_self_redeploy —<br/>verifies a NEW deployment happened,<br/>posts ✅ or ⚠️, clear-before-send"]:::loopc
end
AK --> RC
RK --> SE
RO2["ungated read-only companions:<br/>diagnose_self · railway_deployment_status ·<br/>railway_deploy_logs"]:::tool
Field notes
Plane A is what makes the two live experiments abortable from Telegram without a redeploy.
RAILWAY_API_TOKEN itself is never agent-settable — the deny-substring gate blocks it regardless of any registry entry.
ALTITUDE 4 · SUBSYSTEM
Telegram Mini App
The first pull-shaped surface: a web page inside the chat that finally shows the ~650 facts and pending approvals that were previously write-only. Auth is cryptographic, not a shared secret — and the docs have zero diagrams for it, so these are the first.
How is a request trusted before anything in it is read?#auth sequence
sequenceDiagram
participant T as Telegram app
participant P as GET /miniapp/ — unauthenticated shell
participant J as page JS
participant V as verify_init_data
participant API as /miniapp/api/*
T->>P: open Mini App
P-->>T: static HTML — contains no data by design
J->>API: fetch with Telegram-signed initData
API->>V: verify FIRST — payload untrusted until then
V->>V: 1 · bot token configured? initData present?
V->>V: 2 · parse fields, pop hash
V->>V: 3 · HMAC("WebAppData", bot_token) →<br/>HMAC over sorted k=v lines → compare_digest
note over V: nothing in the payload — not even user id —<br/>is read before this signature check passes
V->>V: 4 · auth_date window — ≤ 24 h old, ≤ 5 min future
V->>V: 5 · parse user JSON, require id
V->>V: 6 · same allowlist as the bot —<br/>an EMPTY allowlist blocks everyone
alt any step fails
API-->>J: generic 401 — the reason stays server-side
end
API-->>J: data
Which tab hits which of the 14 endpoints, and what makes each real?#surface map
flowchart LR
TB1["Today"]:::iface --> E1["GET /api/today — one round trip<br/>GET /api/agenda — calendar kept separate,<br/>so first paint never waits on Google"]:::tool
TB2["Memory"]:::iface --> E2["GET /api/facts (search) ·<br/>PATCH /api/facts/:key · DELETE /api/facts/:key —<br/>a correction becomes source='user',<br/>which extraction can no longer overwrite"]:::mem
TB3["Approve"]:::iface --> E3["GET /api/approvals + count badge ·<br/>confirm / revise / cancel —<br/>all through app.approval,<br/>the same path as the inline keyboard.<br/>Risky classes gated by biometrics"]:::safe
TB4["Status"]:::iface --> E4["GET /api/status — deploy state, heartbeats,<br/>recent exceptions · POST /api/redeploy ·<br/>GET /api/delegations (+ detail)"]:::obs
Field notes
Four page-level bugs were invisible to all ~1,400 tests and found only by driving the real page: a confirm API that throws below Bot API 6.2, stale-response rendering, escaped-JSON walls, and a literal × on delete buttons. UI truth needs a browser.
Editing an approval payload re-runs the originating tool's own validators — an edit path that skipped db_execute's guards would be worse than no edit path.