view .md
How to · Start

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.

Time
≈ 15 min
Outcome
A live HubSpot contact card, backed by an isolated Worker in your own Cloudflare account, with a typed schema shared between the sync, the worker, and the extension.
Prerequisites
  • HubSpot developer account (free at developers.hubspot.com — get a sandbox portal)
  • Cloudflare account with Workers enabled (free tier is plenty for this walkthrough)
  • Bun ≥ 1.1 installed locally (curl -fsSL https://bun.sh/install | bash)
  • A coding agent — Claude Code, Cursor, or Codex. Optional, but the CLI auto-registers as an MCP server, so once you finish step 1, your agent can drive every subsequent step from a chat prompt.

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. Uses hs-uix (HS-X's React component library) so it matches HubSpot's design language.

How this guide is structured

Three phases · six steps
01
Setup
Install · scaffold · wire accounts (steps 1–3)
02
Build
Define a sync · build a UI extension (steps 4–5)
03
Ship
Deploy to your Cloudflare + HubSpot (step 6)

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 --version

What the install did

Three things happen in the post-install hook beyond writing the binary to your global bin:

  1. Shell completion is written to your shell's completion dir (~/.bun/completions and ~/.zfunc for zsh). Restart your shell or source your rc to pick it up. After that, hs-x <tab> lists commands and hs-x deploy --<tab> lists flags.
  2. 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 call hs-x init, hs-x deploy, etc. as tools, with the same authentication you set up locally.
  3. Doctor cache is initialized. hs-x doctor is 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.
Expect
$ 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. Add export 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/cli works fine — just prefix every command with bunx hs-x instead of hs-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-portal

What 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 secrets

A 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 .env and fill in the two tokens you'll generate next. .env is 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 dev

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: Edit plus Account: Read on 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.write and objects.read scopes. 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.

Why two tokens

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 whoami

Lists 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. Use hs-x login cloudflare --device-code for 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. Use hs-x login --import and --export to 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.

Data flow

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.

SourceHS-X workerHubSpotairtablebase · Contacts tableyour data, your sourceworker.syncevery 5 m · upsert by emailcursor · retries · dedup5 mhubspot.contactstyped propertiesemail · stage · revenue · ownerpullupsert

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.

  • key is the unique id HubSpot uses to upsert. Using email here 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 with crypto.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.
  • cursor is opaque to HS-X. Whatever your source's fetch returns gets handed back to the next invocation as cursor. This lets you do incremental syncs without holding state in the Worker itself. On the very first run, cursor is undefined — your code should handle that as "start from the beginning." Common cursor shapes: a row number for paginated APIs, an updated_after ISO timestamp for change-data-capture, a continuation token for batched cloud APIs.

Common sync patterns

  • Full table on every run — set schedule: '@daily', ignore cursor, return every row. Fine for tables under a few thousand rows.
  • Incremental by timestamp — use cursor as an ISO timestamp, query WHERE updated_at > cursor, return rows + new max timestamp. Cheap and correct as long as your source has reliable updated_at.
  • Continuation-token pagination — pass cursor straight to your source's next_page_token parameter. Most modern APIs work this way.
  • Webhook-driven (no polling) — drop the schedule and use defineSource.push({ auth: { type: 'hmac', ... }, receive }) instead. The runtime mounts a verified webhook endpoint and the sync runs only when the external system pings it.
Run it locally
$ 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_stage is narrowed to 'lead' | 'customer' from the enum in your schema/contact.ts. Rename lifecycle_stage to stage in the schema and the extension fails type-check until you update every callsite.
  • Hot reload against the live portal. hs-x dev opens 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 in worker.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. The DataTable alone 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 deploy
01Bundledone1.2sworker 84 kb · extension 41 kb
02Validate schemadone0.8s3 properties · 0 conflicts
03Deploy to portaldone2.1sv17 · 1 extension registered
04Push Workerdone1.6siad → my-portal.workers.dev

What the four phases do

You'll see four checkmarks fly past in the terminal — here's what each one actually checks:

  1. Bundle — esbuild compiles worker.ts and every extensions/*.tsx into 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.
  2. Validate — the schema in schema/*.ts is diffed against the portal's current property definitions. New properties get a WILL CREATE line; renamed properties get a WILL ALTER line and a yes/no prompt (the only time hs-x deploy is interactive). Type mismatches between schema and portal abort the deploy.
  3. 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.
  4. 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.
Expect
✓ 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 its 5m schedule, future record updates show up automatically.)
  • "Portal version mismatch." Your local hs-x.toml pins a platform version older than what the portal is running. Bump with hs-x upgrade portal. The CLI also checks for this on hs-x dev startup, so you usually catch it locally.
  • "Worker exceeded 1 MB bundle limit." You imported something heavy. Run hs-x analyze for a flame graph of the bundle by package — most often the culprit is a full lodash (use specific imports), a full moment (use date-fns or Temporal), 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.