Skip to content

Shared AI Tools Platform

All AI tool definitions live in src/mods/ai/tools/ — a shared platform module that any feature (assistant, dashboard briefings, scheduled insights) can import without depending on assistant internals.

src/mods/ai/tools/
├── mod.rs # Re-exports, tool_schema_for(), make_tool_execute! macro
├── runtime.rs # Dedicated tokio runtime for tool dispatch
├── contact_validation.rs # Batch contact ID validation helper
├── calls/
│ └── ai_get_call_details_tool.rs
├── contacts/
│ ├── ai_get_contact_details_tool.rs
│ └── ai_get_bulk_contact_memory_tool.rs
├── messages/
│ └── ai_get_bulk_contact_messages_tool.rs
├── tasks/
│ └── ai_get_tasks_tool.rs
└── sales/
├── ai_get_active_offers_tool.rs
└── ai_get_offer_details_tool.rs

Every sub-module is cfg-gated to not(target_family = "wasm") — tool handlers run server-side only. WASM builds see the type definitions but not the execution logic.

Tool closures run synchronously (required by aisdk::ToolExecute), but handlers need async DB access. A dedicated multi-threaded tokio runtime in runtime.rs bridges the gap:

// runtime.rs — static 2-worker runtime
static TOOL_RUNTIME: LazyLock<Result<Runtime, String>> =
LazyLock::new(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("ai-tool")
.build()
.map_err(|e| format!("Failed to create AI tool runtime: {e}"))
});

run_tool_async(future) spawns work on this runtime and blocks the caller via a sync_channel. Each tool gets a 30-second timeout — if the future exceeds it, tokio::time::timeout cancels it, releasing DB connections and preventing zombie tasks.

pub(crate) fn run_tool_async<F, T>(future: F) -> Result<T, String>
where
F: Future<Output = Result<T, String>> + Send + 'static,
T: Send + 'static,
{
let rt = TOOL_RUNTIME.as_ref().map_err(|e| { /* ... */ })?;
let (tx, rx) = std::sync::mpsc::sync_channel(1);
rt.spawn(async move {
let result = match tokio::time::timeout(Duration::from_secs(30), future).await {
Ok(r) => r,
Err(_) => Err("Tool timed out after 30s".into()),
};
let _ = tx.send(result);
});
rx.recv().map_err(|e| format!("Tool channel disconnected: {e}"))?
}

Eliminates boilerplate for building ToolExecute closures. Two forms:

One-liner — handler takes (captures..., input):

crate::make_tool_execute!("get_tasks", GetTasksInput, session @ input => {
handler(session, input.status, input.category).await.map_err(|e| e.to_string())
})

Handler shorthand — handler takes the full input struct:

crate::make_tool_execute!("tool_name", InputType, handler => session, page_context)

Both forms handle JSON deserialization, debug tracing (prefixed [ai-tool]), capture cloning, and dispatch through run_tool_async.

Each tool is a build_*_tool function returning aisdk::core::tools::Tool:

pub fn build_get_tasks_tool(session: Session) -> Tool {
#[derive(JsonSchema, Serialize, Deserialize)]
struct GetTasksInput {
status: Option<String>,
category: Option<String>,
contact_id: Option<String>,
assigned_to: Option<String>,
sort: Option<String>,
limit: Option<u64>,
page: Option<u64>,
}
Tool {
name: AiToolName::GetTasks.api_name().to_string(),
description: "List tasks filtered by status, category, contact, or assignee.".to_string(),
input_schema: tool_schema_for::<GetTasksInput>(),
execute: crate::make_tool_execute!("get_tasks", GetTasksInput, session @ input => {
handler(session, input.status, input.category, /* ... */).await
.map_err(|e| e.to_string())
}),
}
}

Key conventions:

  • Input structs are private to the builder function (defined inline)
  • tool_schema_for::<T>() generates a JSON Schema stripped of title, format, and $schema to reduce LLM token usage
  • AiToolName enum is the single source of truth for tool name strings and UI labels
  • Handlers are async fn defined below the builder, gated to non-WASM

contact_validation::validate_contact_batch provides shared logic for tools that accept multiple contact IDs:

pub(crate) async fn validate_contact_batch(
session: &Session,
db: &DatabaseConnection,
raw_ids: &[String],
max_ids: usize,
) -> Result<Vec<Uuid>, AppError>

Parses UUIDs, deduplicates, enforces a max count, and verifies all contacts exist in the caller’s organization with proper access. Used by get_bulk_contact_memory and get_bulk_contact_messages.

Tools that need page awareness (like get_contact_details) accept an optional PageContext:

pub struct PageContext {
pub route: String,
pub entity_type: Option<String>,
pub entity_id: Option<String>,
pub active_contact_scope: Option<AssistantActiveContactScope>,
pub filter_query_string: Option<String>,
}

The assistant maps the current route to a PageContext via route_to_page_context, giving tools access to which entity the user is viewing.

ToolBuilderModule
get_call_detailsbuild_get_call_details_toolcalls
get_contact_detailsbuild_get_contact_details_toolcontacts
get_bulk_contact_memorybuild_get_bulk_contact_memory_toolcontacts
get_bulk_contact_messagesbuild_get_bulk_contact_messages_toolmessages
get_tasksbuild_get_tasks_tooltasks
get_active_offersbuild_get_active_offers_toolsales
get_offer_detailsbuild_get_offer_details_toolsales

All builders are re-exported from crate::mods::ai::tools — import from there, not from the sub-modules directly.