Meeting Prep Architecture

Full-stack data flow — Frontend, Xano, Nylas, Cloud Run, LLM

Last updated April 15, 2026

Contents
1. UI Walkthrough (Screenshots) 2. Overview & Current State 3. End-to-End System Flow 4. Calendar Events Pipeline (Backend) 5. classify-participants Algorithm 6. POST /meeting-prep (LLM Generation) 7. POST /chat — Meeting Mode 8. Frontend Components & State Machine 9. Database Tables (Complete Map) 10. Deep Research Pipeline (Cloud Run) 11. External Services 12. April 15 Fixes (Phase 1) 13. Context Persistence Pipeline (Phase 2 & 3) 14. E2E Testing — Full Flow Verified 15. E2E Bug Fixes (4 Issues Found & Fixed) 16. Remaining Work & Roadmap
UI Walkthrough

Step-by-Step User Flow

Here is exactly what the user sees and what happens at each step. These are live screenshots from Robert's account (Mark Pederson populated the contact and meeting data used here).

Step 1: Landing — Today's Meetings on Canvas

Three-panel layout: sidebar (left), canvas (center), context panel (right)

When you open Meeting Prep (/network?active-view=meeting-prep), the canvas shows today's meetings as rich cards. The sidebar shows all upcoming meetings grouped by day (Today, Tomorrow, Fri Apr 17, etc.). Each sidebar item shows the first participating company's logo. The right panel is empty until you select a meeting.

Step 2: Select a Meeting — See Classified Attendees

Mark/Robert Sync selected — canvas shows prep card, right panel shows classified attendees

Clicking a sidebar meeting shows a MeetingCard on the canvas with:

The right context panel shows: meeting title, date, time, duration, video call indicator, and the full classified attendee list with titles.

Step 3: Multi-Participant Meeting

HAPPY FRIDAY RETURNS — showing external participants Mario and Rene Castillo

Meetings with multiple external participants are classified into groups. Here, Mario and Rene Castillo show under the "PARTICIPANTS" section. The backend classify-participants function sorts these by company domain — same domain = grouped under one company logo.

Step 4: Meeting with Description

Jordan Cameron - Project Session — right panel shows meeting description

When a meeting has a description, it appears in the context panel below the attendees. Here: "Working session on mortgage CRM platform (Xano + Next.js)". This description is also included in the enriched prompt sent to the AI.

Step 5: Click "Prep this meeting" — CrayonChat Opens

After clicking "Prep this meeting" — CrayonChat loads with conversation starter

Clicking the green button flips prepStarted to true. The canvas switches from the MeetingCard to CrayonChat — a full SSE-streaming AI chat interface. The conversation starter button (Prep for "Block") contains a rich auto-generated prompt with all meeting context.

Step 6: AI Response — No In-Network Attendees

When no attendees are in the user's network, the AI asks for clarification

If no meeting participants are in the user's network (no master_person_id), the system falls back to POST /chat with mode=meeting. The AI asks "Who are you meeting with?" with interactive options: "I'll tell you the name", "Multiple people", "Not sure yet".

Step 7: In-Network Attendee — Meeting Goal Card

Mark Pederson is in-network — "Prep this meeting" triggers the meeting-prep endpoint

When a participant IS in the user's network (has a master_person_id), clicking prep sends POST /meeting-prep with that person's ID. If no user_goal is provided, the backend returns a MeetingGoalCard with 6 suggested goals:

  1. Close a deal or partnership
  2. Get an introduction to someone in their network
  3. Pitch my product or idea
  4. Build the relationship
  5. Follow up on a previous conversation
  6. Ask for advice or mentorship

After selecting a goal, the backend calls Claude Sonnet 4 with the full person context and returns a MeetingPrepCard with talking points, openers, landmines, and shared context.

Sidebar — Full Calendar View

Sidebar with meetings grouped by day — scrolled to show Tomorrow and Friday

Overview

What Meeting Prep Does

Meeting Prep analyzes calendar events, classifies attendees by company and relationship, and generates contextual talking points, openers, landmines, and shared context for each meeting. It spans 2 API groups, 4 core Xano functions, 15+ database tables, and 3 external services (Nylas, OpenRouter/Claude, Cloud Run).

Current State

DONE Frontend UI — PR #291 merged

Three-panel layout (sidebar + canvas + context panel), company logos, classified attendees, "Prep this meeting" gate, CrayonChat with SSE streaming, enriched auto-prompts. 1,085 lines in canvas component alone.

DONE classify_participants (Xano function #12742)

Server-side function that classifies participant emails into my_team, participating_companies, personal_participants using domain matching, email_provider table, and master_company logos. Runs per-event inside GET /calendar-events.

DONE Meeting Prep LLM (endpoint #8098)

POST /meeting-prep calls Claude Sonnet 4 via OpenRouter (temp 0.4, 2048 tokens). Gathers: person context (YAML), user memories, 14 days of calendar events, and user info. Returns structured JSON with talking points.

DONE Context persistence — meeting_prep_context table

Table created, 8 CRUD endpoints deployed, frontend wired. Context created on first "prep" click with nylas_event_id + desired_outcome. Revisits load instantly from persisted context.

DONE Deep research integration wired to meeting prep

Cloud Run deep research auto-triggers on prep click for each in-network attendee. Email threads fetched from Nylas. Prior meetings with same people surfaced automatically.

DONE Email thread integration (Nylas API)

POST /meeting-prep-context/email-threads fetches real Nylas threads for meeting participants. Uses safe |get field access for Nylas API compatibility.

DONE Prior meeting detection

POST /meeting-prep-context/prior-meetings finds past calendar events with the same participants. Configurable lookback window (default 90 days).

System Flow

End-to-End Data Flow

Google Calendar (user's events)
     |
     v
Nylas v3 API  (per grant_id, paginated, 200/page)
     |
     v
+------------------------------------------------------+
|  GET /calendar-events  (V2 Calendar Events, #4329)   |
|                                                       |
|  1. user table -> get working_timezone                |
|  2. calendar table -> get active calendars            |
|  3. utc-timestamps_v2 -> compute 14-day UTC range     |
|  4. FOR EACH calendar:                                |
|     +- fetch-calendar-events -> Nylas API (paginated) |
|  5. Lambda -> extract unique participant emails        |
|  6. Bulk query: master_email + master_person           |
|  7. Query my_person -> "New to Network" badges         |
|  8. nylas_grant -> email_account per grant_id          |
|  9. FOR EACH event:                                   |
|     +- classify-participants -> my_team / companies /  |
|        personal (email_provider, master_company)       |
|  10. Embed meeting_prep into each event                |
+------------------------+-----------------------------+
                         |
                         v
            Frontend: useCalendarEvents()
            +- Filters out past events
            +- Sorts by start_time
            +- Re-filters every 60 seconds
                         |
           +-------------+-------------+
           v                           v
     MeetingPrepList             MeetingPrepCanvas
     (sidebar, all days)         (today's events only)
           |                           |
           | click                     | "Prep this meeting"
           v                           v
     MeetingCard ----------------> setPrepStarted(true)
     (preview mode)                    |
                                       v
                              CrayonChat (SSE)
                                       |
                     +-----------------+
                     v                 v
          POST /meeting-prep    POST /chat (mode=meeting)
          (in-network person)   (no in-network person)
                     |
          +----------+-----------+
          v                      v
   No user_goal:          Has user_goal:
   -> MeetingGoalCard     -> get-person-context
   -> 6 suggested goals   -> build-memory-context
                          -> GET /calendar/events
                          -> Claude Sonnet 4 (OpenRouter)
                          -> MeetingPrepCard
Calendar Pipeline

GET /calendar-events — The Main Data Source

10-step processing chain: Google Calendar → Nylas → enrich → classify → Sidebar + Canvas

GET/calendar-events
The most complex endpoint in the system. Fetches all calendar events for 14 days ahead, enriches every participant with identity data, classifies participants into teams/companies, and embeds meeting_prep into each event.
V2 Calendar Events — LR2ywW7R — Endpoint #4329 — Auth required

Processing Chain (10 Steps)

1

Get User Timezone

Reads user.settings.working_timezone to convert local dates to UTC.

2

Get Active Calendars

Queries calendar table for non-holiday calendars where event_widget_preference = true.

3

Compute UTC Range

Calls utc-timestamps_v2 with time_range=14. Returns midnight today → midnight + 14 days in UTC.

4

Fetch Nylas Events

For each calendar, calls fetch-calendar-events which hits Nylas v3 API (GET /v3/grants/{grant_id}/events). Handles pagination with next_cursor, 200 events per page. Logs failures to log_crash.

5

Extract Unique Emails

Lambda collects all unique participant emails across all events into a Set. Used for batch DB lookups.

6

Bulk Identity Resolution

Single batch query: master_email + master_person join for all participant emails at once. Gets name, avatar, created_at for each person.

7

Network Badge Lookup

Queries my_person for the authenticated user to get created_at per contact. Used for "New to Network" (< 30 days) vs "In Network" badges.

8

Grant Email Mapping

Queries nylas_grant to map grant_idemail_account. Used to exclude the user's own email from participant lists.

9

Classify Participants

For each event, calls classify-participants (function #12742) with participant emails minus the user's own. Embeds result as event.meeting_prep.

10

Return Enriched Response

Clean event objects with: id, title, when (start/end), conferencing, participants (with email_data + my_person), and meeting_prep classification.

Tables read: user (125), calendar (223), master_email (155), master_person (139), my_person (128), nylas_grant (183), master_company (142), email_provider (175), environment_variables (272)
Classification

classify-participants Algorithm

Emails sorted into My Team (green), Participating Companies (purple), Personal Participants (amber)

mvp/meeting-prep/classify-participants
Xano function #12742. Inputs: user_email (email), participant_emails (email[]). Called once per event inside GET /calendar-events.

Algorithm

Pass 1 — Classify each email by domain:

  1. Extract domain from each participant email
  2. If domain matches user's domain → my_team (same company)
  3. Else, check email_provider table (gmail, yahoo, hotmail, etc.):
    • If personal domain → personal_participants
    • If corporate domain → add to companyDomains list for Pass 2
  4. For each classified person, resolve master_emailmaster_person to get name, avatar, title

Build my_team: Query master_company by user's domain for company name + logo. Fall back to logo.dev if not in DB.

Pass 2 — Build participating_companies:

  1. For each unique corporate domain, look up master_company by company_domain
  2. Collect all participant emails with that domain
  3. Resolve each to master_person via master_email
  4. Fall back to logo.dev for missing company logos: https://img.logo.dev/{domain}?token=pk_...

Output Structure

{
  "my_team": {
    "master_company_id": 10,
    "name": "Orbiter",
    "logo": "https://...",
    "members": [
      { "master_person_id": 12, "name": "Mark Pederson", "avatar": "...", "title": "CEO" }
    ]
  },
  "participating_companies": [
    {
      "master_company_id": 20, "name": "JP Morgan", "logo": "...",
      "members": [{ "master_person_id": 530, "name": "Susan", "title": "VP" }]
    }
  ],
  "personal_participants": [
    { "master_person_id": 540, "name": "Bob", "title": "Consultant" }
  ]
}
Key design insight (from Mark): Think of it like "plaintiffs vs defendants." my_team = your side. participating_companies = the other side(s). A meeting can have people from MULTIPLE external companies. Each company gets its own section with logo and members.
LLM Generation

POST /meeting-prep — The Intelligence Engine

Phase 1: ask for goal → Phase 2: gather context + Claude Sonnet 4 → MeetingPrepCard

POST/meeting-prep
Generates AI-powered meeting prep for a specific person. Two-phase: ask-first (get goal), then generate (full prep).
Robert API — Bd_dCiOz — Endpoint #8098 — Auth required

Inputs

ParameterTypeRequiredDescription
master_person_idintYesThe person to prep for
meeting_idintNoSpecific meeting (unused in current logic)
contexttextNoAdditional context from user
user_goaltextNoUser's desired outcome — triggers full generation

Phase 1: Ask-First (no user_goal)

If user_goal is empty, the endpoint returns immediately with suggested goals:

{
  "success": true,
  "needs_user_goal": true,
  "master_person_id": 520,
  "person": { "name": "Ray Deck", "title": "CEO", "avatar": "..." },
  "suggested_goals": [
    "Close a deal or partnership",
    "Get an introduction to someone in their network",
    "Pitch my product or idea",
    "Build the relationship",
    "Follow up on a previous conversation",
    "Ask for advice or mentorship"
  ]
}

Phase 2: Context Gathering (user_goal provided)

When a goal is provided, the endpoint gathers context from 4 sources:

  1. Person context — calls get-person-context (#12570): work history, education, skills, roles, location. Returns YAML string.
  2. User memories — calls build-memory-context (#12665): active memories from copilot_memory table, formatted as [USER MEMORY]...[/USER MEMORY] tags.
  3. Requesting user info — fetches the authenticated user's name/title from user table with master_person addon.
  4. Calendar events — creates a temp auth token and calls its own GET /calendar/events (#8125) for 14 days ahead, limited to 10 events.

Phase 3: LLM Call

ParameterValue
ProviderOpenRouter (https://openrouter.ai/api/v1/chat/completions)
Modelanthropic/claude-sonnet-4
Temperature0.4 (lower for factual output)
Max tokens2048
Timeout60 seconds
System prompt"You are a meeting prep AI. Output ONLY valid JSON."

Phase 4: Response

{
  "success": true,
  "master_person_id": 520,
  "person": { "name": "Ray Deck", "title": "CEO", "avatar": "..." },
  "prep": {
    "person_summary": "Ray is the CEO of StateChange, a Series A startup...",
    "talking_points": [
      {
        "topic": "Recent funding round",
        "opener": "I saw StateChange just closed their Series A...",
        "why_they_care": "They're scaling the team and looking for partnerships"
      }
    ],
    "suggested_openers": ["Congrats on the funding...", "How's the team scaling going?"],
    "listen_for": ["Signs of budget constraints", "Hiring challenges"],
    "landmines": ["Don't mention competitor X", "Avoid pricing discussions"]
  },
  "model": "anthropic/claude-sonnet-4"
}
Note: Response parsing uses a Lambda with regex fallback — strips markdown code fences, tries JSON.parse first, then regex extraction of {"person_summary"...}. On total failure, returns a static error object with empty arrays.
Meeting Chat

POST /chat — Meeting Mode Fallback

POST/chat
General copilot chat. When mode=meeting, uses a meeting-specific system prompt from copilot_config (ID: 4). Used when no in-network attendees exist, or for follow-up questions after initial prep.
Robert API — Bd_dCiOz — Endpoint #8064 — Auth required

Meeting Mode Differences

Frontend

React Component Map & State Machine

File Inventory

ComponentFileLinesPurpose
MeetingPrepCanvasmeeting-prep-canvas.tsx1,247Main orchestrator — MeetingCards + CrayonChat + context persistence + processMessage handler
MeetingPrepListmeeting-prep-list.tsx192Sidebar — events grouped by day, company logos, search integration
MeetingPrepContextPanelmeeting-prep-context-panel.tsx499Right panel — meeting details, classified attendees with click-to-profile
MeetingPrepSearchmeeting-prep-search.tsx33Search input with debounced filtering
MeetingGoalCardcrayon/meeting-goal-card.tsx~120CrayonChat template: goal selection with person avatar + suggested goals
MeetingPrepCardcrayon/meeting-prep-card.tsx~200CrayonChat template: full prep display with talking points, openers, landmines

Canvas State Machine (3 modes + context persistence)

No meeting selected         Meeting selected            Prep started
(prepStarted=false)         (prepStarted=false)         (prepStarted=true)
-----------------          -----------------           -----------------
Show today's meetings       Show single MeetingCard     Show CrayonChat
as rich cards               with classified attendees   with SSE streaming

     |                           |                          |
     | click sidebar item        | click "Prep this         | processMessage()
     +-------------------------->|  meeting" button         | handles:
                                 |                          | 1. getMeetingPrep()
                                 v                          | 2. [MEETING_GOAL] prefix
                          prepInitiatedRef = true           | 3. chat(mode=meeting)
                          createMeetingPrepContext()
                          triggerResearch() (fire & forget)
                          fetchEmailThreads() (fire & forget)
                          fetchPriorMeetings() (fire & forget)
                                 |
                                 +------------------------>|
                                                            |
On revisit: useEffect loads persisted context from
getMeetingPrepContext(eventId) — skips re-gathering

Enriched Prompt (auto-generated)

When CrayonChat opens, the conversation starter contains an auto-generated prompt built from the selected event:

  1. Help me prepare for my meeting "[Title]"
  2. Time + duration + relative time ("in 45 minutes")
  3. Attendees list with network status + master_person_ids
  4. Team members on call
  5. "Pull full context on [network attendees] from my network"
  6. Meeting description (first 500 chars)
  7. Location / video call URL
  8. Desired outcome (if user typed one before clicking "Prep this meeting") NEW
  9. "Give me talking points, key context, and anything I should be prepared for"

URL State

HookURL ParamPurpose
useSelectedMeeting()?meeting-id=<eventId>Currently selected meeting (resets prepStarted on change)
useMeetingPrepSearch()?meeting-prep-q=<query>Search filter for sidebar (uses useDeferredValue)

Key Logic

Network Badge Detection

participant.email_data.my_person.created_at determines badge: < 30 days = New to Network, ≥ 30 days = In Network.

Critical: Must use my_person.created_at (when THIS USER added the person to THEIR network), NOT master_person.created_at (when the record was created globally). This was a bug that was fixed.
Database

Complete Table Map

Calendar & Events

TableIDKey FieldsRole
calendar223user_id, external_id, grant_id, name, is_primary, event_widget_preferenceUser's connected calendars (Nylas OAuth)
event221title, external_id, start, end, calendar_id, master_event_id, event_dataCalendar events (local cache from Nylas)
master_event222external_id, calendar_id, title, recurrenceRecurring event templates
nylas_grant183user_id, grant_id, email_account, provider, grant_expiredNylas OAuth grants — maps grant_id to email

Person & Company Identity

TableIDKey FieldsRole
master_person139name, avatar, current_title, bio, bio_500, deep_bio, social_insights, linkedin_url, master_company_idMaster identity record (enriched)
master_company142company_name, logo, company_domain, about, about_500, tagline, linkedin_urlMaster company record (logo used in classify)
master_email155email_address, master_person_id, master_company_id, email_typeEmail → person mapping (bulk lookup in calendar-events)
my_person128user_id, master_person_id, created_at, last_activity_atUser's network — created_at drives "New to Network" badge
email_provider175provider_domainPersonal email domain list (gmail, yahoo, hotmail) for classify

Context & Intelligence

TableIDKey FieldsRole
work_experience147master_person_id, master_company_id, title, company_name, start/end yearWork history for person context prompt (YAML)
education_experience230master_person_id, school_name, field_of_study, degree_nameEducation for person context prompt
copilot_memory658user_id, scope, category, key, value, confidence, activeUser memories injected into LLM prompts
copilot_config541name, promptSystem prompts per mode (meeting = config ID 4)
profile_enrichment_job686job_id, master_person_id, request_type, model, processing, llm_response, errorDeep research job queue — Cloud Run writes here
deep_biography591master_person_id, biography, processingDeep bio storage (legacy)

Meeting Prep Context NEW

TableKey FieldsRole
meeting_prep_contextuser_id, nylas_event_id, desired_outcome, team_structure, participant_context, email_threads, prior_meetings, checklist_status, qa_historyPersistent prep context per event — created on first "prep" click, loaded instantly on revisit
meeting_transcriptionsuser_id, nylas_event_id, transcript, video_url, audio_url, markdown, summaryMeeting recordings & transcripts — standalone table, linked to events but separate from prep
Design decision: meeting_prep_context persists forever. Next visit loads from table instantly (no re-gathering). meeting_transcriptions is standalone — users transcribe ALL meetings, not just prepped ones. meeting_prep_context can reference transcriptions to auto-surface prior meeting notes when re-meeting the same people.

Copilot Chat

TableIDKey FieldsRole
copilot_conversations636user_id, mode, title, master_person_idConversation threads
copilot_chat_messages637conversation_id, role, content, template_nameIndividual messages

Supporting

TableIDKey FieldsRole
user125master_person_id, workos_id, settings (JSON with working_timezone)User accounts (WorkOS auth)
environment_variables272name, value, is_nylasRuntime config (Nylas API keys)
process633user_id, type, status, progress, current_stepAsync process tracking
log_crash542function_name, error_message, dataError logging
Deep Research

Cloud Run → Webhook Pipeline

Xano → Cloud Run (async) → Webhook → deep_bio + bio_100/300/500 + knowledge graph

Xano: deep-research-person-or-company (#12747)
  |
  +- Inputs: master_person_id OR master_company_id
  |          model, temperature, max_tokens, request_type
  |          system_prompt, user_prompt, provider_order
  |
  +- Fetches person/company record from DB
  +- Builds OpenRouter-compatible payload (Lambda)
  |   +- Perplexity models: collapse system+user into single message
  |
  v
POST https://orbiter-enrich-20506320032.us-east4.run.app/enrich
  |  Query params: master_person_id or master_company_id
  |  Body: { webhook_url, payload: { model, messages, max_tokens } }
  |  Auth: X-API-Key header ($env.deep_research_api_key)
  |
  +- Creates profile_enrichment_job record (job_id, request_type, model)
  |
  v  (async — minutes of processing on Cloud Run)
  |
Webhook: POST /enrich-profile (#8303, webhooks group)
  |
  +- On success:
  |  +- Update profile_enrichment_job: llm_response, processing=false
  |  +- Write deep_bio to master_person record
  |  +- Run create-llm-person-bios -> generates bio_100, bio_300, bio_500
  |  +- Run update-person-node -> sync knowledge graph
  |
  +- On error:
     +- Update profile_enrichment_job: error, processing=false
Now wired to meeting prep (April 15). triggerMeetingPrepResearch(contextId, personIds) fires on prep click for each in-network attendee. Runs as fire-and-forget — the frontend continues to CrayonChat immediately while deep research runs asynchronously in Cloud Run.
External Services

Integration Map

ServiceUsed ByPurposeAuth
Nylas v3 API
api.us.nylas.com
fetch-calendar-events, send-rsvp, email-threads Calendar event sync (paginated, 200/page) + email thread search for meeting context Bearer token from environment_variables
OpenRouter
openrouter.ai
/meeting-prep, /chat LLM routing to Claude Sonnet 4 Bearer token from $env.openRouter
Cloud Run
orbiter-enrich-*.run.app
deep-research function Async long-running LLM research X-API-Key from $env.deep_research_api_key
logo.dev classify-participants Fallback company logos by domain Token in URL: pk_Twvl42WzTX2-ySNbnZKlSA
April 15 Updates

Phase 1 Fixes — Frontend Only (No Backend Dependency)

FIXED Desired outcome input now wired to API

The MeetingCard's "What is the ideal outcome?" input was dead code — the focus state was captured but never passed to the API. Now:

  • onPrepMeeting callback accepts (eventId, desiredOutcome?)
  • Outcome stored in desiredOutcomeRef (persists across renders)
  • Passed to getMeetingPrep(personId, { userGoal }) in processMessage Branch B
  • Included in enrichedPrompt for CrayonChat conversation starter
  • Effect: If user types a goal, the MeetingGoalCard (Phase 1) is skipped — goes straight to full AI prep
FIXED Fake "Gathering context..." footer removed

The animated progress bar at the bottom of MeetingCards was purely cosmetic — no async context gathering was happening. Removed entirely. Will be re-added as a real progress indicator once meeting_prep_context table and deep research wiring ship.

Verified by Curl & Browser

TestMethodResult
POST /meeting-prep (no goal)curl via dev-tokenPASS Returns needs_user_goal: true + 6 goals
POST /meeting-prep (with goal)curl via dev-tokenPASS Returns full prep (talking points, openers, landmines)
MeetingCard UI → CrayonChat flowBrowser (WorkOS auth)PASS MeetingGoalCard + MeetingPrepCard render correctly
Build + Lintpnpm build && pnpm lintPASS 0 new warnings

Commit: 281900d on feat/meeting-prep-ui-overhaul

Context Pipeline

Context Persistence Pipeline (Phase 2 & 3)

Context persistence pipeline — gather once, persist forever, load instantly on revisit

The context persistence pipeline was built on April 15 — 8 Xano endpoints, 2 new tables, and full frontend integration. When a user clicks "Prep this meeting", the system:

  1. Creates or loads a meeting_prep_context record (idempotent — POST returns existing if already created)
  2. Triggers deep research for each in-network attendee (fire-and-forget to Cloud Run)
  3. Fetches email threads from Nylas for participant emails (fire-and-forget)
  4. Finds prior meetings with the same participants in the last 90 days (fire-and-forget)
  5. Launches CrayonChat immediately — all background gathering runs in parallel

New API Endpoints (Robert API)

POST/meeting-prep-context/create
Create or load existing context for a meeting. Idempotent — returns created: false if context already exists for this event. Stores nylas_event_id, desired_outcome, team_structure, participant_context.
Robert API — Bd_dCiOz — Auth required
GET/meeting-prep-context
Load persisted context by nylas_event_id. Returns context: null if none exists (first visit). Used by useEffect on meeting selection to check for existing context.
Robert API — Bd_dCiOz — Auth required
POST/meeting-prep-context/update
Update context fields: qa_history, desired_outcome, checklist_status, email_threads, prior_meetings. Used after CrayonChat Q&A to persist conversation.
Robert API — Bd_dCiOz — Auth required
GET/meeting-prep-context/status
Poll checklist status + Q&A count for a context record. Returns checklist_status (JSON) and qa_count (int). Designed for real-time progress indicators.
Robert API — Bd_dCiOz — Auth required
POST/meeting-prep-context/trigger-research
Trigger deep research for attendees. Accepts context_id and person_ids[]. Calls deep_research_person_or_company for each person in a loop. Fire-and-forget from frontend.
Robert API — Bd_dCiOz — Auth required
POST/meeting-prep-context/email-threads
Fetch email threads from Nylas for participant emails. Uses safe |get:"field":default access pattern for Nylas API fields. Returns thread summaries (subject, snippet, message count, timestamp).
Robert API — Bd_dCiOz — Auth required
POST/meeting-prep-context/prior-meetings
Find past calendar events with the same participants. Configurable days_back (default 90). Uses add_secs_to_timestamp with days * 86400 for date math.
Robert API — Bd_dCiOz — Auth required
POST/meeting-prep-context/upload
Upload files into meeting prep context (e.g., RFPs, briefs). File is associated with the context record for LLM prompting. Preparation for Josh’s file upload feature request.
Robert API — Bd_dCiOz — Auth required

Meeting Transcriptions API

POST/meeting-transcriptions
Create a meeting transcription record. Fields: nylas_event_id, transcript, video_url, audio_url, markdown, summary.
Robert API — Bd_dCiOz — Auth required
GET/meeting-transcriptions
List transcriptions for a specific event by nylas_event_id. Returns array of transcription records.
Robert API — Bd_dCiOz — Auth required

Frontend Integration

The canvas component (meeting-prep-canvas.tsx, now 1,247 LOC) integrates the context pipeline with a ref-guarded state machine:

SOLVED useEffect Race Condition

The useEffect([selectedId]) was resetting prepStarted to false after onPrepMeeting set it to true in the same render cycle. Fixed with prepInitiatedRef — a ref guard that skips the reset when prep is being actively initiated:

const prepInitiatedRef = useRef(false);

// In onPrepMeeting handler:
prepInitiatedRef.current = true;   // Guard before state change
setSelectedId(id);                  // Triggers useEffect
setPrepStarted(true);               // Would be reset without guard

// In useEffect:
if (prepInitiatedRef.current) {
  prepInitiatedRef.current = false; // Consume the guard
  return;                           // Skip reset
}
setPrepStarted(false);              // Normal reset on navigation
Fire-and-Forget Pattern

Deep research, email threads, and prior meetings are triggered as .catch(console.warn) calls from the onPrepMeeting handler. The frontend launches CrayonChat immediately and doesn't wait for background gathering to complete. This keeps the UI responsive while context accumulates in the meeting_prep_context table.

XanoScript Fixes (Runtime-Discovered)

FIXED Email threads — safe field access

Nylas thread API returns fields that XanoScript couldn’t access via direct $thread.field syntax. Rewrote to use safe |get:"field":default pattern for all thread properties (id, subject, last_message_timestamp, snippet, message_ids).

FIXED Prior meetings — timestamp math

XanoScript has no add_days_to_timestamp filter. Fixed by converting to seconds (days * 86400) and using add_secs_to_timestamp with negated value: now|add_secs_to_timestamp:$neg_secs.

E2E Testing

Full Flow Verified — April 15, 2026

Complete end-to-end testing: curl verified all 11 backend endpoints, then browser-tested the full meeting prep flow with live WorkOS auth and real Xano data.

Step 1: Select Meeting & Type Desired Outcome

User types "Close the partnership deal" — this persists to meeting_prep_context.desired_outcome

Step 2: CrayonChat Launches with Context Saved

CrayonChat launches — context persisted in Xano, deep research + email threads + prior meetings triggered in background

Step 3: AI Generates Full Meeting Prep

Full AI prep — market expansion strategy, talking points, and "Copy Brief" button

Step 4: Landmines & Follow-up

LANDMINES section warns about pitfalls — actionable intelligence for the meeting

Step 5: Continued Conversation

Follow-up conversation — AI suggests personal connection topics, planning strategies, and conversation recovery

Backend Curl Test Results

EndpointMethodResultNotes
/meeting-prep-contextGETPASSReturns context: null for new events, full context for existing
/meeting-prep-context/createPOSTPASSIdempotent — created: true first time, created: false on repeat
/meeting-prep-context/updatePATCHPASSUpdates QA history + desired outcome
/meeting-prep-context/statusGETPASSReturns checklist + qa_count
/meeting-prep-context/trigger-researchPOSTPASSTriggers deep research for person IDs
/meeting-prep-context/email-threadsPOSTPASSFound 5 real Nylas threads (after XanoScript fix)
/meeting-prep-context/prior-meetingsPOSTPASSFound past meetings with Mark (after timestamp fix)
/meeting-prep-context/uploadPOSTPASSFile upload endpoint ready
/meeting-transcriptionsPOSTPASSTranscription created successfully
/meeting-transcriptionsGETPASSReturns transcription records
/meeting-prep (existing)POSTPASSFull prep generation still works

Commits: 1bd230f (context pipeline) + 4ed33e8 (race condition fix) on feat/meeting-prep-ui-overhaul

Roadmap

Remaining Work & Roadmap

Context Checklist (Gather Once, Persist)

#Context ItemSourceStatus
1Deep bio per attendeeCloud Run deep researchWIRED
2Social insights per attendeeEnrichment pipelinePLANNED
3Deep company research per companyCloud Run deep researchWIRED
4Recent email threads related to meetingNylas email APIDONE
5YouTube transcripts (if available)Web searchENDPOINT EXISTS
6Prior meeting transcript (same people)meeting_transcriptions tableDONE

Implementation Phases

DONE Phase 1: Frontend Fixes (Robert, April 15 AM)
  • Wire desired outcome input → getMeetingPrep API
  • Remove fake "Gathering context..." footer
DONE Phase 2: Backend Tables & Endpoints (Robert, April 15)
  • Created meeting_prep_context table in Xano
  • Created meeting_transcriptions table in Xano
  • Built 8 CRUD endpoints (create, get, update, status, trigger-research, email-threads, prior-meetings, upload)
  • Built 2 transcription endpoints (create, list)
  • Wired deep research trigger to prep flow
  • Fixed XanoScript issues: safe field access for Nylas, timestamp math
DONE Phase 3: Frontend Integration (Robert, April 15)
  • On "Prep" click: POST to create context record (idempotent)
  • On revisit: GET context → skip gathering (instant load)
  • Fire-and-forget: deep research + email threads + prior meetings
  • Fixed useEffect race condition with prepInitiatedRef
  • Build + lint clean, E2E verified in browser
NEXT Phase 4: UX Polish & Real-time Progress
  • Real "Gathering context..." checklist with live progress (poll /status endpoint)
  • Persist CrayonChat Q&A history to context table
  • YouTube transcript search integration
  • Social insights per attendee
  • Cost tracking per prep (model, tokens, latency)
LATER Phase 5: Advanced Integrations
  • Web search tools (Surper.dev for live context)
  • File upload UX in meeting prep (RFP drop — endpoint ready, UI needed)
  • Meeting transcription recording pipeline
BLOCKED Phase 6: Enterprise Features
  • Multi-tenant file management — blocked on multi-tenant user graphs
  • Document vector store (unstructure.io → Markdown → Qdrant)

Architecture Observations & Key Insights

Two calendar fetch paths exist. The V2 /calendar-events (#4329) uses fetch-calendar-events with full enrichment. The Robert API /calendar/events (#8125) uses the older nylas-calendar-get-filtered-events with no enrichment. The meeting-prep endpoint calls the simplified one internally for LLM context — these are independent calls.
classify-participants runs per-event. For a user with 50 events in 14 days, that's 50 function calls with multiple DB queries each. Potential performance concern for users with busy calendars.
XanoScript gotcha: |get:"field":default is mandatory for Nylas API responses. Direct property access ($thread.field) causes "Unable to locate var" errors when fields are missing. Always use the safe filter pattern for external API data.
XanoScript gotcha: add_days_to_timestamp doesn’t exist. Must use add_secs_to_timestamp with days * 86400. This is a common trap — the filter name suggests it should exist but it doesn’t.
React useEffect race condition pattern. When a handler sets state A then state B, and useEffect depends on A and resets B, use a ref guard (prepInitiatedRef) to skip the reset in the same render cycle.
April 15 Update — Context Injection & UX Fixes
Context injection pipeline visualization

What Changed

The meeting prep context pipeline was gathering data but never using it. Deep research bios, email threads, and prior meetings were stored in the meeting_prep_context table on Xano — but the LLM prompt never included them. Three fixes shipped today:

1. Context Injection into LLM Prompt

Before each getMeetingPrep() or chat() call, the system now re-fetches the latest context from Xano and builds a structured context string with three sections:

This context is passed via the existing context parameter — no API changes needed.

2. Auto-Send on “Prep this meeting”

Previously, clicking “Prep this meeting” opened CrayonChat and showed a starter chip that the user had to click again. Now the message fires automatically — one click, prep starts immediately.

Goal selection card auto-triggered

3. Informative Status Banner

The status banner was opaque — just “Gathering context...” then “Context ready” with no visibility into what was gathered. Now it shows specific items:

Before/after context gap visualization

4. Fire-and-Forget Fix

Three async calls (triggerMeetingPrepResearch, fetchMeetingPrepEmailThreads, fetchPriorMeetings) were discarding their results with .catch(console.warn). Now they re-fetch the context after completing, so subsequent LLM calls have the latest data.

Backend Verification — 8/8 Endpoints Pass
Status polling dashboard

All 8 meeting prep API endpoints curl-tested against live data (Mark/Robert Sync event):

EndpointStatusResult
GET /meeting-prep-context200Context ID 8, 3 QA entries found
POST /meeting-prep-context200Idempotent — returned existing context
GET /meeting-prep-context-status200Checklist + deep research job status
POST /trigger-meeting-prep-research2002 jobs triggered (Claude Sonnet 4)
POST /meeting-prep-email-threads20010 email threads found
POST /meeting-prep-prior-meetings20020 prior meetings in 90 days
POST /meeting-prep200Full prep generated with injected context
PATCH /meeting-prep-context200QA history + desired outcome updated
E2E Browser Test — Full Flow
Meeting card with Prep this meeting button
Step 1: Meeting selected — classified participants
Goal card auto-triggered with informative banner
Step 2: Goal card auto-triggered + informative banner
Bug: wall of unformatted text
Bug found: wall of text (before fix)
Final working prep card
After fix: structured MeetingPrepCard
E2E Bug Fixes
E2E bug fix visualization — holographic code diff in dark control room

4 Issues Found & Fixed During Browser Testing

Live E2E testing revealed four UX issues that were invisible during build-time verification. All four were found, fixed, and re-verified in a single session.

1. Auto-Send — Eliminate Double-Click Flow

Auto-send flow: one click triggers prep automatically
FIXED Double-click eliminated

Bug: Clicking “Prep this meeting” opened CrayonChat but only displayed a starter chip. The user had to click the chip again to trigger the actual prep — a confusing double-click UX.

Root cause: CrayonChat conversation starters are clickable chips that require explicit user interaction — they don’t auto-fire.

Fix: Added autoSentRef + a useEffect that polls for the CrayonChat starter button and auto-clicks it when prepStarted becomes true. Retry logic (30 attempts, 200ms apart) handles CrayonChat DOM mount timing.

2. Informative Status Banner

Meeting card before prep
Before: meeting card with attendees
Goal card with informative banner
After: banner shows “found 10 email threads”
FIXED Opaque status replaced with live counts

Bug: Status banner showed generic “Gathering context...” with no indication of what was actually being gathered.

Fix: Banner now reads prepContextRef.current and displays specific counts:

  • While gathering: “Gathering context — found 2 bios, 10 email threads...”
  • When ready: “Context ready — 2 bios, 10 emails, 3 prior Q&A”

3. needs_user_goal Value Check CRITICAL

in operator vs value check visualization
Wall of unformatted text bug
Before: wall of unformatted text
Properly formatted MeetingPrepCard
After: structured MeetingPrepCard
CRITICAL Template rendering bypassed

Bug: After selecting a goal, the prep response rendered as a wall of unformatted text instead of a structured meeting_prep_card template with sections and a Copy Brief button.

Root cause: The check !("needs_user_goal" in result) tested key existence, not the value. When the API returned {needs_user_goal: false, prep: {...}}, the in operator returned true (key exists), so the conditional evaluated to false — skipping the template rendering and falling through to general chat().

// BEFORE (broken — checks key existence):
if (!("needs_user_goal" in result)) { /* render meeting_prep_card */ }

// AFTER (correct — checks actual boolean value):
if (!(result as Record<string, unknown>).needs_user_goal) { /* render meeting_prep_card */ }

4. Clean Goal Message

FIXED Internal [MEETING_GOAL] prefix removed from UI

Bug: When a user selected “Build the relationship”, the chat bubble showed [MEETING_GOAL] Build the relationship — leaking an internal routing prefix.

Fix: Goal card sends clean text. Canvas uses meetingGoalPendingRef.current (ref-based state machine) for detection instead of prefix matching.

// BEFORE (meeting-goal-card.tsx):
message: `[MEETING_GOAL] ${goal}`

// AFTER:
message: goal

// Detection moved from prefix matching to ref-based state machine:
// if (prompt.startsWith(MEETING_GOAL_PREFIX))  →  if (meetingGoalPendingRef.current)
Takeaway: Build-time verification (TypeScript + lint) catches syntax errors but not runtime logic bugs. The in operator vs value check is a classic JavaScript gotcha that only surfaces during actual API interaction. E2E browser testing is essential for flows that depend on real API responses.