_private/qwestly-docs/Features/plan/unauthenticated-agent-onboarding-tech-design.md

Table of Contents

Unauthenticated Agent Onboarding — Tech Design

Status: Draft — decisions in section 11
Related: linkedin-and-resume-grader-tech-design.md (separate initiative, grader prompts feed into this flow)
Asana: "Integrate drafts & documents into qwestly-agent" (relevant for grader feature)


1. System Overview

graph TB subgraph Browser["Browser (questly.com/agent)"] ChatUI["Chat UI"] end subgraph PublicSite["public-site (Next.js 16)"] AgentPage["/agent page"] ProxyRoute["/agent/api/chat proxy"] end subgraph QwestlyAgent["qwestly-agent (FastAPI/Python)"] Orchestrator["Orchestrator"] SSE["SSE streaming"] PreAuthTools["Preauth tools"] end subgraph Candidate["candidate (Next.js 16)"] ProvisionAPI["Provision API (service-key only)"] ClaimFlow["Claim flow"] Auth0["Auth0"] Migration["Claim migration (25 collections)"] end subgraph MongoDB["MongoDB (candidate_portal)"] ProvisionedAccounts["provisioned_accounts"] AgentConversations["agent_conversations"] Candidates["candidates"] end ChatUI -->|"POST chat"| ProxyRoute ProxyRoute -->|"1. initiate provision"| ProvisionAPI ProxyRoute -->|"2. sign preauth JWT"| ProxyRoute ProxyRoute -->|"3. POST /api/chat (SSE)"| SSE ProxyRoute -->|"4. set httpOnly session cookie"| ChatUI ProvisionAPI --> ProvisionedAccounts SSE --> Orchestrator Orchestrator --> PreAuthTools PreAuthTools -->|"ingest LinkedIn"| Candidate PreAuthTools -->|"grade & edit profile"| Candidate PreAuthTools -->|"capture email"| ProvisionAPI ProvisionAPI -->|"send claim email"| ClaimFlow ClaimFlow -->|"create Auth0 user"| Auth0 ClaimFlow --> Migration Migration --> Candidates Migration --> AgentConversations PreAuthTools -.->|"X-API-Key (service auth)"| ProvisionAPI PreAuthTools -.->|"X-API-Key (service auth)"| Candidate

Key difference from existing agent flow: The existing flow (/agent on the candidate app) requires an Auth0 session → JWT → agent. The new flow has NO Auth0 session. Instead, the public-site creates an anonymous session, the candidate app provisions a bare account on first message, and the agent operates against that provisioned user_id.


2. Onboarding Flow: Then vs Now

The second meeting with Adam (19:03) redesigned the canonical onboarding flow to move ranking, upload, preferences, and interview into the chat UI as inline components, eliminating standalone onboarding screens.

flowchart LR subgraph Old["Old: Journey 2 (separate screens)"] direction LR O1["Rank: 6 value props, drag-and-drop"] --> O2["Upload: LinkedIn + resume, standalone screen"] O2 --> O3["Preferences: role-fit form, standalone screen"] O3 --> O4["Interview: Story, Wins, Skills, standalone chat"] O4 --> ODash["Dashboard"] end subgraph New["New: All inline chat (single ingress)"] direction LR N1["Agent greets with inline ranking widget (tap-to-rank)"] --> N2["Ask LinkedIn (live lookup, inline)"] N2 --> N3["Resume upload (plus-button widget, 5-8s ingestion)"] N3 --> N4["Preferences sprinkled contextually per value prop"] N4 --> NDash["Dashboard"] end style Old fill:#FFF4F4,stroke:#E57373 style New fill:#F0FFF4,stroke:#66BB6A

Key changes (Adam, 19:03):

Before After
Ranking = standalone drag-and-drop screen Inline tap-to-rank widget in chat (mobile-friendly)
Upload = dedicated upload page Plus-button in chat, asks at the right moment
Preferences = single upfront form Sprinkled contextually per value prop throughout the conversation
Interview = separate step after 3 screens Chat IS the interview — no artificial boundary
Multiple ingress points One ingress: agent chat

This redesign applies to the authenticated onboarding flow (post-claim). The pre-auth flow (section 6) is LinkedIn grading only — the inline widgets described here appear after the user has claimed their account and entered the full agent experience.


2. ProvisionedAccount Model Changes

New fields on the existing provisioned_accounts collection:

// candidate/src/types/provisioned-account.d.ts — add to IProvisionedAccount

source: "admin" | "user_initiated";          // how the provision was created
                                              // "admin" = existing recruiting flow
                                              // "user_initiated" = self-serve /agent flow
                                              // default: "admin" for backward compat

linkedin_username: string | null;            // captured from user during chat (optional)

agent_conversation_id: string | null;        // links to agent_conversations collection
                                              // so we can find/restore the conversation
                                              // after the user claims their account

claim_link_sent_at: Date | null;             // when the claim email was sent
                                              // separate from claim_invite_sent (admin flow)

Migration: One-time MongoDB migration to add source: "admin" to all existing provisioned accounts.


3. Candidate Model Changes

3.1 Signup Method Enum

Add "agent_self_serve" to the existing signup_method enum:

// candidate/src/types/candidate.d.ts
signup_method?: "waitlist" | "direct" | "provision" | "agent_self_serve";
// "agent_self_serve" = user-initiated provision via questly.com/agent

3.2 First-Login Banner Tracking

Add a field to track whether the "welcome preauthed user" banner has been shown:

// candidate/src/types/candidate.d.ts — add to ICandidate
has_seen_preauth_welcome: boolean;  // true after first dashboard visit for agent_self_serve users
                                     // default: false, set to true on first dashboard render

This field drives the temporary banner on the dashboard page. On first login after claiming, the dashboard renders a banner at the top: "Welcome! Your profile and LinkedIn grade are ready. Continue the conversation →" (the link goes to /agent). Setting has_seen_preauth_welcome: true prevents it from showing on subsequent visits. A user navigating away or logging back in won't see it again.

TODO: Update the dashboard banner component to check signup_method === "agent_self_serve" && !has_seen_preauth_welcome. Don't forget to implement the has_seen_preauth_welcome = true mutation on the first /api/user/dashboard data fetch or page render.

3.3 Profile Status on Claim

When an agent_self_serve user claims their account, set profile_status: "draft" (same as existing provision flow). The dashboard handles routing for draft profiles like it does for provisioned accounts. After they publish, profile_status transitions to "active" normally.


4. Public-Site: /agent Route

4.1 Location

public-site/src/app/(no-site-chrome)/agent/

Uses the (no-site-chrome) layout group (no header, no footer) — just the Qwestly logo + chat UI.

4.2 Layout

src/app/(no-site-chrome)/agent/
├── layout.tsx          # Sets <PublicHtmlShell> with agent-specific metadata
├── page.tsx            # Server component — renders AgentClient
├── client.tsx          # Full chat UI (borrowed + adapted from candidate app)
├── _components/
│   ├── agent-logo.tsx  # Qwestly logo (links to questly.com)
│   ├── message-bubble.tsx
│   ├── file-upload-widget.tsx  # Borrows from candidate /agent HITL flow
│   └── chat-markdown.tsx # Shared markdown renderer (from packages/ui/)
└── api/
    └── chat/
        └── route.ts    # POST /agent/api/chat — proxy to qwestly-agent

4.3 /agent/api/chat Proxy Route

// public-site/src/app/(no-site-chrome)/agent/api/chat/route.ts
//
// Proxies chat requests from the browser to qwestly-agent.
// Creates an anonymous session on first message, then uses the provisioned
// user_id for subsequent turns.

export async function POST(request: NextRequest) {
  const body = await request.json();
  const { message, session_id } = body;

  // 1. Get or create anonymous session
  let session = session_id 
    ? await getAnonymousSession(session_id)
    : await createAnonymousSession();

  // 2. Create a JWT using the provisioned user_id as the subject
  //    This is a special "preauth" JWT that tells qwestly-agent to use
  //    the preauth system prompt variant
  const jwt = await signPreAuthJwt({
    user_id: session.provisional_user_id,
    is_pre_auth: true,
    source: "user_initiated",
  });

  // 3. Forward to qwestly-agent
  const agentResponse = await fetch(`${AGENT_API_URL}/api/chat`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${jwt}`,
    },
    body: JSON.stringify({
      message,
      conversation_id: session.agent_conversation_id,
    }),
  });

  // 4. Stream SSE back to browser
  return new Response(agentResponse.body, {
    status: 200,
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      "Connection": "keep-alive",
      "X-Accel-Buffering": "no",
    },
  });
}

4.4 Anonymous Session Management

A new server-side utility in public-site/src/lib/anonymous-session.ts:

// Anonymous sessions are stored in the candidate app's MongoDB.
// The public-site has NO direct DB access — it calls candidate app APIs.

interface AnonymousSession {
  session_id: string;          // UUID v4, stored in httpOnly cookie
  provisional_user_id: string;  // = provisioned account _id.toString()
  agent_conversation_id: string | null;  // set after first agent turn
}

async function getOrCreateSession(cookieSessionId?: string): Promise<AnonymousSession> {
  // If cookie has valid session_id, call GET /api/provision/session/{session_id}
  // Otherwise, call POST /api/provision/initiate to create a new provision
  // Store session_id in httpOnly cookie
}

4.5 Chat UI Adaptation

The client component (client.tsx) is adapted from candidate/src/app/agent/client.tsx with these changes:

Area Existing (candidate) New (public-site)
Auth Cookie-based Auth0 session Anonymous session httpOnly cookie
Token exchange POST /api/agent/token POST /agent/api/chat directly (server-side JWT)
SSE reader directAgentFetch() with JWT fetch() to same-origin proxy route
Session sidebar All user sessions, pinning, deletion None (single session, no sidebar)
Memory extraction POST /api/agent/memories via sendBeacon Skipped (no memories for anon users)
File uploads HITL flow with request_file_upload Supported (resume PDF upload, same HITL pattern)
Email capture N/A Natural language + capture_email tool (early, skippable)
Claim link N/A Shown in agent text after email is captured

Shared components to extract from candidate → public-site:

  • ChatMarkdown → already in packages/ui/ at candidate/packages/ui/src/components/chat-markdown.tsx — use via @qwestly/ui
  • FileUploadWidget → copy from candidate/src/app/agent/_components/file-upload-widget.tsx + adapt
  • MessageBubble → adapt for unauthenticated context
  • HitlMessage → copy from candidate/src/app/agent/_components/hitl-message.tsx
  • SSE reader logic → reuse pattern from candidate/src/lib/agent-client.ts

4.6 Email Capture Flow

Email capture is a hard gate after the grade. The flow:

  1. Agent asks for LinkedIn username → ingests the profile → grades it
  2. Agent shows the grade with actionable detail (e.g. "you scored 70/100 — you don't have an About section, your headline is too vague, your experience lacks metrics")
  3. Agent says: "Want me to improve this? Enter your email and I'll send you the results plus a link to claim your account."
  4. User enters email → capture_email(email) called → claim token generated + email sent via SendGrid → agent continues to the improvement demo
  5. If user declines → they leave with the grade and actionable feedback only, no account created

The agent does not ask for email earlier in the flow. Grading is free for anyone. Conversion happens at the hard gate, before the improvement demo. Users who drop off without entering email are acceptable product risk (confirmed by Adam, 2026-06-09).


5. Candidate App: New Provision API Routes

5.1 New Public Endpoints

These are service-key-only — gated by X-API-Key: <QWESTLY_SERVICE_API_KEY>. They are NOT in UNAUTHENTICATED_APIS because the browser never hits them directly.

Route Method Purpose
/api/provision/initiate POST Create a bare provisioned account + return (provisional_user_id, session_id)
/api/provision/session/{session_id} GET Look up existing anonymous session
/api/provision/session/{session_id} PATCH Update session (linkedin_username, agent_conversation_id, email, name)
/api/provision/session/{session_id}/claim POST Generate claim token + send email

5.2 POST /api/provision/initiate

// Creates the minimum viable provisioned account on first chat message.
// Returns the provisional_user_id which becomes the "user_id" for all
// subsequent agent operations (LinkedIn fetch, grading, etc.)

// Request: { session_token?: string }  // UUID for the anonymous browser session
// Response: { provisional_user_id, session_id }

// What it creates:
// 1. ProvisionedAccount { name: "Anonymous User", email: null, source: "user_initiated" }
// 2. Candidate record { user_id: provisionedAccount._id, signup_method: "agent_self_serve", profile_status: "draft" }
// 3. Nothing else — no preferences, no KB entries, no stints. Those come later as the user chats.

5.3 PATCH /api/provision/session/{session_id}

// Updates the anonymous session with data captured during the chat.
// All fields optional — this is progressive enrichment.

// Body: {
//   linkedin_username?: string;
//   agent_conversation_id?: string;
//   name?: string;
//   email?: string;  // sets email on provisioned account
// }

5.4 POST /api/provision/session/{session_id}/claim

// Generates a claim JWT and sends the claim email.
// Requires the provisioned account to have an email set.

// Request: {}  (no body needed — email must already be set via PATCH)
// Response: { claim_url, email_sent: boolean }

5.5 Service Key Bypass

Add /api/provision/initiate, /api/provision/session to QWESTLY_SERVICE_API_ROUTES in candidate/src/config/server.ts.


6. qwestly-agent: Multi-Prompt Support

6.1 Approach: Prompt Variant Selection

Add a pre_auth field to ChatRequest:

class ChatRequest(BaseModel):
    message: str
    conversation_id: str | None = None
    user_id: str | None = None
    include_tool_results: bool = False
    upload_result: dict | None = None
    pre_auth: bool = False  # NEW — selects preauth system prompt variant

The orchestrator selects the prompt and tool set based on this flag:

# agents/orchestrator.py

PREAUTH_PROMPTS = {
    "pre_auth": PREAUTH_SYSTEM_PROMPT,
    "authenticated": ORCHESTRATOR_SYSTEM_PROMPT,
}

class QwestlyOrchestrator:
    def __init__(self, deps: AgentDeps, prompt_variant: str = "authenticated"):
        self.deps = deps
        self.prompt_variant = prompt_variant
        self.agent = self._build_agent()

    def _build_agent(self):
        prompt = PREAUTH_PROMPTS[self.prompt_variant]
        agent = Agent[AgentDeps](
            ORCHESTRATOR_MODEL,
            system_prompt=prompt,
            ...
        )
        if self.prompt_variant == "pre_auth":
            register_preauth_tools(agent)
        else:
            register_all_tools(agent)
        return agent

6.2 Preauth System Prompt

PREAUTH_SYSTEM_PROMPT = """\
You are Qwestly, an AI career agent. You help professionals evaluate and improve
their LinkedIn profiles and resumes. The person chatting with you has not yet
created an account — your goal is to demonstrate value quickly and guide them
to create an account when they're ready.

## Your Personality
- Warm, professional, and direct. You're here to be useful, not to sell.
- Concise. Get to the point. Show don't tell.
- No marketing fluff. The product should speak for itself.

## The Flow
1. **Introduce yourself briefly** — who you are, what you can do.
2. **Ask for their LinkedIn** — "Do you have a LinkedIn profile you'd like me to look at? I can grade it and show you exactly what's working and what needs improvement."
3. **Grade it** — run the LinkedIn grader. Show the composite score, per-criterion breakdown, evidence quotes, and actionable detail (e.g. "you don't have an About section", "your headline is too vague", "your experience lacks metrics"). Make them want to fix it.
4. **Hard gate — ask for email** — "Want to save this and keep going? Enter your email — I'll send you the grade and a link to claim your account. You can also sign in with Google right now to skip the email step."
   - If they enter email: call `capture_email(email)`, then wrap up
   - If they click Google sign-in: they create an account directly
   - If they decline: thank them, summarize, and let them go
5. **Wrap up** — "Check your email! Your grade and claim link are waiting. When you come back, I can help you improve every section and generate your full Qwestly profile."

## Available Tools
- `ingest_linkedin_profile(linkedin_username)`: Fetch LinkedIn profile data.
- `grade_linkedin_profile()`: Grade the profile on 3 criteria (0-10).
  Returns composite score + per-criterion breakdown + evidence + recommendations.
  Only call this after the profile has been ingested.
- `capture_email(email)`: Store the user's email, generate claim token, and send the claim email.
  Call this after showing the grade, before wrapping up.
  If the user declines, do not call this tool — just summarize and let them go.
- `request_file_upload(upload_type, label)`: Ask the user to upload a file (resume PDF).
- `query_qwestly_knowledge(query)`: Search Qwestly's knowledge base for company info.

## Rules
- **You grade LinkedIn profiles. That's it.** You do not generate improvements (About sections, experience rewrites, etc.) — those unlock after the user has an account.
- Never ask the user to "sign up" or "create an account" before showing the grade.
- The user sees you at questly.com/agent — a simple chat interface.
- If the user asks what Qwestly is, explain briefly and offer to show the grade.
- If the LinkedIn fetch fails, guide to alternatives (resume upload, PDF export).
- After email capture, wrap up cleanly. Don't start asking interview questions — that's post-auth.
- Keep responses under 3 paragraphs. Use bullet points for lists.
- Don't invent metrics. Source from the profile data.

## Available Tools
- `ingest_linkedin_profile(linkedin_username)`: Fetch LinkedIn profile data.
- `grade_linkedin_profile()`: Grade the profile on 3 criteria (0-10). 
  Returns composite score + per-criterion breakdown + recommendations + about callout.
  Only call this after the profile has been ingested.
- `generate_linkedin_about(profile_json, additional_instructions)`: Rewrite the About section.
  Returns JSON with `{section: "about", content: "..."}`.
- `generate_linkedin_experience(profile_json, role_ids?, additional_instructions?)`: 
  Grade and rewrite experience section roles.
- `capture_email(email)`: Store the user's email, generate claim token, and send the claim email.
  Call this after showing the grade, before generating improvements.
  If the user declines, do not call this tool — just summarize and let them go.
- `request_file_upload(upload_type, label)`: Ask the user to upload a file (resume PDF).
- `query_qwestly_knowledge(query)`: Search Qwestly's knowledge base for company info.

## Rules
- Never ask the user to "sign up" or "create an account" before showing value.
- The user sees you at questly.com/agent — a simple chat interface.
- If the user asks what Qwestly is, explain briefly and offer to show what you can do.
- If the LinkedIn fetch fails (invalid username, private profile), guide the user to alternatives (resume upload, PDF export from LinkedIn).
- Don't mention the claim process until the user has seen concrete value (grade + improvement).
- Keep responses under 3 paragraphs. Use bullet points for lists.
- Don't invent metrics. Source from the profile data.
"""

6.3 Preauth Tool Registration

The preauth flow uses a subset of tools — grading only, no generation:

New tools ONLY available in preauth mode:

  • grade_linkedin_profile — the LinkedIn analyzer (prompt 2 from grader docs)
  • capture_email — stores email + sends claim link

Shared with both modes:

  • ingest_linkedin_profile
  • query_qwestly_knowledge
  • list_capabilities
  • request_file_upload (for resume upload as alternative input)

Tools NOT available in preauth mode (require an account):

  • generate_linkedin_about — the about writer (prompt 5) — post-auth only
  • generate_linkedin_experience — the page editor (prompt 4) — post-auth only
  • All profile editing tools (contact, summaries, stints, education, achievements, challenges, preferences, company preferences)
  • generate_qwestly_card, edit_qwestly_card, show_qwestly_card
  • get_user_profile
  • search_user_memories
  • manage_qwestly_card_anonymity
  • save_card_as_active

6.4 capture_email Tool

# tools/capture_email.py

from pydantic_ai import Agent, RunContext
from lib.schemas import AgentDeps

def register(agent: Agent[AgentDeps]) -> None:
    @agent.tool
    async def capture_email(
        ctx: RunContext[AgentDeps],
        email: str,
    ) -> dict:
        """Capture the user's email and send a claim link.
        
        Call this after showing the grade, before generating improvements.
        The claim link is sent via SendGrid automatically.
        Only call this if the user explicitly provides their email.
        """
        ...

7. New Tools: LinkedIn Grader + Editor Prompts

7.1 grade_linkedin_profile — PRE-AUTH

Implements Prompt 2 (LinkedIn Analyzer) from the grader requirements. The tool:

  1. Takes the structured LinkedIn profile (ingested by ingest_linkedin_profile)
  2. Calls the LLM with the LinkedIn analyzer system prompt
  3. Returns structured JSON with per-criterion scores, evidence, recommendations, about callout, and activity note

7.2 generate_linkedin_about — POST-AUTH ONLY

Implements Prompt 5 (About Writer). Takes the profile JSON + additional_instructions (from the analyzer's about callout) and returns {section: "about", content: "..."}. Only available in authenticated mode.

7.3 generate_linkedin_experience — POST-AUTH ONLY

Implements Prompt 4 (LinkedIn Page Editor). Rewrites experience entries, headline, etc. Only available in authenticated mode.

7.4 Prompt Location

Decision: Inline prompts in the agent tool files for v1 MVP. The grader is the entire value prop — getting it right quickly matters more than architectural purity. Once validated, migrate to api-python prompt infrastructure.


8. Admin Page for User-Initiated Provisions

8.1 Location

candidate/src/app/admin/provision/user-initiated/ (or as a filter on the existing admin provision page)

8.2 What it shows

Column Source
Name provisioned_account.name (default: "Anonymous User" until captured)
Email provisioned_account.email
LinkedIn provisioned_account.linkedin_username
Status chatting (active session, no email yet) / claim_sent (email sent, not claimed) / claimed
Created provisioned_account.created_at
Conversation Link to agent conversation (if agent_conversation_id is set)

8.3 Features

  • Filter by status (chatting, claim_sent, claimed)
  • Delete provision (cascading, same as existing delete)
  • View conversation transcript (read-only, loaded from agent_conversations)
  • Manually send/re-send claim email

9. Data Flow: Full Sequence

sequenceDiagram actor User participant Browser as Browser (questly.com/agent) participant PublicSite as public-site proxy participant Agent as qwestly-agent participant Candidate as candidate API participant SendGrid as SendGrid participant Auth0 Note over User,Auth0: === Chat Flow (Unauthenticated) === User->>Browser: "Hi, what can you do?" Browser->>PublicSite: POST /agent/api/chat {message, session_id: null} PublicSite->>Candidate: POST /api/provision/initiate Candidate-->>PublicSite: {provisional_user_id, session_id} PublicSite->>PublicSite: Sign JWT {user_id, pre_auth:true} PublicSite->>Agent: POST /api/chat (SSE) Agent-->>Browser: SSE stream — agent introduction PublicSite-->>Browser: Set-Cookie: session_id (httpOnly) User->>Browser: "LinkedIn: john-doe-123" Browser->>PublicSite: POST /agent/api/chat PublicSite->>Candidate: PATCH /api/provision/session/X {linkedin_username} PublicSite->>Agent: POST /api/chat Agent->>Candidate: ingest_linkedin_profile("john-doe-123") [X-API-Key] Agent->>Candidate: grade_linkedin_profile() [X-API-Key] Agent-->>Browser: SSE — "Your profile scores 70/100..." (actionable detail) Agent-->>Browser: SSE — "Want to save this? Enter your email..." User->>Browser: "me@example.com" Browser->>PublicSite: POST /agent/api/chat Agent->>Candidate: capture_email("me@example.com") [X-API-Key] Candidate->>SendGrid: Send claim invitation email Agent-->>Browser: SSE — "Check your email! Your grade is saved." Note over User,Auth0: === Claim Flow (Authenticated) === User->>Candidate: Clicks claim link → /claim?token= Candidate->>Candidate: Validates JWT, detects source="user_initiated" Candidate-->>User: Custom claim page: grade summary + signup form User->>Candidate: Creates password (or Google OAuth) Candidate->>Auth0: Create Auth0 user Candidate->>Candidate: migrateClaimedAccount() — 25 collections Candidate->>Candidate: signup_method="agent_self_serve" Candidate->>Candidate: has_seen_preauth_welcome=false Candidate->>Candidate: mark claimed Candidate-->>User: Redirect to login → dashboard User->>Candidate: Dashboard (/) Candidate->>Candidate: Render welcome banner (one-time) Candidate-->>User: Dashboard with "Welcome preauthed user" banner

10. Shared Chat Components Extraction

Several components need to move from candidate/ → shared location so both apps can use them:

Component Current location Target location
ChatMarkdown candidate/packages/ui/src/components/chat-markdown.tsx Already in packages/ui/ — use via @qwestly/ui
SSE reader utility candidate/src/lib/agent-client.ts Extract core SSE parsing to public-site/src/lib/sse-reader.ts
MessageBubble candidate/src/app/agent/_components/message-bubble.tsx Copy + adapt for public-site
HitlMessage candidate/src/app/agent/_components/hitl-message.tsx Copy + adapt for public-site (file upload)
FileUploadWidget candidate/src/app/agent/_components/file-upload-widget.tsx Copy + adapt for public-site
Chat input (growing textarea) Embedded in candidate/src/app/agent/client.tsx Extract to shared packages/ui/ component

10.1 Chat UI Complexity Decision

The candidate app's client.tsx is 1073 lines with features like session sidebar, pinning, memory extraction, and HITL widgets. The v1 public-site client should be significantly simpler:

Feature v1 Public-Site Future
Chat messages (user + agent) Yes
SSE streaming (typewriter) Yes
Tool call/result display Yes
HITL widgets (file upload) Yes (resume PDF)
Session sidebar No Maybe
Session switching No Maybe
Memory extraction No No
Email capture Yes (via agent natural language)

Recommendation: Build the public-site chat as a new, simpler component (~400-500 lines), borrowing SSE parsing + ChatMarkdown from the existing codebase but not trying to support every feature the authenticated agent has.


11. Security Considerations

11.1 Rate Limiting

Unauthenticated sessions are abuse vectors. Implement:

  • Per-IP rate limiting on /agent/api/chat: max 10 requests per minute per IP
  • Per-session rate limiting: max 30 agent turns per anonymous session
  • LinkedIn API rate limiting: max 3 ingest_linkedin_profile calls per anonymous session
  • Provision creation rate limiting: max 5 provisions per IP per hour

11.2 Session Cleanup

  • Unclaimed sessions older than 7 days are automatically deleted (cleaned up by a cron job or Vercel cron)
  • Claim tokens expire after 7 days by default (shorter than the existing 365-day admin provision expiry)

11.3 Service Key Protection

The new POST /api/provision/initiate and related endpoints are gated by X-API-Key: <QWESTLY_SERVICE_API_KEY>. The public-site never exposes this key to the browser. All provision creation happens server-side in the proxy route.


12. Decided Questions

12.1 Prompt Management — ✅ RESOLVED

Decision: Inline prompts in agent tool files for v1 MVP. Migrate to api-python prompt infrastructure once validated.

Decision: httpOnly cookie. Server creates session on first message, sets cookie. Session persists across tab close, inaccessible to client JS.

12.3 What Happens After Claim? — ✅ RESOLVED

Decision:

  1. Clicking claim link → custom page at /claim?token=<jwt> that shows the grade summary + signup form (reuses existing claim page form pattern — create username + password or Google OAuth)
  2. After account creation + login → dashboard page (/) with a temporary "welcome preauthed user" banner
  3. Banner shows only once, driven by DB field has_seen_preauth_welcome: boolean
  4. The banner links to /agent so they can continue the conversation in authenticated mode
  5. Need to track signup_method: "agent_self_serve" (add to existing enum)
  6. TODO: Remember to implement the banner logic — check signup_method === "agent_self_serve" && !has_seen_preauth_welcome
  7. When the user navigates away or logs back in later, banner is gone

12.4 When to Create the Provisioned Account — ✅ RESOLVED

Decision: On first chat message. Creates the user_id that all subsequent operations need.

12.5 Resume Upload in v1 — ✅ RESOLVED

Decision: Yes, included in v1. Use the same HITL file upload pattern as the candidate app /agent page. The request_file_upload tool is already available and prompts the user to upload in the chat. The resume analyzer prompt (Prompt 1 from the grader docs) can be run as an alternative to the LinkedIn grader.

12.6 Conversation History Migration — ✅ RESOLVED

Decision: Option A — Migrate. Update agent_conversations.user_id from provisional ID to Auth0 ID during claim migration, using the same pattern as the existing migrateClaimedAccount() function. Add agent_conversations to the list of collections migrated (currently 24 collections). This makes the conversation history immediately available under the user's authenticated sessions.

12.7 Email Capture Approach — ✅ RESOLVED

Decision: Natural language + tool. Agent asks for email alongside LinkedIn username, explains purpose (claim link later), allows skipping. capture_email tool supports two phases: store-only (early) and send (after value demo). No dedicated email form widget needed.

12.8 SOC 2 Badges on Agent Page — ⏳ TBD

The PRD mentions placing Vanta/SOC 2 badges. Location TBD — will add later.

12.9 What Happens After the Initial Value Demo? — ✅ RESOLVED

Decision (Adam, 19:03): Nothing — pre-auth is grading only. The unauthenticated agent draws a hard line at the grade. After showing the grade + suggestions, the agent asks for email and wraps up. No improvement generation, no interview questions, no re-grade loop in pre-auth. All of that unlocks after the user claims their account and enters the authenticated onboarding flow.


13. Implementation Phases

Phase 1: Foundation (Week 1)

  1. Add source, linkedin_username, agent_conversation_id, claim_link_sent_at fields to ProvisionedAccount model
  2. Add has_seen_preauth_welcome field to Candidate model
  3. Add "agent_self_serve" to signup_method enum
  4. Migration to set source: "admin" on existing provisions
  5. Create POST /api/provision/initiate endpoint (candidate app)
  6. Create GET/PATCH /api/provision/session/{session_id} endpoints
  7. Create POST /api/provision/session/{session_id}/claim endpoint
  8. Add agent_conversations to migrateClaimedAccount() (collection #25)
  9. Add new routes to QWESTLY_SERVICE_API_ROUTES
  10. Add pre_auth field to ChatRequest (qwestly-agent)
  11. Implement prompt variant selection in orchestrator

Phase 2: Public-Site Chat UI (Week 1-2)

  1. Create /agent route in public-site with (agent-chrome) layout
  2. Build client.tsx (simplified chat UI, adapted from candidate app)
  3. Build /agent/api/chat proxy route (anonymous session management + JWT creation)
  4. Implement anonymous session httpOnly cookie management
  5. Copy FileUploadWidget + HitlMessage from candidate app
  6. Add rate limiting to proxy route

Phase 3: Preauth Agent Tools (Week 2)

  1. Write PREAUTH_SYSTEM_PROMPT (grade → email gate → wrap up, no generation)
  2. Implement grade_linkedin_profile tool (LinkedIn analyzer)
  3. Implement capture_email tool (store + send claim)
  4. Implement preauth tool registration (grade + email + file upload + knowledge)
  5. Implement generate_linkedin_about and generate_linkedin_experience tools (post-auth only, for authenticated onboarding flow)

Phase 4: Claim Flow & Polish (Week 2-3)

  1. Custom claim page: show grade summary + signup form (reuse existing claim page pattern)
  2. Dashboard welcome banner component (check signup_method + has_seen_preauth_welcome)
  3. Set has_seen_preauth_welcome = true on first dashboard render for agent_self_serve users
  4. Admin page for user-initiated provisions
  5. Session cleanup (7-day expiry for unclaimed)
  6. SOC2 badges in agent page footer (TBD)
  7. E2E testing with test credentials

14. Files Changed / Created Summary

candidate app

File Change
src/types/provisioned-account.d.ts Add source, linkedin_username, agent_conversation_id, claim_link_sent_at
src/models/ProvisionedAccount.ts Add new fields to schema
src/types/candidate.d.ts Add has_seen_preauth_welcome, add "agent_self_serve" to signup_method
src/models/Candidate.ts Add has_seen_preauth_welcome field
src/services/db/provisioned-account.ts Add initiateSelfServe(), updateSession(), sendSelfServeClaim()
src/services/db/claim-migration.ts Add agent_conversations to migration (collection #25)
src/app/api/provision/initiate/route.ts NEW — POST initiate
src/app/api/provision/session/[session_id]/route.ts NEW — GET, PATCH
src/app/api/provision/session/[session_id]/claim/route.ts NEW — POST claim
src/config/server.ts Add new routes to QWESTLY_SERVICE_API_ROUTES
src/app/claim/page.tsx Adapt to detect source: "user_initiated" and render custom page
src/app/claim/client.tsx Add grade summary section for self-serve users (reuse signup form)
src/app/dashboard/client.tsx Add welcome banner (check signup_method + has_seen_preauth_welcome)
src/app/admin/provision/user-initiated/page.tsx NEW — admin listing
migrations/ Add migration for source field

qwestly-agent app

File Change
lib/schemas.py Add pre_auth to ChatRequest
agents/orchestrator.py Add prompt variant selection, preauth prompt, preauth tool registration
tools/grade_linkedin_profile.py NEW
tools/generate_linkedin_about.py NEW (or adapt existing suggest_linkedin_about)
tools/generate_linkedin_experience.py NEW (or adapt existing suggest_linkedin_experience)
tools/capture_email.py NEW
api/index.py Pass pre_auth flag to orchestrator, add session cleanup

public-site app

File Change
src/app/(agent-chrome)/layout.tsx NEW — minimal chat layout
src/app/(agent-chrome)/agent/page.tsx NEW — server component
src/app/(agent-chrome)/agent/client.tsx NEW — chat UI
src/app/(agent-chrome)/agent/_components/message-bubble.tsx NEW
src/app/(agent-chrome)/agent/_components/agent-logo.tsx NEW
src/app/(agent-chrome)/agent/_components/file-upload-widget.tsx NEW (copied + adapted from candidate)
src/app/(agent-chrome)/agent/_components/hitl-message.tsx NEW (copied + adapted from candidate)
src/app/(agent-chrome)/agent/api/chat/route.ts NEW — SSE proxy
src/lib/anonymous-session.ts NEW — session management
src/lib/sse-reader.ts NEW — SSE parsing utility
src/lib/preauth-jwt.ts NEW — JWT signing for preauth
package.json Add jose (if not already present, for JWT signing)

15. Gaps with Master Onboarding Flow

These discrepancies were identified by comparing this tech design against the Journey 2 (LinkedIn ingress) from the master onboarding flow. Reviewed with Adam across two meetings 2026-06-09.


Gap 1: Email gate positioned too late — ✅ RESOLVED

This Design (before) Journey 2 (Onboarding Doc)
When email is captured Early + skippable: asked alongside LinkedIn, can defer to end Hard gate right after the grade: "Save your grade — enter email to continue"
What happens if they skip Agent notes it, asks again after the value demo They leave with the grade only — nothing is saved

Decision (Adam, 17:31): Hard gate after the grade. Show the grade plus actionable detail (e.g. "you don't have an About section, here's what's missing"), then prompt for email to save and continue. No email capture earlier in the flow. Users who drop off without entering email are acceptable product risk — anyone can grade any LinkedIn URL freely.

Decision (19:03): Support both email claim AND direct sign-up in the same agent response. The agent offers two paths at the email gate: "enter your email and we'll send you a link" (low-friction, no account required yet) AND a Google sign-in button for users who want to create an account immediately. This avoids complete drop-off for users who hesitate to sign up but are willing to leave an email.

Implementation impact:

  • capture_email tool simplified — no send_email parameter. Email capture and claim email send happen together, after grade.
  • Preauth system prompt updated: grade → email gate (hard) → claim email sent. No early email ask.
  • Google sign-in button added alongside email input in the agent response UI.
  • Section 4.6 (Email Capture Flow) rewritten to match.

Gap 2: No Rank or Preferences steps post-claim — ✅ RESOLVED

This Design Journey 2 (Onboarding Doc)
Post-claim flow Dashboard with temporary welcome banner Rank 6 value props → Preferences (role-fit form) → Upload (resume) → then dashboard

Decision (Adam, 19:03): Dashboard-first. Land user on the dashboard after claim, not on onboarding screens. Show a contextual banner/prompt to continue onboarding, not a blocking gate. Grader output is already visible; other dashboard sections are grayed out to signal remaining value. The agent chat already knows what's been collected (LinkedIn, resume) and skips redundant asks.

This decision also collapses the canonical onboarding steps (ranking, upload, preferences) into the agent chat itself:

  • Upload: ask in chat via the plus button (5-8 sec ingestion)
  • Preferences: sprinkle contextually per value prop, not as a single upfront screen
  • Ranking: surface as an in-chat UI widget (tap-to-rank, mobile-friendly), not a standalone drag-and-drop screen

Gap 3: Scope of generated improvements — ✅ RESOLVED

This Design Journey 2 (Onboarding Doc)
What gets rewritten About section only "UPDATED LinkedIn rewritten from their answers"
Pre-auth scope Grade + about generation (ambiguous)

Decision (Adam, 19:03): Hard line — pre-auth agent does LinkedIn GRADING ONLY. No generation (About section, experience rewrites, etc.) in pre-auth mode. The agent shows the grade + section-level suggestions + improvement summary, then captures email. All improvements (About generation, experience rewrites, re-grade) happen post-auth after the user has an account.

Rationale: keeps the pre-auth agent simple and the scope clean. The About generator needs more context (interview data, user answers) to produce quality output — that context comes from the authenticated onboarding flow.


Gap 4: Interview framing — ✅ RESOLVED

This Design Journey 2 (Onboarding Doc)
User Q&A 2-3 targeted questions per grading gaps Full interview step: Story · Wins · Skills · Ideal role
Pre-auth scope Mini-interview before email gate Full interview after email gate

Decision (Adam, 19:03): No interview in pre-auth. The improvement loop (targeted questions → generation → re-grade) only happens after the user has an account. Pre-auth is purely: LinkedIn → grade → suggestions → email gate. The full interview step (Story, Wins, Skills, Ideal role) in Journey 2 refers to the post-auth authenticated agent flow, not the unauthenticated one.


Additional Decision: Session/Cookie Architecture — ✅ CONFIRMED (17:31)

Provisioned account + cookie created on first chat message. Returning users resume their session automatically. Grading a different person's profile requires incognito or a new-session trigger (sharing feature deferred).


Summary

Gap Status Decision
Email gate too late ✅ Resolved Hard gate after grade; support both email claim + direct sign-up
No Rank/Preferences ✅ Resolved Dashboard-first post-claim; onboarding steps collapse into chat
Only About rewritten ✅ Resolved Pre-auth = grading only; no generation before account
Interview framing ✅ Resolved No interview in pre-auth; improvement loop is post-auth
Session/cookie architecture ✅ Confirmed Create provision on first message, cookie for resume
  1. LinkedIn API rate limiting. Unauthenticated users hammering the LinkedIn API. Mitigated by per-IP + per-session rate limits on ingest_linkedin_profile.

  2. LLM cost. Each anonymous session drives 3-5 tool calls (LinkedIn fetch, grade, about generation, experience generation). At DeepSeek pricing this is cheap (~$0.001/session), but volume could add up. Monitor and consider a cost cap.

  3. Abandoned provisions. Users who chat, don't give email, and leave. Cleanup cron handles this (7-day expiry), but the dead documents accumulate. Acceptable for v1 given the low storage cost.

  4. Prompt quality. The grader is the core value prop. If the grades feel arbitrary or the rewrites are generic, the product fails. The grader docs recommend hand-grading 12-20 real profiles first to calibrate. This should happen BEFORE implementation.

  5. SSE proxy overhead. Adding a proxy layer (browser → public-site → qwestly-agent → SSE stream → public-site → browser) adds latency. In practice, Next.js streams response bodies efficiently, and the added hop is < 5ms in production (same Vercel network region).