Development
Creating Objects
How to create custom business objects in HotCRM using TypeScript
Creating Objects
HotCRM uses a metadata-driven architecture where all business objects are defined in TypeScript using the @objectstack/spec protocol. This guide shows you how to create custom objects.
File Suffix Protocol
HotCRM follows a strict file naming convention:
{object_name}.object.ts # Object definitions
{object_name}.hook.ts # Business logic triggers
{object_name}.page.ts # UI page configurations
{object_name}.view.ts # UI view configurationsNaming Rules:
- Use
snake_casefor file names - Always include the appropriate suffix (
.object.ts,.hook.ts, etc.) - Object names in the schema use
PascalCase
Basic Object Structure
Step 1: Import the Schema Type
import type { ObjectSchema } from '@objectstack/spec/data';Step 2: Define the Object
const MyCustomObject: ObjectSchema = {
name: 'CustomObject', // PascalCase, no spaces
label: 'Custom Object', // Display name (singular)
labelPlural: 'Custom Objects', // Display name (plural)
icon: 'briefcase', // Icon name
description: 'Description of the object',
features: {
searchable: true, // Enable full-text search
trackFieldHistory: true, // Track field changes
enableActivities: true, // Link to activities
enableNotes: true, // Allow notes
enableAttachments: true, // Allow file attachments
enableDuplicateDetection: true // Check for duplicates
},
fields: [
// Field definitions go here
],
relationships: [
// Relationship definitions go here
],
listViews: [
// List view definitions go here
],
validationRules: [
// Validation rules go here
],
pageLayout: {
// Page layout configuration
}
};
export default MyCustomObject;Step 3: Export the Object
export default MyCustomObject;Field Types
Text Fields
// Simple text
{
name: 'Name',
type: 'text',
label: 'Name',
required: true,
searchable: true,
length: 255,
unique: false
}
// Long text
{
name: 'Description',
type: 'textarea',
label: 'Description',
length: 32000,
rows: 5
}
// Email
{
name: 'Email',
type: 'email',
label: 'Email',
unique: true,
searchable: true
}
// Phone
{
name: 'Phone',
type: 'phone',
label: 'Phone'
}
// URL
{
name: 'Website',
type: 'url',
label: 'Website'
}Number Fields
// Integer
{
name: 'Quantity',
type: 'number',
label: 'Quantity',
precision: 0,
min: 0,
max: 99999
}
// Decimal
{
name: 'Price',
type: 'number',
label: 'Price',
precision: 2,
min: 0
}
// Currency
{
name: 'Amount',
type: 'currency',
label: 'Amount',
precision: 2,
currency: 'CNY'
}
// Percentage
{
name: 'Discount',
type: 'percent',
label: 'Discount',
precision: 2,
min: 0,
max: 100
}Date & Time Fields
// Date only
{
name: 'StartDate',
type: 'date',
label: 'Start Date'
}
// Date and time
{
name: 'CreatedAt',
type: 'datetime',
label: 'Created At',
defaultValue: '$now',
readonly: true
}
// Time only
{
name: 'MeetingTime',
type: 'time',
label: 'Meeting Time'
}Select Fields
// Single select (picklist)
{
name: 'Status',
type: 'select',
label: 'Status',
required: true,
defaultValue: 'Draft',
options: [
{ label: '📝 Draft', value: 'Draft' },
{ label: '🔄 In Progress', value: 'In Progress' },
{ label: '✅ Completed', value: 'Completed' },
{ label: '❌ Cancelled', value: 'Cancelled' }
]
}
// Multi-select
{
name: 'Skills',
type: 'multiselect',
label: '技能',
options: [
{ label: 'JavaScript', value: 'javascript' },
{ label: 'Python', value: 'python' },
{ label: 'Java', value: 'java' },
{ label: 'Go', value: 'go' }
]
}Boolean Fields
{
name: 'IsActive',
type: 'checkbox',
label: 'Active',
defaultValue: true
}Lookup Fields (Relationships)
// Many-to-one relationship
{
name: 'AccountId',
type: 'lookup',
label: 'Account',
referenceTo: 'Account', // Related object
required: true,
cascadeDelete: false // Delete behavior
}
// Polymorphic lookup (multiple object types)
{
name: 'WhatId',
type: 'lookup',
label: 'Related To',
referenceTo: ['Account', 'Opportunity', 'Case']
}Formula Fields
// Calculated field
{
name: 'ExpectedRevenue',
type: 'currency',
label: 'Expected Revenue',
precision: 2,
formula: 'Amount * Probability / 100',
readonly: true,
description: 'Calculated based on amount and win probability'
}
// Date calculation
{
name: 'DaysOpen',
type: 'number',
label: 'Days Open',
precision: 0,
formula: 'DATEDIFF(TODAY(), CreatedDate)',
readonly: true
}AI-Enhanced Fields
// AI-generated score
{
name: 'LeadScore',
type: 'number',
label: 'Lead Score',
precision: 0,
min: 0,
max: 100,
readonly: true,
description: 'AI-calculated lead quality score'
}
// AI-generated text
{
name: 'AISummary',
type: 'textarea',
label: 'AI Summary',
readonly: true,
length: 2000
}Relationships
One-to-Many (hasMany)
relationships: [
{
name: 'Opportunities', // Relationship name
type: 'hasMany', // One-to-many
object: 'Opportunity', // Related object
foreignKey: 'AccountId', // Foreign key in related object
label: 'Opportunities'
}
]Many-to-One (belongsTo)
// Automatically created when you define a lookup field
{
name: 'AccountId',
type: 'lookup',
label: 'Account',
referenceTo: 'Account'
}
// Creates reverse relationship: Account.belongsToMany-to-Many
// Use junction object
const CampaignMember: ObjectSchema = {
name: 'CampaignMember',
fields: [
{
name: 'CampaignId',
type: 'lookup',
referenceTo: 'Campaign',
required: true
},
{
name: 'LeadId',
type: 'lookup',
referenceTo: 'Lead'
},
{
name: 'ContactId',
type: 'lookup',
referenceTo: 'Contact'
},
{
name: 'Status',
type: 'select',
options: [
{ label: 'Sent', value: 'Sent' },
{ label: 'Responded', value: 'Responded' }
]
}
]
};List Views
Define how records are displayed in list views:
listViews: [
{
name: 'AllRecords',
label: 'All Records',
filters: [], // No filters - show all
columns: ['Name', 'Status', 'CreatedDate'],
sort: [['CreatedDate', 'desc']]
},
{
name: 'MyActive',
label: 'My Active Records',
filters: [
['OwnerId', '=', '$currentUser'],
['Status', '=', 'Active']
],
columns: ['Name', 'Priority', 'DueDate'],
sort: [['DueDate', 'asc']]
},
{
name: 'HighPriority',
label: 'High Priority',
filters: [
['Priority', '=', 'High'],
['Status', 'not in', ['Completed', 'Cancelled']]
],
columns: ['Name', 'Status', 'AssignedTo', 'DueDate'],
sort: [['DueDate', 'asc']]
}
]Filter Operators
// Comparison
['Field', '=', 'value']
['Field', '!=', 'value']
['Field', '>', 100]
['Field', '>=', 100]
['Field', '<', 100]
['Field', '<=', 100]
// Lists
['Field', 'in', ['value1', 'value2']]
['Field', 'not in', ['value1', 'value2']]
// Null checks
['Field', 'is null']
['Field', 'is not null']
// Text search
['Field', 'like', '%search%']
// Date ranges
['CreatedDate', 'last_n_days', 7]
['CreatedDate', 'this_month']
['CreatedDate', 'this_year']
// Current user
['OwnerId', '=', '$currentUser']Validation Rules
Add business rules to ensure data quality:
validationRules: [
{
name: 'EmailOrPhoneRequired',
errorMessage: 'Email or Phone is required',
formula: 'AND(ISBLANK(Email), ISBLANK(Phone))'
},
{
name: 'EndDateAfterStartDate',
errorMessage: 'End date must be after start date',
formula: 'EndDate < StartDate'
},
{
name: 'DiscountLimit',
errorMessage: 'Discount cannot exceed 30%',
formula: 'AND(Discount > 30, NOT(ISPICKVAL(ApprovalStatus, "Approved")))'
},
{
name: 'AmountRequiredForAdvancedStage',
errorMessage: 'Amount is required for Proposal stage',
formula: 'AND(ISPICKVAL(Stage, "Proposal"), ISBLANK(Amount))'
}
]Formula Functions
ISBLANK(field)- Check if field is emptyAND(condition1, condition2, ...)- Logical ANDOR(condition1, condition2, ...)- Logical ORNOT(condition)- Logical NOTISPICKVAL(field, 'value')- Check picklist valueDATEDIFF(date1, date2)- Days between datesTODAY()- Current dateNOW()- Current date and timePRIORVALUE(field)- Previous value (before update)
Page Layout
Define how fields are displayed on detail pages:
pageLayout: {
sections: [
{
label: 'Basic Information',
columns: 2, // 2-column layout
fields: ['Name', 'Status', 'Priority', 'OwnerId']
},
{
label: 'Details',
columns: 2,
fields: ['StartDate', 'EndDate', 'Amount', 'Probability']
},
{
label: 'Description',
columns: 1, // 1-column layout (full width)
fields: ['Description']
},
{
label: 'AI Analysis',
columns: 1,
fields: ['AISummary', 'AIRecommendedAction']
}
]
}Complete Example
Here's a complete custom object for tracking projects:
import type { ObjectSchema } from '@objectstack/spec/data';
const Project: ObjectSchema = {
name: 'Project',
label: 'Project',
labelPlural: 'Projects',
icon: 'folder',
description: 'Project management and tracking',
features: {
searchable: true,
trackFieldHistory: true,
enableActivities: true,
enableNotes: true,
enableAttachments: true
},
fields: [
// Basic Information
{
name: 'Name',
type: 'text',
label: 'Project Name',
required: true,
searchable: true,
length: 255
},
{
name: 'AccountId',
type: 'lookup',
label: 'Account',
referenceTo: 'Account',
required: true
},
{
name: 'OwnerId',
type: 'lookup',
label: 'Project Manager',
referenceTo: 'User',
required: true,
defaultValue: '$currentUser'
},
// Status & Dates
{
name: 'Status',
type: 'select',
label: 'Status',
required: true,
defaultValue: 'Planning',
options: [
{ label: '📋 Planning', value: 'Planning' },
{ label: '🚀 In Progress', value: 'In Progress' },
{ label: '⏸️ On Hold', value: 'On Hold' },
{ label: '✅ Completed', value: 'Completed' },
{ label: '❌ Cancelled', value: 'Cancelled' }
]
},
{
name: 'Priority',
type: 'select',
label: 'Priority',
options: [
{ label: 'Critical', value: 'Critical' },
{ label: 'High', value: 'High' },
{ label: 'Medium', value: 'Medium' },
{ label: 'Low', value: 'Low' }
]
},
{
name: 'StartDate',
type: 'date',
label: 'Start Date',
required: true
},
{
name: 'EndDate',
type: 'date',
label: 'End Date',
required: true
},
{
name: 'DurationDays',
type: 'number',
label: 'Duration (Days)',
precision: 0,
formula: 'DATEDIFF(EndDate, StartDate)',
readonly: true
},
// Budget
{
name: 'Budget',
type: 'currency',
label: 'Budget',
precision: 2,
currency: 'CNY'
},
{
name: 'ActualCost',
type: 'currency',
label: 'Actual Cost',
precision: 2,
currency: 'CNY'
},
{
name: 'BudgetVariance',
type: 'currency',
label: 'Budget Variance',
precision: 2,
formula: 'Budget - ActualCost',
readonly: true
},
// Progress
{
name: 'PercentComplete',
type: 'percent',
label: 'Percent Complete',
precision: 0,
min: 0,
max: 100,
defaultValue: 0
},
{
name: 'Description',
type: 'textarea',
label: 'Description',
length: 32000
},
// AI Fields
{
name: 'AIRiskScore',
type: 'number',
label: 'AI Risk Score',
precision: 0,
min: 0,
max: 100,
readonly: true,
description: 'AI-predicted project risk score'
},
{
name: 'AIRecommendations',
type: 'textarea',
label: 'AI Recommendations',
readonly: true
}
],
relationships: [
{
name: 'Tasks',
type: 'hasMany',
object: 'Task',
foreignKey: 'ProjectId',
label: 'Tasks'
},
{
name: 'Activities',
type: 'hasMany',
object: 'Activity',
foreignKey: 'WhatId',
label: 'Activities'
}
],
listViews: [
{
name: 'AllProjects',
label: 'All Projects',
filters: [],
columns: ['Name', 'AccountId', 'Status', 'Priority', 'StartDate', 'EndDate', 'PercentComplete'],
sort: [['StartDate', 'desc']]
},
{
name: 'MyActiveProjects',
label: 'My Active Projects',
filters: [
['OwnerId', '=', '$currentUser'],
['Status', 'in', ['Planning', 'In Progress']]
],
columns: ['Name', 'AccountId', 'Status', 'PercentComplete', 'EndDate'],
sort: [['EndDate', 'asc']]
},
{
name: 'AtRisk',
label: 'At Risk Projects',
filters: [
['AIRiskScore', '>', 70],
['Status', 'not in', ['Completed', 'Cancelled']]
],
columns: ['Name', 'AccountId', 'AIRiskScore', 'PercentComplete', 'EndDate', 'OwnerId'],
sort: [['AIRiskScore', 'desc']]
}
],
validationRules: [
{
name: 'EndDateAfterStartDate',
errorMessage: 'End date must be after start date',
formula: 'EndDate < StartDate'
},
{
name: 'ActualCostNotExceedBudget',
errorMessage: 'Actual cost cannot exceed 150% of budget',
formula: 'ActualCost > (Budget * 1.5)'
},
{
name: 'CompletedProjectMustBe100Percent',
errorMessage: 'Completed project must be 100% complete',
formula: 'AND(ISPICKVAL(Status, "Completed"), PercentComplete < 100)'
}
],
pageLayout: {
sections: [
{
label: 'Project Information',
columns: 2,
fields: ['Name', 'AccountId', 'OwnerId', 'Status', 'Priority']
},
{
label: 'Time Planning',
columns: 2,
fields: ['StartDate', 'EndDate', 'DurationDays', 'PercentComplete']
},
{
label: 'Budget Management',
columns: 2,
fields: ['Budget', 'ActualCost', 'BudgetVariance']
},
{
label: 'AI Analysis',
columns: 1,
fields: ['AIRiskScore', 'AIRecommendations']
},
{
label: 'Description',
columns: 1,
fields: ['Description']
}
]
}
};
export default Project;Best Practices
1. Use Meaningful Names
// ✅ Good
{
name: 'ExpectedCloseDate',
label: 'Expected Close Date'
}
// ❌ Bad
{
name: 'Date1',
label: 'Date 1'
}2. Add Descriptions
// ✅ Good
{
name: 'LeadScore',
label: 'Lead Score',
description: 'AI-calculated lead quality score (0-100)'
}3. Use Validation Rules
// ✅ Good - Enforce data quality
validationRules: [
{
name: 'EmailOrPhoneRequired',
errorMessage: 'Email or Phone is required',
formula: 'AND(ISBLANK(Email), ISBLANK(Phone))'
}
]4. Provide Helpful List Views
// ✅ Good - Multiple useful views
listViews: [
{ name: 'All', ... },
{ name: 'MyActive', ... },
{ name: 'HighPriority', ... },
{ name: 'OverdueItems', ... }
]5. Use Readonly for Calculated Fields
// ✅ Good
{
name: 'TotalAmount',
formula: 'Quantity * UnitPrice',
readonly: true
}Next Steps
- Business Logic - Add hooks and triggers
- UI Development - Create custom UI
- ObjectQL API - Query your objects