view .md
Reference · CLI

The generated .hs-x directory.

HS-X keeps one machine-written directory inside your project: .hs-x/. Codegen rewrites the capability manifest and the typed refs stubs there on every dev and deploy run; the deploy adds its Cloudflare entrypoints, the resource plan, and a handful of local state files beside them. Exactly one of those files belongs in version control. This page is the contract for all of them: the real shape of each file, which command writes it, who reads it back, and how linked and unlinked projects differ.

Time
≈ 7 min read
Outcome
A complete map of .hs-x/: every file's shape, writer, and reader; the regeneration rules; and the git policy the scaffold encodes.

The 30-second answer

.hs-x/ is the CLI's working directory inside your project. Three kinds of files live there: codegen output (manifest.json, refs.d.ts, refs.js), deploy artifacts (alchemy.run.ts and the cloudflare/ build directory), and per-machine state (project.json, cloudflare.json, hubspot.json, last-invocation.json, and the MCP dev-session pair). Every file under .hs-x/ is machine-written; hand edits are overwritten on the next hs-x dev or hs-x deploy.

The directory does not exist until the first dev or deploy creates it, and the scaffolded .gitignore ignores all of it except .hs-x/alchemy.run.ts: the checked-in record of the Cloudflare resources your deploys manage, kept so the project stays deployable without HS-X.

If you only read one thing

Commit .hs-x/alchemy.run.ts; let everything else regenerate. hs-x dev and hs-x deploy rewrite the manifest and refs stubs on every run, the deploy rewrites its own artifacts, and the state files are per-machine. Deleting the directory loses nothing a deploy can't rebuild, except local deploy state (cloudflare.json, hubspot.json) that makes repeat deploys land on the same resources.

Every file, one table

FileWritten byRead by
manifest.jsonhs-x dev (startup), hs-x deploy (every run, including --plan)Deploy planning (it is the body of the plan request), hs-x dev invoke capability routing, hs-x link
refs.d.ts, refs.jsSame two commands, same runsYour card code: typed dispatch routes for card backends, ids for app objects and app events
alchemy.run.tshs-x deploy, once a deploy plan existsYou. Nothing in the CLI reads it back; it is the leaveability record (§04)
cloudflare/<worker>.entry.tshs-x deploy (Cloudflare push)The wrangler bundle: it is the deployed Worker's main
cloudflare/<worker>.wrangler.tomlhs-x deploy (Cloudflare push)wrangler; hs-x dev invoke --remote reads the script name from it
cloudflare.jsonFirst Cloudflare deploy; hs-x link rewrites the ownerRepeat deploys (account-id fallback), hs-x flags, unlinked hs-x rollback, hs-x link, hs-x unlink
project.jsonhs-x init (when logged in), hs-x link, hs-x deployhs-x status / logs / routes / drift default ids; deploy and dev account preference
hubspot.jsonhs-x deploy, after a successful HubSpot uploadThe next deploy's app-id auto-reuse
last-invocation.jsonhs-x dev invokehs-x dev invoke --last
dev-session.json, dev-server.logThe MCP server's dev-session toolsThe MCP server's status, stop, and log-tail tools

The split matters more than the count. The first two rows are codegen output, identical in linked and unlinked projects and rewritten wholesale on every run. alchemy.run.ts and the cloudflare/ directory are deploy artifacts, derived from the active deploy plan. Everything else is per-machine state: small JSON files that let the next command on this checkout pick up where the last one stopped.

manifest.json — the project's capability inventory

manifest.json is the serialized inventory of everything your hsx.config.ts and worker files declare: every worker, every capability with its kind (tool, card-backend, trigger, or sync), and any app objects, app-object associations, and app events. A generatedAt timestamp is the only metadata.

{
  "generatedAt": "2026-06-11T14:01:38.000Z",
  "workers": [
    {
      "name": "email-guard",
      "capabilities": [
        {
          "kind": "tool",
          "id": "validate-email",
          "label": "Validate email",
          "objectType": "contact",
          "input": { "email": { "type": "string" } },
          "output": { "ok": { "type": "boolean" } },
          "runtimeNeeds": ["worker"]
        },
        {
          "kind": "card-backend",
          "id": "email-health",
          "runtimeNeeds": ["worker"]
        }
      ]
    }
  ]
}

Top-level keys beyond workers appear only when declared: appObjects, appObjectAssociations, appEvents. Each capability carries the fields relevant to its kind (schedule and into for syncs, eventType for triggers, input/output for tools), plus runtimeNeeds, the list that drives resource provisioning in §04.

Three consumers make this file load-bearing rather than informational:

  1. A linked deploy sends the manifest as the body of the deploy-plan request, and the plan's manifestHash is computed over it. The deployed Worker attests that same hash back, which is how drift detection can tell whether running code matches the recorded revision.
  2. hs-x dev invoke resolves which worker serves a capability by reading the manifest, and refuses ids it can't find there.
  3. hs-x link falls back to the manifest when resolving the project name for control-plane registration.

refs.d.ts and refs.js — typed handles for card code

A UI-extension card runs in HubSpot's sandboxed iframe and must never import your worker module: anything a worker file pulls in (connectors, secrets handling, Node dependencies) would ride into the card bundle. The refs stubs are the bridge. For every card-backend capability, codegen emits one frozen export carrying the capability id and its dispatch route, and nothing else:

// .hs-x/refs.d.ts — generated by hs-x. Do not edit.
export declare const emailHealth: {
  readonly id: "email-health";
  readonly kind: "card-backend";
  readonly dispatch: { readonly method: "POST"; readonly path: "/_hsx/cards/email-health" };
};

refs.js is the matching runtime module with the same exports as Object.freezed values. Kebab-case ids become camelCase identifiers (email-health becomes emailHealth), and the dispatch path URL-encodes the id. Card code imports the ref by relative path (from src/app/cards/, that is ../../../.hs-x/refs.js) and builds its hubspot.fetch call from it instead of hand-writing the route string:

import { emailHealth } from "../../../.hs-x/refs.js";
 
const verdict = await hubspot
  .fetch(`${WORKER}${emailHealth.dispatch.path}`, {
    method: emailHealth.dispatch.method,
    body: { portalId, objectTypeId, objectId },
  })
  .then((response) => response.json());

A refs stub carries an id and a route, never handler code, so importing one cannot leak Worker code into the card bundle. The type contract is deliberately thin: the stubs do not carry your handler's payload or return types today, only the literal id, kind, and dispatch route, which is enough to make a misspelled backend id a compile error instead of a runtime 404 unknown_card_backend.

Two more export families ride in the same files. Each declared app object emits { id, kind: "app-object", name } and each app event emits { id, kind: "app-event", name }, where name is the HubSpot-side object or event name; code that references them gets the same typo protection. A project with no card backends, app objects, or app events still gets both files, containing a bare export {}.

alchemy.run.ts — the Cloudflare leaveability artifact

alchemy.run.ts is written during hs-x deploy as soon as a deploy plan exists (including a --plan dry run, once a project id is in play). It records the plan's identity and the full set of Cloudflare resources the deploy manages:

// Generated by hs-x. Do not edit by hand.
// This file is checked in as the Cloudflare leaveability artifact.
 
export const hsXAccountId = "acct_demo";
export const projectId = "email-guard";
export const deployId = "deploy_email-guard_007";
 
export const resources = [
  {
    "workerName": "hsx-acct-demo-email-guard-email-guard",
    "durableObjectNamespaces": [],
    "kvNamespaces": [],
    "d1Databases": [],
    "queues": [],
    "cronTriggers": []
  }
] as const;

Every name follows one scheme: hsx-<account-slug>-<project>-<worker>, with a suffix per resource type. Which resources appear is driven by the runtimeNeeds your capabilities declare:

A capability needsResources planned for its worker
durable-object<name>-do Durable Object namespace and <name>-d1 D1 database
kv<name>-kv KV namespace
queue<name>-queue queue and <name>-cron cron trigger

Nothing in the CLI reads this file back. It exists for the day you stop using HS-X: the names of every resource your project owns in your Cloudflare account, in a checked-in TypeScript module you can feed to your own tooling. That is why it is the one .hs-x/ file the scaffolded .gitignore tracks (§06).

The identity exports are also where linked and unlinked deploys visibly diverge. A linked plan carries your HS-X account id and a control-plane counter deploy id (deploy_email-guard_007); an unlinked plan substitutes the machine-local owner (local_865a8a52) and a timestamped id (deploy_email-guard_1781140081_ca9c), which is why unlinked Worker names start with hsx-local-….

The cloudflare/ build directory and the state files

A Cloudflare push writes two files per worker under .hs-x/cloudflare/. The entrypoint, <worker>.entry.ts, wraps your worker module in the generated runtime bootstrap: install OAuth and managed-HubSpot token stores in both modes; checkpoint metrics, attestation, and the tenant data plane for flags and webhook dedup on linked deploys; the opt-in anonymous heartbeat on unlinked ones. The config, <worker>.wrangler.toml, names the Worker, points main at that entrypoint, sets nodejs_compat, and declares the bindings the deploy provisioned: INSTALL_KV always once an app id is known, plus CHECKPOINT, FLAGS_KV, TENANT_DB, and Workers Logs observability on linked deploys. Both are rebuilt on every push; the wrangler config doubles as the place hs-x dev invoke --remote learns the deployed script name.

The state files are smaller and longer-lived:

  • cloudflare.json pins this project to a Cloudflare account and its hsx-state KV namespace, plus an owner id: local_<hash> until the project is claimed, an acct_… owner after hs-x link rewrites it.

    {
      "cloudflareAccountId": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "stateKvNamespaceId": "4f9d2a7c1e8b4630a5d9c2f7b1e84a06",
      "stateKvNamespaceName": "hsx-state",
      "ownerId": "local_865a8a52"
    }

    It is written on the first deploy and read widely afterward: repeat deploys take the account id from it instead of requiring CLOUDFLARE_ACCOUNT_ID re-exported every time, hs-x flags loads it to reach the tenant state that holds the flag-authoring grant (no pointer means No .hs-x/cloudflare.json found. Deploy this project first with hs-x deploy.), unlinked hs-x rollback refuses to run without it, and hs-x unlink's reset path is to delete it and redeploy.

  • project.json binds this checkout to an HS-X account and project id ({ "accountId": "acct_demo", "projectId": "email-guard" }). hs-x init writes it when a session is active, link and deploy keep it fresh, and the project-scoped reads (status, logs, routes, drift) use it so those commands work inside a project without --project-id flags.

  • hubspot.json records the app identity a successful HubSpot upload resolved: { "appId": 1234567, "projectName": "email-guard", "developerAccountId": "12345678" }. The next deploy resolves its app id as flag, then HSX_HUBSPOT_APP_ID, then this file, so an app id the CLI already learned never has to be replayed.

  • last-invocation.json is the failure-replay seed from hs-x dev invoke: the capability id, the exact payload, the response status, and a timestamp, which is what hs-x dev invoke --last re-runs.

  • dev-session.json and dev-server.log exist only when an agent drives the dev loop through the MCP server: the session record (pid, port, URL, log path) for the background hs-x dev process, and its captured output.

Per-machine state is the one part of .hs-x/ a deploy cannot fully rebuild: deleting cloudflare.json mints a fresh local_* owner on the next deploy, and deleting hubspot.json makes the next upload re-resolve (or re-create) the HubSpot app rather than reuse it.

What is committed, what is ignored, what ships

hs-x init scaffolds the policy directly into the project's .gitignore:

# HS-X generated (regenerated on dev/deploy).
# .hs-x/alchemy.run.ts is the leaveability artifact and IS tracked.
.hs-x/*
!.hs-x/alchemy.run.ts

Everything in the directory is ignored except the resource plan. The codegen output would churn every run if tracked, and the state files are per-machine by design; project.json's account binding stays out of source control for the same reason HubSpot's own CLI keeps the active account out of project source.

One deliberate exception to the exception: after hs-x link claims a project, the CLI prints Updated .hs-x/cloudflare.json. Commit the change so teammates inherit the link. Under the scaffolded rules that file is still ignored, so a team that wants clones to inherit the claimed pointer adds a second exception line, !.hs-x/cloudflare.json, alongside the alchemy one.

The .hs-x/ directory never ships to HubSpot: the project archive a deploy uploads excludes it wholesale, the same way it excludes node_modules/. Nothing here is part of your app's deployed source on either platform; the entrypoints under cloudflare/ are bundled into the Worker by wrangler at push time, not uploaded as files.

Which command writes what

CommandWrites under .hs-x/
hs-x initproject.json (only when a session is active; best-effort), plus the .gitignore policy at the project root
hs-x devmanifest.json, refs.d.ts, refs.js on startup
hs-x dev invokelast-invocation.json after each dispatch
hs-x deploy --planmanifest.json, refs.d.ts, refs.js; alchemy.run.ts once a project id is in play
hs-x deployAll of the above, then cloudflare/<worker>.entry.ts and <worker>.wrangler.toml per worker, cloudflare.json on the first Cloudflare deploy, project.json, and hubspot.json after a successful HubSpot upload
hs-x linkRewrites cloudflare.json's ownerId from local_* to the claimed account; refreshes project.json
MCP dev-session toolsdev-session.json, dev-server.log

hs-x flags, unlinked hs-x rollback, and the project-scoped reads (status, logs, routes, drift) stay read-only: when a file they need is missing, the error names the command that creates it rather than creating it on the spot. Deletion is yours alone, too. Nothing removes .hs-x/ on your behalf; hs-x unlink names deleting cloudflare.json as the reset for a fresh local_* owner, but the delete is manual.

The deploy output confirms the codegen half explicitly: every run prints Generated .hs-x/manifest.json and refs stubs. before its final status line.

Linked and unlinked projects produce the same set of files; the mode changes contents, not inventory. The manifest and refs stubs carry no mode at all: codegen never sees whether the project is linked. Where the contents do diverge:

ArtifactLinkedUnlinked
alchemy.run.ts identityHS-X account id, counter deploy id (deploy_email-guard_007)local_<hash> owner, timestamped id (deploy_email-guard_1781140081_ca9c)
Worker names throughouthsx-<account>-<project>-<worker>hsx-local-<hash>-<project>-<worker>
cloudflare/<worker>.entry.tsAttestation, checkpoint metrics, tenant data planeOpt-in anonymous heartbeat only
cloudflare/<worker>.wrangler.tomlAdds CHECKPOINT, FLAGS_KV, TENANT_DB, and Workers Logs observabilityCore bindings only (INSTALL_KV once an app id is known)
cloudflare.json ownerIdacct_… once claimedlocal_…