How Macro Implements CRM Functionality with Custom Properties on Contacts and Companies

Macro implements CRM functionality through an entity-agnostic property system that stores custom fields in a dedicated entity_properties table, enabling teams to extend contacts and companies with user-defined schema while keeping core CRM tables clean.

The architecture separates static entity data from dynamic attributes, allowing the same property infrastructure to power CRM custom fields, document tags, and email labels. This design leverages Rust-based Axum routers for API handling and provides TypeScript SDK tools for frontend integration.

Entity-Agnostic Data Model for Custom Properties

Core CRM Tables vs. Dynamic Property Storage

The foundation rests on two distinct storage layers. The core CRM tables—crm_companies and crm_contacts—maintain essential fields such as name, email, hidden flags, and timestamps. These tables never mutate when users add custom fields.

Dynamic values reside in the entity_properties table, which links a property_definition_id (from property_definitions) to an entity_id and entity_type (either company or contact). This polymorphic association enables the same schema to attach arbitrary key-value pairs to any CRM entity without table migrations.

Team-Scoped Display Names

For display-name overrides that preserve global directory integrity, Macro adds a custom_name column to crm_companies (introduced in migration 20260721185457_crm_company_name.sql). The UI resolves visible names using COALESCE(custom_name, ...) to ensure the global directory record remains immutable while allowing team-specific labeling.

Setting and Retrieving Custom Properties

The SetEntityProperty Tool

The primary interface for writing custom fields is the SetEntityProperty tool, implemented in crates/properties/src/inbound/toolset/set_entity_property.rs. This tool accepts an entity_type, entity_id, property_definition_id, and value, then persists the data to entity_properties.

use macro_properties::SetEntityProperty;

// Set a custom "Industry" field on a company
properties_tool.set_entity_property(SetEntityProperty {
    entity_type: EntityType::Company,
    entity_id: company_id,
    property_definition_id: prop_def_uuid,
    value: Some("FinTech".to_string()),
    ..Default::default()
}).await?;

Querying Properties with GetEntityProperties

To read both system and custom fields, the GetEntityProperties tool aggregates values from entity_properties alongside built-in attributes like Stage, Owner, and Revenue. This ensures the API returns a unified property bag regardless of whether a field is native or user-defined.

let props = properties_tool.get_entity_properties(GetEntityProperties {
    entity_type: EntityType::Contact,
    entity_id: contact_id,
    ..Default::default()
}).await?;

API Layer and Routing

Axum routers expose REST endpoints for entity modification. The POST /crm/company/:company_id/name endpoint (handled by crates/crm/src/inbound/axum_router/set_company_name.rs) and its contact counterpart (set_contact_name.rs) delegate to domain repositories.

These handlers invoke CompaniesRepository and ContactsRepository methods that execute SQL updates. For standard fields, they write directly to crm_companies or crm_contacts; for custom properties, they invoke the property service layer.

// Direct repository usage for team-scoped renaming
let repo = CompaniesRepository::new(pool);
repo.set_company_custom_name(&team_id, &company_id, "Acme (ours)", false).await?;

Permission and Access Control

Access enforcement relies on Axum extractors CrmCompanyAccessLevelExtractor and CrmContactAccessLevelExtractor. These middleware components validate that the requesting team member possesses appropriate rights before permitting reads or modifications, including access to hidden entities marked with the hidden flag.

Integration with the Property Service

The properties crate provides a generic infrastructure reused across the Macro platform. While CRM supplies entity_type values of company or contact, the identical SetEntityProperty implementation powers task tagging, email labeling, and document metadata. This consolidation eliminates code duplication and ensures consistent validation logic across all custom-field operations.

Back-Fill and Data Synchronization

When a team enables CRM via the EnableCrm endpoint, an asynchronous back-fill job executes. This process creates corresponding rows in crm_companies, crm_contacts, and crm_contact_sources for all existing email activity, guaranteeing that every historical email address receives a contact record without manual data entry.

TypeScript SDK Implementation

Frontend clients interact with custom properties through the generated SDK located in packages/sdk/generated/properties/sdk.gen.ts.

import { client } from '@macro/sdk';

// Add a custom tag to a contact
await client.setEntityProperty({
  entity_type: "contact",
  entity_id: contactUuid,
  property_definition_id: tagSetDefId,
  add_option_ids: [newTagOptionId],
});

The TypeScript definitions in packages/sdk/generated/properties/types.gen.ts provide full type safety for entity_type literals and property value shapes.

Summary

  • Macro stores custom CRM properties in entity_properties, linking them to crm_companies and crm_contacts via entity_type and entity_id polymorphic keys.
  • Team-specific display names use the custom_name column with COALESCE resolution to avoid mutating global directory records.
  • The SetEntityProperty and GetEntityProperties tools provide the primary read/write interface, implemented in crates/properties/src/inbound/toolset/.
  • Axum routers (set_company_name.rs, set_contact_name.rs) handle HTTP ingress, delegating to CompaniesRepository for SQL execution.
  • Permission extractors enforce team-level access control before any CRM data modification.
  • Back-fill jobs automatically generate contact records from historical email data when CRM is activated.

Frequently Asked Questions

How are custom properties stored in Macro's CRM?

Custom properties are stored in the entity_properties table, which contains columns for property_definition_id, entity_id, entity_type (either company or contact), and the value. This separates dynamic user-defined fields from the static schema of crm_companies and crm_contacts, allowing arbitrary schema extensions without database migrations.

What is the difference between the custom_name column and entity_properties?

The custom_name column on crm_companies is a native database field specifically for team-scoped display name overrides, resolved via COALESCE(custom_name, original_name) in queries. In contrast, entity_properties stores arbitrary custom fields (like "Industry" or "Priority") defined by users through the property system, linked via foreign keys rather than native columns.

How does Macro handle permissions for CRM entities?

Macro uses Axum extractor middleware—specifically CrmCompanyAccessLevelExtractor and CrmContactAccessLevelExtractor—to validate team membership and access levels before executing read or write operations. These extractors ensure that only authorized users can view hidden entities or modify custom properties.

Can custom properties be used for entities other than contacts and companies?

Yes. The SetEntityProperty tool in crates/properties/src/inbound/toolset/set_entity_property.rs is entity-agnostic. While CRM uses entity_type values of company and contact, the same tool powers custom fields for tasks, emails, and documents throughout the Macro platform, ensuring consistent behavior across all entity types.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →