import { GuideHero, GuideStep, GuideOut } from '~/components/Guide';
import { GuideArticleSchema } from '~/components/Guide/GuideArticleSchema';
import { CopyAsMarkdown } from '~/components/CopyAsMarkdown/CopyAsMarkdown';
import { Diagram, DeployProgress, Figure } from '@hs-x/design';

export const metadata = {
  title: 'Get started with HS-X — zero to a live HubSpot card in 15 minutes',
  description: 'From an empty directory to a live HubSpot contact card backed by a Cloudflare Worker — about fifteen minutes, end-to-end.',
  alternates: { canonical: '/docs/guides/getting-started' },
  openGraph: {
    type: 'article',
    title: 'Get started with HS-X — zero to a live HubSpot card in 15 minutes',
    description: 'From an empty directory to a live HubSpot contact card backed by a Cloudflare Worker — about fifteen minutes, end-to-end.',
    url: '/docs/guides/getting-started',
  },
};

<GuideArticleSchema
  url="/docs/guides/getting-started"
  title="Get started with HS-X — zero to a live HubSpot card in 15 minutes"
  description="From an empty directory to a live HubSpot contact card backed by a Cloudflare Worker — about fifteen minutes, end-to-end."
  datePublished="2026-05-18"
  keywords={['HubSpot', 'HubSpot developer', 'UI extensions', 'Cloudflare Workers', 'HS-X', 'getting started']}
  howTo={{
    totalTime: 'PT15M',
    steps: [
      { name: 'Install the CLI' },
      { name: 'Scaffold a project' },
      { name: 'Wire your Cloudflare and HubSpot accounts' },
      { name: 'Define a sync' },
      { name: 'Build a UI extension' },
      { name: 'Deploy' },
    ],
  }}
/>

<CopyAsMarkdown src="/docs/guides/getting-started.md" />

<GuideHero
  eyebrow="How to · Start"
  title="From zero to a live HubSpot card."
  tagline="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."
  duration="≈ 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.',
  ]}
/>

<GuideStep n={0} title="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:

<Diagram dsl={`title: HS-X architecture
cols:  Your machine | Cloudflare · your account | HubSpot · your portal

1 cli * : hs-x CLI / scaffold · deploy · agent

2 worker * : Worker / sync · actions · triggers · tools
2 schema : schema / typed contract

3 portal : Portal / contacts · deals · UI extensions
3 ui : UI extension / hs-uix · rendered in portal

cli -> worker : deploy
cli -> ui : register @from=bottom @to=bottom @bend=1.3
schema -> worker : types
schema -> ui : types
worker <-> portal : graphql
ui -> portal : runs inside`} />

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

<Figure tag="Three phases · six steps">
  <Figure.Row gap="md">
    <Figure.Step n={1} title="Setup" caption="Install · scaffold · wire accounts (steps 1–3)" />
    <Figure.Arrow />
    <Figure.Step n={2} title="Build" caption="Define a sync · build a UI extension (steps 4–5)" />
    <Figure.Arrow />
    <Figure.Step n={3} title="Ship" caption="Deploy to your Cloudflare + HubSpot (step 6)" tone="emphasis" />
  </Figure.Row>
</Figure>

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.

</GuideStep>

<GuideStep n={1} total={6} title="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.

```sh
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.

<GuideOut terminal={`$ hs-x --version\nhs-x v3.1.0`}>
Run <code>hs-x doctor</code> next — it prints a green checkmark for each of the three install side effects above, plus a fourth for <strong>tokens stored</strong> (still empty; we fix that in step 3).
</GuideOut>

#### 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`.

</GuideStep>

<GuideStep n={2} total={6} title="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`.

```sh
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.

</GuideStep>

<GuideStep n={3} total={6} title="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.

```sh
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.

<GuideOut label="Why two tokens">
<strong>HS-X is not in your data path.</strong> 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.
</GuideOut>

#### Verifying the wiring

```sh
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.

</GuideStep>

<GuideStep n={4} total={6} title="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.

<Figure
  tag="Data flow"
  title="Airtable rows in, HubSpot contacts out — every five minutes."
  caption="The Worker holds the cursor and runs the upsert. You write the pull function; HS-X handles retries, dedup, and rate-limit-aware batching."
>
  <svg
    viewBox="0 0 880 200"
    width="100%"
    role="img"
    aria-label="Sync data flow: Airtable feeds the worker.sync runner via a pull function every five minutes, which upserts records into HubSpot contacts by email."
    style={{ display: 'block' }}
  >
    <defs>
      <style>{`
        .sync-zone-lbl { font: 500 10.5px var(--font-mono); fill: var(--ink-50); letter-spacing: 1.4px; text-transform: uppercase; }
        .sync-card-bg { fill: var(--paper-raised); stroke: var(--ink-15); stroke-width: 1; vector-effect: non-scaling-stroke; }
        .sync-card-bg.em { fill: var(--ink); stroke: var(--ink); }
        .sync-name { font: 500 14px var(--font-mono); fill: var(--ink); }
        .sync-name.on-ink { fill: var(--paper); }
        .sync-sub { font: 12.5px var(--font-sans); fill: var(--ink-60); }
        .sync-sub.on-ink { fill: rgba(245,245,242,0.7); }
        .sync-edge { fill: none; stroke: var(--ink-40); stroke-width: 1; stroke-dasharray: 4 4; animation: sync-flow 1.6s linear infinite; }
        .sync-edge-strong { fill: none; stroke: var(--orange-500); stroke-width: 1.6; stroke-dasharray: 4 4; animation: sync-flow 1.6s linear infinite; }
        .sync-edge-lbl { font: 500 11px var(--font-mono); fill: var(--ink-50); letter-spacing: 0.06em; }
        .sync-tip { fill: var(--ink-40); }
        .sync-tip-strong { fill: var(--orange-500); }
        @keyframes sync-flow { to { stroke-dashoffset: -16; } }
      `}</style>
    </defs>

    {/* zone labels */}
    <text className="sync-zone-lbl" x="40" y="22">Source</text>
    <text className="sync-zone-lbl" x="340" y="22">HS-X worker</text>
    <text className="sync-zone-lbl" x="660" y="22">HubSpot</text>

    {/* Source card */}
    <rect className="sync-card-bg" x="40" y="62" width="220" height="84" rx="2" />
    <text className="sync-name" x="60" y="92">airtable</text>
    <text className="sync-sub" x="60" y="114">base · Contacts table</text>
    <text className="sync-sub" x="60" y="130" style={{ opacity: 0.65 }}>your data, your source</text>

    {/* Worker.sync — emphasis card (the runner) */}
    <rect className="sync-card-bg em" x="320" y="46" width="240" height="116" rx="2" />
    <text className="sync-name on-ink" x="340" y="76">worker.sync</text>
    <text className="sync-sub on-ink" x="340" y="98">every 5 m · upsert by email</text>
    <text className="sync-sub on-ink" x="340" y="116" style={{ opacity: 0.65 }}>cursor · retries · dedup</text>
    {/* small inline schedule pill */}
    <g transform="translate(340, 130)">
      <rect x="0" y="0" width="56" height="20" rx="10" fill="rgba(245,245,242,0.12)" stroke="rgba(245,245,242,0.18)" strokeWidth="0.5" />
      <text x="28" y="14" textAnchor="middle" style={{ font: '500 11px var(--font-mono)', fill: 'rgba(245,245,242,0.85)', letterSpacing: '0.04em' }}>5 m</text>
    </g>

    {/* Target card */}
    <rect className="sync-card-bg" x="620" y="62" width="220" height="84" rx="2" />
    <text className="sync-name" x="640" y="92">hubspot.contacts</text>
    <text className="sync-sub" x="640" y="114">typed properties</text>
    <text className="sync-sub" x="640" y="130" style={{ opacity: 0.65 }}>email · stage · revenue · owner</text>

    {/* edges */}
    {/* airtable -> sync (pull, regular) */}
    <path className="sync-edge" d="M 260 104 L 320 104" />
    <polygon className="sync-tip" points="314,100 320,104 314,108" />
    <text className="sync-edge-lbl" x="268" y="96">pull</text>

    {/* sync -> contacts (upsert, emphasis heavy arrow — this is the load-bearing flow) */}
    <path className="sync-edge-strong" d="M 560 104 L 620 104" />
    <polygon className="sync-tip-strong" points="612,98 620,104 612,110" />
    <text className="sync-edge-lbl" x="568" y="96" style={{ fill: 'var(--orange-700)' }}>upsert</text>
  </svg>
</Figure>

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.

```ts
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.

<GuideOut label="Run it locally" terminal={`$ hs-x dev\n→ worker.ts        bundled · 84 kb\n→ schema/contact   typed · 4 fields\n→ dev portal       connected · my-portal-dev\n\n$ hs-x sync run airtableContacts\n→ pulled 142 rows · upserted 142 · 0 errors · 1.8s`}>
<code>hs-x dev</code> 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.
</GuideOut>

</GuideStep>

<GuideStep n={5} total={6} title="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.

```tsx
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.

</GuideStep>

<GuideStep n={6} total={6} title="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.

```sh
hs-x deploy
```

<DeployProgress
  steps={[
    { id: 'bundle', label: 'Bundle', state: 'done', timing: '1.2s', detail: 'worker 84 kb · extension 41 kb' },
    { id: 'validate', label: 'Validate schema', state: 'done', timing: '0.8s', detail: '3 properties · 0 conflicts' },
    { id: 'portal', label: 'Deploy to portal', state: 'done', timing: '2.1s', detail: 'v17 · 1 extension registered' },
    { id: 'worker', label: 'Push Worker', state: 'done', timing: '1.6s', detail: 'iad → 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.

<GuideOut terminal={`✓ bundle      1.2s   worker 84 kb · extension 41 kb\n✓ validate    0.8s   3 properties · 0 conflicts\n✓ portal      2.1s   v17 · 1 extension registered\n✓ worker      1.6s   iad → my-portal.workers.dev\n\n→ https://app.hubspot.com/portal/<id>/extensions`}>
Open it. The card is live, rendering whatever your most recent sync wrote.
</GuideOut>

#### 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](/docs/guides/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](/docs/guides/sell)** — turn what you built into a private internal tool, a marketplace app, or a billable product.
- **[Reference · Rate limits](/docs/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.

</GuideStep>
