Architecture
The Ozzie platform is split into two layers: a stateful Channel API that consumer apps call directly, and a stateless Intelligence API that the channel proxies internally.
Two-layer designβ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β YOUR APPLICATION (Lovable / consumer app) β
βββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β all API calls
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 1 β Channel API (channel.ozzieapp.com) β
β β
β β’ Stateful: owns users, OTP, onboarding, chat history β
β β’ Handles end-user authentication (OTP via WhatsApp or email) β
β β’ Stores channel_users, otp_codes, chat_messages, transactions β
β β’ Proxies intelligence requests to Layer 2 internally β
β β
β Base URL: https://channel.ozzieapp.com/v1 β
βββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β internal proxy (not called by consumers)
βΌ
ββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββ
β Layer 2 β Intelligence API (api.ozzieapp.com) β
β β
β β’ Stateless: receives full context as request input β
β β’ Returns plans, envelopes, chat responses, quiz scores β
β β’ No session affinity, no stored history β
β β’ personality_type is always a caller-supplied parameter β
β β
β Base URL: https://api.ozzieapp.com/v1 (internal use only) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Consumer apps β including Lovable β call only channel.ozzieapp.com. The intelligence layer at api.ozzieapp.com is an internal service; you do not call it directly.
Why two layers?β
Channel API handles everything stateful:
- End-user identity and OTP authentication
- Onboarding stage progression (
financial_intakeβpersonalityβplanβactive) - Persistent chat history
- Transactions, goals, and spending alerts
Intelligence API handles everything compute-only:
- Stateless AI chat inference
- Personality scoring
- Plan and envelope generation
- Money move recommendations
Keeping them separate means the intelligence layer can scale independently and be reused across different channels without ever owning user state.
How personality_type flowsβ
Personality is scored once (using the quiz endpoints) and stored by your application. On every subsequent intelligence call, your application reads the stored personality_type and passes it as a parameter:
User takes quiz
β
βΌ
POST /v1/personality/score
β returns personality_type (e.g. "GIVER")
β your app stores it
β
βΌ
On every subsequent intelligence call, your app passes it explicitly:
GET /v1/users/:id/financial-profile?personality_type=GIVER
POST /v1/users/:id/plan/generate body: { personality_type: "GIVER" }
POST /v1/money-moves/generate body: { personality_type: "GIVER", ... }
Key principle: personality_type is never stored in the intelligence layer. Your application is the source and always passes it explicitly.
Database tablesβ
Channel API tablesβ
| Table | Purpose |
|---|---|
channel_users | Channel-side user records with onboarding state |
otp_codes | OTP code storage (bcrypt-hashed, TTL-tracked) |
chat_messages | Persistent conversation history per user |
transactions | User transaction records |
user_goals | User savings goals |
money_move_tasks | Scheduled and completed passos |
spending_alerts | Envelope overspend alert records |
Intelligence API tablesβ
| Table | Purpose |
|---|---|
api_clients | API client credentials |
api_rate_windows | Rate limiting state |
users | User identity records |
financial_intake_results | Submitted intake snapshots |
financial_plans | Generated plan records |
personality_questions | Quiz question catalog |
money_moves_catalog | Move template library |
Request flow examplesβ
OTP authentication (channel layer)β
Consumer app: POST /v1/auth/otp/request
body: { identifier: "+5511999990000", language: "pt" }
β
βΌ
channel-api:
1. Generates 6-digit code, bcrypt-hashes it, stores with 10-min TTL
2. Sends code via WhatsApp (auto-detected from E.164 phone)
3. Returns { channel, masked, expires_at }
Consumer app: POST /v1/auth/otp/verify
body: { identifier: "+5511999990000", code: "482910" }
β
βΌ
channel-api:
1. Validates code against bcrypt hash
2. Returns { user_id, core_user_id, onboarding_stage }
Plan generation (routed through channel layer)β
Consumer app: POST /v1/users/usr_123/plan/generate
body: { personality_type: "STOCKPILER" }
β
βΌ
channel-api:
1. Verifies user belongs to this client
2. Loads user's stored intake data
3. Proxies to intelligence layer
β
βΌ
intelligence-api:
1. Applies STOCKPILER envelope weights
2. Generates plan with action items
3. Returns plan
β
βΌ
channel-api:
4. Persists plan to channel DB
5. Returns plan to consumer app
Chat completion (with persistent history)β
Consumer app: POST /v1/users/usr_123/chat/completion
body: {
message: "Am I on track?",
personality_type: "STOCKPILER",
user_context: { ... }
}
β
βΌ
channel-api:
1. Loads conversation history from chat_messages
2. Proxies to intelligence layer with history injected
β
βΌ
intelligence-api:
1. Uses message + history + personality_type + user_context
2. Returns AI reply
β
βΌ
channel-api:
3. Persists user message + assistant reply to chat_messages
4. Returns reply to consumer app
Context injectionβ
The chat endpoint accepts a user_context object that your application assembles from its own database before each call. This is the primary mechanism for passing rich, real-time user state to the intelligence layer without the API needing to store any of it.
Fields your application can inject:
- Identity & preferences:
name,language,personality_type,plan_tier,plan_speed - Financial snapshot:
monthly_income,goal_progress,total_saved_cents - Behavioral history:
completed_moves_count,last_confirmation_note,next_move - Session signals:
entry_point,spending_alert_envelope,spending_alert_ratio - Retrieval context:
rag_contextβ free text from your own retrieval pipeline
All user_context fields are optional. The intelligence layer uses whatever is present to personalize the response; missing fields are simply not referenced.
Your database channel-api request
ββββββββββββββββ ββββββββββββββββββββββββββββββ
users.name ββββΊ user_context.name
users.personality_type ββββΊ user_context.personality_type
plans.plan_tier ββββΊ user_context.plan_tier
plans.plan_speed ββββΊ user_context.plan_speed
goals.percentage ββββΊ user_context.goal_progress.percentage
moves.completed_count ββββΊ user_context.completed_moves_count
moves.next_date ββββΊ user_context.next_move.date
session.entry_point ββββΊ user_context.entry_point
rag_pipeline.output ββββΊ user_context.rag_context
Entry-point routingβ
When a user opens the chat screen, your app knows how they arrived. Passing entry_point in user_context lets Ozzie generate a context-appropriate opening message (entry_point_opener) that your app can display before the user types anything.
User arrives entry_point value entry_point_opener
ββββββββββββββββ βββββββββββββββββ ββββββββββββββββββββββββββββββ
Opens app directly ββββΊ "unprompted" ββββΊ Personalized greeting with passos + next move
WhatsApp expense prompt ββββΊ "weekly_expense" ββββΊ Asks for expenses with input options
Overspending notification ββββΊ "spending_alert" ββββΊ References envelope + asks about recalibration
Monthly review link ββββΊ "monthly_review" ββββΊ Summarizes last month + asks about this month
Passo reminder ββββΊ "move_reminder" ββββΊ null (channel renders the passo card instead)
No entry_point sent ββββΊ (omit field) ββββΊ null
Your app should resolve entry_point from the session launch parameters (deep link, notification payload, etc.) before assembling the user_context object.
Error statesβ
| Code | HTTP | Description |
|---|---|---|
INTAKE_REQUIRED | 422 | Financial intake must be submitted before generating a plan |
PERSONALITY_REQUIRED | 422 | personality_type must be provided (if required by the endpoint) |
PLAN_REQUIRED | 422 | Plan must be generated before this endpoint can be called |
INVALID_CODE | 400 | OTP code is wrong or already used |
CODE_EXPIRED | 400 | OTP code has passed its 10-minute TTL |