Skip to Content
Part 5: Chat AgentCh 23: Action Tools

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

  1. 22 action tools handle everything from email composition to CRM sync, all through the same executeAction dispatch.

  2. Draft email uses the 70B model with full deal context, recent touches, and intent signals for personalization.

  3. Auto-logging ensures that sends and interactions are automatically tracked in touch_events.

  4. Add-to-pipeline is a 6-step workflow compressed into one click — from discovery cache to CRM sync.

  5. 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.

Last updated on