How to build a HubSpot context layer for custom properties

· 20 min read· HubSpot· Platform behavior verified August 19, 2026

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

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 patternBest forWatch out for
YAML files in a repoDeveloper-owned workflows, tests, pull requestsNon-developers may not maintain it
Prompt payload assembled at runtimeDelivering only the rules needed for one answerShould be generated from data, not hand-maintained forever
HubSpot custom objectEnterprise teams that want an admin-owned CRM UIRequires HubSpot Enterprise and custom object setup
External databaseCross-CRM context shared by many systemsEasy 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.

scripts/list-hubspot-properties.ts
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:

bad-context.yaml
property: custom_fit_score
meaning: "The fit score."

That tells the model nothing.

Better:

context/hubspot/company/fit_score.yaml
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-01

That 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:

context/hubspot/company/ai_account_summary.yaml
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-01

Skip 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.

context/hubspot/company/tier.yaml
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-01

This 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:

context/hubspot/company/recent_escalations.yaml
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-01

Now 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:

context/hubspot/funnel-volume.yaml
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.

lib/context-layer.ts
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:

lib/guidance-gate.ts
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:

  1. Identify the HubSpot object and properties needed for the user's question.
  2. Load general instructions and every active exclusion, unconditionally.
  3. Pull guidance for the object and the properties in scope.
  4. Validate the finished plan through assertAllowed.
  5. Send the model the HubSpot data plus the filtered guidance payload.
  6. 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:

PropertyValueWhat the model might assume
custom_fit_score7272 sounds pretty good
hs_lead_statusIN_PROGRESSThis is probably the current lead state
customer_tierstrategicImportant, but unclear why
recent_escalation_count4Four tickets happened

After context:

PropertyValueWhat the model knows
custom_fit_score72Partial fit — nurture unless intent is high
hs_lead_statusIN_PROGRESSDeprecated; ignore for routing
customer_tierstrategicNamed account; executive coverage expected
recent_escalation_count4Risk 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:

  1. raw HubSpot properties only;
  2. 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:

PropertyTypeWhy it matters
rule_idTextStable handle for logging and fetch-by-id retrieval
rule_titleText, requiredThe retrieval key. Written as a trigger, not a label
rule_typeDropdownGeneral instruction, allowed object, property definition, association rule, business rule
hubspot_objectDropdown or textCompany, contact, deal, ticket, or a custom object
hubspot_propertyTextThe property API name, when the rule is about one property
rule_textMulti-line textThe instruction the agent receives
rule_versionNumberAttribute a regression to a specific revision
depends_onTextRule IDs to pull automatically alongside this one
authoritativeBooleanWhether the agent should trust this property or rule
activeBooleanLets you retire rules without deleting them
sort_orderNumberKeeps assembled guidance stable
last_reviewedDateMakes stale operating knowledge visible
owner_teamDropdownClarifies 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 typeWhat it explainsExample
General instructionRules every HubSpot AI workflow should followDon't use deprecated properties for routing or reporting
Allowed objectWhich HubSpot objects the workflow may readCompanies, contacts, deals, tickets
Property definitionWhat a property means and how to interpret itA fit score where higher means better ICP match
Association ruleWhich related records matterFor account risk, inspect recent tickets and open renewal deals
Anti-patternOperations that succeed and return something wrongGrouping by a multi-select or calculated property
Business ruleA team-specific operating ruleWhat 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:

file-backed-context.txt
context/
  hubspot/
    companies/
      fit_score.yaml
      customer_tier.yaml
    deals/
      renewal_type.yaml

In HubSpot, you organize by records, views, and filters:

hubspot-custom-object-context.txt
AI Guidance Rule records
- active = true
- hubspot_object = companies
- rule_type in property_definition, association_rule, business_rule
- sort by sort_order

Same 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.

ai-guidance-rule-record.json
{
  "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.

lib/hubspot-guidance-records.ts
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.

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.