Skip to main content

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) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
info

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​

TablePurpose
channel_usersChannel-side user records with onboarding state
otp_codesOTP code storage (bcrypt-hashed, TTL-tracked)
chat_messagesPersistent conversation history per user
transactionsUser transaction records
user_goalsUser savings goals
money_move_tasksScheduled and completed passos
spending_alertsEnvelope overspend alert records

Intelligence API tables​

TablePurpose
api_clientsAPI client credentials
api_rate_windowsRate limiting state
usersUser identity records
financial_intake_resultsSubmitted intake snapshots
financial_plansGenerated plan records
personality_questionsQuiz question catalog
money_moves_catalogMove 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​

CodeHTTPDescription
INTAKE_REQUIRED422Financial intake must be submitted before generating a plan
PERSONALITY_REQUIRED422personality_type must be provided (if required by the endpoint)
PLAN_REQUIRED422Plan must be generated before this endpoint can be called
INVALID_CODE400OTP code is wrong or already used
CODE_EXPIRED400OTP code has passed its 10-minute TTL