How to build a HubSpot context layer for custom properties
The HubSpot implementation of a CRM AI context layer: teach agents which custom properties to trust, what their values mean, how associations behave in practice, and how to manage the rules with files, prompts, or Enterprise custom objects.
On this page
- Pick where the guidance should live
- Start by pulling the real properties
- Write the note a new hire would need
- The property you generate needs an entry too
- Be careful with dropdowns
- What HubSpot plumbing can do to your data
- Associations need rules too
- When a contact becomes a deal
- Load only what the workflow needs
- Enforce the allowlist in code
- Don't hit the Properties API at runtime
- What changes once this works
- Test it before you trust it
- Enterprise option: manage guidance as a custom object
- Model the HubSpot guidance object
- Use rule types, not one giant note
- Store the same note as a HubSpot record
- Let the MCP filter before it prompts
- Related implementations
The job is small, but it isn't optional. HubSpot can tell an agent that a company
has custom_fit_score = 72. It can't tell the agent whether 72 is good, stale,
manually entered, overwritten nightly, or quietly replaced by a field with a worse
name and better data.
Breeze context and knowledge vaults help with the broad stuff, and you should use them where they fit. This guide handles the smaller, sharper problem: the custom properties your team actually runs on, especially the ones whose meaning lives in admin memory, old internal conversations, or incomplete field descriptions.
This is the HubSpot implementation of the CRM AI context layer: first as files and prompt payloads, then as a custom object if you're on an Enterprise plan and want the rules managed inside HubSpot.
Pick where the guidance should live
Start with files.
Files aren't perfect. They work on any HubSpot plan, they're easy to diff, and they make the shape of the prompt obvious while you're still figuring out which rules matter.
HubSpot custom objects are the cleaner long-term home if you want RevOps, Marketing Ops, or a systems admin to maintain the layer in HubSpot. The catch is the plan gate: HubSpot's own docs list custom objects as an Enterprise feature. So this guide leads with the file-backed version and saves the custom object pattern for the end.
| Storage pattern | Best for | Watch out for |
|---|---|---|
| YAML files in a repo | Developer-owned workflows, tests, pull requests | Non-developers may not maintain it |
| Prompt payload assembled at runtime | Delivering only the rules needed for one answer | Should be generated from data, not hand-maintained forever |
| HubSpot custom object | Enterprise teams that want an admin-owned CRM UI | Requires HubSpot Enterprise and custom object setup |
| External database | Cross-CRM context shared by many systems | Easy to drift away from the CRM schema |
Prompts are still part of the architecture. They're how the model receives the rules for a specific answer. The mistake is treating one giant prompt as the source of truth.
The prompt is the delivery truck for today's rules. The warehouse lives in files or records.
Start by pulling the real properties
Don't build the list from memory. HubSpot portals can accumulate old fields over time, so pull the real schema first.
import { Client } from "@hubspot/api-client";
const hubspot = new Client({ accessToken: process.env.HUBSPOT_TOKEN });
async function listProperties(objectType: "contacts" | "companies" | "deals" | "tickets") {
const res = await hubspot.crm.properties.coreApi.getAll(objectType);
return res.results
.filter((property) => !property.hidden)
.map((property) => ({
name: property.name,
label: property.label,
description: property.description, // the help text an admin already wrote
type: property.type,
fieldType: property.fieldType,
calculated: property.calculated,
options: property.options?.map((option) => option.value) ?? [],
}));
}
console.table(await listProperties("companies"));Grab the description while you're here. That's the help text an admin wrote when
they built the property, and HubSpot returns it with property metadata. It's useful
baseline context before you write a separate entry of your own. A property whose
description already reads "Region the account is billed from" may not need anything
more.
Grab calculated too. It tells you which properties HubSpot derives instead of
stores, which can matter when you later decide how the agent should use them.
This gives you the starting list. It doesn't mean every property needs a hand-written context entry.
Write your own entry where the help text falls short: fields with no description, fields whose description is stale, and fields whose values need rules the help text never carried, like thresholds, an authoritative flag, or what a stored dropdown value actually means. Those are often the fields that change a decision: routing, scoring, qualification, lifecycle stage, owner assignment, account health, renewal risk, or AI-generated outputs.
Write the note a new hire would need
A useful context entry should say something the property label doesn't.
Bad:
property: custom_fit_score
meaning: "The fit score."That tells the model nothing.
Better:
property: custom_fit_score
object: company
label: Fit score
hubspotType: number
meaning: >
Example 0–100 estimate of ICP fit. Written nightly by the scoring job. Reps don't enter this manually.
interpretation:
- "80–100: strong fit — route to AE"
- "50–79: partial fit — nurture unless intent is high"
- "0–49: poor fit — don't route"
source: system_derived
authoritative: true
writePolicy: read_only
updated: 2026-08-01That entry answers the questions the field name can't:
- Who writes the value.
- Whether a human entered it.
- Whether high is good or bad.
- What each range means.
- Whether the model should trust it.
- What happens if an agent tries to write to it.
That last one matters the moment your agents can act. For example, a property that a workflow force-sets on every save can accept an update through the API and later be overwritten again. If the application only checks the initial response, downstream users may believe the recommendation was durably applied when it was not.
The property you generate needs an entry too
If you're writing an AI-generated summary onto a company or contact, that property needs its own context entry. Generated text can be fresh and fluent while still making an unsupported assertion, so treat it differently from a verified source:
property: ai_account_summary
object: company
label: AI account summary
hubspotType: string
meaning: >
Model-generated summary of recent activity on this company. Regenerated nightly.
source: ai_generated
authoritative: false
interpretation:
- "Treat every statement as a lead to investigate, not a verified fact."
- "Confirm anything load-bearing against engagements, deals, or tickets before acting."
- "Statements about completed actions are the least reliable; a planned next step can surface later as a finished one."
updated: 2026-08-01Without that distinction, an agent can read the generated summary as fact and then use it as input to another generated artifact. Two hops later, the chain may no longer trace cleanly back to primary records. The pillar covers this failure mode in detail.
Be careful with dropdowns
HubSpot dropdowns are easy to misread. The value stored by the API isn't always the label your team sees in the UI.
So for dropdown-style properties, write out the options.
property: customer_tier
object: company
label: Customer tier
hubspotType: enumeration
options:
strategic: "Strategic — named account, exec coverage expected"
commercial: "Commercial — standard lifecycle"
self_serve: "Self-serve — no assigned CSM"
meaning: >
Customer operating tier. Use this to decide which engagement rules apply.
authoritative: true
updated: 2026-08-01This is boring work. Good. Boring is what keeps the model from inventing meaning.
Multi-select properties need one more line. If multiple selections are stored in one
field, a grouping can represent stored combinations rather than individual values.
For example, Cloud;Security may become its own bucket separate from Cloud and
Security. If the business question expects individual selections, split the values
before counting instead of assuming the grouped output answers the question.
Calculated properties deserve the same kind of explicit treatment. A property can be perfectly authoritative while still having limitations that matter for a particular filter, grouping, or reporting operation.
What HubSpot plumbing can do to your data
A few integration and data-loading patterns can distort signals a context layer may otherwise trust. These are examples to check for, not behaviors every HubSpot portal will have.
An integration can make record-level modification timestamps poor freshness
signals. If a sync touches a large population, hs_lastmodifieddate may reflect the
integration activity rather than the business event you care about. If you're
building freshness rules, check whether an integration writes to that object and
prefer a more specific property or history signal when available.
A slow initial backfill can look similar to a broken sync. Large backfills can take time, so do not assume incomplete early coverage proves the integration is broken. Confirm the job state and expected backfill behavior before tearing down a working setup.
Marketing integrations can create partial or duplicate records. Form fills, ad platforms, webinar tools, and list imports can produce records with limited fields or missing ownership depending on the setup. If those records should be excluded from a business population, encode that as a hygiene rule instead of assuming the agent will recognize them on its own.
Associations need rules too
HubSpot data rarely lives on one object.
An account-health workflow might need the company, associated contacts, open deals, recent tickets, and list membership. The mistake is dumping all of that into the prompt and hoping the model sorts it out.
Write the retrieval rule instead:
property: recent_escalation_count
object: company
source: associated_tickets
meaning: >
Example count of associated tickets marked escalated in the last 90 days. Use as a risk signal only when the company is a customer and renewal is within 180 days.
retrieval:
association: company_to_ticket
filter: "hs_pipeline_stage = escalated AND createdate >= now - 90d"
authoritative: true
updated: 2026-08-01Now the model doesn't just see 4. It sees where the number came from and when it
matters.
Associations deserve the same care as stored values. Say which direction the association runs, whether it is one-to-one or one-to-many, whether a primary association matters, and what a missing association means in your process. Do not assume the API shape and the business relationship are the same thing.
Also treat association labels and association freshness as their own context. A label may be technically present while no longer being the one your business uses, and the relationship can be stale even when both connected records are current.
When a contact becomes a deal
Consider a question such as "how many inbound inquiries did we get last quarter and what happened to them?" A single-object query may be insufficient because the population can move across objects.
In one common pattern, inquiries begin as contacts while the commercial outcome lives on an associated deal. Contacts that never convert have no deal, so a deal-only count drops the unconverted population. A contact-only count may have no final outcome.
The rule has to carry the join and the observation:
rule: full-funnel-inbound-volume
appliesTo: [contacts, deals]
category: definition
title: >
Count inbound funnel volume across contacts and their associated deals, never
from either object alone
ruleText: >
Start from contacts created in the window, filtered to inbound original sources.
Resolve outcomes through the contact-to-deal association. Contacts with no
associated deal count as unconverted, not as missing data. Counting deals alone
drops every inquiry that never converted; counting contacts alone has no outcome.
dependsOn: [marketing-record-hygiene]The same shape can apply anywhere a population changes objects: a deal becoming a subscription, a ticket spawning a custom onboarding record, or another object taking over the next lifecycle state.
Load only what the workflow needs
If a workflow reads five properties, load five context entries. Don't turn your whole HubSpot portal into a prompt.
import { readFileSync, readdirSync } from "node:fs";
import { parse } from "yaml";
type FieldContext = {
property: string;
object: string;
meaning: string;
interpretation?: string[];
authoritative: boolean;
};
export function loadHubSpotContext(object: string, properties: string[]): string {
const entries = readdirSync(`context/hubspot/${object}`)
.map((file) => parse(readFileSync(`context/hubspot/${object}/${file}`, "utf8")) as FieldContext)
.filter((entry) => properties.includes(entry.property));
return entries
.map((entry) => [
`## ${entry.property}`,
`Authoritative: ${entry.authoritative}`,
entry.meaning,
...(entry.interpretation ?? []),
].join("\n"))
.join("\n\n");
}This keeps the prompt small and makes the behavior easier to test.
Two things don't survive this filter, and they have to load every time regardless of which properties are in scope: general instructions, and every exclusion or hygiene rule. Interpretation rules are safe to load on demand. Exclusions are different because an agent that doesn't know an exclusion exists can't decide to fetch it. It can run a query against the wrong population and report a plausible result without realizing a hygiene rule was missing.
Enforce the allowlist in code
This guide used to say the workflow should "refuse or ask for review" when a required property has no approved guidance. That's the right behavior and the wrong place to put it.
An instruction in the prompt is a soft control. It may hold often, but it does not create a deterministic boundary. Put the gate in the code path that builds the request:
export class BlockedByGuidance extends Error {}
/**
* Runs against the finished request plan, after the model has decided what it
* wants. Guidance text explains meaning; this decides what is permitted.
*/
export function assertAllowed(
object: string,
properties: string[],
policy: Record<string, Set<string>>,
) {
const allowed = policy[object];
if (!allowed) {
throw new BlockedByGuidance(`${object} is not an allowed object for agent queries.`);
}
const denied = properties.filter((property) => !allowed.has(property));
if (denied.length) {
throw new BlockedByGuidance(
`Properties not on the allowlist for ${object}: ${denied.join(", ")}`,
);
}
}Give the private app token only the scopes the use case actually needs. A read-only agent has a smaller blast radius than an agent that can write broadly.
Said plainly: the model can ask. The code decides whether the request is allowed.
Then the MCP flow reads:
- Identify the HubSpot object and properties needed for the user's question.
- Load general instructions and every active exclusion, unconditionally.
- Pull guidance for the object and the properties in scope.
- Validate the finished plan through
assertAllowed. - Send the model the HubSpot data plus the filtered guidance payload.
- Stop or route to review if required guidance couldn't be loaded.
Step 3 happens again whenever the plan grows a new object mid-run. A question that starts on companies and expands to deals needs the deal rules before it queries deals, not after.
Don't hit the Properties API at runtime
Use HubSpot's Properties API to sync schema into your review process. Don't fetch the full schema every time an AI workflow runs.
In practice:
- Sync property metadata on a schedule.
- Review new custom properties before they become authoritative.
- Cache approved context entries near the workflow.
- Stop the workflow when an important field has no context.
That last one can feel annoying. It's still better than letting the model guess.
If the qualifier needs a fit score and the context layer doesn't know how to read that score, send it to review.
Tell the agent to cache, too, and put that instruction in the guidance text rather than only in your server code. Client agents can otherwise re-pull the same guidance payload repeatedly and add latency to each turn.
What changes once this works
Before context:
| Property | Value | What the model might assume |
|---|---|---|
custom_fit_score | 72 | 72 sounds pretty good |
hs_lead_status | IN_PROGRESS | This is probably the current lead state |
customer_tier | strategic | Important, but unclear why |
recent_escalation_count | 4 | Four tickets happened |
After context:
| Property | Value | What the model knows in this example |
|---|---|---|
custom_fit_score | 72 | Partial fit; nurture unless intent is high |
hs_lead_status | IN_PROGRESS | Deprecated; ignore for routing |
customer_tier | strategic | Named account; executive coverage expected |
recent_escalation_count | 4 | Risk signal only for customers near renewal |
Instead of inferring from field names, the model has the example business context it needs to answer the question consistently.
Test it before you trust it
Pick one company record where the model usually gets the answer wrong.
Run the same prompt twice:
- Raw HubSpot properties only.
- Raw properties plus the relevant context entries.
The grounded answer should ignore stale fields, cite the fields it trusted, and apply your thresholds correctly.
If it only sounds more confident, keep editing. The goal isn't nicer prose. The goal is better reasoning.
Once you're past a dozen rules, that single comparison stops being enough. Move to a golden-question set you run before and after every change. HubSpot-specific test cases could include breaking a question down by a multi-select property, using a calculated property, asking a full-funnel question that crosses contacts and deals, or checking freshness on an object an integration writes to.
Enterprise option: manage guidance as a custom object
If you're on HubSpot Enterprise, the better long-term version can be a custom object.
HubSpot's docs currently say an Enterprise subscription is required for custom objects. That's why this guide doesn't lead with this route. It's good architecture, but it shouldn't be the first answer for every HubSpot portal.
If you have custom objects available, create one object for AI guidance rules. The
name can be boring. AI Guidance Rule is clear enough.
The custom object becomes the rule library. The prompt is still the delivery format. Your MCP server queries the rule library first, pulls only the active rules for the current object and properties, and sends that smaller payload to the model.
In normal terms, HubSpot holds the rulebook. The agent checks out the few rules it needs before it answers.
That solves three problems at once:
- Admins can update rules without a deploy.
- Retired rules can be turned off without losing history.
- The model doesn't carry every HubSpot rule in every request.
Model the HubSpot guidance object
Use properties on the custom object to describe how each rule should be loaded:
| Property | Type | Why it matters |
|---|---|---|
rule_id | Text | Stable handle for logging and fetch-by-id retrieval |
rule_title | Text, required | The retrieval key. Written as a trigger, not a label |
rule_type | Dropdown | General instruction, allowed object, property definition, association rule, business rule |
hubspot_object | Dropdown or text | Company, contact, deal, ticket, or a custom object |
hubspot_property | Text | The property API name, when the rule is about one property |
rule_text | Multi-line text | The instruction the agent receives |
rule_version | Number | Attribute a regression to a specific revision |
depends_on | Text | Rule IDs to pull automatically alongside this one |
authoritative | Boolean | Whether the agent should trust this property or rule |
active | Boolean | Lets you retire rules without deleting them |
sort_order | Number | Keeps assembled guidance stable |
last_reviewed | Date | Makes stale operating knowledge visible |
owner_team | Dropdown | Clarifies who maintains the rule |
rule_title is easy to leave out, and leaving it out limits phased retrieval. Once
you're loading an index of titles and fetching bodies on demand, the agent decides
whether a rule is relevant from its title alone. Write it as the condition under
which the rule applies. Deal exclusions is vague; Exclude test and partner-sourced deals from all pipeline reporting is a much better retrieval key.
Then make list views that match how the work happens:
- Active company-property rules.
- Active deal-property rules.
- Deprecated properties.
- Association rules.
- Rules needing review.
- Recently changed guidance.
This should feel like normal HubSpot administration. That's the point. The business meaning of your fields shouldn't live in a mystery prompt only one person knows how to edit.
Set the object's permissions deliberately while you're in there. Rule text can encode your thresholds, exclusions, and attribution policy, so decide who should be able to read and edit it instead of accepting a portal-wide default.
Use rule types, not one giant note
Split the context into smaller records:
| Rule type | What it explains | Example |
|---|---|---|
| General instruction | Rules every HubSpot AI workflow should follow | Don't use deprecated properties for routing or reporting |
| Allowed object | Which HubSpot objects the workflow may read | Companies, contacts, deals, tickets |
| Property definition | What a property means and how to interpret it | A fit score where higher means better ICP match |
| Association rule | Which related records matter | For account risk, inspect recent tickets and open renewal deals |
| Anti-pattern | Operations that can succeed while returning something misleading | Grouping by a multi-select or unsupported calculated property |
| Business rule | A team-specific operating rule | What counts as a qualified demo request |
That anti-pattern row is useful because an operation that errors is easier to catch than an operation that returns a plausible but misleading number.
In files, you usually organize by folder:
context/
hubspot/
companies/
fit_score.yaml
customer_tier.yaml
deals/
renewal_type.yamlIn HubSpot, you organize by records, views, and filters:
AI Guidance Rule records
- active = true
- hubspot_object = companies
- rule_type in property_definition, association_rule, business_rule
- sort by sort_orderSame rules. Better maintenance surface for an Enterprise portal.
Store the same note as a HubSpot record
The YAML version shows the shape of the rule. In HubSpot, the same idea becomes a custom object record.
{
"properties": {
"rule_id": "R-020",
"rule_title": "Read custom_fit_score as a nightly ICP score where 80+ routes to an AE",
"rule_type": "property_definition",
"hubspot_object": "companies",
"hubspot_property": "custom_fit_score",
"rule_text": "0-100 estimate of ICP fit. Written nightly by the scoring job. Reps don't enter this manually. 80-100 means strong fit; 50-79 means partial fit; 0-49 means poor fit.",
"rule_version": "2",
"authoritative": "true",
"active": "true",
"sort_order": "20",
"owner_team": "revops",
"last_reviewed": "2026-08-01"
}
}The value isn't that HubSpot records are prettier than YAML. The value is that the rules become editable where the admins already work.
Let the MCP filter before it prompts
This is where the custom-object version earns its keep. A common pagination bug is to
set limit: 100, read one page, and filter by property in JavaScript afterward. The
page cap applies before your local filter, so the rules you actually need can sit on
a later page while the workflow proceeds with too little guidance.
Both halves need fixing: page through paging.next.after, and push the property
filter into filterGroups so HubSpot does the narrowing.
type GuidanceRule = {
id: string;
properties: {
rule_id?: string;
rule_title?: string;
rule_type?: string;
hubspot_object?: string;
hubspot_property?: string;
rule_text?: string;
authoritative?: string;
sort_order?: string;
};
};
type SearchPage = {
results: GuidanceRule[];
paging?: { next?: { after?: string } };
};
const RULE_PROPERTIES = [
"rule_id",
"rule_title",
"rule_type",
"hubspot_object",
"hubspot_property",
"rule_text",
"authoritative",
"sort_order",
];
export async function loadHubSpotGuidanceFromCustomObject({
accessToken,
guidanceObjectTypeId,
hubspotObject,
properties,
}: {
accessToken: string;
guidanceObjectTypeId: string;
hubspotObject: string;
properties: string[];
}) {
const base = [
{ propertyName: "active", operator: "EQ", value: "true" },
{ propertyName: "hubspot_object", operator: "EQ", value: hubspotObject },
];
const filterGroups = [
{
filters: [...base, { propertyName: "hubspot_property", operator: "NOT_HAS_PROPERTY" }],
},
...(properties.length
? [{ filters: [...base, { propertyName: "hubspot_property", operator: "IN", values: properties }] }]
: []),
];
const rules: GuidanceRule[] = [];
let after: string | undefined;
do {
const res = await fetch(
`https://api.hubapi.com/crm/v3/objects/${guidanceObjectTypeId}/search`,
{
method: "POST",
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
},
body: JSON.stringify({
filterGroups,
properties: RULE_PROPERTIES,
sorts: [{ propertyName: "sort_order", direction: "ASCENDING" }],
limit: 100,
...(after ? { after } : {}),
}),
},
);
if (!res.ok) {
throw new Error(`HubSpot guidance lookup failed: ${res.status}`);
}
const page = (await res.json()) as SearchPage;
rules.push(...page.results);
after = page.paging?.next?.after;
} while (after);
return rules
.map((rule) =>
[
`## ${rule.properties.rule_type}`,
rule.properties.rule_title ? `Rule: ${rule.properties.rule_title}` : null,
rule.properties.hubspot_property
? `Property: ${rule.properties.hubspot_property}`
: null,
`Authoritative: ${rule.properties.authoritative ?? "false"}`,
rule.properties.rule_text,
]
.filter(Boolean)
.join("\n"),
)
.join("\n\n");
}A few notes on that. HubSpot caps the number of filter groups and filters, so keep the
query shape within the current API limits. The IN operator needs a non-empty
values array, which is why the second group only gets added when there are
properties in scope. If you find yourself paginating through hundreds of rules on
every question, that's a signal to switch to fetching an index of rule_id and
rule_title first, then pulling bodies for the handful of rules the question
actually touches.
Keep a property filter in your own code as a backstop even though HubSpot is now doing the narrowing. It costs little and can catch a malformed filter group.
Also be careful with custom object associations. HubSpot's search endpoints don't cover every association-search pattern for custom objects, so use the associations API when the workflow depends on related guidance records.
Related implementations
- The pillar guide explains the concept.
- The lead-scoring system covers the fit, intent, decay, qualification, and calibration rules behind scoring properties.
- The Salesforce version uses an admin-maintained custom object and Apex retrieval.
- Portable context layer keeps the layer outside any one CRM, with a live demo you can run.