The unified endpoint /ws/unified/{session_id} replaces three separate WebSocket endpoints with a single connection that handles all flows:
| Old Endpoint | New Message Type | Flow |
|---|---|---|
/ws/search/{session_id} |
query |
Free-text search with streaming LLM response |
/ws/category-recommendations/{session_id} |
start (with current_page) |
Category page → discovery questions → recommendations |
/ws/product-recommendations/{session_id} |
start (with current_page) |
Product page → discovery questions → fit evaluation |
| (new) | resolve_platform |
Platform resolution → optional handoff to product discovery |
Key change: the frontend sends current_page on every request. The server resolves the URL to determine if it's a product page, category page, or content page, and routes to the correct agent automatically. No need for category_url or sku fields — just the page URL.
- Replace endpoint URLs (one connection instead of three)
- Send
current_page(the page URL) andwebsite_codeon everystartmessage - Remove all client-side logic that determines "am I on a product page or category page?"
- Handle
platform_resolvedandinterface_clarificationmessages (new) - Remove endpoint-selection logic — the server routes automatically via entity resolution
wss://api.example.com/ws/unified/{session_id}
session_id: Client-generated unique ID (max 255 chars)- Origin header must match CORS configuration
- Connection is rate-limited per IP
Send {"type": "ping"} periodically. Server responds with {"type": "pong"} and sends its own pings every 30 seconds. Idle connections (no activity for 30s) are closed.
The first message containing website_code binds the session. All subsequent messages inherit that value. Sending a different website_code returns an error.
// First message sets website_code for the session
{"type": "start", "website_code": "https://example.com/", "current_page": "/shoes/running/"}
// Later messages can omit website_code (but should still send current_page)
{"type": "answer", "question_id": 1, "answer_id": 10, "current_page": "/shoes/running/"}Send current_page on start and resume messages. The server resolves the URL against the product and category database to determine the page type:
- Product URL → routes to Product Discovery Agent (questions → fit evaluation)
- Category URL → routes to Category Discovery Agent (questions → recommendations)
- Unknown URL → returns error
The frontend never needs to know which agent to use — just send the URL the user is on.
{
"type": "query",
"content": "best running shoes for flat feet",
"website_code": "https://example.com/",
"current_page": "/running-shoes/",
"conversation_id": "conv-abc123",
"metadata": {}
}| Field | Required | Notes |
|---|---|---|
content |
Yes | Query text, 1–2000 chars |
website_code |
First message | Binds session |
current_page |
No | Current page URL for context |
conversation_id |
No | Omit to create new conversation |
metadata |
No | Arbitrary metadata passed to the agent |
← {"type": "session_id", "session_id": "abc123"}
← {"type": "conversation_id", "conversation_id": "conv-xyz"}
← {"type": "status", "status": "thinking"}
← {"type": "status", "status": "searching"}
← {"type": "token", "content": "Based on your needs, "}
← {"type": "token", "content": "I'd recommend..."}
← {"type": "status", "status": "formatting"}
← {"type": "answer", "content": "Full response text..."}
← {"type": "products", "content": [...]}
← {"type": "recommendations", "content": [...]}
← {"type": "discovery_question", "content": {...}}
← {"type": "metadata", "content": {...}}
← {"type": "done"}
Key messages to handle:
session_id/conversation_id— store for subsequent queriestoken— appendcontentto streaming response displayanswer— complete response text (render as final answer)products— product cards to displayrecommendations— related product suggestionsdiscovery_question— follow-up question widgetdone— streaming complete, re-enable input
User lands on a category page. The server asks discovery questions, then returns recommendations.
{
"type": "start",
"website_code": "https://example.com/",
"current_page": "/running-shoes/"
}The server resolves /running-shoes/ → recognizes it as a category → routes to Category Discovery Agent.
Optional: first_answer — if the first question was already shown on the page:
{
"type": "start",
"website_code": "https://example.com/",
"current_page": "/running-shoes/",
"first_answer": {
"question_id": 1,
"answer_id": 10,
"answer_text": "Trail running"
}
}One of these sequences:
Normal (has questions):
← {"type": "question", "question": {...}, "question_number": 1, "is_last": false}
Expert user (questions skipped):
← {"type": "status", "status": "questions_complete"}
← {"type": "status", "status": "analyzing_preferences"}
← ... recommendation messages ...
No product type for this category:
← {"type": "status", "status": "no_product_type", "message": "No product type found for this category"}
Resuming existing session:
← {"type": "resumed", "answered_count": 2, "message": "Resuming from where you left off"}
← {"type": "question", "question": {...}, "question_number": 3, "is_last": true}
{"type": "answer", "question_id": 1, "answer_id": 10, "answer_text": "Trail running"}Server responds with either the next question or terminal:
← {"type": "question", "question": {...}, "question_number": 2, "is_last": true}
or (when is_last was true on the previous question, or no more children):
← {"type": "status", "status": "questions_complete"}
← {"type": "status", "status": "analyzing_preferences"}
← {"type": "status", "status": "finalizing"}
← {"type": "recommendations", "products": [...], "fallback_used": false, ...}
← {"type": "done"}
{
"type": "question",
"question": {
"id": 1,
"question": "What will you primarily use these for?",
"summary": "Usage type",
"answers": [
{
"id": 10,
"answer": "Trail running",
"explanation": "Rugged terrain with uneven surfaces",
"examples": "Mountain trails, forest paths"
},
{
"id": 11,
"answer": "Road running",
"explanation": null,
"examples": null
}
]
},
"question_number": 1,
"is_last": false
}Use is_last to show "Get Recommendations" instead of "Next" on the last question.
{
"type": "recommendations",
"products": [
{
"id": 123,
"sku": "RUN-TRAIL-01",
"name": "Trail Runner Pro",
"display_name": "Trail Runner Pro X",
"product_line": "Pro Series",
"critical_attributes": [
{"code": "weight", "name": "Weight", "value": "280g"}
],
"price": 149.99,
"url": "/products/trail-runner-pro/",
"score": 0.95,
"match_reasons": ["Excellent traction", "Lightweight"],
"tag": {"value": "Recommended", "type": "recommended"},
"benefits": ["Waterproof membrane", "Rock plate"],
"use_cases": [{"name": "Trail Running", "score": 0.95}],
"why": "Best match for trail running with superior grip",
"variant_match": null,
"review_insights": {
"average_rating": 4.7,
"review_count": 342,
"summary": "Highly rated for durability and comfort",
"likes": ["Great grip", "Comfortable fit", "Durable"]
}
}
],
"fallback_used": false,
"fallback_reason": null,
"fallback_scope": null,
"fallback_explanation": null
}Tags: First product gets {"value": "Recommended", "type": "recommended"}, second gets {"value": "Runner Up", "type": "runner_up"}, rest get null.
If the user navigates away and returns, send their previous answers for validation:
{
"type": "resume",
"website_code": "https://example.com/",
"category_url": "/running-shoes/",
"answered_intents": {
"1": {"answer_id": 10, "answer_text": "Trail running"},
"2": {"answer_id": 25, "answer_text": "Under 300g"}
}
}Server validates each answer against the current question tree and responds:
← {"type": "resumed", "answered_count": 2, "session_id": "abc123"}
← {"type": "question", ...} // Next unanswered question
or if some answers are no longer valid:
← {"type": "resumed", "answered_count": 1, "invalidated_questions": ["2"], "reason": "Some questions or answers are no longer valid"}
← {"type": "question", ...} // Re-ask from the invalidated point
User is on a product page. The server asks discovery questions, then evaluates product fit.
{
"type": "start",
"website_code": "https://example.com/",
"current_page": "/products/trail-runner-pro/"
}The server resolves /products/trail-runner-pro/ → recognizes it as a product → routes to Product Discovery Agent.
### Step 2: Answer Questions
Same as category flow. Questions include an additional `sort_order` field:
```json
{
"type": "question",
"question": {
"id": 5,
"question": "What distances do you run?",
"summary": null,
"sort_order": 2,
"answers": [...]
},
"question_number": 1,
"is_last": false
}
After the last answer, the server automatically evaluates product fit:
← {"type": "status", "status": "questions_complete"}
← {"type": "status", "status": "processing"}
← {"type": "fit_evaluation", ...}
← {"type": "done"}
{
"type": "fit_evaluation",
"current_product": {
"id": 123,
"sku": "RUN-TRAIL-01",
"name": "Trail Runner Pro",
"display_name": "Trail Runner Pro X",
"product_line": "Pro Series",
"critical_attributes": [
{"code": "weight", "name": "Weight", "value": "280g"}
],
"price": 149.99,
"url": "/products/trail-runner-pro/",
"tags": [{"value": "Good Fit", "type": "recommended"}],
"variant_match": null,
"review_insights": {
"average_rating": 4.7,
"review_count": 342,
"summary": "Highly rated for durability",
"likes": ["Great grip", "Comfortable"]
}
},
"fit_evaluation": {
"is_good_fit": true,
"fit_score": 0.87,
"fit_threshold": 0.6
},
"fit_explanation": {
"why_good_fit": "Excellent traction for trail running with lightweight design",
"why_not_good_fit": null
},
"alternative": {
"id": 456,
"sku": "RUN-ULTRA-02",
"name": "Ultra Trail X",
"display_name": "Ultra Trail X",
"product_line": "Ultra Series",
"critical_attributes": [...],
"price": 189.99,
"url": "/products/ultra-trail-x/",
"score": 0.92,
"match_reasons": ["Better cushioning", "More durable"],
"why_better_for_you": ["Superior cushioning for long distances"],
"tags": [],
"variant_match": null,
"review_insights": {...}
},
"fallback_reason": null
}Tags on current_product: "Good Fit" / "recommended" if is_good_fit is true, "Poor Fit" / "warning" if false.
Tags on alternative: "Top Pick" / "recommended" only when the current product is a poor fit.
For expert users or when questions aren't needed:
{
"type": "evaluate_fit",
"website_code": "https://example.com/",
"sku": "RUN-TRAIL-01",
"discovery_answers": {
"1": {"answer_id": 10, "answer_text": "Trail running"}
}
}Resolve an unknown platform (e.g., user's equipment) to find compatible products.
{
"type": "resolve_platform",
"website_code": "https://example.com/",
"make": "Acme",
"model": "X-2000",
"handoff_target": "product_discovery",
"sku": "ACC-MOUNT-01"
}handoff_target is optional. When set, the server automatically chains into that agent after resolution.
← {"type": "platform_resolved", "platform_id": 42, "archetype_id": 10}
If handoff_target was set, the product discovery flow starts automatically (questions appear next).
{
"type": "interface_clarification",
"archetype_id": 10,
"question": "Which interfaces does your Acme X-2000 have?",
"options": [
{"id": 1, "label": "Standard Mount", "description": "Universal mounting system"},
{"id": 2, "label": "Quick-Release", "description": "Tool-free attachment"},
{"id": 3, "label": "Threaded Base", "description": "Screw-in attachment"}
],
"make": "Acme",
"model": "X-2000"
}{
"type": "select_interfaces",
"selected_interface_ids": [1, 3],
"archetype_id": 10,
"make": "Acme",
"model": "X-2000",
"website_code": "https://example.com/"
}Server responds with platform_resolved (and optional handoff to product discovery).
← {"type": "platform_resolved", "platform_id": null, "archetype_id": null}
The frontend should handle this gracefully — show a "we couldn't identify your platform" message.
The server automatically determines which agent handles each message. For start and resume, the server resolves current_page against the entity database to determine the page type.
| Message Type | Routing |
|---|---|
query |
Always → Search Agent |
start / resume (with current_page) |
Resolved via entity DB: product URL → Product Agent, category URL → Category Agent |
start (with explicit sku) |
→ Product Discovery Agent (backward compat) |
start (with explicit category_url) |
→ Category Discovery Agent (backward compat) |
answer |
→ Whichever agent is currently active |
get_recommendations |
→ Category Discovery Agent |
evaluate_fit |
→ Product Discovery Agent |
resolve_platform |
→ Platform Resolver Agent |
select_interfaces |
→ Platform Resolver Agent |
ping |
Handled directly (not routed to agent) |
The server processes one message at a time per connection. If a message arrives while a previous one is still processing:
← {"type": "error", "content": "Still processing previous message"}
Wait for done or the final response before sending the next message.
All errors follow this shape:
{"type": "error", "content": "Human-readable error message"}Some errors include an error_code for programmatic handling:
{"type": "error", "content": "website_code is required", "error_code": "INVALID_PAYLOAD"}| Error Code | Meaning |
|---|---|
INVALID_PAYLOAD |
Missing or malformed required fields |
INVALID_WEBSITE |
Website code not recognized |
INVALID_CATEGORY |
Category not found |
INVALID_PRODUCT |
Product not found |
No message changes. Same query type, same response stream.
Replace category_url with current_page in start messages. The server resolves the URL automatically.
- {"type": "start", "website_code": "...", "category_url": "/shoes/"}
+ {"type": "start", "website_code": "...", "current_page": "/shoes/"}Response messages are identical.
Replace sku / current_page product resolution with just current_page. The server resolves the URL to find the product.
- {"type": "start", "website_code": "...", "sku": "SHOE-001"}
+ {"type": "start", "website_code": "...", "current_page": "/products/shoe-001/"}Response messages are identical.
The old field names (sku, category_url, category_id) still work as fallbacks. You can migrate incrementally — start sending current_page alongside the old fields, then remove the old fields once verified.
The backend supports UNIFIED_WS_ENABLED=true to route /ws/search queries through the unified orchestrator. This allows gradual rollout of the search flow without changing the URL.
Client Server
| |
|--- start {current_page} ---->|
| (resolved as category) |
|<---- question (Q1) ----------|
| |
|--- answer {q1, a1} --------->|
| |
|<---- question (Q2) ----------|
| |
|--- answer {q2, a2} --------->|
| |
|<---- status: questions_complete
|<---- status: analyzing_preferences
|<---- status: finalizing ------|
|<---- recommendations --------|
|<---- done -------------------|
Client Server
| |
|--- start {current_page} ---->|
| (resolved as product) |
|<---- question (Q1) ----------|
| |
|--- answer {q1, a1} --------->|
| |
|<---- status: questions_complete
|<---- status: processing ------|
|<---- fit_evaluation ----------|
|<---- done -------------------|
Client Server
| |
|--- resolve_platform -------->|
| {make, model, |
| handoff: product} |
| |
|<---- interface_clarification -|
| {options: [...]} |
| |
|--- select_interfaces ------->|
| {selected_ids: [...]} |
| |
|<---- platform_resolved -------|
| {platform_id: 42} |
| |
| (automatic handoff) |
| |
|<---- question (Q1) ----------|
| |
|--- answer {q1, a1} --------->|
| ... |
|<---- fit_evaluation ----------|
|<---- done -------------------|
Client Server
| |
|--- query {content} --------->|
| |
|<---- session_id --------------|
|<---- conversation_id --------|
|<---- status: thinking --------|
|<---- token: "Based on..." ---|
|<---- token: "I recommend..." |
|<---- answer: "Full text..." -|
|<---- products: [...] --------|
|<---- recommendations: [...] -|
|<---- discovery_question: {..}|
|<---- done -------------------|