Editor: Decide with Clarity
| Layer | Technology | Purpose |
|---|---|---|
| iOS App | Swift / SwiftUI | Native iOS client (iOS 26+) |
| Backend | Python / FastAPI | API server, LLM orchestration |
| LLM Providers | OpenAI, Google Gemini | AI reasoning |
| Monetization | RevenueCat | Subscription management |
| Integrations | Notion API | Artifact export |
| Persistence | UserDefaults (iOS), JSON files | Local storage |
┌─────────────────────────────────────────────────────────────┐
│ iOS App │
├─────────────────────────────────────────────────────────────┤
│ ElevatorView (vertical paging navigation) │
│ ├── Settings Floor (-1) │
│ ├── Start Session Floor (0) ← default │
│ └── Library Floor (1) │
├─────────────────────────────────────────────────────────────┤
│ AppRouter (state machine) │
│ ├── flowState: elevator | modeSelection | guidedInput | │
│ │ thinkingOutput | sessionComplete | ... │
│ ├── currentSession: ThinkingSession? │
│ └── paywallContext: PaywallContext? │
├─────────────────────────────────────────────────────────────┤
│ Services │
│ ├── SubscriptionService (RevenueCat) │
│ ├── APIClient (backend communication) │
│ ├── SessionStore (persistence) │
│ ├── ProfileStore (user context) │
│ └── NotionService (artifact export) │
└──────────────────────────────┬──────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Backend │
├─────────────────────────────────────────────────────────────┤
│ Endpoints │
│ ├── GET /v1/health (health check) │
│ ├── GET /v1/models (available models) │
│ ├── POST /v1/chat (thinking session) │
│ ├── POST /v1/onboarding (onboarding flow) │
│ ├── POST /v1/extract (profile extraction) │
│ └── GET /v1/notion/oauth-redirect (Notion OAuth) │
├─────────────────────────────────────────────────────────────┤
│ Prompt Assembly │
│ ├── Persona blocks (Editor, Operator, Strategist) │
│ ├── Mode blocks (5 thinking modes) │
│ ├── Memory blocks (Free: stateless, Pro: contextual) │
│ └── Output schema (structured JSON response) │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ LLM Providers │
│ ├── OpenAI (gpt-4o-mini) │
│ └── Google (gemini-flash-lite-latest) │
└─────────────────────────────────────────────────────────────┘The AppRouter is the single source of truth for navigation and session state:
enum FlowState {
case elevator(ElevatorStage) // Main navigation
case modeSelection // Choosing thinking mode
case guidedInput(ThinkingMode) // Structured input form
case thinkingOutput(sessionId: UUID) // AI response
case sessionComplete(sessionId: UUID) // Hard stop
case artifact(...) // Artifact generation
}Key principle: No chat view. Users follow a structured flow:
Vertical paging replaces traditional tab bar:
| Floor | View | Purpose |
|---|---|---|
| -1 | SettingsStageView | Subscription, legal, about |
| 0 | SessionsHomeView | Start new session (default) |
| +1 | LibraryView | Past sessions and artifacts |
Swipe up/down to navigate between floors.
Editor uses structured thinking modes instead of free-form chat. Each mode has a specific goal, required questions, and mandatory output structure.
| Mode | Tier | Goal |
|---|---|---|
| Review an Idea | Free | Sharpen idea until concrete, scoped, testable |
| Get Unstuck | Free | Identify real blocker and unblock progress |
| Make a Decision | Pro | Make clear decision with explicit trade-offs |
| Prioritize | Pro | Select highest-leverage work |
| Trade-offs / Strategy | Pro | Evaluate strategy with risks and second-order effects |
Review an Idea (Free)
Get Unstuck (Free)
Make a Decision (Pro)
Prioritize (Pro)
Trade-offs / Strategy (Pro)
Personas are policy layers, not personality skins. Changing the persona changes reasoning, not just tone.
| Attribute | Value |
|---|---|
| Role | Clarity, simplification, reduction of noise |
| Bias | Reduce > Expand, Precision > Exploration |
| Voice | Calm, editorial, precise, minimal words |
Always: Ask "What are we really deciding?", identify core constraint, compress to one sentence
Never: Over-explain, add motivational language, explore tangents
| Attribute | Value |
|---|---|
| Role | Execution, systems, throughput |
| Bias | Action > Discussion, Shipping > Polishing |
| Voice | Practical, direct, slightly impatient |
Always: Ask for deadlines, define Definition of Done, convert to weekly/daily actions
Never: Stay at vision level, accept vague plans, assume unlimited time
| Attribute | Value |
|---|---|
| Role | Trade-offs, long-term thinking, decision quality |
| Bias | Long-term > Short-term, Explicit trade-offs > Ambiguity |
| Voice | Analytical, calm, demanding |
Always: Ask what optimizing for, surface risks, provide 2-3 options with consequences
Never: Say "it depends" without structure, avoid choosing when required
The system prompt is assembled dynamically from structured blocks, never written as a monolithic string.
┌─────────────────────────────────────────────┐
│ FINAL SYSTEM PROMPT │
├─────────────────────────────────────────────┤
│ 1. Base System Prompt (always included) │
│ 2. Coach Persona Block (one of three) │
│ 3. Thinking Mode Block (one of five) │
│ 4. Output Schema Block (mandatory format) │
│ 5. Memory Block (tier-dependent) │
└─────────────────────────────────────────────┘Always included, defines global rules:
You are an AI thinking partner, not a chatbot.
GLOBAL RULES:
- No motivational language
- No emotional reassurance
- No emojis
- No generic advice
- Prefer short sentences and bullet points
- Always drive toward a concrete outcome
You must:
- Challenge assumptions
- Surface trade-offs
- End every session with a recommendation and next actions
Never end with:
- "Let me know if you want more"
- Open-ended invitations to continueOne persona block is injected per session:
# Backend: assembler.py
if persona == "editor":
blocks.append(EDITOR_PERSONA_BLOCK)
elif persona == "operator":
blocks.append(OPERATOR_PERSONA_BLOCK)
elif persona == "strategist":
blocks.append(STRATEGIST_PERSONA_BLOCK)One thinking mode block is injected per session:
if mode == "reviewIdea":
blocks.append(REVIEW_IDEA_MODE_BLOCK)
elif mode == "getUnstuck":
blocks.append(GET_UNSTUCK_MODE_BLOCK)
# ... etcAccess is validated before calling the LLM:
# Backend tier validation
if tier == "free":
if persona not in ["editor"]:
raise HTTPException(403, "Coach requires Unlimited")
if mode not in ["reviewIdea", "getUnstuck"]:
raise HTTPException(403, "Mode requires Unlimited"){
"messages": [
{"role": "user", "content": "...user input..."}
],
"persona": "editor",
"mode": "reviewIdea",
"tier": "free",
"user_context": null
}For Pro users with memory:
{
"messages": [...],
"persona": "strategist",
"mode": "makeDecision",
"tier": "pro",
"user_context": {
"goals": ["Launch MVP by Q2", "Validate pricing model"],
"constraints": ["Solo founder", "Limited runway"],
"principles": ["Speed over perfection"],
"past_insights": ["Previous pivot was too slow"]
}
}Every AI response must follow this structure:
{
"problem": "One-sentence problem statement",
"constraints": ["Constraint 1", "Constraint 2"],
"options": [
{
"name": "Option A",
"description": "...",
"pros": ["..."],
"cons": ["..."]
}
],
"recommendation": "Clear recommendation with rationale",
"next_actions": [
"Action 1",
"Action 2",
"Action 3"
],
"assumptions": "Explicit assumptions being made",
"confidence": "Medium - needs user validation"
}{
"id": "chat_abc123",
"provider": "gemini",
"model": "gemini-flash-lite-latest",
"content": "{...structured JSON output...}",
"usage": {
"prompt_tokens": 1250,
"completion_tokens": 890,
"total_tokens": 2140
},
"latency_ms": 2340
}The iOS app parses the structured JSON response:
private func parseJSONOutput(_ content: String) -> SessionOutput? {
// Extract JSON from response
guard let data = jsonString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return nil }
var output = SessionOutput(rawContent: content)
output.problem = json["problem"] as? String ?? ""
output.recommendation = json["recommendation"] as? String ?? ""
// ... parse other fields
return output
}A response is valid if it includes all schema sections.
A session is successful if:
A session fails if:
// SubscriptionService.swift
private let apiKey = "appl_xxxxxxxxxxxxxxxxxxxxx" // RevenueCat public key
private let unlimitedEntitlementId = "unlimited"RevenueCat is initialized on app launch:
func configure() {
Purchases.configure(withAPIKey: apiKey)
Task { await refreshStatus() }
}Single entitlement: unlimited
When active, unlocks all premium features:
// Checking subscription status
var isPro: Bool {
tier == .pro
}
private func updateTier(from customerInfo: CustomerInfo) {
if customerInfo.entitlements[unlimitedEntitlementId]?.isActive == true {
tier = .pro
} else {
tier = .free
}
}All gating uses the isPro property:
// Before starting a session
func startNewSession() {
if !subscriptionService.isPro && !profileStore.canStartSession(isPro: false) {
presentPaywall(.sessionLimitReached)
return
}
// ... continue
}
// Before selecting a Pro-only mode
func selectMode(_ mode: ThinkingMode) {
if mode.requiresPro && !subscriptionService.isPro {
presentPaywall(.modeLocked(mode))
return
}
// ... continue
}Paywalls are context-aware:
enum PaywallContext {
case sessionLimitReached
case modeLocked(ThinkingMode)
case coachLocked(CoachPersona)
case artifactLocked
case historyLocked
case exportLocked
case generic
}Each context triggers a tailored paywall message.
func purchase(package: Package) async throws {
let result = try await Purchases.shared.purchase(package: package)
updateTier(from: result.customerInfo)
}
func restorePurchases() async throws {
let customerInfo = try await Purchases.shared.restorePurchases()
updateTier(from: customerInfo)
}In-app subscription management uses RevenueCat's CustomerCenterView:
// SettingsStageView.swift
@State private var showingCustomerCenter = false
Button("Manage Subscription") {
showingCustomerCenter = true
}
.sheet(isPresented: $showingCustomerCenter) {
CustomerCenterView()
}Free users are limited to 7 sessions per month:
// ProfileStore.swift
func canStartSession(isPro: Bool) -> Bool {
if isPro { return true }
resetSessionCountIfNewMonth()
return profile.monthlySessionCount < FreeTierLimits.monthlySessionLimit
}Session count is incremented only when output is generated (not on mode selection):
// AppRouter.swift - setOutput()
let isFirstOutput = session.output == nil
if isFirstOutput && !subscriptionService.isPro {
profileStore.incrementSessionCount()
}This prevents counting abandoned sessions.
After each session, the backend extracts insights:
// AppRouter.swift
if isFirstOutput && subscriptionService.isPro {
Task {
await extractProfileInsights(from: session, output: output)
}
}Extracted data includes:
Pro users get context injected into each session:
// ThinkingOutputView.swift
let userContext: UserContextPayload? = subscriptionService.isPro
? UserContextPayload.from(profileStore.profile)
: nil
let response = try await APIClient.shared.chat(
messages: [...],
userContext: userContext // Injected for Pro
)The backend assembles different memory blocks based on tier:
Free tier:
MEMORY RULE:
You have no long-term memory.
Treat this session as stateless.
Do not reference past conversations.Pro tier:
USER CONTEXT:
- Current goals: [...]
- Constraints: [...]
- Principles: [...]
RELEVANT PAST INSIGHTS:
- [insight 1]
- [insight 2]
Use this context to improve reasoning and consistency.Unlimited users can export artifacts directly to their Notion workspace. This keeps decisions connected to existing workflows without copy-pasting.
The integration uses Notion's OAuth 2.0 flow:
ASWebAuthenticationSession/v1/notion/oauth-redirect)iOS App Backend Notion
│ │ │
│──── Open OAuth URL ──────┼────────────────────────────▶
│ │ │
│◀───────────────────── Redirect with code ─────────────│
│ │ │
│ │◀─── Exchange code ─────────│
│ │──── Return token ──────────▶
│ │ │
│◀─── Redirect to app ─────│ │
│ (editor://notion-callback?token=xxx) │
│ │ │
└── Store token in Keychain │When exporting, the app creates a Notion page with rich blocks:
// NotionService.swift
func saveArtifact(_ artifact: Artifact) async throws {
let blocks = buildBlocks(for: artifact)
try await createPage(title: artifact.title, blocks: blocks)
}Block types used:
| Layer | Testing | Framework |
|---|---|---|
| Backend | Automated test suite | pytest |
| iOS | Automated test suite | Swift Testing |
Backend tests cover:
iOS tests cover:
| Component | Environment |
|---|---|
| Backend | VPS (Docker + Caddy) |
| iOS | App Store (TestFlight) |
| RevenueCat | Production dashboard |
Backend URL: https://api.aicoach.framara.net
Editor is built with:
The architecture enforces the product philosophy:
RevenueCat is the single source of truth for entitlements, enabling clean separation between free and unlimited experiences.
© 2026 Editor. All rights reserved.