Page Context & Deep Links
The assistant is page-aware. When a user opens Vernis on a detail page like /contacts/abc-123, the system prompt automatically includes which entity they’re viewing — so they can say “show me their calls” without specifying who.
How it works
Section titled “How it works”User navigates to /contacts/abc-123 → AssistantPanel reads the current Route signal → route_to_page_context() maps Route → PageContext → PageContext sent with every WsClientMessage::UserMessage → Server validates entity_type and entity_id → Server fetches entity name from DB (e.g. "John Doe") → System prompt injects: "The user is viewing contact John Doe (ID: abc-123)" → AI resolves "this contact" / "them" / "their" to that entityPageContext type
Section titled “PageContext type”#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]pub struct PageContext { pub route: String, // "/contacts/abc-123" pub entity_type: Option<String>, // "contact" pub entity_id: Option<String>, // "abc-123"}On list pages and non-entity routes, entity_type and entity_id are None. The assistant still knows the route (e.g. “the contacts list”) but has no entity to scope to.
Route detection (client)
Section titled “Route detection (client)”The AssistantPanel component reads the current route via use_route::<Route>() and converts it on every navigation:
let page_context = route_to_page_context(¤t_route);This PageContext is attached to every WsClientMessage::UserMessage:
WsClientMessage::UserMessage { content: user_text, page_context: Some(page_context),}Route-to-context mapping
Section titled “Route-to-context mapping”page_context_mapper.rs defines route_to_page_context() — a match over all detail-view Route variants:
| Route variant | entity_type | Example path |
|---|---|---|
ContactDetailsView { id } | contact | /contacts/{id} |
CallDetailsView { id } | call | /calls/{id} |
PlanDetailsView { id } | plan | /plans/{id} |
PlanTemplateDetailsView { id } | plan_template | /plan-templates/{id} |
AgentDetailsView { id } | agent | /agents/{id} |
TaskDetailsView { id } | task | /tasks/{id} |
PhoneDetailsView { id } | phone | /phones/{id} |
KnowledgeDetailsView { id } | knowledge | /knowledge/{id} |
AnalyzerDetailsView { id } | analyzer | /analyzers/{id} |
TextAgentDetailsView { id } | text_agent | /text-agents/{id} |
MessagingContactView { contact_id } | contact | /messaging/{id} |
| Any other route | None | varies |
When you add a new detail-view route, add a match arm here so the assistant picks it up.
ENTITY_ROUTES — single source of truth
Section titled “ENTITY_ROUTES — single source of truth”All entity type ↔ route path mappings live in one constant:
pub const ENTITY_ROUTES: &[(&str, &str, &str, &str)] = &[ // (entity_type, route_prefix, display_label, prompt_label) ("contact", "/contacts", "contact", "contact"), ("call", "/calls", "call", "call"), ("plan", "/plans", "plan", "plan"), ("plan_template", "/plan-templates", "plan_template", "plan template"), ("agent", "/agents", "agent", "agent"), ("task", "/tasks", "task", "task"), ("phone", "/phones", "phone", "phone number"), ("knowledge", "/knowledge", "knowledge", "knowledge base"), ("analyzer", "/analyzers", "analyzer", "analyzer"), ("text_agent", "/text-agents", "text_agent", "text agent"), ("messaging", "/messaging", "messaging (contact)", "messaging thread"),];This constant drives three functions:
| Function | Purpose |
|---|---|
entity_deep_link(entity_type, entity_id) | Returns the URL path for an entity (e.g. /contacts/abc-123) |
all_deep_link_patterns() | Returns all patterns for system prompt link docs |
entity_type_label(entity_type) | Returns a human-friendly label (e.g. “phone number”) |
Deep link generation
Section titled “Deep link generation”All assistant tools use entity_deep_link() instead of hardcoded format!() strings:
use crate::mods::assistant::page_context_mapper::entity_deep_link;
// Before (scattered across tool files):let link = format!("/contacts/{}", contact_id);
// After (one function, one source of truth):let link = entity_deep_link("contact", &contact_id.to_string());Tool result deep links
Section titled “Tool result deep links”Every tool that returns entity data includes a deep_link field in its JSON response. This applies to both read-only and write tools — the AI uses these links directly instead of constructing URLs.
Read-only tools with deep_link in results:
| Tool | Entity type | Example deep_link |
|---|---|---|
ai_search_contacts | contact | /contacts/abc-123 |
ai_get_contact_details | contact | /contacts/abc-123 |
ai_find_contact_by_phone | contact | /contacts/abc-123 |
ai_get_contact_messages | contact | /contacts/abc-123 |
ai_get_unanswered_contacts | contact | /contacts/abc-123 |
ai_get_plan_templates | plan_template | /plan-templates/def-456 |
Data types with deep_link field:
These shared types include a deep_link: String field populated via entity_deep_link() at construction time:
| Type | Entity type | Used by |
|---|---|---|
AiAgentInfo | agent | Agent listing/detail tools |
AiKnowledgeBaseInfo | knowledge | Knowledge base tools |
AiPhoneNumberInfo | phone | Phone number tools |
AiPlanEnrollment | plan | Plan listing tools |
Write tools also return deep_link in their success responses — see Write Actions & Confirmation Gate.
System prompt constraint
Section titled “System prompt constraint”The system prompt includes an explicit instruction:
Never construct URLs yourself. Only use the
deep_linkvalues returned by tool results.
This prevents the AI from fabricating URLs with incorrect entity IDs. All links must come from actual tool responses where the server provides verified, org-scoped entity IDs.
Prompt injection hardening
Section titled “Prompt injection hardening”The server validates all client-supplied fields before interpolating them into the system prompt:
- Entity type — checked against
ENTITY_ROUTES. Unknown types produce a generic “unknown page” message. - Entity ID — parsed as a UUID. Non-UUID strings are rejected.
- Entity name — fetched from DB, then passed through
sanitize_for_prompt()which strips control characters and truncates to 200 chars. - User name — also sanitized before prompt interpolation.
fn sanitize_for_prompt(s: &str) -> String { s.chars() .filter(|c| !c.is_control()) .take(200) .collect::<String>() .trim() .to_string()}Entity summary lookup
Section titled “Entity summary lookup”When the user is on a detail page, the server fetches a readable name for the entity. Each entity type has a custom query:
| Entity type | What’s fetched | Org scoping |
|---|---|---|
contact | first_name + last_name | Direct organization_id filter |
call | Direction + date + phone number | JOIN through phone_number.organization_id |
plan | title | Direct filter |
task | title | Direct filter |
agent | name | Direct filter |
phone | number | Direct filter |
All queries scope to the user’s organization to prevent cross-tenant data leakage.
Key files
Section titled “Key files”| File | Purpose |
|---|---|
src/mods/assistant/page_context_mapper.rs | ENTITY_ROUTES, route_to_page_context(), entity_deep_link() |
src/mods/assistant/types/assistant_types.rs | PageContext struct definition |
src/mods/assistant/services/build_system_prompt_service.rs | build_page_context_section(), sanitize_for_prompt() |
src/mods/assistant/components/assistant_panel_component.rs | Route detection via use_route |