From zero to a live HubSpot card.
The shortest path from an empty directory to a HubSpot UI extension running against your own Cloudflare Worker. By the end you'll understand how HS-X glues a typed schema to a sync, a worker, and a UI surface — and you'll have all four pieces deployed and talking to each other.
Before you begin
HS-X is not a service you call. It's a developer platform 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.
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 (
extensions/*.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.
How this guide is structured
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 hs-x CLI is a single TypeScript binary distributed via npm. Install it globally so it's 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.
bun add -g @hs-x/cli
hs-x --versionWhat the install did
Three things happen in the post-install hook beyond writing the binary to your global bin:
- Shell completion is written to your shell's completion dir (
~/.bun/completionsand~/.zfuncfor zsh). Restart your shell orsourceyour rc to pick it up. After that,hs-x <tab>lists commands andhs-x deploy --<tab>lists flags. - MCP server entry is appended to your coding agent's config —
~/.claude.json,~/.config/cursor/mcp.json, etc. The CLI itself is the MCP server (it runs in a different mode when an agent talks to it via stdio). Once registered, your agent can callhs-x init,hs-x deploy, etc. as tools, with the same authentication you set up locally. - Doctor cache is initialized.
hs-x doctoris a one-stop diagnostic — it checks the binary version, your shell PATH, the MCP entries, and your stored credentials. Run it whenever something feels off.
$ hs-x --version hs-x v3.1.0
Run hs-x doctor next — it prints a green checkmark for each of the three install side effects above, plus a fourth for tokens stored (still empty; we fix that in step 3).
Common install issues
command not found: hs-x— your Bun global bin isn't on PATH. Addexport PATH="$HOME/.bun/bin:$PATH"to your shell rc and reload it.- Corporate proxy blocking the npm registry? The binary is also distributed as a self-contained shell installer:
curl -fsSL hs-x.dev | sh. It downloads a pre-built static binary, no Bun required. - macOS Gatekeeper warning? The binary is signed and notarized; allow it once in System Settings → Privacy & Security. Apple Silicon and Intel are both supported.
- Want to install per-project instead of global?
bun add -D @hs-x/cliworks fine — just prefix every command withbunx hs-xinstead ofhs-x.
Scaffold a project
hs-x init lays down a project skeleton. You get a worker.ts entry point, a typed schema/, a UI extension stub, and an hs-x.toml with sensible defaults. There are no prompts — opinionated defaults are baked in, and the rare cases are flags. Pick a directory name that's also a valid npm package name (lowercase, hyphens, no spaces) because it gets written into package.json.
hs-x init my-portal
cd my-portalWhat the scaffold contains
my-portal/
├── hs-x.toml # project config — portal id, region, env mapping
├── worker.ts # entry point — sync · actions · triggers · tools
├── extensions/
│ └── ContactPanel.tsx
├── schema/
│ └── contact.ts # typed shape · pulled into Worker + extension
├── package.json
└── .env.example # template for local secretsA short tour, because the layout is small but every file has a purpose:
hs-x.toml— non-secret config: which portal you're targeting, which region the Worker deploys to, and how environment names (dev,staging,prod) map onto Cloudflare account IDs. Checked into git.worker.ts— your runtime. Everything that runs on the edge is declared here: syncs, workflow actions, triggers, agent tools, scheduled jobs. A single file by default; once you have more than ~500 lines, split it however you like — the import graph is what matters, not the file shape.schema/contact.ts— the source of truth for the contact shape. Both the sync (server-side, in the Worker) and the UI extension (client-side, in the portal) import from here. A schema edit propagates everywhere; forgetting a usage is a compile error.extensions/ContactPanel.tsx— the UI extension that ships to HubSpot. We'll fill this in at step 5..env.example— copy to.envand fill in the two tokens you'll generate next..envis gitignored by default.package.json— minimal. Just the SDK, the connector pack, and the UI extension package. No build tooling here; the CLI bundles for you.
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 tokens are needed: a Cloudflare API token with Workers Edit scope, and a HubSpot dev portal token. The CLI runs an OAuth-style flow for each — pops open your browser, you click approve, the resulting token gets written to ~/.config/hs-x/credentials.toml. The tokens are scoped narrowly and used only at deploy time. Your Worker at runtime uses its own copy of the HubSpot token, embedded into the deployed bundle.
hs-x login cloudflare
hs-x login hubspot --portal devWhy 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. Nothing more. - 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. 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 whoamiLists the connected Cloudflare account, the connected HubSpot portal, and the scopes on each token. If a scope is missing (HubSpot occasionally adds a new required scope when a portal is upgraded), the CLI prints the exact URL to re-authorize just that scope, without re-running the whole flow.
Common wiring issues
- "Browser opened but the callback never fired." Some shells (
tmux, headless SSH, certain VS Code remote sessions) can't open browsers. Usehs-x login cloudflare --device-codefor the device-code flow — the CLI prints a code and a URL you open on a phone or another machine. - "Permission denied: extensions.write." Your HubSpot user role in the portal doesn't have the developer permission. Talk to your portal admin (or, if you're solo, switch to your dev sandbox portal — those grant full perms by default).
- Sharing credentials across machines? Don't copy
credentials.toml. Usehs-x login --importand--exportto move tokens around — the file is plaintext and the export bundle is symmetrically encrypted.
Define a 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. The schema declaration types both the pull return value and the resulting HubSpot properties, so one source of truth feeds the whole pipeline.
Airtable rows 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 worker.ts and replace the scaffolded stub. 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.
import { defineSource, defineWorker, env } from '@hs-x/sdk';
const airtableContacts = defineSource({
name: 'airtableContacts',
auth: { type: 'bearer', token: env('AIRTABLE_TOKEN') },
async fetch({ cursor, http }) {
const res = await http.get('https://api.airtable.com/v0/appXYZ/Contacts', {
query: { pageSize: 100, offset: cursor },
});
return {
cursor: res.body.offset,
rows: res.body.records.map((r) => ({
key: r.fields.Email,
data: {
email: r.fields.Email,
lifecycle_stage: r.fields.Stage,
annual_revenue: r.fields.ARR,
owner: r.fields.OwnerEmail,
},
})),
};
},
});
export default defineWorker(({ worker }) => {
worker.sync(airtableContacts, {
into: 'contacts',
schedule: '5m',
schema: {
email: 'email',
lifecycle_stage: { type: 'enum', values: ['lead', 'customer'] },
annual_revenue: 'currency',
owner: 'user',
},
});
});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. Usingemailhere means re-running the sync with the same row updates the existing contact instead of duplicating. 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 — and 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. 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 → worker.ts bundled · 84 kb → schema/contact typed · 4 fields → dev portal connected · my-portal-dev $ hs-x sync run airtableContacts → pulled 142 rows · upserted 142 · 0 errors · 1.8s
hs-x dev runs the Worker against your dev portal with hot reload — every save retypechecks the schema and re-bundles in under a second. Trigger the sync from another terminal; the dev CLI tails the log and prints the upserted count when it finishes.
Build a UI extension
The ContactPanel.tsx stub already imports from hs-uix — HS-X's component library for HubSpot UI Extensions. It's a thin layer over @hubspot/ui-extensions that adds typed schemas, hot-reload, and a handful of higher-level components (KeyValueList, DataTable, SectionHeader, Stat) you'd otherwise rebuild yourself.
Drop a KeyValueList into the panel, bound to the same schema your sync just wrote. The context.crm object is fully typed because hs-uix reads schema/contact.ts at build time and threads the types through. Rename a property in the schema and you get a compile error in the extension — no drift between server and UI.
import { KeyValueList, SectionHeader } from 'hs-uix/common-components';
import { Tile, hubspot } from '@hubspot/ui-extensions';
import { formatCurrency } from 'hs-uix/utils';
function ContactPanel({ context }) {
return (
<Tile>
<SectionHeader title="Synced from Airtable" />
<KeyValueList
items={[
{ label: 'Lifecycle', value: context.crm.lifecycle_stage },
{ label: 'Annual rev.', value: formatCurrency(context.crm.annual_revenue) },
{ label: 'Owner', value: context.crm.owner },
]}
/>
</Tile>
);
}
hubspot.extend(() => <ContactPanel />);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.lifecycle_stageis narrowed to'lead' | 'customer'from the enum in yourschema/contact.ts. Renamelifecycle_stagetostagein the schema and the extension 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 extension 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. - Server function bridge. Need data the extension can't read directly — say a list of deals filtered by a complex query? Add
runServerless({ name: 'listDeals', parameters: {...} })and HS-X generates the matching declaration inworker.ts. The function runs on the Worker with full HubSpot access, the extension consumes the typed return value. Same dev-server hot reload applies. - Higher-level components.
KeyValueList,DataTable,Stat,SectionHeader,Banner— 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: builds the extensions, validates the schema against your portal, pushes the Worker to Cloudflare, and registers everything with HubSpot. The CLI streams build output live, so warnings and timing show up as they happen instead of dumped at the end.
hs-x deployWhat the four phases do
You'll see four checkmarks fly past in the terminal — here's what each one actually checks:
- Bundle — esbuild compiles
worker.tsand everyextensions/*.tsxinto separate bundles. Worker bundle is bare ESM targeting Cloudflare's runtime; extension bundles are React-compatible JS targeting the iframe HubSpot renders them in. Prints the gzipped size for each. - Validate — the schema in
schema/*.tsis diffed against the portal's current property definitions. New properties get aWILL CREATEline; renamed properties get aWILL ALTERline and a yes/no prompt (the only timehs-x deployis interactive). Type mismatches between schema and portal abort the deploy. - Deploy to portal — UI extensions and workflow action declarations get uploaded to HubSpot via the developer projects API. The portal version of your project bumps. Live extensions stay live during the swap; users mid-session don't see a flicker.
- Worker pushed — the Worker bundle goes to Cloudflare with a Wrangler-equivalent deploy. The CLI uses your stored Cloudflare token from step 3, picks the right region from
hs-x.toml, and emits the deployed URL.
✓ bundle 1.2s worker 84 kb · extension 41 kb ✓ validate 0.8s 3 properties · 0 conflicts ✓ portal 2.1s v17 · 1 extension registered ✓ worker 1.6s iad → my-portal.workers.dev → https://app.hubspot.com/portal/<id>/extensions
Open it. The card is live, rendering whatever your most recent sync wrote.
Common deploy errors
- "Schema validates but no records found in the card." Sync hasn't run yet. Trigger it manually:
hs-x sync run airtableContacts. The card renders empty until the first sync finishes and writes records. (Once the sync runs on its5mschedule, future record updates show up automatically.) - "Portal version mismatch." Your local
hs-x.tomlpins a platform version older than what the portal is running. Bump withhs-x upgrade portal. 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. Run
hs-x analyzefor a flame graph of the bundle by package — 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). - "Type mismatch on property
lifecycle_stage: portal has enum, schema has string." Someone changed the property type in the portal UI directly. Either revert in HubSpot, or update your schema to match and re-deploy.
What you built
In ~15 minutes, you've assembled four things that talk to each other:
- A typed schema (
schema/contact.ts) that's the single source of truth for the contact shape. - A scheduled sync running on your Cloudflare account, pulling Airtable into HubSpot contacts every 5 minutes, with cursor persistence and rate-limit-aware retries.
- A UI extension rendered on every contact record, reading the same schema and rendering it with hs-uix components.
- An MCP entry so your coding agent can re-run any step of this guide —
hs-x deploy,hs-x sync run,hs-x analyze— from a chat prompt.
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.
Where next
- How to · Build — add a workflow action so a HubSpot workflow can call your code, then expose the same action to Breeze as an agent tool.
- How to · Sell — turn what you built into a private internal tool, a marketplace app, or a billable product.
- Reference · Rate limits — what HubSpot's API will and won't let you do at scale, how HS-X's sync engine batches and backs off, and how to read the response headers.