_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 thehas_seen_preauth_welcome = truemutation 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 inpackages/ui/atcandidate/packages/ui/src/components/chat-markdown.tsx— use via@qwestly/uiFileUploadWidget→ copy fromcandidate/src/app/agent/_components/file-upload-widget.tsx+ adaptMessageBubble→ adapt for unauthenticated contextHitlMessage→ copy fromcandidate/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:
- Agent asks for LinkedIn username → ingests the profile → grades it
- 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")
- Agent says: "Want me to improve this? Enter your email and I'll send you the results plus a link to claim your account."
- User enters email →
capture_email(email)called → claim token generated + email sent via SendGrid → agent continues to the improvement demo - 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_profilequery_qwestly_knowledgelist_capabilitiesrequest_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 onlygenerate_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_cardget_user_profilesearch_user_memoriesmanage_qwestly_card_anonymitysave_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:
- Takes the structured LinkedIn profile (ingested by
ingest_linkedin_profile) - Calls the LLM with the LinkedIn analyzer system prompt
- 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) |
| provisioned_account.email | |
| 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_profilecalls 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.
12.2 Cookie vs localStorage for Session — ✅ RESOLVED
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:
- 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) - After account creation + login → dashboard page (
/) with a temporary "welcome preauthed user" banner - Banner shows only once, driven by DB field
has_seen_preauth_welcome: boolean - The banner links to
/agentso they can continue the conversation in authenticated mode - Need to track
signup_method: "agent_self_serve"(add to existing enum) - TODO: Remember to implement the banner logic — check
signup_method === "agent_self_serve" && !has_seen_preauth_welcome - 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)
- Add
source,linkedin_username,agent_conversation_id,claim_link_sent_atfields toProvisionedAccountmodel - Add
has_seen_preauth_welcomefield toCandidatemodel - Add
"agent_self_serve"tosignup_methodenum - Migration to set
source: "admin"on existing provisions - Create
POST /api/provision/initiateendpoint (candidate app) - Create
GET/PATCH /api/provision/session/{session_id}endpoints - Create
POST /api/provision/session/{session_id}/claimendpoint - Add
agent_conversationstomigrateClaimedAccount()(collection #25) - Add new routes to
QWESTLY_SERVICE_API_ROUTES - Add
pre_authfield toChatRequest(qwestly-agent) - Implement prompt variant selection in orchestrator
Phase 2: Public-Site Chat UI (Week 1-2)
- Create
/agentroute in public-site with(agent-chrome)layout - Build
client.tsx(simplified chat UI, adapted from candidate app) - Build
/agent/api/chatproxy route (anonymous session management + JWT creation) - Implement anonymous session httpOnly cookie management
- Copy
FileUploadWidget+HitlMessagefrom candidate app - Add rate limiting to proxy route
Phase 3: Preauth Agent Tools (Week 2)
- Write
PREAUTH_SYSTEM_PROMPT(grade → email gate → wrap up, no generation) - Implement
grade_linkedin_profiletool (LinkedIn analyzer) - Implement
capture_emailtool (store + send claim) - Implement preauth tool registration (grade + email + file upload + knowledge)
- Implement
generate_linkedin_aboutandgenerate_linkedin_experiencetools (post-auth only, for authenticated onboarding flow)
Phase 4: Claim Flow & Polish (Week 2-3)
- Custom claim page: show grade summary + signup form (reuse existing claim page pattern)
- Dashboard welcome banner component (check
signup_method+has_seen_preauth_welcome) - Set
has_seen_preauth_welcome = trueon first dashboard render for agent_self_serve users - Admin page for user-initiated provisions
- Session cleanup (7-day expiry for unclaimed)
- SOC2 badges in agent page footer (TBD)
- 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_emailtool simplified — nosend_emailparameter. 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 |
-
LinkedIn API rate limiting. Unauthenticated users hammering the LinkedIn API. Mitigated by per-IP + per-session rate limits on
ingest_linkedin_profile. -
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.
-
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.
-
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.
-
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).