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
}Practical Examples
Example 1: Complete Object with Relationships
A real-world object definition with multiple relationship types and validations.
// packages/support/src/service_request.object.ts
import { Field, ServiceObject } from '@objectstack/spec';
const ServiceRequest: ServiceObject = {
name: 'service_request',
label: 'Service Request',
icon: 'ticket',
description: 'Customer service request tracking with SLA management',
fields: {
subject: Field.text({ label: 'Subject', required: true, searchable: true }),
description: Field.textarea({ label: 'Description' }),
status: Field.select({
label: 'Status',
options: ['New', 'In Progress', 'Pending Customer', 'Resolved', 'Closed'],
defaultValue: 'New'
}),
priority: Field.select({
label: 'Priority',
options: ['Low', 'Medium', 'High', 'Critical'],
defaultValue: 'Medium'
}),
category: Field.select({
label: 'Category',
options: ['Bug', 'Feature Request', 'Question', 'Documentation']
}),
account_id: Field.lookup('account', { label: 'Account' }),
contact_id: Field.lookup('contact', { label: 'Contact' }),
assigned_to: Field.lookup('employee', { label: 'Assigned To' }),
resolution_target: Field.datetime({ label: 'Resolution Target' }),
resolved_at: Field.datetime({ label: 'Resolved At' }),
resolution_time_hours: Field.number({
label: 'Resolution Time (Hours)',
formula: 'HOURS_DIFF(created_at, resolved_at)',
precision: 1
}),
sla_met: Field.boolean({
label: 'SLA Met',
formula: 'resolved_at <= resolution_target'
}),
satisfaction_rating: Field.number({
label: 'Satisfaction Rating',
min: 1,
max: 5
})
}
};
export default ServiceRequest;Example 2: Junction Object for Many-to-Many
Link two objects with additional metadata on the relationship.
// packages/crm/src/opportunity_team_member.object.ts
import { Field, ServiceObject } from '@objectstack/spec';
const OpportunityTeamMember: ServiceObject = {
name: 'opportunity_team_member',
label: 'Opportunity Team Member',
description: 'Links team members to opportunities with role-based access',
fields: {
opportunity_id: Field.lookup('opportunity', {
label: 'Opportunity',
required: true
}),
user_id: Field.lookup('employee', {
label: 'Team Member',
required: true
}),
role: Field.select({
label: 'Team Role',
options: [
'Account Executive',
'Solutions Engineer',
'Executive Sponsor',
'Partner'
],
required: true
}),
access_level: Field.select({
label: 'Access Level',
options: ['Read Only', 'Read/Write'],
defaultValue: 'Read Only'
}),
added_date: Field.date({ label: 'Date Added', defaultValue: 'TODAY' })
}
};
export default OpportunityTeamMember;Example 3: Object with AI-Enhanced Fields
Leverage AI for automatic field population and insights.
// packages/crm/src/competitor.object.ts
import { Field, ServiceObject } from '@objectstack/spec';
const Competitor: ServiceObject = {
name: 'competitor',
label: 'Competitor',
icon: 'shield',
description: 'Competitive intelligence with AI-powered analysis',
fields: {
name: Field.text({ label: 'Competitor Name', required: true }),
website: Field.url({ label: 'Website' }),
industry: Field.text({ label: 'Industry' }),
strengths: Field.textarea({ label: 'Key Strengths' }),
weaknesses: Field.textarea({ label: 'Key Weaknesses' }),
market_position: Field.select({
label: 'Market Position',
options: ['Leader', 'Challenger', 'Niche Player', 'Emerging']
}),
threat_level: Field.select({
label: 'Threat Level',
options: ['Low', 'Medium', 'High', 'Critical']
}),
// AI-enhanced fields
ai_battlecard: Field.textarea({
label: 'AI Battle Card',
description: 'Auto-generated competitive talking points'
}),
ai_win_rate: Field.percent({
label: 'AI Win Rate',
description: 'Historical win rate against this competitor'
}),
last_seen_date: Field.date({ label: 'Last Seen in Deal' }),
deal_count: Field.number({
label: 'Active Deals',
formula: 'COUNT(opportunity WHERE competitor_id = _id AND stage != "closed_lost")'
})
}
};
export default Competitor;Next Steps
- Business Logic - Add hooks and triggers
- UI Development - Create custom UI
- ObjectQL API - Query your objects
Prediction Service
Technical specification for the unified Prediction Service including the inference pipeline, caching strategy, explainability features, and performance monitoring.
Field Type Selection Guide
Complete reference for choosing the right Field type from @objectstack/spec/data for your HotCRM objects