Data tools read. Action tools write. When Cosmo drafts an email, creates a CRM task, or pushes scores to HubSpot, it’s using one of 22 action tools — the hands that reach out from the AI into real systems.
The Action Tool Map
Every action tool has a handler function registered in a dispatch map:
// src/infrastructure/chat/actionTools.ts
const ACTION_TOOL_MAP: Record<ActionToolName, ActionHandler> = {
send_email: executeSendEmail,
draft_email: executeDraftEmail,
log_touch: executeLogTouch,
create_followup_task: executeCreateFollowupTask,
notify_rep: executeNotifyRep,
broadcast_team: executeBroadcastTeam,
create_hubspot_task: executeCreateHubspotTask,
push_hubspot_scores: executePushHubspotScores,
add_to_pipeline: executeAddToPipeline,
// ... more
};
export async function executeAction(
userId: string,
pendingAction: PendingAction
): Promise<ActionResult> {
const handler = ACTION_TOOL_MAP[pendingAction.actionTool];
return handler(userId, pendingAction.params, pendingAction.resolvedEntities);
}The executeAction function is the single dispatch point. Every action, whether triggered by user confirmation, autonomous execution, or direct chat command, flows through here.
Draft Email: The LLM-Powered Composer
The most sophisticated action tool. It uses the 70B model to generate personalized emails:
async function executeDraftEmail(
userId: string,
params: DraftEmailParams,
resolved?: ResolvedEntities
): Promise<ActionResult> {
// 1. Resolve the recipient
const contact = resolved?.contact || await resolveContact(userId, params.contactName);
const company = resolved?.company || await resolveCompany(userId, params.companyName);
// 2. Gather context for personalization
const dealContext = await getDealContext(userId, company.id);
const recentTouches = await getRecentTouches(userId, company.id, 5);
const intentSignals = await getActiveIntentSignals(userId, company.id);
// 3. Generate email via 70B
const prompt = `Draft a professional B2B sales email.
To: ${contact.full_name} (${contact.title}) at ${company.company_name}
Purpose: ${params.purpose || 'follow-up'}
Tone: ${params.tone || 'professional'}
Context:
- Deal stage: ${dealContext?.pipeline_stage || 'No active deal'}
- Last interaction: ${recentTouches[0]?.channel} ${recentTouches[0]?.notes || ''}
- Intent signals: ${intentSignals.map(s => s.topic).join(', ')}
Write a concise email (under 200 words). Include a specific call to action.`;
const emailContent = await groqClient.createCompletion({
model: GROQ_MODEL_GOLDILOCKS, // 70B for quality
messages: [{ role: 'user', content: prompt }],
temperature: 0.4,
maxTokens: 500,
});
// 4. Return as email_preview (UI renders this as a formatted email card)
return {
success: true,
type: 'email_preview',
data: {
to: contact.email,
toName: contact.full_name,
subject: params.subject || generateSubject(company.company_name, params.purpose),
body: emailContent,
companyId: company.id,
contactId: contact.id,
},
};
}The key insight: draft_email doesn’t send anything. It returns an email_preview that the UI renders as an editable email card. The user can review, modify, and then explicitly send — or discard.
Send Email: The HubSpot Bridge
When the user confirms, send_email pushes through HubSpot’s API:
async function executeSendEmail(
userId: string,
params: SendEmailParams,
resolved?: ResolvedEntities
): Promise<ActionResult> {
// 1. Verify HubSpot connection
const connection = await getCrmConnection(userId, 'hubspot');
if (!connection) {
return { success: false, error: 'HubSpot not connected. Connect in Settings → Integrations.' };
}
// 2. Send via HubSpot API
await hubspotApiClient.sendEmail(connection.access_token, {
to: params.to,
subject: params.subject,
body: params.body,
});
// 3. Auto-log the touch
await pool.query(
`INSERT INTO touch_events (user_id, company_id, contact_id, channel, direction, outcome, notes, event_timestamp)
VALUES ($1, $2, $3, 'email', 'outbound', 'sent', $4, NOW())`,
[userId, params.companyId, params.contactId, `Subject: ${params.subject}`]
);
return { success: true, message: `Email sent to ${params.to}` };
}Notice the auto-logging: every sent email creates a touch_events row. The rep doesn’t need to manually log their outreach — the system tracks it automatically.
Create Follow-Up Task
Tasks are inserted with sensible defaults:
async function executeCreateFollowupTask(
userId: string,
params: TaskParams,
resolved?: ResolvedEntities
): Promise<ActionResult> {
const company = resolved?.company;
const result = await pool.query(
`INSERT INTO tasks (user_id, company_id, task, reason, priority, category, source, due_date)
VALUES ($1, $2, $3, $4, $5, $6, 'cosmo', $7)
RETURNING id, task, due_date`,
[
userId,
company?.id || null,
params.task || `Follow up with ${company?.company_name || 'contact'}`,
params.reason || 'Created by Cosmo',
params.priority || 'medium',
params.category || 'follow_up',
params.dueDate || new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // +2 days default
]
);
return {
success: true,
message: `Task created: "${result.rows[0].task}" due ${result.rows[0].due_date}`,
};
}Default due date is 2 days from now — soon enough to be urgent, far enough to be actionable. The source: 'cosmo' tag distinguishes AI-created tasks from manual ones.
Log Touch: Recording Interactions
Every sales interaction should be logged for pipeline intelligence. Cosmo makes this frictionless:
async function executeLogTouch(
userId: string,
params: LogTouchParams,
resolved?: ResolvedEntities
): Promise<ActionResult> {
const company = resolved?.company;
const contact = resolved?.contact;
await pool.query(
`INSERT INTO touch_events
(user_id, company_id, contact_id, channel, direction, outcome, notes, event_timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
userId,
company?.id,
contact?.id,
params.channel || 'other', // email, phone, meeting, linkedin, other
params.direction || 'outbound', // inbound, outbound
params.outcome || 'completed', // completed, no_answer, voicemail, scheduled
params.notes || '',
params.timestamp || new Date(),
]
);
return { success: true, message: `Touch logged: ${params.channel} with ${company?.company_name}` };
}The touch data feeds back into intent scoring (recency signals) and the Command Center’s activity metrics.
Notify Rep: Slack Integration
When Cosmo needs to alert a specific rep:
async function executeNotifyRep(
userId: string,
params: NotifyRepParams,
resolved?: ResolvedEntities
): Promise<ActionResult> {
const rep = resolved?.rep;
if (!rep) {
return { success: false, error: `Could not find rep: ${params.repName}` };
}
// Find the rep's Slack connection
const repSlack = await pool.query(
`SELECT * FROM slack_connections WHERE user_id = $1 AND is_active = true`,
[rep.matched_user_id || userId]
);
if (repSlack.rows.length === 0) {
// Fallback: post to the CRO's team channel
const croSlack = await pool.query(
`SELECT * FROM slack_connections WHERE user_id = $1 AND is_active = true`,
[userId]
);
// Send to team channel...
}
// Build Slack Block Kit message
const blocks = [
{ type: 'header', text: { type: 'plain_text', text: params.title || 'Cosmo Alert' } },
{ type: 'section', text: { type: 'mrkdwn', text: params.message } },
];
await sendSlackBlocks(slackToken, channelId, blocks);
return { success: true, message: `Notification sent to ${rep.display_name} via Slack` };
}The fallback chain: rep’s Slack → CRO’s team channel → error. Cosmo doesn’t silently fail — if it can’t reach the rep directly, it escalates to the team channel.
Add to Pipeline: Discovery → CRM
The add_to_pipeline tool converts a discovered company into a real pipeline entry:
async function executeAddToPipeline(
userId: string,
params: AddToPipelineParams
): Promise<ActionResult> {
// 1. Load from discovery_results cache
const discovery = await pool.query(
`SELECT * FROM discovery_results WHERE id = $1 AND user_id = $2`,
[params.discoveryId, userId]
);
// 2. Create company record (or find existing by domain)
const company = await upsertCompany(userId, {
company_name: discovery.company_name,
domain: discovery.company_domain,
industry: discovery.company_industry,
employee_count: discovery.company_employee_count,
// ...
});
// 3. Create initial scores
await pool.query(
`INSERT INTO scores (user_id, company_id, fit_score, intent_score, composite_score, scored_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (user_id, company_id) DO UPDATE SET ...`,
[userId, company.id, discovery.ml_fit_score, discovery.ml_intent_score, discovery.composite_score]
);
// 4. Insert contacts if available
if (discovery.personas) {
for (const persona of discovery.personas) {
await insertContact(userId, company.id, persona);
}
}
// 5. Sync to HubSpot if connected
const hubspot = await getCrmConnection(userId, 'hubspot');
if (hubspot) {
await syncCompanyToHubSpot(hubspot, company);
}
// 6. Mark discovery as converted
await pool.query(
`UPDATE discovery_results SET status = 'converted', converted_at = NOW() WHERE id = $1`,
[params.discoveryId]
);
return { success: true, message: `${company.company_name} added to pipeline` };
}This is a 6-step operation that creates a company, copies scores, inserts contacts, syncs to HubSpot, and marks the discovery as converted — all from a single “add to pipeline” click in the chat.
The Safe vs. Confirmation Split
Why are some actions safe and others require confirmation?
SAFE (execute immediately):
- draft_email → Creates a draft, doesn't send
- log_touch → Records an interaction (reversible)
- create_followup_task → Creates a task (deletable)
- add_to_pipeline → Adds a company (can be removed)
- test_slack → Sends to your own channel
CONFIRMATION REQUIRED:
- send_email → Sends to an external person (irreversible)
- notify_rep → Messages a colleague (visible to others)
- broadcast_team → Messages an entire channel (high blast radius)
- push_hubspot_scores → Modifies CRM data (visible to the org)The principle: if the action is visible to someone else or modifies an external system, confirm first. If it’s internal or reversible, execute immediately for a snappier experience.
Key Takeaways
-
22 action tools handle everything from email composition to CRM sync, all through the same
executeActiondispatch. -
Draft email uses the 70B model with full deal context, recent touches, and intent signals for personalization.
-
Auto-logging ensures that sends and interactions are automatically tracked in
touch_events. -
Add-to-pipeline is a 6-step workflow compressed into one click — from discovery cache to CRM sync.
-
Safe vs. confirmation split balances speed (immediate execution) against safety (user review for irreversible actions).
Next chapter: we leave the application layer and enter Part 6 — production concerns, starting with error handling and observability.