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, the cloudflare/ build directory, and the hubspot-generated-files.json ledger), and per-machine state (project.json, cloudflare.json, hubspot.json, last-invocation.json). Every file under .hs-x/ is machine-written; hand edits are overwritten on the next hs-x dev or hs-x deploy. Managed dev sessions keep their records outside the project, under your XDG state directory (§05).
The directory is created by whichever command first needs it: hs-x init when you are logged in (it writes project.json), otherwise the first dev or deploy. 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 |
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 |
hubspot-generated-files.json | hs-x deploy (HubSpot upload) | The next deploy: a sha256 ledger of every generated HubSpot project file (hsproject.json, *-hsmeta.json, src/app/cards/package.json, src/app/cards/_hsx-backend.ts) used to tell a regenerated file from a hand-edited one before overwriting |
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 |
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, the cloudflare/ directory, and the ledger are deploy artifacts, derived from the active deploy plan and the last HubSpot upload. 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.
A workflow action serializes as "kind": "tool" — that is expected, not a mislabel. worker.action(...) and worker.tool(...) are the same primitive (see the SDK reference), so both land in the manifest under the one kind; the surface it's registered on is decided by the action's own fields, not by a distinct manifest kind.
{
"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.
Two 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.
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: { input: { objectTypeId, objectId } },
})
.then((response) => response.json());Whatever the card sends must sit under input: the runtime decodes the body as the standard dispatch payload and discards other top-level keys, and any install block you send is overwritten with the server-verified install.
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 {}.
src/app/cards/_hsx-backend.ts: the generated client
When a card({ backend: '<id>' }) names its backend, hs-x deploy also writes src/app/cards/_hsx-backend.ts: a secret-free module exporting one function per linked backend (emailHealth(body?) for email-health) that wraps hubspot.fetch against POST /_hsx/cards/<id> on the deployed Worker origin and returns { ok, status, data }, where data is the runtime's { ok, capabilityId, result } envelope. Import it from the card component instead of hand-writing the route:
import { emailHealth } from "./_hsx-backend";
const res = await emailHealth<{ ok: boolean; result: Verdict }>({
input: { contactId: String(context.crm.objectId) },
});
if (res.ok && res.data.ok) setVerdict(res.data.result);The file lives inside src/app/cards/ because that directory is what HubSpot's remote build packages; it is tracked in .hs-x/hubspot-generated-files.json and rewritten on every deploy. The backend link is also what switches on card-backend validation: hs-x check and hs-x deploy fail if the backend id does not exist (card.backend.missing), if its objectTypes do not cover the card's (card.backend.scope-mismatch), or if the Worker origin is not fetch-permitted (card.backend.permission-missing). src/app/cards/package.json is generated once, the first time a deploy sees a card, and then treated as yours.
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 nodejs_compat_populate_process_env (so process.env.X reads work at module scope), and declares the bindings the deploy provisioned: INSTALL_KV once an app id is known; on linked deploys additionally CF_VERSION_METADATA (the immutable Worker version the attestation signs over), CHECKPOINT (Analytics Engine), FLAGS_KV plus the HSX_FLAGS_RATE_LIMITER Cloudflare Rate Limiting binding (120 requests per 60 s), TENANT_DB with a migrations_dir that lets wrangler apply the runtime's tenant migrations on push, and [observability] Workers Logs ingestion. There are no [env.*] blocks: HS-X environments are an identity axis, not wrangler environments, so a project has exactly one script per worker. When the worker declares syncs with wall-clock schedules, it also gets a [triggers] crons list, one entry per distinct cadence normalized from those schedule declarations, that the generated scheduled handler fans out into sync runs. 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. -
hubspot-generated-files.jsonis the HubSpot-side ledger:{ "version": 1, "files": { "<path>": "<sha256>" } }for every generated HubSpot project file (hsproject.json,src/app/app-hsmeta.json, everysrc/app/**/<id>-hsmeta.json,src/app/cards/package.json,src/app/cards/_hsx-backend.ts). The next deploy compares each file's current hash against the ledger before rewriting it, which is how it distinguishes a regenerated file from one you edited by hand;src/app/cards/package.jsonis marked user-owned and never overwritten.
Managed dev sessions (hs-x dev start, hs-x dev --detach) do not write under .hs-x/. Each session gets its own directory at $XDG_STATE_HOME/hs-x/dev-sessions/<session-id>/ (default ~/.local/state/hs-x/dev-sessions/<session-id>/) holding session.json (pid, port, URL, launch argv), events.jsonl (the unified log stream hs-x dev logs --session tails), stdout.log, and stderr.log. hs-x dev status, logs, stop, and restart read them by --session, and so do the MCP server's session tools.
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 plus hubspot-generated-files.json after a successful HubSpot upload |
hs-x link | Rewrites cloudflare.json's ownerId from local_* to the claimed account; refreshes project.json |
hs-x dev start | Nothing here; its session directory lives under $XDG_STATE_HOME/hs-x/dev-sessions/ (§05) |
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 CF_VERSION_METADATA, CHECKPOINT, FLAGS_KV, HSX_FLAGS_RATE_LIMITER, TENANT_DB, and Workers Logs observability | Core bindings only (INSTALL_KV once an app id is known) |
cloudflare.json ownerId | acct_… once claimed | local_… |
The same commands also write the HubSpot project files outside .hs-x/ that the ledger tracks: hsproject.json, src/app/app-hsmeta.json, one <id>-hsmeta.json under src/app/workflow-actions/, app-objects/, app-object-associations/, app-events/, mcp-server/, and cards/ per declaration, plus src/app/cards/package.json and src/app/cards/_hsx-backend.ts when the app has cards. Those are the upload, so they are not ignored; the ledger is what keeps a regenerating deploy from clobbering a deliberate edit.