view .md
Guides · Test

Local dev

One command opens a HubSpot dev session for your UI extensions and streams your deployed Worker’s live invocations and logger output into the same terminal. UI extensions hot-reload on save. Workflow-action, agent-tool, sync, and card-backend handlers run on your machine through hs-x dev invoke, reloaded from source on every dispatch under Bun, and ship to the portal with hs-x deploy.

Time
≈ 10 min
Outcome
A live dev session against your HubSpot portal: saving a card file updates the portal, every request your deployed Worker handles lands in your terminal with its logger lines, and any handler body can be exercised locally before it ships.
Prerequisites
  • A project scaffolded with hs-x init, with the HubSpot account bound by hs-x connect hubspot. An authenticated personal access key alone is not enough for dev; the account has to be bound to this machine.
  • A deployed Worker (hs-x deploy run at least once) if you want live log streaming and the UI-extension session. Without one, hs-x dev still runs the local server and the invoke harness.
  • cloudflared on your PATH (developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/). The CLI spawns it for the quick tunnel; without it the tunnel row is skipped and live Worker logs never reach the terminal.
  • Free TCP ports 8787 (the local dev server) and 9099 (the HS-X log/telemetry sidecar). Both are overridable with flags.
  • Outbound HTTPS so the quick tunnel can reach the public internet. A Cloudflare credential only matters for hs-x dev invoke --remote; the getting-started guide lists the token permissions.

TL;DRhs-x dev opens a HubSpot dev session for your UI extensions and streams your deployed Worker’s live requests and logger.* output into one terminal. UI extensions hot-reload on save. Workflow actions, agent tools, syncs, and card backends run locally through hs-x dev invoke, reloaded per dispatch under Bun, and reach the portal with hs-x deploy.

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. For those surfaces HS-X drives the same dev-session API HubSpot’s CLI uses; it does not wrap hs project dev. 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 read the run history. Every iteration is a deploy, and the run history is the only place the handler’s output shows up.

hs-x dev gives you three loops in one terminal. It registers your UI-extension nodes for a projects-localdev/2025-09/dev-sessions handshake, so the card in the portal loads from your machine. It registers a dev override on your deployed Worker through the HS-X control plane and starts a small log sidecar plus an auto-managed Cloudflare quick tunnel to it; with the override in place the deployed Worker keeps handling traffic exactly as before, but after each request it POSTs a telemetry envelope (the request line plus your ctx.logger calls) through the tunnel into your terminal. And it starts a local dev server whose invocation harness dispatches any handler through the production runtime router on your machine, re-reading your worker source on every dispatch.

What it does not do, by default, is proxy live invocations to your machine. When a workflow fires, the deployed handler runs. To run edited handler code you dispatch it locally with hs-x dev invoke, and you ship it with hs-x deploy.

The mental model

One terminal · three loops
01
Your machine
Editor · hs-x dev on :8787 · log sidecar on :9099 · cloudflared subprocess
02
Control plane + tunnel
dev override registered in observe mode · trycloudflare.com URL pointing at the sidecar · TTL-bounded
03
Your portal
Deployed Worker tees request + logger telemetry · iframe HMR for UI extensions · dev session opens the badge

Four things spin up when you run hs-x dev:

  • A local dev server on 127.0.0.1:8787 with four routes: GET /_hsx/health (CLI version, what hs-x dev status probes), GET /_hsx/manifest (the discovered workers and capabilities), POST /_hsx/invoke/<capability-id> (the same invocation engine hs-x dev invoke uses, over HTTP), and POST /_hsx/cards/<card-backend-id> (the contract a deployed Worker serves to a card’s hubspot.fetch, so a migrated card can point at the local server). Everything else returns a 404 naming the endpoints. This server is small on purpose; the dev HTTP reference documents every envelope.
  • A log + telemetry sidecar on 127.0.0.1:9099. Card loggers post to POST /__hsx/log over loopback; the deployed Worker posts its telemetry envelopes to POST /__hsx/event through the tunnel, authenticated with a per-session token; GET /__hsx/health answers liveness. The sidecar renders all of it as one stream in the terminal.
  • An auto-managed Cloudflare quick tunnel. The CLI spawns cloudflared tunnel --url http://127.0.0.1:9099 --no-autoupdate, parses the trycloudflare.com URL it prints, and tears it down on Ctrl-C. The tunnel points at the sidecar rather than the dev server; it carries telemetry in and never carries requests. cloudflared has to be installed; the CLI only spawns it.
  • A dev-override registration through the HS-X control plane, keyed by portal and capability ids, plus a HubSpot dev-session registration when the project has UI-extension nodes and a hsproject.json from a first deploy. The override runs in observe mode unless you pass --target-origin, and it is bounded by --ttl-seconds (default 7200, two hours). Ctrl-C ends the tunnel and the HubSpot session; the control-plane override simply expires at its TTL, or you clear it now with hs-x dev cleanup, or you start the next session with --force.

What is and isn’t live

PrimitiveLive on save?How
UI extension (src/app/cards/*.tsx)Yes, in the portalHubSpot’s iframe dev-session shim; needs the hsproject.json a first deploy writes
Workflow action handler (validate-email’s handler body)On your machine, yes; in the portal, nohs-x dev invoke validate-email (or POST /_hsx/invoke/validate-email) re-imports your worker source on every dispatch under Bun. The deployed Worker keeps running the last deployed handler; hs-x dev streams its logs. Ship with hs-x deploy.
Agent tool handler (check-email-health)On your machine, yes; in Breeze, noSame harness, same rule
Card-backend handler (email-health)On your machine, yes; in the portal, nohs-x dev invoke email-health, or POST /_hsx/cards/email-health for a card pointed at the local server
Sync handler (suppression-list)On your machine, yes; scheduled runs, nohs-x dev invoke suppression-list dispatches one pull through the router
Trigger handler (email-changed)On your machine, yeshs-x dev invoke email-changed replays a delivery
Workflow action schema (input/output fields)No, requires hs-x deployHubSpot caches the registered contract
Agent tool parameters and descriptionNo, requires hs-x deployBreeze caches the tool definition until re-registered
UI extension manifest (which surfaces it renders on)No, requires hs-x deployPortal caches the registered surface list

The pattern: the card is hot in the portal, every handler body is hot on your machine, and the deployed Worker changes only when you deploy. What hs-x dev adds on the deployed side is visibility. You see every request the Worker handles and every logger.* line it writes, in the terminal where you are editing, without opening the run history.

Start the dev session

From the project root, run the command. Inside an HS-X project it starts without prompting: the HS-X account comes from --account-id (or HSX_ACCOUNT_ID), then the project binding, then the default or sole stored account; the portal id comes from the account bound by hs-x connect hubspot, falling back to the HubSpot CLI’s default account, the same portal hs project dev would use; the project id comes from hsx.config.ts. From a parent workspace, an interactive terminal asks Which local project should run in dev mode?; scripts pick one with --cwd.

hs-x dev

If you want to be explicit:

hs-x dev \
  --port 8787 \
  --hsx-log-port 9099 \
  --portal 46993937 \
  --ttl-seconds 7200

The 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 bound account), --pick (choose components interactively; by default every capability and UI node runs), --only <selector> (pre-filter the log stream by lane frontend|backend|request|hubspot, by level errors|warnings, by cap:<id>, or by free text; repeatable), --capability <id> (repeatable, scope the dev override to specific capability ids), --ttl-seconds <n> (default 7200), --force (clear active overrides for the same portal, project, and capabilities before registering), --telemetry-origin <url> (use your own stable tunnel for the sidecar instead of the auto quick tunnel), --no-hubspot and --no-ui-extensions (skip the HubSpot dev session), --local-proxy <auto|always|never> (how to treat the app’s local.json proxy map; auto applies it only while every target is listening and otherwise keeps HubSpot’s own proxy, always applies it regardless and enters proxy mode even without a local.json, never ignores the file), --detach (same as hs-x dev start), --component <uid>, --project-account, --testing-account, and --profile/-p (HubSpot-direct projects), --project-id <id>, --account-id <id>, --control-plane-url <url>, --runtime-control-plane-url <url>, --developer-id <id>, --session-id <id>.

For a native HubSpot project (an hsproject.json tree with no hsx.config.ts) startup mirrors hs project dev. Unless --testing-account names one, a terminal asks which configured account to test on: the project account's developer test accounts first, then its sandboxes, then the project account itself marked as production. A project account that is already a test account or sandbox is used without asking, and --yes or --json runs skip the picker, test on the project account, and warn that they are doing so. For a marketplace app hs-x also counts the portals that have it installed and, when there are any, confirms before starting (default No) because changes made in local development can affect those installs.

One flag changes what the deployed Worker does: --target-origin <url> flips the override from observe (stream logs) to proxy (forward live invocations to that origin). The origin has to serve POST /capabilities/<id>/invoke itself; the local dev server does not, so proxy mode is for a Worker or service you host, not for the harness on 8787.

What the startup checklist tells you

When the dev session is healthy you get a railed checklist, one row per surface: server (the local URL and how long startup took), workers (worker and capability counts; every capability the worker declares is counted, triggers included), and a row for each optional piece that spun up: portal when a dev override registered, ui-extensions with the HubSpot session id (numeric, when your project has UI-extension nodes), logs with the local log stream URL, HubSpot app logs with the app whose renders and proxy executions are polled into the hubspot lane (it turns into off with the reason when that lane could not be set up, since a silent lane looks identical to a broken one), tunnel with the public URL, card backends when a migrated card is being served from a temporary dev origin, and telemetry when you passed --telemetry-origin. Two rows exist only to warn you: local proxy appears once per proxy mapping in the app’s local.json and turns into a warning when the local target is not accepting connections, and frontend logs off appears when the card’s logs have no path to the terminal. HubSpot runs app functions locally only for private static-auth apps on 2026.03+; for every other app shape hs-x carries card logs through HubSpot’s local proxy mode instead (the logs row says card logs via HubSpot local proxy). Proxy mode reroutes every hubspot.fetch of the app, mapped origins to their local target and everything else straight to the real origin from your machine, so hs-x enters it only where hs project dev would: when local.json has mappings and every target is listening, or on --local-proxy always. Backends that verify HubSpot signatures reject unsigned direct traffic unless HUBSPOT_CLIENT_SECRET (or CLIENT_SECRET) is in the app’s environment, in which case HubSpot’s local signer signs it. The HubSpot session and the tunnel each print a step line as they come up, above the checklist. Two probes are useful to know about:

  • curl http://127.0.0.1:8787/_hsx/health returns { "ok": true, "cliVersion": "<version>" }. The same probe is what hs-x dev status calls when you ask whether a session is running.
  • curl http://127.0.0.1:8787/_hsx/manifest returns the JSON manifest of discovered workers and capabilities. Useful when you want to see exactly which capability ids the override registered.
Expect
$ hs-x dev
[ok] HubSpot UI-extension session  id 1187423 · 1 component
[ok] Cloudflare quick tunnel  https://cool-mongoose-23.trycloudflare.com
# hs-x  dev  v0.4.1  *  portal 46993937

[ok] server         http://127.0.0.1:8787  ready in 412ms
[ok] workers        1 worker · 5 capabilities
[ok] portal         46993937  dev override registered
[ok] ui-extensions  session 1187423
[ok] logs           streaming on http://127.0.0.1:9099
[ok] tunnel         https://cool-mongoose-23.trycloudflare.com

For Email Guard the five counted capabilities are the validate-email action, the check-email-health tool, the email-health card backend, the suppression-list sync, and the email-changed trigger. The tunnel URL is what got registered as the override’s telemetry target. If a row is missing (no tunnel, no portal), read it as “that piece did not start” and skip to step 6 for the recovery sequence. In a TTY the stream ends with a pinned status line instead of a Press Ctrl+C to stop reminder; that line only prints when there is no status line (piped output, NO_COLOR).

Driving the stream from the keyboard

In a TTY the log stream takes single keystrokes: f cycles the lane, 1/2/3/4 jump to a lane (again for all), a shows all lanes, e shows errors only, w warnings, / searches, d or opens the details browser (/ move through events, PgUp/PgDn scroll the selected event's details, Home/End jump to the oldest or newest, t narrows the list to events sharing the selected trace/invocation id or its nearby context, y copies that redacted trace as agent-ready Markdown, and Esc returns to the stream), c resets filters, p pauses (buffered rows flush on resume), ? prints the key help, and q quits. The browser's compact event list uses seconds-only time, short lane markers, and lane-specific identity such as request path/status or HubSpot target/duration/invocation suffix. The inspector keeps lane-specific request, portal, duration, capability, and HubSpot invocation fields above the remaining metadata. Copied traces include the selected event and its related timeline; credential-shaped values, email addresses, and phone numbers are redacted again at copy time. The same filters are available up front as --only selectors, which is the form scripts and agents use.

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. Any sidecar route reached through that tunnel requires a per-session token, so a guessed tunnel URL is not enough to inject frontend, backend, or request lines into your terminal. If you need a stable URL for an integration that points at your local sidecar, run your own named tunnel to 127.0.0.1:<hsx-log-port> and pass it as --telemetry-origin.
  • Each session registers a new override on the control plane, bounded by --ttl-seconds. Overrides are not cleared on Ctrl-C and they are not replaced automatically; a stale one from a crashed or interrupted session expires on its own. --force lists active overrides and clears only the sessions that match the same portal, project, and selected capability ids before registering, and hs-x dev cleanup --session-id <id> removes one explicitly. Neither touches unrelated dev sessions in the account.
  • The HubSpot account bound by hs-x connect hubspot (or --account-id) is reused. If none is bound and a portal id can still be derived, the CLI names the fix and exits: Dev override needs a connected HubSpot account … Run `hs-x connect hubspot` or pass --account-id.

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 deployed Worker’s 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. The human output puts the handler’s result fields on the rail; the wire envelope stays behind --json.

Expect
$ 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  v0.4.1  *  email-health

[ok] Invoked card-backend via worker email-guard  200 in 61ms
email      mia@example.com
status     deliverable
score      0.97
checkedAt  2026-06-11T02:49:43.839Z

* Full payload: re-run with `--json`

Invoked in 64ms

The rows are exactly what the card’s hubspot.fetch call would receive, so you can iterate on the backend’s shape before touching the React side. Edit the handler, save, run the same command again: the harness re-imports your worker source on every dispatch under Bun, so the second run executes the edit.

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 to the loopback HS-X log sidecar in dev mode. So a logger.info('reverify clicked', { contactId }) from inside the card shows up in your terminal on the frontend lane, next to the email-health request the click triggered on the deployed Worker, which arrives on the request lane over the quick tunnel. Local iframe logs stay on 127.0.0.1; backend and request telemetry arrives through the tunnel with the session token described above. In production builds the card logger 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.
  • “I want to run only some components.” By default every capability and UI node runs. Pass --pick to choose components interactively, --no-ui-extensions to skip the HubSpot session for this run, or --capability <id> flags to scope the override 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 warns HSX_W_DEV_HUBSPOT_SESSION_FAILED with HubSpot’s error verbatim and continues: you still get the deployed Worker’s log stream and the local invoke harness, just no UI-extension HMR.
  • “Every hubspot.fetch fails, and the backend lane shows Request to … failed: , correlationId=… with nothing after the colon.” Your app root has a local.json with a proxy map. HubSpot’s dev server reads that file and switches hubspot.fetch into local proxy mode: requests to a mapped origin are rewritten to the local target and sent from the CLI process instead of through HubSpot’s backend proxy. When nothing is listening on the target, every fetch to that origin fails with an empty connection-refused error, which is what hs project dev does. HS-X probes each target before the dev server starts. With the default --local-proxy auto, the map is honoured while at least one target is listening; when none is, HS-X hides local.json for that run so hubspot.fetch reaches the real origins through HubSpot’s proxy, and the checklist prints a local proxy bypassed warning row per mapping saying so. Start the local server the mapping points at (typically wrangler dev on 8787) and restart hs-x dev to proxy locally again. Pass --local-proxy always to mirror HubSpot and fail loudly instead, which is the safer choice when the deployed backend must never see dev traffic, or --local-proxy never to ignore the file for a run.
  • “The card shows hubspot.serverless is not available in this platform version.” HubSpot executes app functions inside the CLI process only for private, static-auth apps on platform 2026.03 or later. HS-X’s frontend console relay and --preserve-state ride on that serverless channel, so for any other app shape (marketplace distribution, OAuth auth, or a 2025.2 project) they stay off and the checklist shows a frontend logs off row with the reason. The card’s logger and console output still reaches HubSpot’s own dev overlay; only the terminal frontend lane is affected. CLI releases before this check injected the relay regardless, which is what raised that overlay message in the card.

Watch a workflow action run, then iterate locally

This is the step that has no equivalent in hs project dev. Fire the workflow from the portal, watch the deployed handler’s request and log lines land in your terminal, then dispatch the same handler locally with edits until it’s right. The declaration itself is unchanged from the workflow-actions guide; what you are iterating on is the body:

// src/workers/email-guard.ts — validate-email's handler body; run it locally with hs-x dev invoke, ship it with hs-x deploy
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, execution?, env, hubspot, appObjects, appEvents, store, http, billing?, sync?, flags?, 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 deployed handler runs, and its request line and logger.* output arrive in your terminal through the tunnel, one line per event, timestamped, with the lane and level in fixed columns:

Expect
  14:02:31.184  request   info   validate-email  POST /capabilities/validate-email/invoke  200  212ms {"traceId":"inv_01JYQ4V9K3M8Z2R7T6B5N1PXWA"}
  14:02:31.190  backend   info   verified {"id":"3301452","status":"deliverable","traceId":"inv_01JYQ4V9K3M8Z2R7T6B5N1PXWA"}

The request line carries method, path, status, and duration; the backend line is your logger.info call with its fields, and both share the invocation’s traceId. That is the deployed handler, not your working copy: the portal runs whatever the last hs-x deploy shipped.

Iterating without a workflow

Re-enrolling a contact to test every edit gets old, and it wouldn’t run your edits anyway. 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. Under Bun the harness re-imports your worker source on every dispatch, so edit, save, and re-run is the whole loop.

Expect
$ 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  v0.4.1  *  validate-email

[ok] Invoked tool via worker email-guard  200 in 57ms
status  deliverable
score   0.97

* Full payload: re-run with `--json`

Invoked in 58ms

The same dispatch is reachable over HTTP at POST /_hsx/invoke/validate-email on the local dev server, which is the door for CI jobs and agents; hs-x dev invoke --last replays the previous dispatch after an edit, and --json emits one structured result. Handlers that call ctx.hubspot against a real portal need real install tokens, which the fixture install does not carry: hs-x dev invoke validate-email --remote dispatches the same payload at the deployed Worker instead (signed, real portal), with --url <origin> to pin the Worker origin or CLOUDFLARE_API_TOKEN set so the CLI can resolve it.

What just happened

When worker.action() registers at startup, it lives inside the deployed Worker bundle. The control plane stores the dev override you registered (portal id, capability ids, mode, TTL, and the telemetry URL on your tunnel). On every invocation the deployed Worker looks the override up. In the default observe mode it runs the embedded handler as normal, collects the handler’s ctx.logger calls, and after the response POSTs a {request, logs} envelope to the telemetry URL; the sidecar renders that as the request and backend lines you saw. Only in proxy mode (--target-origin) does the Worker forward the request to the override’s target origin instead of running the embedded handler.

Locally, the harness builds the same payload the runtime would receive, signs it with HSX_HUBSPOT_CLIENT_SECRET when .dev.vars defines one (so the production signature verifier runs too), and dispatches it through the router. Each invocation gets a request log line with method, path, status, duration, and a traceId; any ctx.logger.* calls from your handler appear inline with that same field. If the caller sends x-hsx-trace-id, HS-X preserves it; otherwise the runtime generates one for the invocation.

What the handler-vs-schema split means in practice

  • Edit the handler body. Run it locally with hs-x dev invoke; the next dispatch runs the new code. The portal keeps running the deployed body until you hs-x deploy.
  • Edit the input or output field maps. HubSpot still has the old contract registered. The workflow editor in the portal won’t see new fields until you run hs-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.action entirely. The harness can dispatch it immediately (it reads your source), 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 Worker, which still carries the old handler until you deploy the removal. Deploy it, or keep a no-op body in place until the workflows are updated.

Common workflow action issues

  • “The action runs production code, not my local edits.” That is the default. The dev override observes; it does not proxy. Dispatch your edits with hs-x dev invoke validate-email, and deploy when the body is right.
  • “Nothing arrives when the workflow fires.” Look at the startup checklist. No portal row means the override never registered (usually because no portal id could be derived from the bound account; pass --portal <id> and restart). No tunnel row means cloudflared didn’t start, so the Worker has nowhere to post. If both rows were there and the stream went quiet later, the TTL probably expired; restart the session.
  • “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 output field map. HubSpot validates against the registered contract. Re-deploy after editing the output shape.

Watch an agent tool run

Agent tools sit on the same override as workflow actions, so the loop is identical: the deployed handler runs when Breeze calls it, its request and log lines stream into your terminal, and edits run locally through the harness. 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?”, and watch the request and backend lines for check-email-health arrive. Then iterate on the body locally: hs-x dev invoke check-email-health --input '{"contactId":"3301452"}' (change the format of summary, add a logger.warn for suppressed addresses), and deploy when you like the result. Because this handler reads the contact through ctx.hubspot, the fixture install’s empty portal token won’t reach a real record; --remote dispatches the same input at the deployed Worker with real install tokens.

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 you see what Breeze actually sent and what the handler logged, in the terminal, the moment it happens; the description and parameters still need a deploy when they change. Batch the description tweaks; iterate on what the tool does through the harness.

Common agent tool issues

  • “Breeze doesn’t see my new tool.” Tool registrations (the description, the agent.expose list, the input shape) are what Breeze caches and what hs-x deploy writes. A brand-new tool needs one deploy before Breeze can call it at all; the harness can dispatch it before that.
  • “The tool ran but Breeze surfaced an error.” Same situation as workflow actions: the registered output shape 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 --install take JSON overrides, --last replays the previous dispatch, --json emits a structured result, --remote dispatches at the deployed Worker (with --url <origin> to pin it), and --cwd picks the project root. Steps 2 and 3 show it against the card backend and the action; it reaches syncs and triggers the same way.
  • hs-x dev status probes 127.0.0.1:8787/_hsx/health (and the manifest) and reports whether a local session is running, plus the CLI version that owns it; --port follows a moved server. Pass --account-id <id> and --control-plane-url https://api.hs-x.dev (or set HSX_ACCOUNT_ID and HSX_CONTROL_PLANE_URL) to also list the override sessions recorded on the control plane; there is no stored-account fallback for that listing. --session <id> inspects a background session instead.
  • hs-x dev cleanup --session-id <id> --account-id <id> --control-plane-url https://api.hs-x.dev removes one override session from the control plane (find ids with dev status). A terminal shows the plan and confirms (default No); scripts and --json runs add --yes.
  • hs-x dev start, hs-x dev logs --session <id>, hs-x dev stop --session <id> --yes, and hs-x dev restart --session <id> --yes are the agent-managed form of the same server: start (or hs-x dev --detach) launches it in the background, waits up to --wait-seconds (default 60) for the health probe, and returns a stable session id; logs reads the session’s append-only event stream, with --follow, --lane, --level, --search, --since, --limit, --cursor, and --json-stream for NDJSON. The CLI reference has the full set.
  • hs-x doctor runs the full diagnostic suite: stored accounts, link state, machine id, HubSpot CLI config, PAK validity, control-plane reachability, recent activity. First thing to run when the splash line looks wrong.
  • hs-x whoami (alias hs-x accounts current) prints the active HS-X account; hs-x accounts list prints all of them.
  • hs-x logs reads a merged production timeline: deployed Worker invocations and per-invocation trace lines from Workers Logs ([cf]), interleaved with HubSpot’s own app logs ([hs]). Use --project-id, --account-id, --since-minutes, --limit, --trace, --source all|workers|hubspot, and --json for reads, and --follow (alias --tail, polling every --interval-seconds, default 3) to keep appending new rows; the terminal output of a running hs-x dev is the live view of the same Worker.
  • hs-x checkpoint pulls the aggregate checkpoint panel for a project: totals, latency percentiles, recent failures, and sampled successes.
  • hs-x status validates the local project and lists the capabilities it discovered; inside a deployed project it appends the deployed project’s routes, drift, and telemetry (--project <id> for any project).
  • hs-x history lists your recent local CLI runs (command, outcome, duration), which helps when retracing what you did during a session. Deploy history is a separate record: the dashboard’s Deploys tab, or hs-x status.
  • hs-x rollback rolls the project back to a previous deploy. Pick the target from the Deploys tab or pass --deploy-id; the deploys guide covers which revisions qualify.
  • hs-x deploy pushes the current code to production. The override is keyed by portal and capability id and outlives a deploy until its TTL, so a running session keeps streaming the new Worker’s logs. This is how you ship a batch of handler edits without ending the session.
  • hs-x upgrade checks for a newer CLI release and installs it. The platform-version pin in your project is separate: edit platformVersion in hsx.config.ts directly.
Not in the CLI today

Some commands you might guess at: hs-x analyze (bundle analyzer) and hs-x sync run <name> (force a deployed sync) are not in the shipped CLI. hs-x logs --follow (alias --tail) polls for new rows, so streaming production logs does exist. The local equivalent of a forced sync run exists too: 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 generates the Worker’s entrypoint and wrangler config under .hs-x/cloudflare/, and a wrangler dry run against that config (or hs-x deploy --cloudflare-dry-run) produces the exact bundle locally; the getting-started deploy step has the command.

Recover from common dev mode failures

Most failure modes show up as a missing row on the startup checklist, 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, or no hsproject.json yet (a first deploy writes it). 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 log stream and harness work normally.
  • No portal id reached the session step. The CLI warns HSX_W_DEV_HUBSPOT_NO_PORTAL (Found hsproject.json but no HubSpot portal id — pass --portal <id> to enable the UI-extension dev session.) and continues.
  • 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 hubspot against the developer account that owns the project.
  • The CLI warned HSX_W_DEV_HUBSPOT_SESSION_FAILED 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 startup checklist has no tunnel row, and above it the Cloudflare quick tunnel step shows a warning instead of a URL. The CLI keeps going: the override still registers, with a telemetry target of http://127.0.0.1:<hsx-log-port>, which the deployed Worker cannot reach, so no request or backend lines will arrive. Most common causes:

  • cloudflared isn’t installed. The warning reads HSX_E_CLOUDFLARED_MISSING: Could not spawn `cloudflared`. Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/. Install it, restart.
  • cloudflared couldn’t reach Cloudflare’s edge (HSX_E_CLOUDFLARED_TIMEOUT). Check outbound HTTPS; corporate proxies that intercept TLS will break the quick-tunnel handshake.
  • You want to use a tunnel you already control. Pass --telemetry-origin https://your-tunnel.example.com (pointing at the sidecar port) to skip the auto-managed cloudflared for log streaming. --target-origin <url> is different: it puts the override in proxy mode and forwards live invocations to that origin, which must serve POST /capabilities/<id>/invoke.

Port collisions

The local dev server defaults to 8787 and the log sidecar to 9099. If 8787 is taken the CLI fails fast with HSX_E_DEV_PORT_IN_USE (Port 8787 on 127.0.0.1 is already in use — another `hs-x dev` session may be running.); its hint points at hs-x dev status, hs-x dev cleanup, and --port. If 9099 is taken the CLI warns HSX_W_DEV_LOG_STREAM_FAILED and runs without the log stream, so the session comes up but nothing streams. Override either port:

hs-x dev --port 8788 --hsx-log-port 9100

The telemetry 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 running the registered contract with the deployed handler, and the harness runs your local source with whatever you pass in --input, so the two can disagree without either one being wrong. If a locally dispatched handler expects a field the deployed contract doesn’t send, you’ll see it work under hs-x dev invoke and fail in the run history.

The fix is always: hs-x deploy. The override is keyed by portal and capability id and outlives the deploy until its TTL, so your in-progress session keeps streaming 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 deployed Worker stops posting telemetry and the stream goes quiet; 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; the expired override needs no cleanup.

When dev mode isn’t the right tool

Two situations where a different loop is faster:

  • You’re chasing a behavior that needs a real portal token: the fixture install carries none, so ctx.hubspot calls from a local dispatch have nothing to authenticate with. Use hs-x dev invoke <id> --remote to run the deployed handler with real install tokens, or read the stream while the workflow fires.
  • You’re iterating on OAuth or install-time flows. There’s no install-time lifecycle hook in the SDK today, so neither the override nor the harness is involved in the install handshake; install changes have to ride a deploy.