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.tsExamples from the codebase:
| Package | Action File | Purpose |
|---|---|---|
| CRM | lead_ai.action.ts | AI-powered lead management |
| CRM | lead_convert.action.ts | Lead-to-opportunity conversion |
| CRM | sales_performance.action.ts | Sales analytics and KPIs |
| Finance | revenue_forecast.action.ts | Revenue forecasting |
| Support | case_ai.action.ts | AI case resolution |
| Products | pricing_optimizer.action.ts | Dynamic pricing optimization |
| Marketing | content_generator.action.ts | AI content generation |
| HR | candidate_ai.action.ts | Recruitment 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:
- Fetching context data via ObjectQL (
broker) - Constructing an LLM prompt with business context
- Parsing and applying the AI response
- 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
- Creating Objects — Define the data model your actions operate on
- Business Logic — Add hooks that trigger automatically
- Workflows — Set up rule-based automation
- Metadata Reference — Complete spec reference