Getting started
The shortest path from an empty directory to a working HubSpot app. The app is Email Guard: a worker that scores whether a contact's email address actually accepts mail, a sync that pulls your provider's suppression list into HubSpot, and a contact card that shows the verdict. By the end all of it is deployed and talking, and every other guide keeps building on it.
TL;DR — npm i -g @hs-x/cli, hs-x init email-guard, hs-x dev, hs-x deploy. In about 15 minutes Email Guard is live: a workflow action that scores email deliverability, a sync that imports your provider's suppression list, and a contact card that shows both, all on a Worker in your own Cloudflare account.
Before you begin
HS-X is not a service you call. It's a toolkit that generates a Cloudflare Worker, deploys it to your Cloudflare account, and registers UI extensions and workflow actions on your HubSpot portal. At runtime, HS-X is not in the request path: your Worker talks to HubSpot directly, in your account, with your tokens.
This guide builds a real app, and the rest of the guides keep building it. Email Guard checks whether a contact's email address actually accepts mail: a validate-email workflow action scores deliverability through an external verification API, a suppression-list sync imports the addresses that bounced or complained, and an email-health card shows the verdict on every contact record.
That distinction matters for two reasons. First, your data never crosses our infrastructure: there's no proxy, no shadow copy, no third-party processor in your privacy notice. Second, if you stop using HS-X tomorrow, the Worker keeps running. You can detach the project from the CLI and continue deploying via wrangler directly. Leaveable by design.
The mental model
Four artifacts compose every HS-X project:
- CLI (
hs-x) runs on your machine. Scaffolds, validates, deploys. Also exposes itself as an MCP server so coding agents can drive every command. - Worker (
worker.ts) runs on Cloudflare. Declares syncs, workflow actions, agent tools, and triggers. Single file by default. - Schema (
schema/*.ts) runs everywhere. The typed contract between your sync (what you write) and your UI extension (what you read). One source of truth. - UI extension (
src/app/cards/*.tsx) runs inside HubSpot. Renders on contact records, deal records, settings pages, app cards. Useshs-uix(HS-X's React component library) so it matches HubSpot's design language.
Each step has the command, what the command did, what to expect on success, and the most common ways it can fail. You can copy this page as raw markdown for your agent with the button in the top-right.
Install the CLI
The CLI ships on npm as @hs-x/cli, and installs both hs-x and the shorter hsx alias. They run exactly the same CLI; this guide uses hs-x as the canonical spelling. Install it globally so both commands are on your PATH from any directory: most of what you do with HS-X is run a CLI command, and you'll do it from inside whatever project you're working on. Both node and bun can run it.
npm i -g @hs-x/cli
hs-x --versionIf you prefer not to install anything globally, every command also works through bunx: bunx @hs-x/cli init email-guard, bunx @hs-x/cli deploy, and so on. The rest of this guide writes hs-x … for brevity; read it as bunx @hs-x/cli … if that's your style.
Optional extras
The install itself puts hs-x and hsx on your PATH. Two one-liners make it nicer to live with:
- Shell completion.
hs-x completion zshprints the completion script for zsh (bashandfishare the other accepted arguments); pipe it into your completion directory and reload. The generated script registers both names, sohs-x <tab>andhsx <tab>list the same commands and flags. - The MCP server. If you work with a coding agent, register
@hs-x/mcp; for Claude Code that'sclaude mcp add hs-x -- npx -y @hs-x/mcp. Your agent gets the local dev loop as tools: validate the project, list capabilities, invoke any handler through the same dispatch path production uses, and search the HS-X docs without leaving the agent. The MCP reference lists all twelve tools.
$ hs-x --version 0.3.0
Any version at or above the one shown here is fine — the CLI ships often. Run hs-x doctor next: it checks your stored accounts, link state, HubSpot CLI config, and control-plane reachability. Most of those are still empty; we fix that in step 3.
Common install issues
command not found: hs-xorcommand not found: hsxmeans your package manager's global bin isn't on PATH. Print the prefix withnpm prefix -gand add itsbin/subdirectory to your shell rc, then reload. (npm bin -gwas removed in npm 9;bun pm bin -gis the bun equivalent.)npm auditwarnings on install. The install pulls HubSpot's official local-dev toolchain (@hubspot/local-dev-lib,@hubspot/ui-extensions-dev-server), whose transitive dependencies account for the audit advisories you'll see. They're build-time tooling, not part of your deployed Worker, and resolving them is upstream of HS-X.- CI and one-off machines.
bunx @hs-x/cli <command>fetches and runs the CLI on demand, with no PATH entry to manage. - Pinning the CLI to a single project.
npm i -D @hs-x/cli(orbun add -D @hs-x/cli) works too; invoke it asnpx hs-x …/bunx hs-x …. Note the scaffolded project does not add the CLI as a dependency: it would drag the whole HubSpot local-dev toolchain into a starterinstall, so the generatedpackage.jsonscripts call the globally installedhs-xfrom your PATH instead.
Scaffold Email Guard
hs-x init lays down a project skeleton: an hsx.config.ts that declares your app, one or more Workers under src/workers/, and a package.json/tsconfig.json/.gitignore tuned for HS-X. It picks sensible defaults rather than prompting; the rare choices are flags. The workflow-action template is the default, and we pass --type workflow-action anyway because Email Guard's first capability is one (--type empty gives a bare structure, --type sync-source a webhook-driven data sync). --ui-extension adds a starter app card we'll turn into the email-health card in step 5. Pick a directory name that's also a valid npm package name (lowercase, hyphens, no spaces) because it gets slugified into package.json; email-guard qualifies.
hs-x init email-guard --type workflow-action --ui-extension
cd email-guard
bun installThe scaffold lists @hs-x/sdk and @hs-x/runtime as dependencies (the published npm packages, no workspace:*), so bun install pulls them down. The hs-x CLI itself is not a project dependency; the generated package.json scripts call the globally installed CLI.
What the scaffold contains
email-guard/
├── hsx.config.ts # app declaration — defineApp({ name, scopes, cards, … })
├── src/
│ ├── app/
│ │ └── cards/
│ │ ├── DemoCard.tsx # starter UI-extension card (step 5 makes it email-health)
│ │ └── package.json # the card's own deps — installed by HubSpot's build
│ └── workers/
│ └── deals.ts # starter Worker — defineWorker("deals") + a sample action
├── package.json # worker deps: @hs-x/sdk, @hs-x/runtime · scripts: dev/deploy/check
├── tsconfig.json # standalone, type-checks out of the box
└── .gitignore # ignores the generated .hs-x/ (except alchemy.run.ts)Yes, there are two package.json files, and the split is load-bearing: the root one carries the Worker's dependencies (bundled on your machine), while src/app/cards/package.json carries the card's own dependencies (@hubspot/ui-extensions, react), because HubSpot's remote build installs the card from that file, not from your root.
A short tour, because the layout is small but every file has a purpose:
hsx.config.tsis your app declaration.defineApp({ … })sets the app name, distribution (private), auth mode, platform version, the HubSpot scopes the app requests, and thecardsthe app renders. Checked into git.src/workers/*.tsis your runtime. Everything that runs on the edge is declared in a Worker viadefineWorker("name"): workflow actions (worker.action(…)— an alias the SDK exports forworker.tool, since a workflow action and an agent tool are the same capability registered under different HubSpot surfaces), syncs (worker.sync(…)), and triggers. The template ships one Worker with a sample deal-tagging action; we replace it with Email Guard's next, and this guide writesworker.actionthroughout.src/app/cards/*.tsxis the UI. Card components live here because this is the directory HubSpot's upload bundle packages; step 5 turns the starter into the email-health card.package.jsoncarries@hs-x/sdkand@hs-x/runtimeas dependencies, plusdev/deploy/checkscripts that shell out to the CLI. With--ui-extensionit also lists@hubspot/ui-extensionsandreactas devDependencies, so the card.tsxtype-checks locally; HubSpot supplies both at runtime. No build tooling here; the CLI bundles for you.tsconfig.jsonis standalone (noextends, no path aliases). Oncebun installruns,@hs-x/*resolve fromnode_modulesand the project type-checks in your editor and viatsc..hs-x/doesn't exist yet. The CLI generates it on the firstdev/deployand regeneratesrefs, types, and the manifest there on every run after, so the directory is gitignored, except.hs-x/alchemy.run.ts. The first deploy writes that file — the infrastructure-as-code artifact that lets you keep deploying your Worker withalchemyeven after you walk away from HS-X — and it is checked in.
Make it Email Guard
Rename src/workers/deals.ts to src/workers/email-guard.ts and replace its contents. The worker is email-guard, and its first capability is the validate-email workflow action: it takes an email address from the enrolled contact, checks the format, asks an external verification API for a deliverability score, writes the verdict onto the contact, and returns { status, score } for the workflow to branch on.
import { defineWorker, failContinue, ok } from '@hs-x/sdk';
const worker = defineWorker('email-guard');
worker.action('validate-email', {
label: 'Validate email address',
description: 'Scores email deliverability for the enrolled contact.',
objectType: 'contact',
input: {
email: {
type: 'string',
label: 'Email to validate',
supportedValueTypes: ['OBJECT_PROPERTY'],
},
},
output: {
status: { type: 'enumeration', options: ['deliverable', 'risky', 'undeliverable'] },
score: { type: 'number' },
},
async handler({ input, enrolledObject, env, hubspot, logger }) {
const email = String(input.email ?? enrolledObject.properties.email ?? '');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return failContinue('Not a valid email address', { status: 'undeliverable', score: 0 });
}
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: 'deliverable' | 'risky' | 'undeliverable';
score: number;
};
await hubspot.crm.objects.contacts.update(enrolledObject.id, {
properties: {
email_health_status: verdict.status,
email_health_score: String(verdict.score),
},
});
logger.info('verified', { id: enrolledObject.id, status: verdict.status });
return ok({ status: verdict.status, score: verdict.score });
},
});
export default worker;Two follow-up edits while you're in the project:
- Scopes. The template's sample action requested deal scopes; Email Guard reads and writes contacts. In
hsx.config.ts, setscopesto["crm.objects.contacts.read", "crm.objects.contacts.write"]. - The API key. The handler reads
env.EMAILCHECK_API_KEY. For local dev, putEMAILCHECK_API_KEY=…in a.dev.varsfile (the scaffold's.gitignorealready covers it); for the deployed Worker, set it withwrangler secret put EMAILCHECK_API_KEY. The secrets guide walks both surfaces.
The declaration is doing more than it looks like. The input map renders the form a marketer fills in when adding the action to a workflow (supportedValueTypes: ['OBJECT_PROPERTY'] is what makes the email field a property picker), the output map becomes branchable workflow outputs, and the *-hsmeta.json HubSpot requires is generated from the whole thing on deploy. The workflow-actions guide takes this exact action apart field by field.
What's not in the scaffold
This is deliberately not a Next.js or Astro template. There's no front-end framework, no test runner, no linter config, no CI workflow. HS-X scaffolds the HubSpot-and-edge parts and stays out of the rest. If you want Biome, drop a biome.json in. If you want Vitest, install it. The CLI never touches files outside of its known list.
Wire your Cloudflare and HubSpot accounts
Two credentials are needed: a Cloudflare API token (or OAuth) so the CLI can push Worker code, and a HubSpot personal access key so it can register your app. The fastest path needs no account with us at all: export both as environment variables and skip straight to step 6.
export CLOUDFLARE_API_TOKEN=... # scoped token from your Cloudflare dashboard
export CLOUDFLARE_ACCOUNT_ID=...
export HSX_HUBSPOT_PAK=pat-na1-... # app.hubspot.com/l/personal-access-key
export HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID=... # your developer account idWith those set, hs-x deploy runs unlinked: directly against your two accounts, nothing stored, no HS-X account involved. That is the whole getting-started requirement, and everything below in this step is optional.
HS-X has exactly three onboarding modes (ADR-025); pick by answering two questions — do you want credentials stored locally? and do you want the platform?
- Env-only unlinked — the path above: credentials in environment variables, nothing stored, no HS-X account. Ideal for CI and a fast first deploy.
- Stored-direct —
hs-x connectcaptures the same HubSpot and Cloudflare credentials into the local store so you stop exporting variables; still no HS-X account. The steady state for an individual dev not using the platform. - Linked platform — an HS-X account, added on top of either mode with
hs-x loginandhs-x link. That is the opt-in platform path (dashboard, deploy history, team features), never a prerequisite for deploying.
The stored alternative is hs-x connect (subcommands hs-x connect hubspot and hs-x connect cloudflare), which saves the bindings so you stop exporting variables — mode 2, no HS-X account required. Bindings live in the local store under $XDG_CONFIG_HOME/hs-x/ (defaulting to ~/.config/hs-x/). They're used only at deploy and config time; your Worker at runtime uses its own copy of the HubSpot token, embedded into the deployed bundle.
One file to expect later: the first deploy writes an hsproject.json at the project root (HubSpot's project marker, used by its build tooling) alongside the generated .hs-x/ state directory. hsproject.json belongs in version control; from .hs-x/ only alchemy.run.ts does, and the scaffolded .gitignore already encodes that split.
hs-x connect hubspot # bind a HubSpot developer account (PAK, or auto-discovered from the HubSpot CLI)
hs-x connect cloudflare # OAuth — browser consent screen, no token to pasteIf you already authenticated the official HubSpot CLI (hs accounts auth), hs-x connect hubspot discovers those credentials and offers them as a default; otherwise pass a personal access key with --pak <key>. For Cloudflare, hs-x connect cloudflare is OAuth-only: it opens Cloudflare's consent screen (or, without a TTY, prints the authorize URL for you to open yourself), and the granted credential is brokered server-side.
Why two tokens, and what each is for
- Cloudflare: the CLI needs to push Worker code. It does not need to read your Cloudflare data, list your DNS records, or touch any other resource. The token it requests is
Workers Scripts: EditplusAccount: Readon the one account you authorize. - HubSpot: the CLI needs to register UI extensions and workflow actions against your portal. That's a portal-scoped dev token with the
extensions.writeandobjects.readscopes — and if you use--apply-schemato let deploy create contact properties (step 6 does), the key also needscrm.schemas.contacts.write. At runtime, your Worker uses a separate, narrower token (auto-rotated every 30 days) embedded into its deployment bundle.
This separation is the load-bearing part of "leaveable by design": if you uninstall HS-X tomorrow, the Worker keeps running until you delete it from Cloudflare directly. The CLI's stored tokens only authenticate deploys and config fetches, never request-path traffic.
HS-X is not in your data path. The CLI's tokens authenticate deploys and config fetches. The Worker uses its own embedded token at runtime, talking directly to HubSpot from your Cloudflare account. No HS-X server proxies your data; no HS-X dashboard reads it.
Verifying the wiring
hs-x doctorhs-x doctor is the check that works on both paths. It reads the PAK from HSX_HUBSPOT_PAK (or from your stored binding) and makes a live auth call; [ok] HubSpot PAK authenticated is the line you're looking for. The Cloudflare token has no doctor probe — it gets exercised on the first hs-x deploy, which fails fast with the Cloudflare API's own error if the token is wrong or under-scoped. On the environment-variable path nothing is stored, so hs-x whoami (which lists stored accounts) has nothing to show; if you went the hs-x connect route instead, whoami lists the bindings, and re-running the relevant hs-x connect subcommand fixes a single one.
Common wiring issues
- "No HubSpot CLI accounts found."
hs-x connect hubspottries to read the HubSpot CLI's config: ahubspot.config.ymlin or above the current directory, or~/.hscli/config.yml(the same placeshs-x doctorchecks). If you've never runhs accounts auth, neither exists; either authenticate the HubSpot CLI first, or pass a PAK directly with--pak. The PAK lives behind Settings → Integrations → Private apps in the portal you want to bind. - "Cloudflare browser flow won't open." Some shells (
tmux, headless SSH, certain VS Code remote sessions) can't open browsers. The CLI detects this and prints the authorize URL instead — open it in any browser on the same machine and the loopback redirect completes the flow. (For fully headless deploys, the environment-variable path withCLOUDFLARE_API_TOKENskips account connection entirely.) - Sharing credentials across machines. The HS-X store is plain JSON under
~/.config/hs-x/. There's nocredentials.toml. Rather than copying files around, just re-runhs-x connecton the second machine; it's the same one-time paste.
Add the suppression-list sync
A sync pulls rows from an external source on a schedule and upserts them into a HubSpot object. You declare three things (the target object, the schedule, and a pull function that returns records keyed by a stable id) and HS-X handles the rest: cursor persistence, retries, dedup, schema validation, and rate-limit-aware batching against HubSpot's API. Email Guard's source is the verification provider's suppression list, the addresses that hard-bounced or complained. Pulling it every five minutes means a suppressed contact is flagged in HubSpot before the next send, and the schema declaration types both the pull return value and the resulting HubSpot properties, so one source of truth feeds the whole pipeline.
Suppression list in, HubSpot contacts out — every five minutes.
The Worker holds the cursor and runs the upsert. You write the pull function; HS-X handles retries, dedup, and rate-limit-aware batching.
Open src/workers/email-guard.ts and add a source and a sync alongside the action. Put the defineSource import at the top with the other imports, and the suppressionList source plus the worker.sync(…) registration below the action but above export default worker; — registrations have to run before the worker is exported. A source owns auth + pagination + per-page fetch; worker.sync owns the HubSpot-facing schema, identity, and upsert. The runtime injects http so retries, backoff, and rate-limit handling are not your problem.
// src/workers/email-guard.ts
// top of file, with the other imports:
import { defineSource } from '@hs-x/sdk';
// …below the validate-email action, above `export default worker;`:
type SuppressionPage = {
next?: string;
entries: Array<{ email: string; reason: string; suppressed_at: string }>;
};
const suppressionList = defineSource({
name: 'suppression-list',
auth: { type: 'bearer', token: process.env.EMAILCHECK_API_KEY },
async fetch({ cursor, http }) {
const res = await http.get('https://api.emailcheck.example/v1/suppressions', {
query: { pageSize: 100, after: cursor },
});
const page = res.body as SuppressionPage;
return {
cursor: page.next,
rows: page.entries.map((entry) => ({
key: entry.email,
data: {
email: entry.email,
email_suppressed: true,
email_suppression_reason: entry.reason,
email_suppressed_at: entry.suppressed_at,
},
})),
};
},
});
worker.sync(suppressionList, {
into: 'contacts',
schedule: '5m',
manageSchema: 'properties',
schema: {
email: 'string',
email_health_status: { type: 'enumeration', options: ['deliverable', 'risky', 'undeliverable'] },
email_health_score: 'number',
email_suppressed: 'bool',
email_suppression_reason: { type: 'enumeration', options: ['bounce', 'complaint', 'manual'] },
email_suppressed_at: 'datetime',
},
});The schema block declares every contact property Email Guard owns, including the two the validate-email action writes. The pull rows only carry the suppression fields, and that's fine: the schema is the superset contract and each row upserts into it. The manageSchema: 'properties' line is what lets HS-X create those properties in the portal for you; the default is false, where HS-X validates rows locally but never touches portal schema. Creation happens at deploy time and is explicit: step 6 runs hs-x deploy --portal-schema-live --apply-schema, which diffs this schema against the portal's property definitions and creates what's missing. ('full' is the third mode — it manages the destination object itself, for syncs into custom objects.)
What key and cursor are doing
These two pieces of state, the per-record key and the per-run cursor, are the entire contract between your pull function and HS-X's sync engine. Understand them once and you can write any sync.
keyis the unique id HubSpot uses to upsert. Using the suppressed address as the key means re-running the sync with the same entry updates the existing contact instead of duplicating it. If your source doesn't have a natural unique id (a UUID, an email, a stripe customer id), generate one withcrypto.randomUUID()and store it back in your source. Stop here to think about whether your data model actually allows idempotent upsert, because if it doesn't, you'll create duplicates on every retry.cursoris opaque to HS-X. Whatever your source'sfetchreturns gets handed back to the next invocation ascursor; here that's the provider'snextcontinuation token. This lets you do incremental syncs without holding state in the Worker itself. On the very first run,cursorisundefined; your code should handle that as "start from the beginning." Common cursor shapes: a row number for paginated APIs, anupdated_afterISO timestamp for change-data-capture, a continuation token for batched cloud APIs.
Common sync patterns
- Full table on every run. Set
schedule: '@daily', ignorecursor, return every row. Fine for tables under a few thousand rows. - Incremental by timestamp. Use
cursoras an ISO timestamp, queryWHERE updated_at > cursor, return rows + new max timestamp. Cheap and correct as long as your source has reliableupdated_at. - Continuation-token pagination. Pass
cursorstraight to your source'snext_page_tokenparameter. Most modern APIs work this way. - Webhook-driven (no polling). Drop the
scheduleand usedefineSource.push({ auth: { type: 'hmac', ... }, receive })instead. The runtime mounts a verified webhook endpoint and the sync runs only when the external system pings it.
$ hs-x dev ▲ hs-x dev local → http://127.0.0.1:8787 * Ready in 6ms — 1 worker · 2 capabilities · log stream http://127.0.0.1:9099 * * Press Ctrl+C to stop
hs-x dev boots a local dev server on port 8787 and a log sidecar on 9099. The Ready line counts what it discovered — one worker, two capabilities (the action and the sync) — and invocation and logger.* lines stream into this terminal as handlers run. Once a Worker is deployed, the same command tunnels live portal traffic to your local code; the local dev guide covers that loop.
Build the email-health card
The DemoCard.tsx starter from step 2 already imports from @hubspot/ui-extensions; the upgrade here also pulls in hs-uix, HS-X's component library for HubSpot UI Extensions. It's a thin layer over the base SDK that adds typed schemas, hot-reload, and a handful of higher-level components (KeyValueList, DataTable, SectionHeader, Stat) you'd otherwise rebuild yourself.
Because hs-uix is a card-side package, installing it follows the two-package.json split from step 2. Add it to the root for local type-checking, and to src/app/cards/package.json so HubSpot's remote build can resolve the import:
bun add -D hs-uix// src/app/cards/package.json — add hs-uix to the card's own dependencies
{
"name": "hs-x-app-cards",
"version": "0.1.0",
"dependencies": {
"@hubspot/ui-extensions": "latest",
"react": "^18.2.0",
"hs-uix": "^2.2.0"
}
}Next, point the card declaration in hsx.config.ts at its new identity. The card renders in the right rail of every contact record:
// hsx.config.ts — the card declaration
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',
}),
],
});Then rename src/app/cards/DemoCard.tsx to EmailHealthCard.tsx and drop in a KeyValueList, bound to the same schema the sync and the action write. The context.crm object is fully typed because hs-uix reads the schema at build time and threads the types through. Rename a property in the schema and you get a compile error in the card: no drift between server and UI.
// src/app/cards/EmailHealthCard.tsx
import { KeyValueList, SectionHeader } from 'hs-uix/common-components';
import { Tile, hubspot } from '@hubspot/ui-extensions';
function EmailHealthCard({ context }) {
return (
<Tile>
<SectionHeader title="Email health" />
<KeyValueList
items={[
{ label: 'Status', value: context.crm.email_health_status },
{ label: 'Score', value: context.crm.email_health_score },
{ label: 'Suppressed', value: context.crm.email_suppressed ? 'Yes' : 'No' },
]}
/>
</Tile>
);
}
hubspot.extend(({ context }) => <EmailHealthCard context={context} />);What hs-uix gives you over raw @hubspot/ui-extensions
The base SDK is fine for static cards. It starts to creak as soon as your extension needs typed data, async fetches, or anything that looks like a table. hs-uix is a layer that fills those gaps:
- Schema-driven types.
context.crm.email_health_statusis narrowed to'deliverable' | 'risky' | 'undeliverable'from the enum in your schema. Renameemail_health_scoretoscorein the schema and the card fails type-check until you update every callsite. - Hot reload against the live portal.
hs-x devopens a WebSocket tunnel that proxies UI bundle changes into the open portal in real time. Keep the card open in HubSpot, save a file in your editor, see the edit land in under a second. The official SDK requires a full re-deploy on every change. - Card-backend bridge. When the card needs data it can't read from
context.crm(a live re-verification, anything that needs your API secret), declareworker.cardBackend('email-health', …)and call it from the card withhubspot.fetch. The handler runs on the Worker with full HubSpot access; the card consumes the typed result. The UI-extensions guide wires exactly that backend for this card. - Higher-level components.
KeyValueList,DataTable,Stat,SectionHeader, andBannerare designed to match HubSpot's own UI conventions, but typed and composable in a way the official components aren't. TheDataTablealone handles sorting, filtering, virtualization, and URL state with a single declaration.
When you'd skip hs-uix
If you only need to render a single static Tile with one button, the raw SDK is shorter. The moment you have typed schema data, async state, or a table, hs-uix is faster to write and faster to read in review.
Deploy
One command does the rest: validates the project, reconciles the portal schema, pushes the Worker to Cloudflare, and uploads the project for HubSpot's remote build. The two flags belong to the first deploy: --portal-schema-live diffs the sync's declared schema against the portal, and --apply-schema creates what's missing. Later deploys that don't change schema are plain hs-x deploy.
hs-x deploy --portal-schema-live --apply-schemaWhat the four phases do
The output streams in this order; here is what each block means:
- Validate: the CLI discovers every Worker under
src/workers/and what it declares, then prints the totals (Validating project 1 workers, 2 capabilities), one line per sync source (Sources: suppression-list (pull, 5m)), and the schema posture (Portal schema management: suppression-list: properties). - Portal schema:
--portal-schema-livereads the portal's current property definitions and prints aPLANline per difference —WILL CREATE contacts.email_health_score (number)for a missing property,WILL ALTERfor a type change. On a first deploy that's five of Email Guard's six (emailalready exists on every portal).--apply-schemathen creates each one and prints anAPPLIEDline. In an interactive terminal the deploy stops at a singleApply this plan?confirmation before changing anything;--yesskips it. - Worker push: the CLI writes a generated entrypoint and wrangler config under
.hs-x/cloudflare/and pushes through wrangler with your Cloudflare credential from step 3. Without an HS-X account the Worker is namedhsx-local-<machine-id>-email-guard-email-guard: the machine-scoped prefix keeps two laptops deploying the same project from overwriting each other's Workers, and the URL is that name on yourworkers.devsubdomain. The deploy lands in your Cloudflare account; HS-X is never in the request path afterward. - HubSpot build: the project — the app, the
validate-emailaction, the email-health card — goes up via the developer projects API, HubSpot runs its remote build, and the CLI prints the per-component verdict ([ok] email-health-card (CARD)and friends) plus the app-auth URL where you grab the client secret. The uploaded metadata carries the Worker's URL — that's how HubSpot reaches your runtime — so this phase depends on the Worker push above. For an app with a runtime handler there's no HubSpot-only deploy; the Cloudflare credential from step 3 is load-bearing for the whole command.
The app's name and description on HubSpot's install screen ship from defineApp in hsx.config.ts with every deploy; logos and the rest of the branding are uploaded in HubSpot's UI, and where branding lives in the marketplace guide maps each field to its home.
$ hs-x deploy --portal-schema-live --apply-schema
# hs-x deploy * email-guard
[ok] Validating project 1 workers, 2 capabilities
* Sources: suppression-list (pull, 5m)
* Portal schema management: suppression-list: properties
* Portal schema:
PLAN WILL CREATE contacts.email_health_status (enumeration) for suppression-list.
…four more PLAN lines, then an APPLIED line per property…
* Runtime: Cloudflare Worker required
* Unlinked deploy id: deploy_email-guard_1781140081_ca9c
* Cloudflare deploy: hsx-local-865a8a52-email-guard-email-guard
* Deploy history for this machine: https://app.hs-x.dev/m/m_865a8a52c372c3806729
* HubSpot build #1: SUCCESS
* [ok] email-guard (APPLICATION)
* [ok] validate-email (WORKFLOW_ACTION)
* [ok] email-health-card (CARD)
* App auth (grab client secret / configure install OAuth): https://app.hubspot.com/developer-projects/<id>/project/email-guard/component/email-guard/auth
* Generated .hs-x/manifest.json and refs stubs.
Deployed (unlinked) to https://hsx-local-865a8a52-email-guard-email-guard.<account>.workers.dev; machine_id m_865a8a52c372c3806729. Run `hs-x link` to claim this history. in 24.1sOpen a contact record. The card is live in the right rail: suppression data lands with the first sync run, and the status fills in the first time a workflow runs validate-email. The machine_id and the hs-x link hint on the last line are the opt-in path for claiming this deploy history into an HS-X account later; unlinked deploys work fine without ever running it.
Common deploy errors
-
HSX_E_DEPLOY_SCHEMA_SCOPESon--apply-schema. Your personal access key can register apps but not create properties. The error names the exact missing scope (for contacts:crm.schemas.contacts.write); add it to the key at HubSpot's personal-access-key page and re-run. -
"Schema validates but the card is empty." Nothing has written to the new properties yet. The suppression fields fill in when the first scheduled sync run finishes (at most five minutes on the
5mschedule), and the status and score land the first time a workflow invokesvalidate-emailfor that contact. -
"Portal version mismatch." The
platformVersionin yourhsx.config.tsis older than what the portal is running. Bump it inhsx.config.tsand re-deploy. The CLI also checks for this onhs-x devstartup, so you usually catch it locally. -
"Worker exceeded 1 MB bundle limit." You imported something heavy. The deploy bundles your Worker from the generated entrypoint at
.hs-x/cloudflare/email-guard.entry.ts; reproduce the bundle locally withbun x wrangler deploy --dry-run --outdir dist-analyze --config .hs-x/cloudflare/email-guard.wrangler.tomland inspect what's shipping. Most often the culprit is a fulllodash(use specific imports), a fullmoment(usedate-fnsorTemporal), or a connector you imported but don't use (tree-shake it by importing the specific export, not the whole package). -
"
contacts.email_health_scoredeclares number, portal is string." Someone created or edited the property in the portal UI directly. With the schema flags from above, the plan prints this as aWILL ALTERline and--apply-schemareconciles it. A plain deploy doesn't read the portal at all, so the drift surfaces later as failed sync writes; re-run with the flags to see and fix it.
What you built
In ~15 minutes, you've assembled Email Guard's first three capabilities and the schema that ties them together:
- A typed schema covering every contact property Email Guard owns, the single source of truth shared by the action, the sync, and the card.
- The
validate-emailworkflow action: format check, external deliverability score, write-back to the contact, and a branchable{ status, score }output. - The
suppression-listsync running on your Cloudflare account, pulling the provider's bounce-and-complaint list into HubSpot contacts every 5 minutes, with cursor persistence and rate-limit-aware retries. - The email-health card rendered on every contact record, reading the same schema through hs-uix components.
Email Guard isn't finished, either: the next guides give the card a live email-health backend, react to address changes with an email-changed trigger, emit an email-verified app event onto the contact timeline, and meter verifications for billing. Everything you wrote runs in your own infrastructure; nothing crosses HS-X's. If you stop paying us tomorrow (we don't currently charge, but pretend), the Worker keeps running until you wrangler delete it yourself.
