HotCRM Logo

Actions

How to build custom actions and AI-powered tools in HotCRM using *.action.ts files

Actions

Actions are the API endpoints and AI tools layer of HotCRM. They expose business capabilities that can be called by the UI, external integrations, and AI agents. Every action is defined in a *.action.ts file.

File Convention

packages/{package}/src/actions/{name}.action.ts

Examples from the codebase:

PackageAction FilePurpose
CRMlead_ai.action.tsAI-powered lead management
CRMlead_convert.action.tsLead-to-opportunity conversion
CRMsales_performance.action.tsSales analytics and KPIs
Financerevenue_forecast.action.tsRevenue forecasting
Supportcase_ai.action.tsAI case resolution
Productspricing_optimizer.action.tsDynamic pricing optimization
Marketingcontent_generator.action.tsAI content generation
HRcandidate_ai.action.tsRecruitment AI assistance

HotCRM currently has 27 action files across 7 packages.

Basic Action Structure

An action exports one or more async functions with typed request/response interfaces:

// packages/crm/src/actions/lead_convert.action.ts
import { broker } from '../db';

export interface ConvertLeadRequest {
  leadId: string;
  createOpportunity: boolean;
  opportunityName?: string;
}

export interface ConvertLeadResponse {
  accountId: string;
  contactId: string;
  opportunityId?: string;
}

export async function convertLead(
  request: ConvertLeadRequest
): Promise<ConvertLeadResponse> {
  const lead = await broker.findOne('Lead', request.leadId);

  // Create Account from Lead
  const account = await broker.create('Account', {
    name: lead.Company,
    industry: lead.Industry,
    phone: lead.Phone,
    website: lead.Website,
  });

  // Create Contact from Lead
  const contact = await broker.create('Contact', {
    first_name: lead.FirstName,
    last_name: lead.LastName,
    email: lead.Email,
    account_id: account._id,
  });

  let opportunityId: string | undefined;
  if (request.createOpportunity) {
    const opp = await broker.create('Opportunity', {
      name: request.opportunityName || `${lead.Company} - New Opportunity`,
      account_id: account._id,
      stage: 'Qualification',
    });
    opportunityId = opp._id;
  }

  // Mark lead as converted
  await broker.update('Lead', request.leadId, {
    status: 'Converted',
    converted_date: new Date().toISOString(),
    converted_account_id: account._id,
    converted_contact_id: contact._id,
  });

  return {
    accountId: account._id,
    contactId: contact._id,
    opportunityId,
  };
}

export default { convertLead };

AI-Powered Actions

Most HotCRM actions integrate with AI to provide intelligent capabilities. The pattern involves:

  1. Fetching context data via ObjectQL (broker)
  2. Constructing an LLM prompt with business context
  3. Parsing and applying the AI response
  4. Updating records with results

Example: Lead AI Enhancement

// packages/crm/src/actions/lead_ai.action.ts
import { broker } from '../db';

export interface EmailSignatureRequest {
  emailBody: string;
  leadId?: string;
}

export interface EmailSignatureResponse {
  extractedData: {
    Name?: string;
    Title?: string;
    Company?: string;
    Phone?: string;
    Email?: string;
  };
  confidence: Record<string, number>;
  leadUpdated: boolean;
}

export async function extractEmailSignature(
  request: EmailSignatureRequest
): Promise<EmailSignatureResponse> {
  const { emailBody, leadId } = request;

  const llmResponse = await callLLM(`
    Extract contact information from this email signature:
    ${emailBody}
    Return JSON with extractedData and confidence scores (0-100).
  `);
  const parsed = JSON.parse(llmResponse);

  let leadUpdated = false;

  // Auto-update lead if confidence is high enough
  if (leadId && parsed.extractedData) {
    const updates: Record<string, any> = {};
    for (const [key, value] of Object.entries(parsed.extractedData)) {
      if (parsed.confidence[key] > 70) {
        updates[key] = value;
      }
    }
    if (Object.keys(updates).length > 0) {
      await broker.update('Lead', leadId, updates);
      leadUpdated = true;
    }
  }

  return {
    extractedData: parsed.extractedData,
    confidence: parsed.confidence,
    leadUpdated,
  };
}

Example: Revenue Forecasting

// packages/finance/src/actions/revenue_forecast.action.ts
import { broker } from '../db';

export interface ForecastRequest {
  period: 'monthly' | 'quarterly' | 'annual';
  includeAIProjection?: boolean;
}

export interface ForecastResponse {
  currentRevenue: number;
  projectedRevenue: number;
  confidence: number;
  breakdown: Array<{
    category: string;
    amount: number;
    trend: 'up' | 'down' | 'stable';
  }>;
}

export async function generateRevenueForecast(
  request: ForecastRequest
): Promise<ForecastResponse> {
  // 1. Gather pipeline data via ObjectQL
  const openDeals = await broker.find('Opportunity', {
    filters: [['stage', '!=', 'closed_lost']],
  });

  const closedDeals = await broker.find('Opportunity', {
    filters: [
      ['stage', '=', 'closed_won'],
      ['close_date', '>=', 'LAST_YEAR'],
    ],
  });

  // 2. Calculate weighted pipeline
  const weightedPipeline = openDeals.reduce(
    (sum: number, deal: any) => sum + deal.amount * (deal.probability / 100),
    0
  );

  // 3. Use AI for trend projection
  const projection = await callLLM(`
    Historical closed revenue: ${JSON.stringify(closedDeals.map((d: any) => d.amount))}
    Current weighted pipeline: ${weightedPipeline}
    Forecast period: ${request.period}
    Project future revenue with confidence level.
  `);

  return JSON.parse(projection);
}

Action Categories

HotCRM actions fall into several categories:

1. Data Operations

Transform, aggregate, or move data between objects.

export async function convertLead(request: ConvertLeadRequest) {
  // Convert a Lead into Account + Contact + Opportunity
}

export async function mergeAccounts(request: MergeAccountsRequest) {
  // Merge duplicate accounts while preserving relationships
}

2. AI Intelligence

Use LLMs for scoring, predictions, and content generation.

// packages/crm/src/actions/enhanced_lead_scoring.action.ts
export async function scoreLeadWithAI(leadId: string) {
  // AI-calculated lead quality score with explanation
}

// packages/marketing/src/actions/content_generator.action.ts
export async function generateEmailContent(params: ContentRequest) {
  // AI-generated marketing email copy
}

// packages/support/src/actions/case_ai.action.ts
export async function suggestCaseResolution(caseId: string) {
  // AI-recommended resolution from knowledge base
}

3. Analytics & Reporting

Compute metrics and generate business insights.

// packages/crm/src/actions/sales_performance.action.ts
export async function getSalesPerformance(params: PerformanceRequest) {
  // KPIs: win rate, pipeline velocity, average deal size
}

// packages/support/src/actions/service_metrics.action.ts
export async function getServiceMetrics(params: MetricsRequest) {
  // SLA compliance, CSAT, first response time
}

4. Predictions

ML-based forecasting and risk assessment.

// packages/support/src/actions/sla_prediction.action.ts
export async function predictSLABreach(caseId: string) {
  // Predict whether a case will breach its SLA target
}

// packages/finance/src/actions/invoice_prediction.action.ts
export async function predictPaymentDate(invoiceId: string) {
  // Predict when an invoice will be paid
}

Registering Actions in Plugins

All actions must be registered in the package's plugin.ts:

// packages/crm/src/plugin.ts
import LeadConvertAction from './actions/lead_convert.action';
import LeadAIAction from './actions/lead_ai.action';
import OpportunityAIAction from './actions/opportunity_ai.action';
import SalesPerformanceAction from './actions/sales_performance.action';

export const CRMPlugin = {
  name: '@hotcrm/crm',
  // ...
  actions: [
    // Data operations
    { name: 'lead_convert', handler: LeadConvertAction.convertLead },
    // AI actions
    { name: 'lead_extract_signature', handler: LeadAIAction.extractEmailSignature },
    { name: 'lead_enrich', handler: LeadAIAction.enrichLead },
    { name: 'lead_route', handler: LeadAIAction.routeLead },
    { name: 'opportunity_analyze', handler: OpportunityAIAction.analyzeOpportunity },
    // Analytics
    { name: 'sales_performance', handler: SalesPerformanceAction.getSalesPerformance },
  ],
};

Best Practices

1. Use Typed Interfaces

Always define explicit request and response types:

// ✅ Good — fully typed
export interface ScoreLeadRequest {
  leadId: string;
  factors?: string[];
}
export interface ScoreLeadResponse {
  score: number;
  breakdown: Record<string, number>;
  explanation: string;
}
export async function scoreLead(req: ScoreLeadRequest): Promise<ScoreLeadResponse> { ... }

// ❌ Bad — untyped
export async function scoreLead(params: any): Promise<any> { ... }

2. Use ObjectQL (Never Raw SQL)

// ✅ Good — ObjectQL via broker
const leads = await broker.find('Lead', {
  filters: [['status', '=', 'New']],
  sort: 'created_date desc',
  limit: 10,
});

// ❌ Bad — raw SQL
const leads = await db.query('SELECT * FROM leads WHERE status = "New"');

3. Handle Errors Gracefully

export async function enrichLead(request: LeadEnrichmentRequest) {
  if (!request.leadId && !request.emailDomain) {
    throw new Error('Either leadId or emailDomain is required');
  }

  try {
    const result = await callExternalAPI(request);
    return result;
  } catch (error) {
    console.error('❌ Lead enrichment failed:', error);
    return { success: false, error: 'Enrichment service unavailable' };
  }
}

4. Keep Actions Focused

Each action should do one thing well. Split complex operations:

// ✅ Good — separate focused actions
export async function extractEmailSignature(req) { ... }
export async function enrichLead(req) { ... }
export async function routeLead(req) { ... }
export async function generateNurturingRecommendations(req) { ... }

// ❌ Bad — one giant function doing everything
export async function processLead(req) {
  // 500 lines of mixed logic
}

5. Document AI Prompts

When using LLM calls, document the expected input/output format:

const systemPrompt = `
You are an expert at extracting contact information from email signatures.

# Task
Extract name, title, company, phone, email from the signature.

# Output Format
Return JSON: { "extractedData": {...}, "confidence": {...} }
Only include fields you find. Use confidence scores 0-100.
`;

Testing Actions

Actions are tested with the standard Vitest setup:

import { describe, it, expect, vi } from 'vitest';
import { convertLead } from './lead_convert.action';

// Mock the broker
vi.mock('../db', () => ({
  broker: {
    findOne: vi.fn(),
    find: vi.fn(),
    create: vi.fn().mockResolvedValue({ _id: 'new_id' }),
    update: vi.fn(),
  },
}));

describe('convertLead', () => {
  it('should create account and contact from lead', async () => {
    const { broker } = await import('../db');
    (broker.findOne as any).mockResolvedValue({
      Company: 'Acme Corp',
      FirstName: 'John',
      LastName: 'Doe',
      Email: 'john@acme.com',
    });

    const result = await convertLead({
      leadId: 'lead_123',
      createOpportunity: false,
    });

    expect(result.accountId).toBeDefined();
    expect(result.contactId).toBeDefined();
    expect(broker.create).toHaveBeenCalledWith('Account', expect.objectContaining({
      name: 'Acme Corp',
    }));
  });
});

Next Steps

On this page