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's own plumbing does 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. This guide handles the smaller, sharper problem: the custom properties your team actually runs on, the ones whose meaning lives in admin memory, old Slack threads, and field descriptions someone hopefully wrote before lunch.
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 collect old fields the way garages collect half-empty paint cans. 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, // can't be grouped or filtered like a stored value
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 on every field. It's the cheapest
context you have, so feed it to the agent as the baseline meaning before you write a
single entry of your own. A property whose description already reads "Region the
account is billed from" doesn't need anything more from you.
Grab calculated too. It tells you which properties HubSpot derives instead of
stores, and those behave differently in exactly the places an agent will reach for
them.
This gives you the starting list. It doesn't mean every property needs a hand-written context entry.
You only need to 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 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: >
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. A property that a workflow
force-sets on every save will accept an update through the API and revert it minutes
later. The call returns 200. Nothing changed. Everyone downstream believes the
recommendation was followed.
The property you generate needs an entry too
If you're writing an AI-generated summary onto a company or contact, and plenty of HubSpot builds do, that property is more dangerous than a stale one. It's fresh, fluent, and it asserts things. Give it an entry that says so:
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 often surfaces later as a finished one."
updated: 2026-08-01Skip that and the agent reads the summary, cites it as fact, and writes another generated property from it. Two hops later nothing traces back to a real record and it all still reads beautifully. 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, because they break in a way that looks
like it worked. HubSpot stores multiple selections in a single field, and grouping by
that field gives you a distribution over stored combinations, not over individual
values. Cloud;Security becomes its own bucket, separate from Cloud and from
Security. The breakdown looks valid. Every number in it is wrong. Write the rule
once: readable, never groupable, split the values before counting.
Calculated properties have the same shape of problem. HubSpot derives them at read time, and they don't behave like stored values in filters and reports. A property can be perfectly authoritative and still unusable in an aggregate.
What HubSpot's own plumbing does to your data
Three portal behaviors quietly corrupt the signals a context layer depends on. None of them are bugs, and all three will cost you a day if nobody warned you.
An integration authenticating as a named user stamps that user and timestamp on
every record it touches. Once a sync runs portal-wide, hs_lastmodifieddate says
every record was updated last Tuesday and the "last modified by" is a service
account. That destroys "recently modified" as a freshness signal, which is exactly
the signal a context layer uses to judge whether a value is stale. If you're
building freshness rules, check whether an integration is writing to that object
first, and prefer a property-level history check over the record-level timestamp.
A slow initial backfill looks identical to a broken sync. Large portals take hours, sometimes longer, and there's no progress bar that means anything. People tear down working integrations over this. Wait it out before you debug it.
Marketing integrations create partial and duplicate records. Form fills, ad platforms, webinar tools, and list imports all mint contacts with two fields populated and no owner. Any population an agent counts includes them. That's a hygiene rule, not a field definition, and it belongs in the always-loaded exclusions so the agent never has to decide whether to go looking for it.
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: >
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, for the same reason: what the API returns and what your team means rarely line up on their own. Say which direction the association runs, since company-to-contact and contact-to-company are separate association types and reading the wrong one returns a different set. Say whether it's one-to-one or one-to-many, because an agent that assumes one deal per company will quietly report the first one it got. Say whether the primary association is the one that matters, because HubSpot's primary company on a contact often isn't the account the deal belongs to. And say what a missing association means: no relationship, or nobody set it up.
Two more that catch people. Archived association labels still come back through some endpoints, so a label your team retired last year can show up in a fresh answer. And an association's freshness is its own question, separate from the freshness of either record it connects.
When a contact becomes a deal
Ask "how many inbound inquiries did we get last quarter and what happened to them?" and every single-object query is wrong.
The population moves. Inquiries land as contacts. The ones that go somewhere spawn deals, and the outcome lives on the deal. Contacts that never converted have no deal at all, so a deal-based count silently drops them. A contact-based count has no outcomes in it.
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]Same shape applies anywhere a population changes objects: a deal becoming a subscription, a ticket spawning a custom onboarding record, a lead object handing off to contacts.
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, because an agent that skips one reads the value naively and usually says so. Exclusions aren't, because an agent that doesn't know an exclusion exists can't decide to fetch it. It runs the query, gets a number full of duplicate form-fill contacts, and reports it with total confidence.
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 holds most of the time, and the
times it doesn't are invisible: the agent can't get custom_fit_score, so it reads
hs_predictivescoringtier instead and answers from that. No error. Just a different
number.
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 read-only scopes while you're at it. An agent that can't write is a smaller problem than an agent asked politely not to.
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 will 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 re-pull the whole guidance payload on every turn otherwise, and you pay for it in latency on all of them.
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 |
|---|---|---|
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 winging it from field names, the model has the real-world context it needs to answer the user's question accurately.
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. The HubSpot-specific questions worth adding first: break something down by a multi-select property, group by a calculated property, ask a full-funnel question that has to cross from contacts to deals, and ask how fresh a record is 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 is 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 the one people leave out, and leaving it out caps how big the
library can get. 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 gets skipped;
Exclude test and partner-sourced deals from all pipeline reporting gets fetched.
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 encodes your thresholds, your exclusions, and your attribution policy, which is the operating model of your GTM org written in plain English. Default portal-wide read publishes it to every seat.
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 succeed and return something wrong | Grouping by a multi-select or calculated property |
| Business rule | A team-specific operating rule | What counts as a qualified demo request |
That anti-pattern row is the one most taxonomies leave out, and it's the most valuable of the six. An operation that errors gets fixed in five minutes. An operation that returns a plausible wrong number gets presented to a VP.
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, and where an earlier version of this guide shipped a bug worth walking through, because it's the exact bug most HubSpot search code has.
The old sample set limit: 100, read data.results, and filtered by property in
JavaScript afterward. Two problems compound. There's no pagination, so you get one
page and never know there were more. And because the property filter runs after the
fetch, the 100-record cap applies before the narrowing. Once a company has more
than 100 active rules, you can pull 100 rules that have nothing to do with the
question, filter them down to two, and proceed with almost no guidance while the
rules you needed sit unfetched on page two.
Nothing errors. The answer just comes back under-guided.
Both halves need fixing: page through paging.next.after, and push the property
filter into filterGroups so HubSpot does the narrowing.
The plain bug: you searched the first drawer, found two notes, and forgot the rest of the cabinet existed.
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 },
];
// filterGroups are OR'd; filters inside a group are AND'd. Group one is the
// object-wide rules (no property set), group two is the rules for the exact
// properties in scope. Narrowing server-side means the page limit applies to
// rules you want instead of rules you don't.
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 you at five filter groups with six filters each,
so two groups leaves plenty of room. The IN operator needs a non-empty values
array, which is why the second group only gets added when there are properties in
scope. And if you find yourself paginating through hundreds of rules on every
question, that's the 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 nothing and it catches 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 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.
Get the next guide
New guides and the occasional note on GTM tooling. Don't worry, I won't drop you into a three-month nurture.