All docs/Hello

docs/architecture/hello-app-architecture.md

Last verified: 2026-03-03 Target: apps/hello Companion: packages/app-hello

Hello App Architecture

Overview

The Hello app is a self-service onboarding wizard that guides users through creating a video testimonial flow. It extracts organization data from websites using LLM, generates interview questions, and allows customization before publishing.

Tech Stack:

  • Next.js 15 (App Router, Server Components, Server Actions)
  • Zustand (DraftStore for state management)
  • Redis (OnboardingDraft persistence)
  • OpenRouter (LLM provider for extraction, generation, translation)
  • @repo/organization-extraction (website scraping + LLM enrichment pipeline)

Integration Map

High-Level Flow

Root page decision tree (apps/hello/app/page.tsx):

  1. ?demo query param → redirect /start?demo=1 (skips welcome-back, draft resume, dev dialog)
  2. Anonymous user → redirect /start
  3. Authenticated, has orgs → render WelcomeBackClient (go to dashboard or create new)
  4. Authenticated, no orgs → redirect /start

Onboarding Steps

StepURLPurposeLLM Interaction
Website/d/[draftId]/websiteCollect website URLTriggers background extraction
Objective/d/[draftId]/objectiveSelect testimonial typeNone
Company/d/[draftId]/plan-interviewReview/edit extracted dataWait for extraction
Studio/d/[draftId]/studioCustomize the flowOptional question generation
Publish/d/[draftId]/publishPublish and shareNone (converts draft to flow)

Data Model

OnboardingDraft (Redis)

The OnboardingDraft is the single source of truth for all onboarding data:

interface OnboardingDraft {
  // Metadata
  id: string;
  currentStep: DraftStep;
  createdAt: number;
  updatedAt: number;
  
  // Organization data (from LLM extraction)
  org: {
    source: "extracted" | "existing";
    profile: DraftOrgProfile | null;
    logoUrl: string | null;
    availableUsps: string[];
    extractionStatus: "pending" | "in_progress" | "completed" | "failed";
    extractionError?: string;
  };
  
  // User inputs (kickoff context)
  kickoff: {
    website: string;
    objective: string;
    selectedUsps: string[];
    targetAudience: string;
    customNotes: string;
  };
  
  // Flow configuration
  flow: {
    name: string;
    questions: DraftQuestion[];
    rewards: DraftReward[];
    rewardsEnabled: boolean;
    branding: DraftBranding | null;
    supportedLanguages: Language[];
  };
  
  // AI-generated/translatable content
  generated: {
    welcomeTitle: Record<Language, string>;
    welcomeSubtitle: Record<Language, string>;
    tipsTitle: Record<Language, string>;
    tipsDescriptions: Array<Record<Language, string>>;
    rewardsTitle: Record<Language, string>;
    confirmTitle: Record<Language, string>;
    // ...
  };
  
  // Publish state
  publish: {
    flowId: string | null;
    flowUrl: string | null;
    publishedAt: number | null;
  };
}

Complete Data Flow Diagram

LLM Interaction Flows

1. Organization Extraction

Triggered when user submits website URL. Runs in background via waitUntil().

2. Question Generation

Triggered when user clicks "Generate Questions" in Studio.

3. Translation (Add Language)

Triggered when user adds a new language in Studio. The translation pipeline extracts all translatable content, sends it to an LLM for translation, and applies the results back to the DraftStore.

For the detailed translation data flow, component responsibilities, known structural problems, and target architecture, see Interview Editor Architecture — Translation Flow.

4. Publish Flow

Converts draft to published FlowConfig.

Generation Snapshots (Analytics)

Generation Snapshots capture what the backend AI/LLM generates for analytics and training purposes. They track the original AI output separately from user-edited draft data.

Purpose

  • Analytics/Training: Compare AI-generated content vs. user edits
  • Not for restoration: Snapshots are never used to restore content
  • Non-blocking: Snapshot failures don't affect main user flows
  • Encapsulated: Snapshot storage is hidden inside LLM wrapper functions (callers don't need to know about snapshots)

Data Model

interface GenerationSnapshot {
  draftId: string;
  organizationExtraction?: OrganizationExtractionSnapshot;  // Single (overwrites)
  questionGenerationHistory: QuestionGenerationSnapshot[];  // Array (appends)
  translationHistory?: TranslationSnapshot[];               // Array (appends)
  createdAt: number;
  updatedAt: number;
}

interface OrganizationExtractionSnapshot {
  profile: ExtractedOrganizationProfile;  // name, description, website, USPs, etc.
  logoUrl: string | null;
  capturedAt: number;
  metrics?: { durationMs?: number; costUsd?: number };
}

interface QuestionGenerationSnapshot {
  type: "initial" | "regenerate" | "generate_more";
  questions: GeneratedQuestion[];
  welcomeTitle?: string;
  welcomeSubtitle?: string;
  generationInput: QuestionGenerationSnapshotInput;
  capturedAt: number;
  metrics?: { durationMs?: number; costUsd?: number; modelId?: string };
}

interface TranslationSnapshot {
  sourceLanguage: Language;
  targetLanguage: Language;
  input: TranslationContent;
  output: TranslationContent;
  capturedAt: number;
  metrics?: { durationMs?: number; costUsd?: number; modelId?: string };
}

Storage: Redis KV with key pattern hello:generation-snapshot:{draftId} (same Redis as drafts via PROCESSED_VIDEO_DATA_KV_URL)

Architecture

Snapshot storage is encapsulated inside the LLM wrapper functions in packages/app-hello/src/server/. The caller passes an optional snapshotRegistry dependency, and the wrapper handles storage as a non-blocking side effect.

LLM Wrapper Functions

The wrapper functions encapsulate LLM calls + snapshot storage as a single operation:

Wrapper FunctionLocationSnapshot Stored
generateQuestionsWithSnapshot()packages/app-hello/src/server/generate-questions.tsQuestion generation
translateContentWithSnapshot()packages/app-hello/src/server/translate-content.tsTranslation
extractOrganization()packages/app-hello/src/server/extract-organization.tsOrganization extraction (via optional snapshotRegistry + draftId params)

Dependency Injection Pattern: The snapshotRegistry is passed as an optional dependency. If not provided, snapshot storage is skipped (useful for tests or dry runs).

// Example: Question generation with snapshot
const result = await generateQuestionsWithSnapshot(input, {
  model,
  modelId: "moonshotai/kimi-k2",
  logger,
  draftId,
  type: "initial",
  snapshotRegistry,  // Optional - skip snapshot if not provided
});

Read Operations (Admin Only)

Snapshots are read only in the admin app for debugging and analytics:

  • Admin page: apps/admin/app/hello-drafts/[draftId]/page.tsx
  • Component: GenerationSnapshotsSection displays:
    • Organization extraction details
    • Question generation history (all events)
    • Translation history (all events)
    • Full JSON view

Note: Mock mode does not store snapshots (only real LLM operations).

File Organization

FilePurpose
packages/app-hello/src/server/generate-questions.tsgenerateQuestionsWithSnapshot() - wraps LLM + stores snapshot
packages/app-hello/src/server/translate-content.tstranslateContentWithSnapshot() - wraps translation + stores snapshot
packages/app-hello/src/server/extract-organization.tsextractOrganization() with optional snapshot params
packages/app-hello/src/server/snapshot-mapping.tsMapping functions that transform LLM results to snapshot format
packages/registries/src/server/generation-snapshot-registry.tsRegistry implementation (extends BaseStorage)
packages/registries/src/server/generation-snapshot-types.tsType definitions and Zod schemas
apps/admin/app/hello-drafts/[draftId]/page.tsxAdmin UI for viewing snapshots

Frontend Architecture

Component Hierarchy

apps/hello/
├── app/
│   ├── d/[draftId]/
│   │   ├── layout.tsx              # Draft layout with progress bar
│   │   ├── website/page.tsx        # Server component - loads draft
│   │   ├── objective/page.tsx      # Server component - loads draft
│   │   ├── plan-interview/page.tsx # Server component - loads draft
│   │   ├── studio/page.tsx         # Server component - loads draft
│   │   └── publish/page.tsx        # Server component - loads draft
├── components/steps/
│   ├── WebsiteStepForm.tsx         # Client form - uses DraftStore
│   ├── ObjectiveStepForm.tsx       # Client form - uses DraftStore
│   ├── CompanyStepForm.tsx         # Client form - uses DraftStore
│   ├── StudioStepForm.tsx          # Client form - uses DraftStore
│   └── ...
└── hooks/
    ├── useDraftPersistence.ts      # Debounced save to backend
    ├── useQuestionGeneration.ts    # LLM question generation
    └── useDraftExtractionPolling.ts # Client-side extraction polling

State Management Pattern

Synchronous Store Initialization

Critical pattern to prevent blank preview on first render:

// In StudioStepForm.tsx
function StudioStepForm({ initialDraft, draftId }) {
  // Initialize SYNCHRONOUSLY during first render
  useState(() => {
    const store = useDraftStore.getState();
    if (store.draftId !== draftId) {
      store.initialize(initialDraft, draftId);
    }
  });
  
  // Now safe to read from store
  const draft = useDraftStore((s) => s.draft);
  const flowConfigForPreview = useDraftFlowConfigForPreview();
  
  // ...
}

Reactive Update Pattern (Zustand Subscriptions)

When server actions return data (e.g., translations, generated questions), FlowEditClient applies changes to the DraftStore directly. Zustand's subscription mechanism automatically triggers re-renders for all components using that data.

No useEffect needed for reactive updates - this is pure subscription-based reactivity:

Key hooks and their subscriptions:

HookSubscribes ToRe-renders When
useDraftQuestions()draft.flow.questionsQuestions change
useDraftWelcome()draft.generated.welcomeTitle/SubtitleWelcome text changes
useDraftTips()draft.generated.tipsTitle/DescriptionsTips change
useDraftRewards()draft.flow.rewards, draft.generated.rewardsTitleRewards change
useDraftFlowConfig()draft (derived)Any draft change
useDraftDesign()draft.flow.brandingDesign settings change

This pattern means:

  1. FlowEditClient applies server action results to the store directly — no data returned to callers
  2. Components re-render automatically via Zustand subscriptions
  3. No manual useEffect synchronization needed
  4. UI updates are instant and consistent
  5. All data manipulation (translation mapping, question extraction, background image operations) happens through store methods or registry utilities — never inline in components

Debounced Persistence Layer

The useDraftPersistence hook handles automatic saving of store changes to the backend:

Fatal errors that stop retries:

  • "Draft not found" - Draft expired or deleted from Redis
  • "Unauthorized" - Auth cookie invalid or expired
  • "Draft validation failed: ..." - Invalid draft data (Zod validation failure)

Key behaviors:

  1. Changes accumulate in pendingUpdate during debounce window
  2. Single save call with all accumulated changes
  3. Draft data is validated before saving (prevents writing unreadable data)
  4. Generation-aware markClean: captures _saveGeneration before save, skips clearing if the store was updated during the save (prevents race conditions)
  5. Fatal errors stop all future save attempts for this draft
  6. Transient errors show toast but allow future saves

Translation Detection and Pre-Publish Check

The system detects missing translations and prompts users before publishing. Key components:

  • GlobalLanguageSwitcher — Shows warning badge on language dots, displays "Translate missing content" button
  • TranslationMissingDialog — Pre-publish prompt offering auto-translate or manual review
  • translateMissingContentAction — Server action that translates only missing content (not re-translating existing)

All translation detection derives from the TRANSLATABLE_FIELDS registry — see Interview Editor Architecture — Translation Pipeline for the full pipeline, including how buildFullLanguageTranslationUpdate applies translations atomically to the store.

Server Actions

All server actions are defined in apps/hello/app/actions/. They use HMAC-signed cookies for draft authorization.

Draft CRUD Actions

Source: draft-actions.ts

ActionPurpose
createDraftActionCreate new draft, set auth cookie
createDraftAndRedirectCreate draft and redirect to first step
getDraftActionLoad draft by ID
getValidatedDraftActionLoad draft with auth validation
saveDraftActionGeneric draft update (merges partial update)
updateDraftStepActionUpdate current step marker
deleteDraftActionDelete draft and clear cookie
clearDraftCookieActionClear auth cookie only

Step-Specific Save Actions

Source: draft-actions.ts

ActionStepPurpose
saveWebsiteStepActionWebsiteSave URL, trigger extraction
saveObjectiveStepActionObjectiveSave objective type
saveCompanyStepActionCompanySave org profile edits
saveDesignStepActionStudioSave branding/design
saveTipsActionStudioSave tips content
saveWelcomeActionStudioSave welcome screen content
saveConfirmActionStudioSave confirm screen content
saveQuestionsActionStudioSave question edits
saveRewardsActionStudioSave rewards configuration
saveSettingsActionStudioSave flow settings

Publish Actions

Source: draft-actions.ts, publish-flow.ts

ActionPurpose
publishDraftActionConvert draft to FlowConfig, publish to user's organization
markDraftPublishedActionMark draft as published (internal)
publishFlowActionLow-level flow publish with org selection/creation

Publish Flow Logic:

  • If user has 0 organizations → auto-creates organization from draft data, then publishes
  • If user has 1 organization → publishes directly to that organization
  • If user has multiple organizations → returns pendingOrgs for client to show OrgPickerForm

LLM/AI Actions

Source: question-actions.ts, background-extraction.ts, translate-content.ts

ActionPurposeLLM Model
runExtractionExtract org from websiteGemini
waitForExtractionCompletePoll until extraction done-
generateQuestionsActionGenerate interview questionsKimi-K2
generateMoreQuestionsActionAppend additional questionsKimi-K2
addLanguageActionAdd language + translate all contentGPT-4o-mini
removeLanguageActionRemove language from draft-
addTipActionAdd tip + translate to all languagesGPT-4o-mini
addCustomFieldActionAdd custom field + translateGPT-4o-mini
translateContentActionTranslate content batchGPT-4o-mini

Asset Actions

Source: asset-actions.ts, unsplash.ts, process-image.ts

ActionPurpose
generateImageActionAI image generation (logo/background/reward)
saveUploadedAssetActionProcess and save uploaded image
removeAssetActionRemove asset from draft
reorderBackgroundsActionReorder background images
selectUnsplashForDraftActionSelect Unsplash photo, download, save to draft
searchUnsplashActionSearch Unsplash photos
selectUnsplashPhotoActionDownload Unsplash photo to blob storage
processImageActionResize/optimize image

Start Actions

Source: start-actions.ts

ActionPurpose
createDraftWithWebsiteActionCreate draft with pre-filled website URL
createDraftForExistingOrgActionCreate draft for existing org (authenticated)

API Routes

API routes exist primarily for external access, client-side operations requiring REST endpoints, and health monitoring. Most UI interactions use server actions directly.

RouteMethodPurposeUsed By
/api/blob/uploadPOSTUpload files to blob storageClient uploads
/api/correlationGETGet/set correlation IDAnalytics
/api/create-draftGETCreate draft + redirect to first stepStartStepForm (client-side redirect)
/api/draft-extraction-statusGETPoll extraction statusClient polling
/api/extract-organizationPOSTTrigger org extractionExternal API
/api/generate-imagePOSTAI image generationExternal API
/api/generate-questionsPOSTGenerate questionsExternal API
/api/infoGETApp info/health checkMonitoring

File Organization

apps/hello/
├── app/
│   ├── _providers/          # Server/client providers
│   ├── actions/             # Server actions (call LLM wrappers)
│   │   ├── draft-actions.ts        # CRUD for drafts
│   │   ├── background-extraction.ts # Calls extractOrganization with snapshot
│   │   ├── question-actions.ts     # Calls generateQuestionsWithSnapshot, translateContentWithSnapshot
│   │   ├── translate-content.ts    # Translation utilities
│   │   └── publish-flow.ts         # Publish to flow registry
│   ├── api/                 # API routes
│   └── d/[draftId]/         # Onboarding step pages
├── components/steps/        # Step form components
├── hooks/                   # Custom hooks
│   ├── useDraftPersistence.ts
│   ├── useQuestionGeneration.ts
│   └── useDraftExtractionPolling.ts
└── lib/
    ├── env.ts               # Environment config
    ├── logger.ts            # Logging singleton
    └── build-flow-config.ts # Draft → FlowConfig builder

packages/app-hello/src/
├── server/                  # LLM wrappers with encapsulated snapshot storage
│   ├── index.ts                    # Server exports
│   ├── extract-organization.ts     # Org extraction + optional snapshot
│   ├── generate-questions.ts       # Question generation + snapshot
│   ├── translate-content.ts        # Translation + optional snapshot
│   └── snapshot-mapping.ts         # Mapping functions for snapshots
├── web/
│   ├── stores/
│   │   ├── draft-store.ts          # DraftStore (single source of truth for all data)
│   │   ├── draft-hooks.ts          # Convenience hooks
│   │   └── build-flow-config-from-draft.ts
│   ├── utils/
│   │   ├── translatable-field-registry.ts  # TRANSLATABLE_FIELDS registry + all translation functions
│   │   └── translationUtils.ts             # Backward-compatible re-exports
│   ├── hooks/
│   │   ├── use-auto-save.ts        # Generation-aware auto-save
│   │   └── use-flow-edit-handlers.ts # Shared wrapper for library/admin
│   └── components/
│       ├── FlowEditClient.tsx      # Translation + question generation orchestration
│       ├── FlowStudioEditor.tsx    # Store init, auto-save, UI delegation
│       ├── StudioStep.tsx          # Studio UI
│       ├── PlanInterviewStep.tsx   # Company step UI
│       └── config-tabs/            # Tab components (read from store via hooks)
└── types/
    └── draft.ts             # Re-exports from registries

packages/registries/src/server/
├── generation-snapshot-registry.ts  # Snapshot storage (extends BaseStorage)
└── generation-snapshot-types.ts     # Snapshot types and Zod schemas

Plan Interview Loading Flow

The plan-interview page uses Next.js Suspense streaming for optimal loading UX. Understanding the component hierarchy is critical to avoid layout jumps.

Key patterns:

  • Suspense fallback (loading.tsx) and content (CompanyStepForm) use the same wrapper (OnboardingScreen)
  • This ensures consistent layout during the streaming transition
  • The only visual change is the inner content (loading animation to form)

Component hierarchy:

FileWrapperPurpose
loading.tsxOnboardingScreenSuspense fallback - streams immediately
CompanyStepForm.tsxOnboardingScreenActual content - renders when data ready

Anti-pattern to avoid: Using different wrapper components (e.g., SplitScreenBuilder for loading, OnboardingScreen for content) causes jarring layout shifts when the async content loads.

Studio Page Loading Flow (Server-Side Initial Generation)

The studio page uses the same Suspense streaming pattern as plan-interview, with an additional step: it runs initial question generation server-side before handing off to the client component. This eliminates the client-side flash that previously occurred when generated data replaced empty/default data.

Key behaviors:

  • Initial generation is server-side only — eliminates the useEffect flash from the old client-side approach
  • Generation failures are non-fatal: logs a warning, passes draft as-is (user can regenerate from UI)
  • Guard: only generates if draft.flow.questions.length === 0 AND draft.org.profile exists
  • User-triggered regeneration and "generate more" stay client-side in StudioStepForm

File: apps/hello/app/d/[draftId]/studio/_components/StudioContent.tsx

Key Principles

  1. OnboardingDraft is Source of Truth

    • Store structure mirrors database exactly
    • No transformation layer between frontend and backend
  2. FlowConfig is Derived

    • Computed on-demand via buildFlowConfigFromDraft
    • Never stored in state
    • Used only for preview and publish
  3. Background LLM Operations

    • Extraction runs via waitUntil() for non-blocking UX
    • Client can poll or server can wait
  4. Debounced Persistence

    • Changes accumulate in pendingUpdate
    • Single saveDraftAction call after debounce
  5. Synchronous Store Initialization

    • Use useState() initializer, not useEffect
    • Prevents blank preview on first render
  6. Validate on Save, Not Read

    • Draft data is Zod-validated before writing to storage
    • Prevents saving data that can never be read back
    • Returns specific validation errors (not generic "Draft not found")
    • Relaxed validation during editing (e.g., empty questions allowed)
    • Strict validation at publish time via buildFlowConfigFromDraft

Anonymous User Flow (Deferred Publish)

Anonymous users can create and configure flows without logging in. Publishing is deferred until after authentication.

Flow Diagram

Key Design Decisions

  1. Deferred Publish: Flows are NOT published until the user is authenticated. The draft is saved to Redis, and the draftId in the URL is the only state needed to resume.

  2. No Intermediate Organization: Previously, flows were published to a "hello org" and then "claimed" by the user. Now, publishing goes directly to the user's organization.

  3. No Flow Cookies: The hc_pending_flow cookie and related claim logic have been removed. The draftId URL parameter is sufficient for state management.

  4. Automatic Org Creation: New signups without organizations get one created automatically using the company name and logo from the draft's organizationProfile.

  5. Multi-Org Selection: Users with multiple organizations see an org picker UI before publishing.

Authentication Redirect Handling

Different auth methods have different redirect mechanisms back to the publish page:

Auth MethodRedirect MechanismKey File
OAuth (Google, Microsoft)sso-callback/page.tsx sets signUpForceRedirectUrlapps/hello/app/d/[draftId]/publish/sso-callback/page.tsx
Email/PassworduseAuth() watcher in AuthGateForm detects isSignedIn and does window.location.hrefapps/hello/components/steps/AuthGateForm.tsx
Direct page load (already authenticated)[...rest]/page.tsx catch-all redirects to /d/[draftId]/publishapps/hello/app/d/[draftId]/publish/[...rest]/page.tsx

Why email/password needs special handling: Clerk Elements (SignUp.Root, SignIn.Root) with routing="path" manage sub-step navigation (e.g., /publish/continue, /publish/verifications). After sign-up completes, Clerk redirects to the global NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL rather than back to the component's path prop. Clerk Elements does not support a per-component fallbackRedirectUrl prop. The AuthGateForm intercepts this by watching useAuth().isSignedIn and performing a full page load to the publish page when auth completes.

Component Responsibilities

ComponentTypeRole
usePublishFlowClient HookChecks auth, either publishes directly or navigates to /publish
publishDraftActionServer ActionPublishes draft to specified org, handles org creation if needed
publishFlowActionServer ActionLow-level publish with org creation logic
PublishPage (page.tsx)Server ComponentChecks auth, determines org, publishes or shows picker/auth
AuthGateFormClient ComponentRenders OAuth buttons and email/password forms; watches isSignedIn for post-auth redirect
OrgPickerFormClient ComponentRenders org selection UI for multi-org users
SSOCallbackPageClient ComponentHandles OAuth callback, redirects back to publish
PublishSubStepPage ([...rest]/page.tsx)Server ComponentCatch-all for Clerk Elements sub-steps; redirects authenticated users to publish

Data Flow

Publish Return Types

The publishDraftAction returns one of three result types:

type PublishDraftResult =
  | { success: true; flowId: string; flowUrl: string; organizationId: string }   // Published successfully
  | { success: true; pendingOrgSelection: true; organizations: Array<{ id: string; name: string; imageUrl?: string }> }  // Multi-org selection needed
  | { success: false; error: string }                                              // Error

Note: All three branches have success: true | false. The multi-org case is distinguished by the presence of pendingOrgSelection: true. The publish success case includes organizationId so callers can setActive on the correct Clerk org.

Slack notifications on publish (fire-and-forget):

  • New signup (0 orgs → creates org): notified to #happyclient-new-signups-notifications
  • Existing user (1+ org, creates new flow): notified to the interview agent channel
  • Notifications fail silently via .catch() — publish success is not blocked

Key Files

FilePurpose
apps/hello/hooks/usePublishFlow.tsClient hook - checks auth, publishes or defers
apps/hello/app/actions/publish-flow.tsServer Action - publish flow with org creation
apps/hello/app/actions/draft-actions.tsServer Action - publishDraftAction wrapper with org resolution
apps/hello/app/d/[draftId]/publish/page.tsxServer Component - orchestrates auth/org selection/publish
apps/hello/components/steps/AuthGateForm.tsxClient Component - OAuth and email auth UI
apps/hello/components/steps/OrgPickerForm.tsxClient Component - multi-org selection UI
apps/hello/app/d/[draftId]/publish/sso-callback/page.tsxOAuth callback handler
apps/hello/app/d/[draftId]/publish/[...rest]/page.tsxCatch-all for Clerk Elements sub-steps (continue, verifications)
packages/app-hello/src/web/components/OrgPickerDemo.tsxDemo version of OrgPickerForm for demo app

Related Documentation