Skip to content

Contact Filter & Bulk SMS Tools

The assistant uses the active page context — filters, inbox tab, search query — to scope contact operations. Two tools enable filter-aware workflows: get_contacts_by_filter resolves the visible contact set, and send_bulk_sms broadcasts messages to multiple contacts with a preview-confirm flow.

ToolPermission RequiredPurpose
get_contacts_by_filterContact:Collection:List or ListAssignedResolve UI filters into a contact list with message counts
send_bulk_smsMessage:Collection:CreateSend identical SMS to up to 50 contacts per call

When a user opens the assistant from a contact-aware page (Contacts list, Messaging inbox), the router extracts an AssistantActiveContactScope from the URL query parameters:

pub struct AssistantActiveContactScope {
pub kind: AssistantActiveContactScopeKind,
pub filter: ContactFilter,
}
pub enum AssistantActiveContactScopeKind {
FilteredContacts,
Unanswered { direction: UnansweredDirection },
}

The kind field maps to the page type:

Page / Tabkinddirection
Contacts list (with filters)FilteredContacts
Messaging → Needs ReplyUnansweredNeedsReply
Messaging → Needs Follow-upUnansweredNeedsFollowUp

The system prompt injects the scope summary and the serialized ContactFilter JSON, so the assistant knows exactly which contacts “these contacts” refers to.

When the user is on a messaging page, contact deep links in tool results route to /messaging/{id} instead of /contacts/{id}. If the page has active filters (tab, search, filter state), those are appended as query parameters so clicking a link preserves the user’s view.

PageContext carries a filter_query_string field populated from the ContactFilterQuery on messaging routes:

pub struct PageContext {
pub route: String,
pub entity_type: Option<String>,
pub entity_id: Option<String>,
pub active_contact_scope: Option<AssistantActiveContactScope>,
/// Dioxus-router-encoded filter query string from the current page URL.
pub filter_query_string: Option<String>,
}

The contact_deep_link() helper in page_context_mapper.rs handles the routing:

pub fn contact_deep_link(contact_id: &str, page_context: Option<&PageContext>) -> String {
if let Some(ctx) = page_context {
if ctx.route.starts_with("/messaging") {
return match &ctx.filter_query_string {
Some(qs) if !qs.is_empty() => format!("/messaging/{contact_id}?{qs}"),
_ => format!("/messaging/{contact_id}"),
};
}
}
entity_deep_link("contact", contact_id)
}

Routing behavior:

User’s current pageGenerated deep link
/messaging?tab~needs_reply&search~vip/messaging/{id}?tab~needs_reply&search~vip
/messaging (no filters)/messaging/{id}
/contacts or any other page/contacts/{id}

Eight tools use contact_deep_link(): get_contacts_by_filter, search_contacts, get_contact_details, find_contact_by_phone, get_fresh_contacts, get_unanswered_contacts, get_contact_messages, and send_sms. Each receives page_context via collect_assistant_tools(), which threads it from the router.

File: src/mods/assistant/page_context_mapper.rs

Resolves a ContactFilter into matching contacts with message count metadata.

Input:

ParameterTypeDefaultDescription
filter_jsonStringJSON-serialized ContactFilter from page context
limitOption<u32>100Results per page (max 500)
offsetOption<u32>0Pagination offset

Response shape:

{
"count": 25,
"total_count": 150,
"has_more": true,
"contacts": [
{
"id": "uuid",
"deep_link": "/messaging/{id}?tab~needs_reply&search~vip",
"first_name": "John",
"last_name": "Doe",
"status": "active",
"phones": [
{ "phone_number": "+15551234567", "label": "mobile", "is_preferred": true }
],
"tags": [{ "name": "vip" }],
"total_messages": 42,
"inbound_messages_count": 18,
"outbound_messages_count": 24
}
]
}

The message counts (total_messages, inbound_messages_count, outbound_messages_count) come from get_contact_message_counts — a batched query that groups by contact_id and direction in a single SQL call.

File: src/mods/assistant/tools/contacts/ai_get_contacts_by_filter_tool.rs

Sends an identical message to multiple contacts. Uses a two-phase preview-confirm flow.

Input:

ParameterTypeDefaultDescription
contact_idsVec<String>Contact UUIDs (max 50 per call)
message_bodyStringThe SMS text to send
confirmedboolfalse = preview, true = send

Phase 1 — Preview (confirmed: false):

{
"status": "preview",
"contact_count": 3,
"contacts": [
{
"contact_id": "uuid",
"deep_link": "/messaging/{id}?tab~needs_reply",
"name": "John Doe",
"phone_number": "+15551234567"
}
],
"message_body": "Your appointment is tomorrow at 10 AM.",
"estimated_cost": "$0.03"
}

Phase 2 — Send (confirmed: true):

{
"status": "sent",
"contact_count": 3,
"message_count": 3
}

Phone resolution uses is_preferred numbers first, then falls back to resolve_phone_number_for_contact for org phone selection.

File: src/mods/assistant/tools/messages/ai_send_bulk_sms_tool.rs

The system prompt guides the assistant to choose between send_bulk_sms and iterative send_sms calls:

Use send_bulk_smsUse send_sms (per contact)
Identical message for all contactsContact has a known name for personalization
No inbound history to referenceContact has inbound message history
Generic outreach or announcementsUser requests personalized drafts

The assistant checks inbound_messages_count from the filter results: if it’s 0, there’s no conversation history to personalize against, so bulk is appropriate. If it’s > 0, the assistant loads the thread with get_contact_messages before drafting.

Both tools and the existing contact/messaging APIs use resolve_contact_collection_scope for ABAC enforcement:

pub struct ContactCollectionScope {
pub can_list_all: bool,
pub visible_member_id: Option<Uuid>,
pub member_scope: ContactMemberScope,
}

This resolver centralizes the permission check — users with List see all contacts, users with ListAssigned see only their assigned contacts. The scope applies identically whether the query comes from the UI API or an assistant tool.

File: src/mods/contact/services/resolve_contact_collection_scope_service.rs