Status: Authoritative. This document describes the active conversation engine architecture.
The conversation engine uses a Router + specialist architecture built on LangChain primitives (agents, tools, structured outputs) with Pydantic for type safety.
- Single Writer: The Router is the only component that commits to stores.
- Proposal-based: Specialist bots propose patches; Router validates and commits.
- Two distinct stores: Structured profile (Store A) and semi-structured memory (Store B).
- LangChain-native: All bot logic uses LangChain agents, tools, and structured outputs.
Schema-validated fields used for generation, scheduling, and protocol logic.
| Field | Description | Set By |
|---|---|---|
prompt_anchor |
Habit anchor cue | Intake |
preferred_time |
Preferred prompt time | Intake |
habit_domain |
Habit category | Intake |
motivational_frame |
Motivational framing | Intake |
intensity |
Prompt intensity level | Feedback |
last_barrier |
Last barrier encountered | Feedback |
last_tweak |
Last adjustment made | Feedback |
last_successful_prompt |
Last working prompt | Feedback |
last_motivator |
Last motivator noted | Feedback |
tone_tags / tone_scores |
Tone preferences | Feedback |
total_prompts / success_count |
Counters | System |
Properties:
- Pydantic validated (
UserProfileDataschema) - Persisted in
user_profilestable as JSON - Only updated through Router commit path
Session summaries and durable facts that don't fit in the profile schema.
Examples:
- "Prefers gentle accountability and short messages"
- "Recurring barrier is evening fatigue"
- "Travels on weekends, misses prompts"
Properties:
- Stored as individual items in
memory_itemstable - Each item has: content, timestamp, source message IDs, optional tags
- Conservative writes only (max 500 chars per item)
- Anchored to user statements and repeated patterns
The Router owns the final commit to both stores. It:
- Routes each turn to the appropriate specialist (INTAKE, FEEDBACK, or COACH)
- Validates all patch proposals deterministically
- Commits approved patches to the stores
- Logs all proposals and decisions to the audit trail
| Condition | Route |
|---|---|
| Required onboarding fields missing | INTAKE |
| Currently in feedback protocol | FEEDBACK |
| Profile complete, normal conversation | COACH |
Before committing any patch, the Router checks:
- Schema validation — Pydantic validates the patch against
UserProfileData - Field-level permissions — enforced by the permission matrix
- Confidence thresholds — INTAKE/FEEDBACK ≥ 0.5, COACH ≥ 0.8
- Evidence spans — must reference recent message IDs, not retrieved materials
- Conservative memory rules — items ≤ 500 chars with source pointers
| Bot | Profile Fields Allowed | Memory Write |
|---|---|---|
| Intake | prompt_anchor, preferred_time, habit_domain, motivational_frame |
Yes (initial summary) |
| Feedback | last_barrier, last_tweak, last_successful_prompt, last_motivator, intensity, tone_tags, tone_scores |
Yes (stable patterns) |
| Coach | None (candidate proposals only) | No |
User Message
│
▼
┌─────────┐
│ Router │── route_turn_deterministic() or route_turn_llm()
└────┬────┘
│
▼
┌──────────────┐
│ Specialist │ (Intake / Feedback / Coach)
│ Agent │ Uses LangChain agent with proposal tools only
│ │ Can call propose_profile_patch / propose_memory_patch
└──────┬───────┘
│ proposals
▼
┌─────────────────┐
│ Router │
│ Validator │ validate_profile_patch() / validate_memory_patch()
│ │ Check: permissions, confidence, evidence
└──────┬──────────┘
│ approved
▼
┌─────────────────┐
│ Commit Path │ save_user_profile() / add_memory_item()
│ + Audit Log │ log_patch_audit() records decision
└─────────────────┘
- Handles onboarding and profile setup
- Proposes updates to onboarding fields (PromptAnchor, PreferredTime, etc.)
- May propose initial memory summary after intake completion
- Handles habit tracking and barrier analysis
- Proposes updates to rolling coaching fields
- May propose memory updates for stable patterns
- Handles normal conversation and encouragement
- Can only propose candidates with low authority
- Router applies higher confidence threshold (0.8)
All prompts are loaded from api/prompts/*.txt files via app.prompt_loader:
| File | Used By |
|---|---|
router_system.txt |
Router (structured output routing) |
intake_system.txt |
Intake specialist agent |
feedback_system.txt |
Feedback specialist agent |
coach_system.txt |
Coach specialist agent |
prompt_generator_system.txt |
Scheduled habit prompt generation |
Each assistant message SSE event includes a prompt_versions field with:
prompt_file: name of the prompt file used by the specialistprompt_sha256: SHA-256 hash of the prompt content at time of generation
This enables tracking which prompt version produced each response, supporting A/B testing and prompt quality analysis.
All proposals are logged in the patch_audit_log table:
| Column | Description |
|---|---|
proposal_type |
"profile" or "memory" |
source_bot |
"INTAKE", "FEEDBACK", or "COACH" |
patch_json |
The proposed patch payload |
confidence |
Proposal confidence (0-1) |
evidence_json |
Evidence spans with message IDs |
decision |
"committed" or "ignored: {reason}" |
committed_at |
Timestamp if committed |
| Table | Purpose |
|---|---|
user_profiles |
Store A — structured profile JSON (1:1 with membership) |
memory_items |
Store B — individual memory items per membership |
patch_audit_log |
Audit trail for all proposals and decisions |
conversations / messages |
Chat history (1:1 conversation per membership) |
conversation_runtime_state |
Runtime state for conversation protocol (JSON) |
conversation_events |
Persisted SSE events for durable replay |
conversation_turns |
Idempotent messaging dedupe (client_msg_id) |
participations |
One row per participant in a study (carries study_start_date) |
daily_intervention_logs |
Per-day telemetry per participation |
daily_summaries |
EOD sterilized cross-day memory (see below) |
scheduled_tasks |
Durable scheduled work with lease + dedupe keys |
notifications / notification_rules / notification_rule_state / notification_deliveries |
Notification engine state + delivery audit |
Flow uses its own Alembic environment separate from h4ckath0n:
- Migration directory:
api/app/db_migrations/ - Version table:
flow_alembic_version - Runner:
api/app/db_migrations/migrate.py(upgrade_to_head(),stamp_head()) - Startup: The lifespan runs
upgrade_to_head()withinit_db()fallback for test environments. - URL normalization: Async driver prefixes (
sqlite+aiosqlite,postgresql+asyncpg) are converted to sync equivalents for Alembic.
The /p/{project_id}/events SSE endpoint supports durable replay:
- Events are persisted to
conversation_eventsbefore being published to in-memory queues. - If a client reconnects with
Last-Event-IDheader, missed events are replayed from the database before switching to live streaming. - Cross-process delivery uses DB polling (no Redis dependency).
POST /p/{project_id}/messages supports idempotent retries:
- If
client_msg_idis provided, the endpoint checksconversation_turnsfor an existing turn. - If found, the existing assistant message is returned without re-processing.
- If not found, a new turn entry is created with
Unique(conversation_id, client_msg_id).
Scheduled nudges and notifications are driven by two cooperating components:
app/services/notification_engine.py— insertsscheduled_tasksrows with idempotent dedupe keys and 5-minute leases (locked_until,locked_by,claimed_at); handles retry with exponential backoff and dead-letter after max attempts.app/worker/notification_worker.py— claims due tasks, generates the nudge (static template for A/B, LLM with regex-gated regen for C, framed LLM for D), persists the assistantMessagewithcondition_sourcetagging, then attempts Web Push delivery.
Push delivery is best-effort. Push failures or timeouts after the message is persisted are logged per-subscription but do not retry the scheduled task. This prevents duplicate messages when push transport fails after the message has already been written.
| File | Purpose |
|---|---|
api/app/agents/engine.py |
Main turn engine: process_turn() |
api/app/agents/intake.py |
Intake specialist agent |
api/app/agents/feedback.py |
Feedback specialist agent |
api/app/agents/coach.py |
Coach specialist agent (incl. Condition-C regex rewrite) |
api/app/agents/runner.py |
Agent runner (LangChain invoke wrapper + tool trace) |
api/app/agents/tool_trace.py |
Tool call tracing callback |
api/app/tools/proposal_tools.py |
Proposal tools + ProposalCollector |
api/app/tools/scheduler_tools.py |
Scheduling LangChain tools |
api/app/prompt_loader.py |
Prompt file loader with caching and versioning |
api/app/services/profile_service.py |
Profile/memory persistence + validation |
api/app/services/event_service.py |
SSE event persistence and replay |
api/app/services/notification_engine.py |
Scheduled-task scheduling, lease, retry, push delivery |
api/app/worker/notification_worker.py |
Worker loop: claims tasks, generates nudges, persists, pushes |
api/app/schemas/patches.py |
Pydantic schemas for proposals |
api/app/schemas/router.py |
RouteDecision schema |
api/app/db_migrations/migrate.py |
Alembic migration runner |
The platform supports a four-condition microcoaching study (codenamed
microcoach_v1). Conditions vary the prompt-generation source and the
psychological framing applied to nudges:
| Code | Prompt source | Framing | Feedback path |
|---|---|---|---|
| A | Static template (api/config/interventions.json → condition_A) |
None | Deterministic script, no LLM, no profile/memory writes |
| B | Static template (condition_B) |
Implementation Intention + Commitment Contract | Deterministic script, no LLM, no profile/memory writes |
| C | LLM (prompts/prompt_generator_condition_c.txt) |
Strictly forbidden (regex-gated post-filter + regen) | LLM (feedback_system.txt) with profile/memory adaptation |
| D | LLM (prompts/prompt_generator_condition_d.txt) |
Required (If/Then + Commitment in every nudge) | LLM (feedback_system.txt) with profile/memory adaptation |
Every participant is enrolled in a Participation row when they pass the
INTAKE phase. The daily condition is computed deterministically by
app/services/randomization.py::get_daily_condition() using BLAKE3 keyed mode
over (participation_id, block_index) with a BLAKE3-derived randomization
subkey to pick a Latin square from the 24 possible 4-day permutations. This
guarantees:
- balanced exposure (every block uses each condition exactly once),
- reproducible audit (the same inputs always produce the same assignment),
- independence between participants.
FLOW_CRYPTO_MASTER_KEY must be an unpadded Base64URL encoding of exactly 32
random bytes. app.services.crypto derives the dedicated randomization subkey
in BLAKE3's dedicated derive-key mode; the master secret is never used directly.
Static A/B template selection is separate from condition assignment. It uses an
unkeyed BLAKE3 content hash over (participation_id, day_index, static_nudge)
to choose a reproducible template and is not a security boundary.
The current master key is applied to every existing and new Participation
when its condition is resolved. Changing the master key therefore changes the
deterministic assignment for future days; existing DailyInterventionLog rows
retain the previously delivered conditions for analysis. Keep a stable key for
the duration of a study unless a deliberate reassignment is intended.
The dominant validity threat is the LLM in Condition C implicitly mimicking the framing it produced under Conditions B / D. Three independent guardrails protect against this:
- Condition-source tagging. Every assistant
Messageand every nudge created by the worker is tagged withcondition_source(COND_A/COND_B/COND_C/COND_D/SYSTEM). - History filtering. When the engine assembles chat history for a
Condition C turn it (a) drops any message whose
condition_sourceisCOND_BorCOND_D, and (b) restricts the window to the trailing 24 hours. SeeCONDITION_C_EXCLUDED_SOURCESand thetimedelta(hours=24)filter inapp/agents/engine.py::process_turn. - Regex framing filter. Both the Coach turn path
(
app/agents/coach.py) and the worker nudge path (app/worker/notification_worker.py::_generate_condition_nudge) run Condition C output throughapp/services/condition_filters.py::contains_condition_c_framing. On match the prompt is regenerated up to three times with an explicit "remove the framing" instruction; if the model still drifts, the worker falls back to a safe neutral string (CONDITION_C_SAFE_FALLBACK) and the Coach returns its last attempt while logging the failure.
The research design forbids any LLM intervention on the feedback path for
the two control conditions (otherwise the dynamic user model would
silently adapt and confound the comparison). The engine therefore short-
circuits the FEEDBACK route under conditions A and B and calls
app/services/feedback_script.py::run_static_feedback instead of the
run_feedback LangChain agent. The script:
- asks one fixed question at a time (attempt → yes/no follow-up → close),
- stores raw answers in
DailyInterventionLog.extracted_state["script"]for observational analysis, - never emits a
propose_profile_patchorpropose_memory_patch.
The turn is tagged RouteDecision(route="STATIC_FEEDBACK", ...). The
STATIC_TEMPLATE and STATIC_FEEDBACK route literals are engine-internal
markers — the Router LLM is post-validated and is never allowed to
emit them (see route_turn_llm coercion).
DailyInterventionLog rows are heartbeated once per day per participant
with (intervention_date, study_day_index, assigned_condition, extracted_state). The FEEDBACK bot in conditions C and D may push
factual state into extracted_state via the record_daily_telemetry
tool; the static A / B script writes its raw answers there directly.
| Setting | Where | Purpose |
|---|---|---|
FLOW_CRYPTO_MASTER_KEY |
env | One Base64URL-encoded 32-byte master secret. BLAKE3 derives domain-separated subkeys; missing or invalid makes the engine fail and makes the worker use the generic prompt. Do not rotate during a study. |
api/config/interventions.json |
repo | Lists of static nudge templates for A and B. Underscore-prefixed keys (e.g. _comment) are ignored. |
MAX_CONDITION_C_REGEN_ATTEMPTS |
notification_worker.py |
Worker-side regen budget before the safe fallback fires. |
MAX_CONDITION_C_REWRITE_ATTEMPTS |
coach.py |
Coach-side regen budget for chat turns. |
| File | Purpose |
|---|---|
api/app/services/randomization.py |
Deterministic 4-day block assignment |
api/app/services/intervention_config.py |
Loads + samples static templates for A/B |
api/app/services/condition_filters.py |
Shared Condition-C framing regex |
api/app/services/feedback_script.py |
Deterministic A/B feedback script |
api/prompts/prompt_generator_condition_c.txt |
Condition C nudge prompt (no framing) |
api/prompts/prompt_generator_condition_d.txt |
Condition D nudge prompt (If/Then + commitment) |
api/app/worker/notification_worker.py::_generate_condition_nudge |
Worker-side condition dispatcher |
api/tests/test_four_condition_experiment.py |
Coverage for all of the above |
Conditions C and D both need access to multi-day participant context (anchor,
barrier, last successful prompt, etc.) so the LLM can stay coherent across the
study. Feeding raw chat_history from prior days reintroduces exactly the
framing the experiment is trying to isolate: a Condition D message ("If it's
8am and I'm at my desk, then I will do one pushup. We agreed.") would leak into
Condition C's context window on the next day and contaminate the comparison.
The fix is a per-day sterilized rolling summary that both C and D read in place of raw history. Both arms see identical condensed memory, so cross-day recall is preserved while framing differences remain isolated to that day's generation step.
A dedicated table daily_summaries (migration 0012) stores one row per
(participation_id, summary_date) with columns:
summary_text(≤60 words, 1-2 sentences)previous_summary_text(what was fed in as prior context — for audit)message_count(raw turns synthesized that day)sterilization_status∈{clean, regenerated, fallback}prompt_sha256(anchors the row to a specific prompt version)
This store has its own writer (the summarizer service) and is not
subject to the Router single-writer rule that governs UserProfile and
Memory. The summarizer is treated as a deterministic transformation of
already-committed chat history, not as a behavior-changing patch.
There is no cron. Before the engine or worker invokes the C/D code path for a participant, it calls:
await ensure_summaries_up_to(db, participation, yesterday)
summary = await load_latest_summary(db, participation.id)
ensure_summaries_up_to walks day-by-day from the last committed summary
(or study_start_date) up to yesterday, producing one row per day. It is
idempotent (existing rows are skipped) and bounded (30-day safety budget per
call). Days with no participant activity write a placeholder row so the chain
never has gaps.
A/B days are summarized too, even though A/B themselves never read summaries — this keeps the incremental synthesis chain unbroken so that when a participant rotates back to C or D the next block, the summary still reflects the full study.
summarize_day →
- Format that day's chat log (
_format_chat_log, assistant turns truncated). - Render
eod_summarizer_system.txtwith$previous_memory,$daily_chat_log, etc. The prompt explicitly forbids framing vocabulary (promise,commit,if/then,reward,bet, ...). - LLM call (15-20s timeout,
ChatOpenAI). - Regex-gated regen via
contains_condition_c_framing(the same filter the worker uses on Condition C nudges). On a hit, retry up toMAX_REGEN_ATTEMPTS=2with an "ADDITIONAL INSTRUCTION" reminder. - On persistent framing or LLM failure, fall back to a deterministic
skeleton built from
DailyInterventionLog.extracted_state.script.attempted("User completed the habit." / "User did not complete the habit."). _truncate_to_word_capenforces the 60-word cap as a final safety net.
Every row records which path produced it via sterilization_status, so the
analysis pipeline can drop or downweight non-clean rows if needed.
- Engine (chat turns): for C/D, the latest summary is prepended as a
SystemMessage("Sterilized cross-day memory ...") in front of the day's filtered chat history. The existing 24h +condition_sourcefilter for C is kept as defense-in-depth. - Worker (nudges):
_llm_generate_nudgeacceptsdaily_summary=and appends it to theHumanMessagefor the prompt-generator. A/B static templates ignore the summary entirely.
Both arms read the same sterilized text — that is the firewall.
| File | Purpose |
|---|---|
api/prompts/eod_summarizer_system.txt |
Sterilization system prompt + forbidden-word list |
api/app/services/eod_summarizer.py |
summarize_day / ensure_summaries_up_to / load_latest_summary + fallback chain |
api/app/models.py::DailySummary |
ORM model |
api/app/db_migrations/versions/0012_add_daily_summaries_table.py |
Migration |
api/tests/test_eod_summarizer.py |
Coverage (clean, regen, fallback, idempotency, chaining, engine/worker injection) |