A summary of how explAIn is built — the stack, the message lifecycle, the data model, and the edge functions. Full schema detail lives in docs/architecture.md in the main repo.
Stack at a glance
| Layer | Choice | Why |
|---|---|---|
| Client | Expo + Expo Router + React Native + NativeWind | Single codebase for iOS, Android, and Web |
| State | Zustand + TanStack Query | Lightweight; TanStack handles server cache and optimistic updates |
| Backend | Supabase (Auth, Postgres, Realtime, Edge Functions) | One vendor for auth, DB, realtime, and serverless |
| LLM routing | Vercel AI SDK v5 (@ai-sdk/deepseek in v0.1; Anthropic/OpenAI/Google scaffolded) | Unified streaming plus a per-call usage object that feeds the token ledger |
| Auth | Supabase Auth — email magic-link | No password to manage in v0.1; OAuth deferred |
| Build / CI | Turborepo + GitHub Actions | Standard across the owner’s repos |
Message lifecycle
[A types] -> draft (client-local only)
v debounce 600ms
[Advisor-A streams] -> advisor_suggestion (visible to A only)
v A picks: original | suggestion | edited blend
[A sends] -> POST /functions/advise (logs call) -> POST /functions/mediate
persists messages row, status='pending_mediator'
[Mediator streams] -> mediator_output + mediator_action (visible to BOTH)
status -> 'delivered' (or 'blocked' for Judge)
[Realtime broadcast] -> B receives the full row
[B's Advisor-B] -> reads incoming, optionally pre-drafts a reply
The advisor runs privately during typing; the mediator runs after send and streams to both parties simultaneously. This split is the architectural form of the transparency contract — the advisor’s stream is owner-only, the mediator’s stream is shared.
Data model (key tables)
conversations—persona_default,chronicle_mode(shared|per_user),judge_charter(only used for Judge).conversation_participants— composite PK on(conversation_id, user_id), aroleofactive|observer, with a cap trigger enforcing up to 3 active and up to 10 observers per conversation.messages— the central ledger. Carriesoriginal_draft,advisor_suggestion,sent_text,mediator_persona,mediator_output,mediator_action(passthrough|annotate|narrate|externalin v0.1),mediator_reasoning,final_rendered,status, plus per-message token/cost columns.sender_idis nullable for DM-injected messages.conversation_events— drives the “logged as event” requirement (e.g.persona_switch,charter_update,participant_joined).llm_calls— one row per advisor or mediator invocation; provider, model, tokens, cost, latency. The basis for the future paid quota and usage analytics.chronicles— Chronicler persona only; chapter summaries with a message range and optionalowner_idfor per-user mode.
Message provenance
Not every messages row is mediator-authored. The kind column disambiguates:
kind='user'rows carry the full transparency ledger; the mediator actually ran.kind='system'rows (membership notices, proposal resolutions) are structural and borrowmediator_action='passthrough'.kind='external_quote'rows (a friend’s response to a cross-chat@-tag) usemediator_action='external'andsender_id=null; the mediator never sees that text. Preferring'external'over'passthrough'keeps mediator decisions and structural insertions distinct in any audit query.
Observers are observer-free by construction
Observers can read freely but can never write to messages — both RLS and the edge-function gate deny it. Because every LLM-call edge function builds its prompt from messages.sender_id (or from the writer’s own draft for the advisor), and observers can never produce a messages row, their identities are excluded from every prompt by construction rather than by an explicit filter. Formatter-contract unit tests pin the “look up by sender_id, do not enumerate the roster” property so a future refactor cannot regress it.
Edge functions
/functions/advise— streams an advisor suggestion (SSE), records tollm_calls./functions/mediate— runs after send, routes to the active persona’s behavior module, streams to both participants, updates themessagesrow./functions/chronicle-tick— cron- or count-triggered; generates the next Chronicler chapter./functions/dm-tick— cron- or pacing-triggered; injects a DM narrative message.
Realtime channels
conversation:{id}:messages— table changes for the conversation.conversation:{id}:presence— online and typing state.message:{id}:mediator— token-by-token mediator streaming to both parties.user:{id}:advisor:{draft_id}— owner-only advisor stream.user:{id}:pending_notifications— drives the in-app notification banner.
The provider abstraction (packages/llm)
A single entry point — streamLLM({ provider, model, messages, onUsage }) — wraps Vercel AI SDK v5’s streamText for each adapter. The onUsage callback fires on completion with { tokensIn, tokensOut, costUsd }, destined for the llm_calls ledger. Provider keys come from one getProviderKey(provider, userId?) helper: server env in hosted mode, a user_keys table in BYO mode. No framework imports beyond the AI SDK and Zod, so the package lifts cleanly into the future open-source variant.
Token accounting
Every LLM call writes one llm_calls row. The daily aggregate per user powers a soft dogfood cap (env-configurable in v0.1), the future paid quota, and analytics such as advisor-acceptance rate and mediator latency.
Related
- The Messaging Model — how personas map to
mediator_action - Developer Guide — repo layout and running the app
- Source of truth:
docs/architecture.md