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.
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.
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
| File | Written by | Read by |
|---|---|---|
manifest.json | hs-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.js | Same two commands, same runs | Your card code: typed dispatch routes for card backends, ids for app objects and app events |
alchemy.run.ts | hs-x deploy, once a deploy plan exists | You. Nothing in the CLI reads it back; it is the leaveability record (§04) |
cloudflare/<worker>.entry.ts | hs-x deploy (Cloudflare push) | The wrangler bundle: it is the deployed Worker's main |
cloudflare/<worker>.wrangler.toml | hs-x deploy (Cloudflare push) | wrangler; hs-x dev invoke --remote reads the script name from it |
cloudflare.json | First Cloudflare deploy; hs-x link rewrites the owner | Repeat deploys (account-id fallback), hs-x flags, unlinked hs-x rollback, hs-x link, hs-x unlink |
project.json | hs-x init (when logged in), hs-x link, hs-x deploy | hs-x status / logs / routes / drift default ids; deploy and dev account preference |
hubspot.json | hs-x deploy, after a successful HubSpot upload | The next deploy's app-id auto-reuse |
last-invocation.json | hs-x dev invoke | hs-x dev invoke --last |
dev-session.json, dev-server.log | The MCP server's dev-session tools | The 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:
- A linked deploy sends the manifest as the body of the deploy-plan request, and the plan's
manifestHashis 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. hs-x dev invokeresolves which worker serves a capability by reading the manifest, and refuses ids it can't find there.hs-x linkfalls 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 needs | Resources 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.jsonpins this project to a Cloudflare account and itshsx-stateKV namespace, plus an owner id:local_<hash>until the project is claimed, anacct_…owner afterhs-x linkrewrites 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_IDre-exported every time,hs-x flagsloads it to reach the tenant state that holds the flag-authoring grant (no pointer meansNo .hs-x/cloudflare.json found. Deploy this project first with hs-x deploy.), unlinkedhs-x rollbackrefuses to run without it, andhs-x unlink's reset path is to delete it and redeploy. -
project.jsonbinds this checkout to an HS-X account and project id ({ "accountId": "acct_demo", "projectId": "email-guard" }).hs-x initwrites it when a session is active,linkanddeploykeep it fresh, and the project-scoped reads (status,logs,routes,drift) use it so those commands work inside a project without--project-idflags. -
hubspot.jsonrecords the app identity a successful HubSpot upload resolved:{ "appId": 1234567, "projectName": "email-guard", "developerAccountId": "12345678" }. The next deploy resolves its app id as flag, thenHSX_HUBSPOT_APP_ID, then this file, so an app id the CLI already learned never has to be replayed. -
last-invocation.jsonis the failure-replay seed fromhs-x dev invoke: the capability id, the exact payload, the response status, and a timestamp, which is whaths-x dev invoke --lastre-runs. -
dev-session.jsonanddev-server.logexist only when an agent drives the dev loop through the MCP server: the session record (pid, port, URL, log path) for the backgroundhs-x devprocess, 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.tsEverything 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
| Command | Writes under .hs-x/ |
|---|---|
hs-x init | project.json (only when a session is active; best-effort), plus the .gitignore policy at the project root |
hs-x dev | manifest.json, refs.d.ts, refs.js on startup |
hs-x dev invoke | last-invocation.json after each dispatch |
hs-x deploy --plan | manifest.json, refs.d.ts, refs.js; alchemy.run.ts once a project id is in play |
hs-x deploy | All 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 link | Rewrites cloudflare.json's ownerId from local_* to the claimed account; refreshes project.json |
| MCP dev-session tools | dev-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:
| Artifact | Linked | Unlinked |
|---|---|---|
alchemy.run.ts identity | HS-X account id, counter deploy id (deploy_email-guard_007) | local_<hash> owner, timestamped id (deploy_email-guard_1781140081_ca9c) |
| Worker names throughout | hsx-<account>-<project>-<worker> | hsx-local-<hash>-<project>-<worker> |
cloudflare/<worker>.entry.ts | Attestation, checkpoint metrics, tenant data plane | Opt-in anonymous heartbeat only |
cloudflare/<worker>.wrangler.toml | Adds CHECKPOINT, FLAGS_KV, TENANT_DB, and Workers Logs observability | Core bindings only (INSTALL_KV once an app id is known) |
cloudflare.json ownerId | acct_… once claimed | local_… |