Local dev
One command opens a HubSpot dev session against your portal and points it at a local Worker. Save a file in your editor and the next invocation (a workflow firing, a Breeze prompt, a contact-card render) runs the new code. UI extensions, workflow actions, and agent tools all behave the same way, which is the part HubSpot's own `hs project dev` does not give you.
TL;DR — hs-x dev opens a HubSpot dev session against your portal and points it at a local Worker. Save a file and the next invocation (a workflow firing, a Breeze prompt, a card render) runs the new code. UI extensions, workflow actions, and agent tools all hot-reload through one command.
Before you begin
Start with what HubSpot's official tooling does and does not cover. hs project dev (the dev server from @hubspot/ui-extensions-dev-server) hot-reloads UI extensions and serverless functions. That covers a lot, and for those two surfaces HS-X uses the same underlying dev-session API HubSpot's CLI uses. But the moment you want to iterate on a workflow action or an agent tool, the official path is to edit, hs project upload, wait for the build, fire the workflow or prompt Breeze, and observe. Every iteration is a deploy.
hs-x dev flattens that loop for the primitives HubSpot's own dev server doesn't. It opens a projects-localdev/2025-09/dev-sessions handshake against your portal, registers a dev override on your deployed Worker via the HS-X control plane, and starts a small local sidecar plus an auto-managed Cloudflare quick tunnel. With the override in place, HubSpot still calls your deployed Worker the same way it always does, but the Worker forwards in-flight invocations down the tunnel to the code running on your machine. Save the file and the next invocation runs the new handler, with no re-deploy and no waiting on a build queue.
The mental model
Four things spin up when you run hs-x dev:
- A local dev server on
127.0.0.1:8787that exposes exactly three routes today:GET /_hsx/health(CLI version, used byhs-x dev status),GET /_hsx/manifest(the discovered workers and capabilities), andPOST /_hsx/invoke/<capability-id>(the same invocation enginehs-x dev invokeuses, over HTTP). Everything else returns a 404 with a one-line message saying so. This server is small on purpose; it's a probe-and-dispatch surface, not a UI. - A log + telemetry sidecar on
127.0.0.1:9099. The Worker tees request and log events here through the tunnel, and the sidecar renders them in the terminal alongside whatever your handlers print withctx.logger.*. - An auto-managed Cloudflare quick tunnel. The CLI spawns
cloudflaredas a subprocess, parses thetrycloudflare.comURL it prints, and tears it down on Ctrl-C. You never have to runcloudflaredyourself; the CLI owns the subprocess. - A dev-override registration through the HS-X control plane (or a
2025-09/dev-sessionsregistration through HubSpot, when the project also has UI-extension nodes). The override is what redirects the deployed Worker's behavior toward your tunnel. It is bounded by--ttl-seconds(default 7200, two hours), and the Worker reverts to its embedded handlers when the TTL expires or you Ctrl-C the session.
What is and isn't live-reloaded
| Primitive | Hot-reloads on save | How |
|---|---|---|
UI extension (src/app/cards/*.tsx) | Yes | HubSpot's iframe dev-session shim, driven through @hubspot/ui-extensions-dev-server |
Workflow action handler (validate-email's handler body) | Yes | Deployed Worker proxies the invocation through the tunnel to your machine |
Agent tool handler (check-email-health's handler body) | Yes | Same proxy mechanism, separate dispatch path |
Card-backend handler (email-health) | Yes | Same proxy mechanism |
Sync handler body (suppression-list) | Yes (on next scheduled fire) | Worker proxies the scheduled invocation through the tunnel |
Trigger handler (email-changed) | Yes | Replay a delivery locally with hs-x dev invoke email-changed; each invoke reloads your worker source |
Workflow action schema (input/output fields) | No, requires hs-x deploy | HubSpot caches the registered contract |
Agent tool parameters and description | No, requires hs-x deploy | Breeze caches the tool definition until re-registered |
| UI extension manifest (which surfaces it renders on) | No, requires hs-x deploy | Portal caches the registered surface list |
The pattern is the same across primitives: the body of a handler reloads on save, the declared contract does not. If you change the shape of validate-email's inputs, you need a hs-x deploy to teach HubSpot about it. If you change what the action does with those inputs, save and trigger.
Start the dev session
From the project root, run the command. The CLI reads the connected account, the project id from hsx.config.ts, and the portal id from your stored account record. There are no required flags for the common case.
hs-x devIf you want to be explicit:
hs-x dev \
--port 8787 \
--hsx-log-port 9099 \
--portal 46993937 \
--ttl-seconds 7200The full set of hs-x dev flags, in order of how often you'll reach for them: --port (local dev server, default 8787), --hsx-log-port (telemetry sidecar, default 9099), --portal <id> (override the portal id derived from your connected account), --target-origin <url> (skip the auto-tunnel and point the Worker at a URL you already control), --ttl-seconds <n> (default 7200), --capability <id> (repeatable, scope the dev override to specific capability ids), --project-id <id>, --control-plane-url <url>, --developer-id <id>, --session-id <id>. There is no --tunnel flag and no --force flag; the CLI does not yet support named Cloudflare tunnels or evicting another developer's session.
What the splash tells you
When the dev session is healthy you get a Ready in <ms> line listing the workers, capability count, and any of the optional pieces that spun up: the UI-extension session id (if your project has UI-extension nodes), the local log stream URL, and the public tunnel URL. The line ends with a Press Ctrl+C to stop reminder. Two probes are useful to know about:
curl http://127.0.0.1:8787/_hsx/healthreturns{ "ok": true, "cliVersion": "<version>" }. The same probe is whaths-x dev statuscalls when you ask whether a session is running.curl http://127.0.0.1:8787/_hsx/manifestreturns the JSON manifest of discovered workers and capabilities. Useful when you want to see exactly which capability ids the override registered.
$ hs-x dev ▲ hs-x dev portal 46993937 → http://127.0.0.1:8787 * Ready in 412ms — 1 worker · 4 capabilities · portal 46993937 · log stream http://127.0.0.1:9099 · tunnel https://cool-mongoose-23.trycloudflare.com * * Press Ctrl+C to stop
For Email Guard the four counted capabilities are the validate-email action, the check-email-health tool, the email-health card backend, and the suppression-list sync; the email-changed trigger registers at dispatch time and is not part of the splash's static count, though hs-x dev invoke email-changed reaches it fine. The tunnel URL is what got registered as the Worker's dev override target. If a segment is missing (no tunnel, no portal id), read it as "that piece did not start" and skip to step 6 for the recovery sequence.
What got registered, what got reused
- The HubSpot dev-session record (when you have UI-extension nodes in the project) is new on every run, and it's what makes the "Developing Locally" badge appear in the portal. Ctrl+C deletes it; if the CLI crashes hard, the session expires server-side on its own heartbeat timeout.
- The Cloudflare quick tunnel is new on every run and its URL changes. If you need a stable URL for an integration that points at your local sidecar, pass
--target-originand run your own tunnel out-of-band. - The dev override on your deployed Worker is registered with the control plane, bounded by
--ttl-seconds, and replaced (not stacked) on each session start. You don't need a cleanup step. - Your stored credentials from
hs-x loginare reused. If they've expired, the CLI prints what to re-run and exits.
Live-edit a UI extension
UI extensions are the part of dev mode that overlaps with hs project dev. HS-X drives the same HubSpot dev-session API (projects-localdev/2025-09/dev-sessions) the official CLI uses, and the same @hubspot/ui-extensions-dev-server shim runs inside the portal iframe. The benefit of going through hs-x dev is unified output: the same terminal that tails your workflow action invocations also shows iframe logger output, with the same trace correlation.
Leave hs-x dev running. Open a contact record that renders the email-health card, then open the .tsx source in your editor and change a visible string; here the section title gets a (dev) marker so the live swap is unmissable. (The card imports from hs-uix, which is a card-side dependency — bun add -D hs-uix plus an entry in src/app/cards/package.json; the UI extensions guide covers the split.)
// 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 (dev)" />
<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} />);Save. The portal iframe picks up the new bundle through HubSpot's existing dev-session machinery and re-renders with the marked title. State inside the component is preserved across the swap when the dev-server shim manages to do a hot module replace instead of a full reload (this is HubSpot's behavior, not HS-X's, and varies by extension shape).
The card's server half is just as testable without leaving the terminal. The email-health backend (the UI extensions guide wires it) is a capability like any other, so the invocation harness can exercise it with a contact-shaped object while the portal tab stays closed:
$ hs-x dev invoke email-health --object '{"id":"3301452","objectType":"contacts","properties":{"email":"mia@example.com"}}'
* [info] card verdict {"id":"3301452","status":"deliverable"}
# hs-x dev invoke * email-health
[ok] Invoked card-backend via worker email-guard 200 in 61ms
* {
* "ok": true,
* "capabilityId": "email-health",
* "result": {
* "email": "mia@example.com",
* "status": "deliverable",
* "score": 0.97,
* "checkedAt": "2026-06-11T02:49:43.839Z"
* }
* }
Invoked in 64msThe result object is exactly what the card's hubspot.fetch call would receive, so you can iterate on the backend's shape before touching the React side.
What just happened
The HS-X CLI registered the UI-extension nodes from hsproject.json against projects-localdev/2025-09/dev-sessions/register. That endpoint is what flips the "Developing Locally" badge on in the portal and tells HubSpot's iframe to load your extension bundle from the local dev server instead of the last deployed bundle. The shim that lives inside the iframe is the one shipped by @hubspot/ui-extensions-dev-server; HS-X did not re-implement it.
A useful side effect: the logger you import from @hubspot/ui-extensions inside an extension forwards its calls back to the HS-X log sidecar in dev mode. So a logger.info('reverify clicked', { contactId }) from inside the card shows up in your terminal alongside the email-health backend call the click triggered. In production builds it falls back to the platform logger and is silent in the terminal.
Common UI extension issues
- "The portal still renders the old bundle." The iframe's dev-session shim is per-tab. If the record was already open before you started
hs-x dev, hard-refresh the tab once so the shim attaches; subsequent edits update normally. - "My project doesn't have any UI extensions and I want to skip the picker." That's the default when there are no UI-extension nodes in the project. If you do have them but want to skip them for this session, deselect them in the interactive picker, or pass
--capabilityflags to scope to specific HS-X capability ids. - "The HubSpot dev session won't register." The 2025-09 API requires the developer account to be on a platform version that exposes it. If your portal is older, the CLI logs the HubSpot error verbatim and continues with HS-X's own overrides for the workflow-action and tool capabilities: you still get the workflow action and agent tool loops, just no UI-extension HMR.
Live-edit a workflow action
This is the step that has no equivalent in hs project dev. Edit validate-email's handler in src/workers/email-guard.ts, trigger the workflow from the portal, watch the new handler run on your machine. The declaration itself is unchanged from the workflow-actions guide; what dev mode makes hot is the body:
// src/workers/email-guard.ts — validate-email's handler body; edits here are live on save
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 });
}
// ...the verification fetch and the contact write-back...
logger.info('verified', { id: enrolledObject.id, status: verdict.status });
return ok({ status: verdict.status, score: verdict.score });
},A few specifics of the SDK shape: defineWorker takes a string name and returns a worker you register capabilities on (const worker = defineWorker('email-guard'); worker.action(...); export default worker;); the field name is input singular, not inputs; env is an object on HandlerContext, not a function call (read env.EMAILCHECK_API_KEY, not env('EMAILCHECK_API_KEY')); the handler receives { input, enrolledObject, install, env, hubspot, http, sync?, logger, request }. Workers live under src/workers/.
In the portal, open the workflow that calls validate-email and re-enroll a test contact (editing its email address does it, if the workflow triggers on that property). The action fires, HubSpot calls your deployed Worker, the Worker recognises the active dev override and forwards the invocation through the tunnel to your machine, your handler runs, the response goes back the way it came. Edit the handler body (change the log message, loosen the format check), save, re-enroll, and the new code runs without a deploy.
Invoking without a workflow
Re-enrolling a contact to test every edit gets old. The invocation harness dispatches one capability through the production runtime router, in process, with fixture defaults you can override; --input fills the action's input fields and --object stands in for the enrolled contact:
$ hs-x dev invoke validate-email --input '{"email":"mia@example.com"}' --object '{"id":"3301452","objectType":"contact","properties":{"email":"mia@example.com"}}'
* [info] verified {"id":"3301452","status":"deliverable"}
# hs-x dev invoke * validate-email
[ok] Invoked tool via worker email-guard 200 in 57ms
* {
* "ok": true,
* "capabilityId": "validate-email",
* "result": {
* "status": "ok",
* "output": {
* "status": "deliverable",
* "score": 0.97
* }
* }
* }
Invoked in 58msThe same dispatch is reachable over HTTP at POST /_hsx/invoke/validate-email on the local dev server, which is what the MCP server's invoke tool calls; hs-x dev invoke --last replays the previous dispatch after an edit, and --json emits one structured result for scripts and agents. Handlers that call ctx.hubspot against a real portal still need the tunneled session; the fixture install carries no portal token.
What just happened
When worker.action() registers at startup, it lives inside the deployed Worker bundle. The control plane stores the dev override you just registered (target origin = your tunnel, capability ids = your action's capability id, TTL = your --ttl-seconds). The deployed Worker, on every invocation, checks the override table; if the invoked capability matches an active override it forwards the request to the override's target origin instead of running the embedded handler.
The CLI surfaces the round-trip in the unified log feed. Each invocation gets a request log line on stderr with method, path, status, and duration, and any ctx.logger.* calls from your handler appear inline. The same trace id propagates so you can correlate one workflow firing with the API calls it made.
What the handler-vs-schema split means in practice
- Edit the
handlerbody. Saves are live; the next invocation runs new code. - Edit the
inputoroutputfield maps. HubSpot still has the old contract registered. The workflow editor in the portal won't see new fields until you runhs-x deploy. Existing workflows that use the old contract continue to pass the old shape into your handler until they're updated. - Add a new
worker.actionentirely. Same situation. The capability is registered locally and the override knows about it, but HubSpot won't list it in the workflow action dropdown until a deploy registers it. - Remove a
worker.action. Workflows that already reference it keep firing against the deployed contract. Until you deploy, those firings will hit your tunnel and your handler isn't there to receive them. Easiest fix: deploy the removal, or temporarily comment the action back in and have it return a no-op.
Common workflow action issues
- "The action runs production code, not my local edits." The dev override didn't attach. Look at the
Ready in...line on startup: if there's noportal <id>segment, the override never registered (usually because no portal id could be derived from the connected account). Pass--portal <id>explicitly and restart. - "The action times out." HubSpot's workflow-action timeout is the binding one; the tunnel hop costs you a few hundred milliseconds beyond what your handler does on its own. If you're close to the wire in production, you'll be over the wire in dev. That's a real signal, not a dev-mode artifact.
- "My handler logs show up in the terminal but the workflow run history shows an error." The output shape your handler returned didn't match the registered
outputfield map. The override forwards the raw response; HubSpot validates against the registered contract. Re-deploy after editing the output shape.
Live-edit an agent tool
Agent tools sit on the same proxy mechanism as workflow actions. The HubSpot agent-tool surface is itself in beta; see HubSpot's agent tools overview for the current state of what can be registered and how Breeze calls it. The SDK shape is the same worker.tool(id, definition) you've already seen, with the same handler({ input, env, logger, ... }) context. Email Guard's Breeze-facing tool is check-email-health (the agent-tools guide builds it in full); abridged:
worker.tool('check-email-health', {
label: 'Check email health',
description:
'Returns the stored deliverability verdict for a contact: status, score, and whether the address is on the suppression list.',
objectType: 'contacts',
input: {
contactId: { type: 'string', label: 'Contact id', required: true },
},
output: {
summary: { type: 'string', label: 'Summary' },
status: { type: 'string', label: 'Status' },
},
agent: {
description:
'Use when the user asks whether a contact can be emailed, whether an address is deliverable, or why a send might bounce.',
expose: ['contactId'],
},
async handler({ input, hubspot, logger }) {
const contact = await hubspot.crm.objects.contacts.get(String(input.contactId), {
properties: ['email', 'email_health_status', 'email_health_score', 'email_suppressed'],
});
logger.info('check-email-health', { contactId: input.contactId });
const status = contact.properties.email_health_status ?? 'unverified';
return ok({ status, summary: `${contact.properties.email} is ${status}.` });
},
});Open Breeze in the portal, ask "can I safely email Mia?", watch the CLI log the invocation. Edit the body of handler (change the format of summary, add a logger.warn for suppressed addresses), save, ask again, and the new body runs.
Why agent tools benefit most
Iterating on tool behavior without dev mode is slow in a way it isn't for workflow actions, because Breeze caches tool definitions and there's no "re-enroll" button for an assistant conversation. With hs-x dev, the handler is hot on save and the description / parameters still need a deploy when they change. Batch the description tweaks; iterate freely on what the tool actually does.
Common agent tool issues
- "Breeze doesn't see my new tool." Tool registrations (the
description, theagent.exposelist, theinputshape) are what Breeze caches and whaths-x deploywrites. A brand-new tool needs one deploy before its body becomes hot-reloadable. - "The tool ran but Breeze surfaced an error." Same situation as workflow actions: the registered
outputshape is what HubSpot validates the response against. Update the shape, deploy, then iterate on the handler again. - "The agent tool API itself changed under me." It might have: the agent-tool surface is beta. When the official docs and HS-X disagree on what's registrable, the docs win; file an issue and fall back to a workflow action with the same body until the SDK catches up.
The rest of the dev-mode CLI surface
hs-x dev is the headline command. A handful of others pair with it for the work you do once a session is running.
hs-x dev invoke <capability-id>dispatches one capability through the production runtime router with fixture defaults;--input,--object, and--installtake JSON overrides,--lastreplays the previous dispatch,--jsonemits a structured result. Steps 2 and 3 show it against the card backend and the action; it reaches syncs and triggers the same way.hs-x dev statuschecks127.0.0.1:8787/_hsx/healthand reports whether a session is running, plus the CLI version that owns it. Useful when you're not sure if a previous run is still holding the port.hs-x dev cleanuptears down the dev override registration on the control plane. Run this if a session ended abnormally and you want to release the override before its TTL expires.hs-x doctorruns the full diagnostic suite: stored accounts, link state, machine id, HubSpot CLI config, PAK, control-plane reachability, recent activity. First thing to run when the splash line looks wrong.hs-x whoami(aliashs-x account) prints the active accounts and the scopes on each stored token.hs-x logspulls structured checkpoint logs for a project. Its flags are--jsonand--project-id; there is no--tailflag today, so use the terminal output of a runninghs-x devfor live tailing.hs-x statusprints the deployed state of the current project (which Worker is live, which version is registered with the portal).hs-x historylists past deploys with their checkpoint ids, so you can find a known-good version to roll back to.hs-x rollbackrolls the project back to a previous deploy. Pair withhs-x historyto pick the target.hs-x deploypushes the current code to production. The dev override stays attached after a deploy; the next invocation routes to your tunnel as before. This is how you batch contract changes into a deploy without ending the session.hs-x upgradechecks for a newer CLI release and installs it. There is nohs-x upgrade portalsubcommand; the platform-version pin in your project is managed by editinghsx.config.tsdirectly.
Some commands you might guess at: hs-x analyze (bundle analyzer), hs-x logs --tail (streaming logs), and hs-x sync run <name> (force a deployed sync) are not in the shipped CLI. The local equivalent of a forced sync run does exist: hs-x dev invoke suppression-list dispatches the sync through the production router on your machine, so you can watch a pull page land before anything ships. If you need bundle analysis today: the deploy doesn't write a bundle to disk, but it does generate the Worker's entrypoint and wrangler config under .hs-x/cloudflare/, so bun x wrangler deploy --dry-run --outdir dist-analyze --config .hs-x/cloudflare/<worker>.wrangler.toml produces the exact bundle locally for inspection.
Recover from common dev mode failures
Most failure modes show up on the Ready in... line at startup, or as an explicit error on stderr before the dev session reaches a ready state. The recovery sequence is almost always: read the missing piece, run the named fix, restart hs-x dev.
The "Developing Locally" badge never appears
The badge is HubSpot's affordance for the projects-localdev/2025-09/dev-sessions record. If it doesn't appear:
- The project may not have UI-extension nodes. The dev-session API is for the iframe HMR loop; if your project only declares workflow actions and tools, the badge legitimately won't appear and the workflow / tool overrides still work normally.
- The HubSpot account you're connected to doesn't have permission to register a dev session for the project. Re-bind it with
hs-x connect hubspotagainst the developer account that owns the project. - The CLI logged a HubSpot error after attempting to register. The error text is from HubSpot, not HS-X; surface it verbatim when you ask for help, because it usually names the exact field or permission that's missing.
The Cloudflare tunnel doesn't start
The Ready in... line shows no tunnel <url> segment. The CLI logs a warning when this happens but keeps going (the dev override falls back to a localhost target, which is only useful for tests that hit your sidecar directly). Most common causes:
cloudflaredcouldn't bind or couldn't reach Cloudflare's edge. Check outbound HTTPS; corporate proxies that intercept TLS will break the quick-tunnel handshake.- You want to use a tunnel you already control. Pass
--target-origin https://your-tunnel.example.comto skip the auto-managedcloudflaredsubprocess entirely; the override registers your URL instead.
Port collisions
The local dev server defaults to 8787 and the log sidecar to 9099. If either port is taken, the CLI fails fast. Override with --port and --hsx-log-port:
hs-x dev --port 8788 --hsx-log-port 9100The override target the control plane sees is derived from the tunnel URL (which points at the sidecar port), so the port change is transparent to the deployed Worker.
Schema drift between local and portal
When you edit a workflow action's input shape or an agent tool's description, the deployed contract HubSpot has cached doesn't match your local source. The deployed Worker keeps using the registered contract; the override forwards invocations against that contract to your handler. If your handler now expects a field that isn't being sent, you'll see undefined values in your logs.
The fix is always: hs-x deploy. The dev override re-attaches automatically after a deploy completes, and your in-progress session continues against the new contract.
The dev session TTL expired
The override is bounded by --ttl-seconds (default 7200, two hours). When it expires mid-session, the next invocation runs the deployed handler instead of your local one and the splash line never tells you about it. If you expect to pair for longer than two hours, start with --ttl-seconds 14400. If a TTL expires unexpectedly, Ctrl+C and start the session again.
When dev mode isn't the right tool
Two situations where a deploy-and-iterate loop is faster:
- You're chasing a behavior that only reproduces under real concurrency. The tunnel hop serializes invocations to your single local process; race conditions that only appear at parallelism N>1 won't reproduce.
- You're iterating on OAuth or install-time flows. There's no install-time lifecycle hook in the SDK today, so the dev-override path isn't involved in the install handshake; install changes have to ride a deploy.
