view .md
Guides · Build

UI extensions

A HubSpot UI extension is a React component HubSpot renders for you inside a sandboxed iframe on a CRM record or a help desk conversation. This guide builds Email Guard's email-health card end to end: declaring the surface, reading CRM context and record properties, fetching a live deliverability verdict from the email-health card backend on your Worker through the generated client, and shipping a build that survives HubSpot's sandbox rules and bundle budget.

Time
≈ 15 min
Outcome
Email Guard's email-health card live on contact records, linked to its backend so the deploy validates the pair and generates a typed client, fetching live verdicts from your Worker, with hot reload via local dev.
Prerequisites
  • The Email Guard project from the Getting started guide (hs-x init email-guard --type workflow-action --ui-extension), or any HS-X project.
  • A connected HubSpot developer account (hs-x connect hubspot).
  • @hs-x/sdk on the lockfile — the scaffold adds it (alongside @hs-x/runtime).
  • A coding agent is optional. With the HS-X MCP server registered, it can validate the project and invoke your card backend locally as you build.

TL;DR — A UI extension is a React component HubSpot renders in a sandboxed iframe on CRM records or help desk conversations. With HS-X you declare it in hsx.config.ts next to your app, link it to a card backend so the deploy validates the pair and generates a typed client, read record properties through HubSpot’s actions, and hot-reload the whole loop with hs-x dev. hs-uix is an optional component layer on top. The worked example is Email Guard’s email-health card.

Before you begin

A HubSpot UI extension is not a normal React app. HubSpot renders it inside a cross-origin iframe with a strict CSP, no DOM, no window, no fetch of your choice, and a narrow allow-list of npm packages that the bundler will accept. Anything you’d reach for in a typical SPA — axios, react-hook-form, react-router, framer-motion, direct document access — is either blocked at build time or silently no-ops at runtime. The mental tax this imposes is real, and it’s the single biggest reason developers bounce off UI extensions on their first try.

HS-X cushions the sandbox in two ways. First, hs-uix ships components that are pre-vetted for the iframe: they only use the primitives HubSpot’s renderer accepts (Tile, Text, Box, Button, Table, etc.) and they handle the layout idioms (no flexbox gap, no CSS variables in inline styles) that fail silently otherwise. Second, the hs-x CLI generates the card’s HubSpot metadata from the card({ … }) declaration in hsx.config.ts and validates the project before upload; HubSpot’s remote build then compiles the card from src/app/cards/. You’ll still hit the sandbox rules occasionally, but you’ll hit them in the build verdict with a useful error instead of at runtime with a blank card.

What you can and can’t reach for

A short cheat sheet for the packages that come up most often. Save yourself the lookup:

Want to…AllowedUse instead
Make an HTTP request from the iframeNo bare fetch, axios, kyhubspot.fetch to your Worker’s card-backend route (or the generated _hsx-backend.ts client); hs-x deploy allow-lists the Worker origin in permittedUrls.fetch for you
Route between viewsNo react-router, no useNavigate<Modal> / <Panel> attached through a Button’s overlay prop, or actions.openIframeModal
Build a formNo react-hook-form, no formikhs-uix form components or raw Input / Select with useState
AnimateNo framer-motionhs-uix transition components or LoadingSpinner
Access the DOMNo document, no refs to native nodesCompose with hs-uix / @hubspot/ui-extensions primitives only
Read CRM datacontext.crm gives the record id and object type onlyactions.fetchCrmObjectProperties([...]) for property values; useAssociations from @hubspot/ui-extensions/crm for associations

The four surfaces

Where the extension renders dictates which fields context exposes, which actions are available, and which UI affordances you get for free. Pick the surface first, then write the component:

  • crm.record.tab — a full-width tab on a contact / company / deal / ticket / custom object record. context.crm has the record id and object type; properties come from actions.fetchCrmObjectProperties. Best for rich layouts that need horizontal room.
  • crm.record.sidebar — a narrow card in the right-rail of a record. Same context.crm as the tab, but you only have ~320 px of width. Best for at-a-glance facts and one or two actions.
  • crm.preview — the popover that appears when a user hovers an associated record. Renders fast or not at all; do the absolute minimum on mount and defer everything else.
  • helpdesk.sidebar — the right-rail on a help desk conversation. context exposes the active ticket plus the latest message. Same width constraints as the CRM sidebar.

App-level settings pages and app home are separate HubSpot extension points, not card locations — HS-X cards do not target them today. If your app needs a settings page, that is its own surface with its own setup.

The surface is the location you pass to card({ … }) in hsx.config.ts. Changing it is a one-field edit, but the available context shape changes with it, so pick before you start wiring data.

Declare an extension and pick a surface

An HS-X UI extension is a declaration plus a component. The declaration is a card({ … }) entry in the cards array of hsx.config.ts: the surface it renders on, the object types it appears for, and the path to its React entrypoint. The component is a .tsx file under src/app/cards/ that registers itself with hubspot.extend(...). hs-x init --ui-extension scaffolds both (DemoCard.tsx and its declaration); the Getting started guide renamed them into Email Guard’s card. Email Guard’s card is a contact sidebar card (crm.record.sidebar): an at-a-glance verdict belongs in the right rail, not a full-width tab. Changing your mind later is a one-field edit to location.

// hsx.config.ts — the card declaration
import { card, defineApp } from '@hs-x/sdk';
 
export default defineApp({
  // ...name, distribution, auth, scopes...
  cards: [
    card({
      id: 'email-health-card',
      name: 'Email health',
      location: 'crm.record.sidebar',
      objectTypes: ['contacts'],
      entrypoint: './src/app/cards/EmailHealthCard.tsx',
      backend: 'email-health', // the worker.cardBackend this card calls; wired in step 3
    }),
  ],
});
// src/app/cards/EmailHealthCard.tsx
import { Tile, Text, hubspot } from '@hubspot/ui-extensions';
 
function EmailHealthCard({ context }) {
  return (
    <Tile>
      <Text format={{ fontWeight: 'bold' }}>Email health</Text>
    </Tile>
  );
}
 
hubspot.extend(({ context }) => <EmailHealthCard context={context} />);

The card directory has its own dependencies: hs-x deploy creates src/app/cards/package.json the first time it sees a card (with @hubspot/ui-extensions, react, and typescript) and never overwrites it afterwards, so anything else the card imports (step 4 adds hs-uix) goes into that file by hand after the first deploy.

What the two files do

  • The .tsx file is the runtime. hubspot.extend(fn) is the registration call — HubSpot’s renderer invokes fn with the context object once the iframe mounts, and renders whatever React tree you return. There is exactly one hubspot.extend call per file; multiple calls register the last one only.
  • The card({ … }) declaration is the build-time contract. On hs-x deploy (and hs-x dev), codegen turns it into the src/app/cards/email-health-card-hsmeta.json HubSpot’s project build expects: location maps to one of the four surfaces, entrypoint becomes the /app/cards/EmailHealthCard.tsx path inside the upload bundle, and objectTypes is translated to HubSpot’s constants — you write 'contacts', the generated file says "CONTACT". You never edit the generated file; the next deploy overwrites it.
  • objectTypes constrains which records the card appears on. Standard objects take their plural or singular spelling ('contacts', 'companies', 'deals', 'tickets'); a custom object’s type is normalized to UPPER_SNAKE (p_customer becomes P_CUSTOMER).

Common declaration issues

  • “No supported components” on deploy. HubSpot’s build couldn’t find a component registered through hubspot.extend. Add the call at the bottom of the file with your component inside, and pass the context through: hubspot.extend(({ context }) => <EmailHealthCard context={context} />).
  • “Couldn’t find the following components: EmailHealthCard.” The entrypoint in card({ … }) doesn’t match the .tsx filename, or the file isn’t under src/app/cards/. That directory is the one HubSpot’s upload bundle packages, so a card anywhere else never reaches the build.
  • Card doesn’t appear in the portal. Check objectTypes first (a card declared for ['deals'] never renders on a contact record), then confirm the deploy’s HubSpot build printed [ok] email-health-card (CARD); a failed component prints its build error in the same list.

Read CRM context with context.crm

The context object HubSpot hands to your extension is the entire data API for the iframe, and context.crm is smaller than most first-timers expect. context.crm carries exactly two things: objectId (a number) and objectTypeId (a string such as 0-1 for contacts). It does not carry property values. The rest of context is the viewing user (context.user), the portal (context.portal), and the surface (context.location). Property values come from an action, not from context: actions.fetchCrmObjectProperties(['email', 'email_health_status']) returns a promise of the current values, every value a string. It is a real round-trip to HubSpot, so treat it like any other fetch: fire it in an effect, render a placeholder until it lands, and re-call it after actions.refreshObjectProperties() when a save happens elsewhere on the page. For a live subscription instead of a one-shot read, actions.onCrmPropertiesUpdate(['email_health_status'], callback) invokes your callback whenever those properties change. Association data has its own hook: useAssociations from @hubspot/ui-extensions/crm.

import { useEffect, useState } from 'react';
import { Tile, Text, hubspot } from '@hubspot/ui-extensions';
 
// The values you fetch are yours to type: fetchCrmObjectProperties returns
// Record<string, string>, and HS-X generates no property typings for cards.
interface EmailHealthProps {
  readonly email?: string;
  readonly email_health_status?: string;
  readonly email_health_score?: string;
  readonly email_suppressed?: string;
}
 
function EmailHealthCard({ context, actions }) {
  const [props, setProps] = useState<EmailHealthProps | null>(null);
 
  useEffect(() => {
    actions
      .fetchCrmObjectProperties(['email', 'email_health_status', 'email_health_score', 'email_suppressed'])
      .then((values) => setProps(values as EmailHealthProps));
  }, [context.crm.objectId]);
 
  if (!props) return <Tile><Text>Loading…</Text></Tile>;
  return (
    <Tile>
      <Text format={{ fontWeight: 'bold' }}>{props.email ?? 'No email on record'}</Text>
      <Text>Status: {props.email_health_status ?? 'unknown'}</Text>
      <Text>Score: {props.email_health_score ?? '–'}</Text>
      <Text>Suppressed: {props.email_suppressed === 'true' ? 'Yes' : 'No'}</Text>
    </Tile>
  );
}
 
hubspot.extend(({ context, actions }) => <EmailHealthCard context={context} actions={actions} />);

Typing what you fetch

There is no schema-to-iframe typing today. The generated refs.d.ts and _hsx-backend.ts type the route and the id of your card backends, not your property names, and hs-uix does not read your schema either. Type the values you fetch yourself: a small interface next to the component, as above, is enough, and it is the same pattern the card-with-backend example uses. Everything fetchCrmObjectProperties returns is a string (HubSpot serializes numbers, booleans, and dates on the wire), so parse at the edge (Number(props.email_health_score), props.email_suppressed === 'true') rather than trusting the shape.

When a property read is not enough

A stored property is exactly what a sidebar card should render first: the last verdict validate-email wrote, cheap to fetch and instantly meaningful. What the iframe cannot do is produce a fresh one. Re-verifying an address means calling the external verification API with a bearer secret, and neither the secret nor the call belongs in the iframe. The same applies to associated objects, custom queries, and computed aggregations. Use your card backend for those; that is the next step.

Fetch server data through your card backend

The bridge between your iframe and your Worker is a card backend: a capability you declare with worker.cardBackend, served by your Worker at POST /_hsx/cards/<id>, and called from the extension through hubspot.fetch. The handler runs in your Cloudflare account with the portal’s auth context (full HubSpot API access through ctx.hubspot) and is the right place for anything that needs network IO, secret-bearing requests, or aggregation across many records. For Email Guard that’s the email-health backend: it re-verifies the contact’s address against the external API, secret and all, and hands the card a fresh verdict. Two declarations make it work: the backend on the worker, and a backend link on the card so hs-x deploy can validate the pair and generate the client the card imports.

// src/workers/email-guard.ts
import { defineWorker } from '@hs-x/sdk';
 
const worker = defineWorker('email-guard');
 
worker.cardBackend('email-health', {
  label: 'Email health',
  objectTypes: ['contacts'],
  // Declaring input types ctx.input and makes the runtime reject a call
  // that omits contactId with 400 missing_required_input.
  input: { contactId: { type: 'string', required: true } },
  async handler({ input, env, hubspot }) {
    const contact = await hubspot.crm.objects.contacts.get(input.contactId, {
      properties: ['email'],
    });
    const email = contact.properties.email ?? '';
 
    const res = await fetch('https://api.emailcheck.example/v1/verify', {
      method: 'POST',
      headers: {
        authorization: `Bearer ${String(env.EMAILCHECK_API_KEY)}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({ email }),
    });
    const verdict = (await res.json()) as { status: string; score: number };
    return { email, status: verdict.status, score: verdict.score, checkedAt: new Date().toISOString() };
  },
});
 
export default worker;
// hsx.config.ts — link the card to its backend
import { card, defineApp } from '@hs-x/sdk';
 
export default defineApp({
  // ...name, distribution, auth, scopes...
  cards: [
    card({
      id: 'email-health-card',
      name: 'Email health',
      location: 'crm.record.sidebar',
      objectTypes: ['contacts'],
      entrypoint: './src/app/cards/EmailHealthCard.tsx',
      backend: 'email-health',
    }),
  ],
});

Declaring backend does two things. It makes hs-x check and hs-x deploy fail if the backend id does not exist (card.backend.missing), if its objectTypes do not cover the card’s (card.backend.scope-mismatch), or if the Worker origin is not fetch-permitted (card.backend.permission-missing). And it makes the deploy write src/app/cards/_hsx-backend.ts: a secret-free module exporting one function per linked backend that wraps hubspot.fetch against POST /_hsx/cards/<id> on the deployed Worker origin. You do not need to allow-list that origin yourself: hs-x deploy always adds the Worker origin it just deployed to the app’s permittedUrls.fetch. Declare permittedUrls.fetch only for a custom domain or a second origin.

// src/app/cards/EmailHealthCard.tsx
import { useEffect, useState } from 'react';
import { hubspot, Tile, Text, LoadingSpinner } from '@hubspot/ui-extensions';
import { emailHealth } from './_hsx-backend'; // generated by hs-x deploy
 
type Verdict = { email: string; status: string; score: number; checkedAt: string };
 
function EmailHealth({ contactId }: { readonly contactId: string }) {
  const [verdict, setVerdict] = useState<Verdict | null>(null);
 
  useEffect(() => {
    // Whatever the card sends must sit under `input`; the runtime decodes the
    // body as the dispatch payload and drops other top-level keys.
    emailHealth<{ ok: boolean; result: Verdict }>({ input: { contactId } }).then((res) => {
      if (res.ok && res.data.ok) setVerdict(res.data.result);
    });
  }, [contactId]);
 
  if (!verdict) return <LoadingSpinner label="Checking deliverability" />;
  return (
    <Tile>
      <Text>{verdict.email}: {verdict.status} ({verdict.score})</Text>
    </Tile>
  );
}
 
hubspot.extend(({ context }) => <EmailHealth contactId={String(context.crm.objectId)} />);

The generated function returns { ok, status, data }, where ok and status describe the HTTP response and data is the runtime’s envelope, { ok, capabilityId, result }, with result carrying whatever your handler returned. If you would rather hand-write the call, the shape is hubspot.fetch(WORKER + '/_hsx/cards/email-health', { method: 'POST', body: { input: { contactId } } }); the .hs-x/refs.js stub exports the same path and method as typed constants. Either way, HubSpot signs the request and the runtime verifies the signature and the calling portal’s install before your handler runs, so the card never authenticates anything itself.

When to fetch vs when to read a property

A simple rule: if the field is a property on the record itself, read it with actions.fetchCrmObjectProperties; it costs one HubSpot round-trip and no Worker. If the data is computed, associated, or external (other records, third-party APIs, anything that needs a secret like a fresh verification), fetch it from your card backend. The cost is one extra network hop plus the cold start of your Worker (a few milliseconds on a warm isolate, more on a cold one), which is fast but not free.

Common card-backend issues

  • hubspot.fetch is rejected before it leaves the iframe. HubSpot proxies hubspot.fetch and enforces the app’s permittedUrls.fetch allow-list. The deploy adds the Worker origin automatically, so the usual causes are a stale origin (a workers.dev URL that changed since the last HubSpot upload) or a card calling somewhere other than the deployed Worker. Redeploy so the HubSpot-side metadata catches up.
  • The Worker answers 404 unknown_card_backend. The id in the URL doesn’t match a declared worker.cardBackend id. Ids are kebab-case and unique per worker; with backend declared, hs-x check catches the typo before deploy and the generated client makes it a compile error.
  • The handler receives an empty input. The card posted its fields at the top level of the body instead of under input. Move them under input (the generated client’s argument is the whole body, so pass { input: {...} }).
  • The handler can’t reach HubSpot data. ctx.hubspot resolves the calling portal’s token; a portal that never installed the app has no token to resolve, and the runtime refuses the call before the handler runs. Test against a portal with a real install.

Compose with higher-level hs-uix components

The base @hubspot/ui-extensions package gives you primitives: Tile, Box, Text, Button, Table, Input. They render correctly inside the sandbox and they match HubSpot’s design tokens, but they’re low-level. Building a labeled property list or a section header from scratch every time is the kind of repetition hs-uix exists to remove.

hs-uix is an optional third-party layer, not part of HS-X or the scaffold. It lives in two places when you use it: bun add -D hs-uix at the root for local type-checking, and a "hs-uix": "^2.2.0" entry in src/app/cards/package.json for HubSpot’s remote build. hs-x deploy creates that package.json the first time it sees a card (with @hubspot/ui-extensions, react, and typescript) and never overwrites it afterwards, so add the dependency after the first deploy, or create the file with those four entries yourself. hs-uix/common-components ships SectionHeader and KeyValueList; heavier components have their own subpaths (hs-uix/datatable, hs-uix/form, hs-uix/kanban, and others). Check the package’s own README for each component’s props; this guide only leans on the two below.

import { useEffect, useState } from 'react';
import { SectionHeader, KeyValueList } from 'hs-uix/common-components';
import { Tile, Statistics, StatisticsItem, hubspot } from '@hubspot/ui-extensions';
 
function EmailHealthCard({ context, actions }) {
  const [props, setProps] = useState<Record<string, string> | null>(null);
 
  useEffect(() => {
    actions
      .fetchCrmObjectProperties(['email', 'email_health_status', 'email_health_score', 'email_suppressed'])
      .then(setProps);
  }, [context.crm.objectId]);
 
  if (!props) return <Tile><SectionHeader title="Email health" /></Tile>;
  return (
    <Tile>
      <SectionHeader title="Email health" subtitle={props.email} />
      <Statistics>
        <StatisticsItem label="Deliverability score" number={Number(props.email_health_score ?? 0)} />
      </Statistics>
      <KeyValueList
        items={[
          { label: 'Status', value: props.email_health_status },
          { label: 'Suppressed', value: props.email_suppressed === 'true' ? 'Yes' : 'No' },
        ]}
      />
    </Tile>
  );
}
 
hubspot.extend((props) => <EmailHealthCard {...props} />);

Statistics and StatisticsItem are native @hubspot/ui-extensions components for labeled metrics; the two hs-uix imports are the section heading and the dense two-column list you see on every native CRM sidebar.

HubSpot exposes three overlay patterns, and the right choice is not obvious from the names:

Use casePickWhy
A form or confirmation rendered in the same iframeDeclarative <Panel> componentStays in the extension sandbox, fastest to open, no extra bundle
A focused workflow that needs the full viewportDeclarative <Modal> componentLarger surface, dims the portal, still in-sandbox
Embedding an external URL or a custom HTML appactions.openIframeModal({ uri, width, height })Renders a separate iframe; a result has to come back through your own channel

<Modal> and <Panel> are declarative components from @hubspot/ui-extensions, but they do not take an open prop. You pass the element as the overlay prop of the Button (or Link, Image, or DropdownButtonItem) that opens it, give it an id, and close it with actions.closeOverlay(id):

import { Button, Modal, ModalBody, Text, hubspot } from '@hubspot/ui-extensions';
 
function ReverifyButton({ actions }) {
  return (
    <Button
      overlay={
        <Modal id="reverify" title="Re-verify this address?">
          <ModalBody>
            <Text>This calls the verification provider and updates the stored verdict.</Text>
            <Button onClick={() => actions.closeOverlay('reverify')}>Cancel</Button>
          </ModalBody>
        </Modal>
      }
    >
      Re-verify
    </Button>
  );
}
 
hubspot.extend(({ actions }) => <ReverifyButton actions={actions} />);

Only openIframeModal is an imperative action, because the child surface is a separate cross-origin iframe; it takes an onClose callback but returns nothing about what happened inside, so a child that needs to hand a value back has to do it through your backend (write, then re-fetch on close) rather than through the overlay API.

Hit the performance budget

HubSpot’s iframe is a cold start. The first time a user opens a record, the renderer downloads your bundle, parses it, mounts React, and only then does your component render. On a fast laptop this is under 400 ms; on a salesperson’s office wifi from a coffee shop it can be two full seconds. The numbers are non-negotiable — you can’t make the cold start faster — but you can make the perceived latency much better by rendering a skeleton on first paint, deferring secondary lookups, and lazy-loading anything you don’t strictly need at mount.

Layout first

Render the shape of the final UI immediately, with LoadingSpinner placeholders in the slots that need data. The user sees the layout in 50 ms and the data fills in as it arrives, instead of staring at a blank tile for a second.

function EmailHealthCard({ context, actions }) {
  const [stored, setStored] = useState<Record<string, string> | null>(null);
  const [verdict, setVerdict] = useState<Verdict | null>(null);
 
  useEffect(() => {
    // Cheap: the last verdict validate-email wrote, straight from the record.
    actions.fetchCrmObjectProperties(['email', 'email_health_status']).then(setStored);
    // Expensive: a fresh verification through the email-health backend.
    emailHealth<{ ok: boolean; result: Verdict }>({
      input: { contactId: String(context.crm.objectId) },
    }).then((res) => {
      if (res.ok && res.data.ok) setVerdict(res.data.result);
    });
  }, [context.crm.objectId]);
 
  return (
    <Tile>
      <SectionHeader title="Email health" subtitle={stored?.email} />
      <KeyValueList items={[{ label: 'Last status', value: stored?.email_health_status }]} />
      {verdict ? (
        <KeyValueList items={[{ label: 'Fresh score', value: verdict.score }]} />
      ) : (
        <LoadingSpinner />
      )}
    </Tile>
  );
}

The last stored verdict lands first (one HubSpot round-trip from inside the portal); the fresh one from the email-health backend fills in when the Worker round-trip lands. Both states are useful, and neither blocks the other.

Deferred lookups

A common mistake is firing every server call in the first useEffect. If your card has a header, a fresh verdict, a verification history, and a suppression check, that’s four card-backend fetches racing the cold start. Stagger them: render the header and last-known status from the property fetch immediately, fire the verdict call on mount, and defer the history and suppression calls to a requestIdleCallback or a hover / scroll trigger. The user sees something useful in under a second, and the secondary data hydrates while they read.

Lazy imports

HubSpot enforces a payload cap on extension bundles; check your platform version’s current limit, since the exact ceiling moves between releases. HubSpot’s remote build reports the bundle size in its build log; hs-x deploy surfaces the per-component verdict but not the byte count. Common culprits and fixes:

  • A chart library imported eagerly. Use React.lazy(() => import('./Chart')) and render the chart behind a <Suspense fallback={<LoadingSpinner />}>. The chart code only downloads when the user actually scrolls to it.
  • A date library on the critical path. date-fns is fine if you import the specific functions you use; moment is not. Inspect the build output (or use wrangler / standard bundle-analyzer tooling) to confirm what’s actually shipping.
  • Importing kitchen-sink helpers from hs-uix. Stick to named imports of just the components you need so tree-shaking inside the HubSpot loader has the best chance of dropping the rest.

Why this matters

The extension that renders in 200 ms feels fast even when it isn’t. The extension that renders in 1,500 ms feels broken even when it returned the right answer. Salespeople open and close record tabs hundreds of times a day; the perceived performance budget is the single biggest factor in whether your extension gets used or quietly ignored.

Deploy and triage validator errors

hs-x deploy runs HS-X’s own validator (the same checks as hs-x check), then uploads and lets HubSpot’s remote build run its checks, plus the Cloudflare push. For UI extensions specifically, the checks are: the generated card metadata matches the card({ … }) declaration and its backend link resolves, the .tsx file registers a component through hubspot.extend, every imported package is on the sandbox allow-list, and the compiled bundle stays under the size budget. The first is HS-X’s; the other three are HubSpot’s remote build, reported per component in the deploy output.

hs-x deploy
Expect
$ hs-x deploy
# hs-x  deploy  *  email-guard

[ok] Validating project  1 workers, 3 capabilities
* Sources: suppression-list (pull, 5m)
* Runtime: Cloudflare Worker required
* Cloudflare deploy: hsx-local-865a8a52-email-guard-email-guard
* HubSpot build #4: SUCCESS
*   [ok] email-guard (APPLICATION)
*   [ok] validate-email (WORKFLOW_ACTION)
*   [ok] email-health-card (CARD)
* Generated .hs-x/manifest.json and refs stubs.

Deployed (unlinked) to https://hsx-local-865a8a52-email-guard-email-guard.<account>.workers.dev

Open any contact record. The card is in the right rail, rendering the last stored verdict and fetching a fresh one through the email-health backend. The [ok] email-health-card (CARD) line is HubSpot’s remote build accepting the extension; a failed component prints its build error in the same list.

One fix-line per validator error

The four errors below cover most of what real users hit. Each one has exactly one cause once you know where to look.

  • No supported components the .tsx file didn’t reach hubspot.extend(...). Add the call at the bottom of the file with your component inside.
  • hubspot.fetch rejected before it leaves the iframe — the origin the card is calling isn’t in the app’s permittedUrls.fetch allow-list. The deploy adds the Worker’s own origin automatically; a custom domain or a second origin has to be declared in defineApp, and a changed workers.dev URL needs a redeploy so HubSpot’s metadata catches up.
  • A CSP violation on connect-src from the iframe — the extension called bare fetch directly. Use hubspot.fetch, which proxies through HubSpot and honors the allow-list.
  • Couldn't find the following components: EmailHealthCard the entrypoint in card({ … }) doesn’t match the .tsx filename under src/app/cards/. Rename one to match the other.

Common bundle-size errors

  • Bundle size exceeded the platform cap — open the HubSpot build log the deploy output links to and look for a per-package breakdown. Usually it’s an eager chart import or a kitchen-sink import from hs-uix. Move charts behind React.lazy, switch to named imports of only what you use.
  • Disallowed import: react-hook-form the package isn’t on the sandbox allow-list and won’t load at runtime. Use hs-uix form components or raw Input + useState for forms.
  • Disallowed import: axios the iframe can’t make arbitrary outbound requests. Make the call from your card backend on the Worker instead.