# The hs-x SDK.

One package, three entry points. defineApp declares the app HubSpot installs, defineWorker declares the code that runs in your Cloudflare account, and defineSource declares the external systems that feed syncs. Everything else in @hs-x/sdk is typed detail around those three: capability methods, result statuses, the handler context, field definitions, flags. This page lists every shipped export with its signature shape and the guide that teaches it.

## The 30-second tour

Everything an HS-X app does is declared through this package. The Worker that `hs-x init` scaffolds is the whole model in one file:

```ts
import { defineWorker, ok } from "@hs-x/sdk";

const worker = defineWorker("deals");

worker.tool("tag-high-value-deals", {
  label: "Tag high value deals",
  objectType: "deal",
  input: {
    threshold: { type: "number", label: "Amount threshold", default: 50000 },
  },
  output: {
    tagged: { type: "boolean" },
  },
  async handler({ input, enrolledObject }) {
    const amount = Number(enrolledObject.properties.amount ?? 0);
    return ok({ tagged: amount >= input.threshold });
  },
});

export default worker;
```

`defineApp` in `hsx.config.ts` describes the app; Workers under `src/workers/` carry the code. The table is the index; the sections below give each export's signature shape, what it declares, and where to learn the journey around it.

| Export | What it declares |
| --- | --- |
| `defineApp` | The app: name, distribution, auth, scopes, cards, billing, permitted URLs |
| `card` | A UI-extension app card, passed to `defineApp` |
| `defineWorker` | A named Worker that collects capabilities |
| `worker.tool` / `worker.action` | A workflow action, optionally exposed to Breeze agents |
| `worker.cardBackend` | A Worker endpoint behind an app card |
| `worker.trigger` | A handler for a HubSpot webhook event |
| `worker.sync` | A scheduled or event-driven data sync |
| `worker.use` / `worker.manifest` | Register prebuilt capabilities; build the deploy manifest |
| `defineSource` | A pull or push source feeding a sync |
| `appObject` / `appObjectAssociation` | An app-owned CRM object and its associations |
| `appEvent` | An app-defined CRM event type |
| `ok` / `failContinue` / `failStop` / `retryLater` / `block` | `ActionResult` constructors for handler returns |
| `HandlerContext` | What every handler receives |
| `FieldDefinition` / `FieldType` | Typed input, output, and property fields |
| `evaluateFlag` family | Pure edge feature-flag evaluation |
| `createHsxOpenFeatureProvider` | OpenFeature provider over the same evaluator |
| `usesScopes` | Scope declarations static analysis cannot infer |
| `redact` | Redact secrets, PII, and configured fields before logging |
| `@hs-x/sdk/ui` | The card-iframe surface: `logger`, dev-mode toggles, `createFlagsClient` |
| `@hs-x/sdk/experimental` | Declared but gated surface (flag-to-CRM projection) |

## What HubSpot installs

#### `defineApp`

```ts
defineApp(definition: {
  name: string;
  description?: string;
  distribution: 'private' | 'marketplace';
  auth: 'static' | 'oauth';
  platformVersion: string;                  // '2026.03'
  scopes: string[];
  optionalScopes?: string[];
  objects?: AppObjectDefinition[];          // from appObject()
  objectAssociations?: AppObjectAssociationDefinition[];
  events?: AppEventDefinition[];            // from appEvent()
  appEvents?: {
    batching?: false | { maxSize?: number; maxDelayMs?: number };
  };
  rateLimits?: {
    retry?: false | { max?: number; baseMs?: number; capMs?: number };
    bucket?: {
      capacity?: number;
      refillPerSecond?: number;
      searchCapacity?: number;
      searchRefillPerSecond?: number;
      requestedTokensPerCall?: number;
    };
  };
  cards?: CardDefinition[];                 // from card()
  billing?: AppBillingDeclaration;
  permittedUrls?: { fetch?: string[]; iframe?: string[]; img?: string[] };
}): AppDefinition
```

The default export of `hsx.config.ts`. Codegen turns it into the HubSpot project a portal installs: name and description on the install screen, the distribution model, the auth mode, and the scopes the app requests. App-level declarations attach here too: CRM objects and events, cards, and the billing catalog. `hs-x check` validates the whole object before anything deploys; the [getting started guide](/docs/guides/getting-started) tours the file line by line.

`appEvents.batching` controls runtime auto-batching for `ctx.appEvents.send`. The default is `500` occurrences or `5000` milliseconds, whichever comes first. Set lower values for latency-sensitive events, or `batching: false` to bypass auto-batching.

`rateLimits` controls the managed `ctx.hubspot` client. Defaults are `retry: { max: 5, baseMs: 200, capMs: 10000 }`, a general bucket of `100` requests refilling `10` per second, and a Search bucket of `4` requests refilling `4` per second. Override these only when your app's latency budget or approved HubSpot tier needs different behavior.

`permittedUrls` is the UI-extension allow-list. Cards run sandboxed, and three lists open the sandbox up: `fetch` for origins the extension may call (your Worker's origin, for card backends), `iframe` for embeddable pages, and `img` for image sources. The [UI extensions guide](/docs/guides/ui-extensions) shows where each one bites.

#### `card`

```ts
card(definition: {
  id: string;
  name: string;
  location: 'crm.record.tab' | 'crm.record.sidebar' | 'crm.preview' | 'helpdesk.sidebar';
  objectTypes: string[];
  entrypoint: string;        // path to your React component
  description?: string;
}): CardDefinition
```

Declares a UI-extension app card; pass the result in `defineApp({ cards: [...] })`. Codegen emits the HubSpot card metadata, and you author the React entrypoint (`hs-x init --ui-extension` scaffolds a starter). The card itself is pure UI; anything that needs network or secrets goes through a card backend, covered under [Workers](#workers) below and in depth in the [UI extensions guide](/docs/guides/ui-extensions).

## Capabilities live on a Worker

#### `defineWorker`

```ts
defineWorker(
  name: string,
  options?: { use?: CapabilityDefinition[] },
): WorkerDefinition
```

Creates a named Worker, the unit that deploys to your Cloudflare account. The convention is one Worker per file under `src/workers/`, default-exported. `defineWorker` takes a string name; a pre-v1 callback form that passed a function no longer exists, so if you find `defineWorker((...) => ...)` in an old snippet, it predates the shipped API. Capabilities register through the methods below, each of which also returns the definition it created. `options.use` seeds the Worker with capabilities built elsewhere.

#### `worker.tool` and `worker.action`

```ts
worker.tool(id: string, {
  label: string;
  description?: string;
  objectType: string;
  input?: FieldMap;
  output?: FieldMap;
  agent?: { description: string; expose: string[] };
  handler(ctx: HandlerContext): ActionResult | OutputShape | Promise<ActionResult | OutputShape>;
}): ToolDefinition
```

One declaration, two surfaces. `tool` and `action` are the same function (the export `action` is an alias of `tool`), and both register a HubSpot workflow action whose `input` fields become the action's form. Adding the `agent` block additionally exposes the capability to Breeze agents, with `expose` naming which input fields the agent may fill. The handler returns an `ActionResult` or a plain object matching `output`. Workflow semantics live in the [workflow actions guide](/docs/guides/workflow-actions); agent exposure in the [agent tools guide](/docs/guides/agent-tools).

#### `worker.cardBackend`

```ts
worker.cardBackend(id: string, {
  label?: string;
  objectTypes?: (string | AppObjectDefinition)[];
  handler(ctx: HandlerContext): unknown | Promise<unknown>;
}): CardBackendDefinition
```

The server half of an app card. The Worker serves it at `POST /_hsx/cards/<id>`, the extension calls it with `hubspot.fetch`, and the response envelope is `{ ok, capabilityId, result }` with `result` carrying whatever the handler returned. This is where card code does network IO, secret-bearing requests, and HubSpot API work through `ctx.hubspot`. The [UI extensions guide](/docs/guides/ui-extensions) wires a full card to its backend.

#### `worker.trigger`

```ts
worker.trigger(id: string, {
  label?: string;
  eventType: string;                   // 'deal.propertyChange.dealstage'
  dedup?: 'best-effort' | 'strict';
  handler(ctx: HandlerContext): unknown | Promise<unknown>;
}): TriggerDefinition
```

Runs when HubSpot delivers a webhook event. The runtime owns the endpoint at `/webhooks/hubspot/<id>`: it verifies HubSpot's v3 signature, drops duplicate delivery ids (a 24-hour window per id, when a dedup store is configured), queues bursts, and invokes the handler once per event. The two `dedup` modes share that store check and differ on the race window around it: `'strict'` claims the delivery id synchronously before reading the store, so a concurrent duplicate cannot slip between the read and the write, while `'best-effort'` skips the claim and accepts that small window for lower contention. `eventType` follows HubSpot's subscription naming. The [triggers guide](/docs/guides/triggers) covers signatures, dedup trade-offs, and queueing.

#### `worker.sync`

```ts
worker.sync(id: string, {
  label?: string;
  schedule: string;                    // interval ('5m'), cron expression, or 'event'
  into?: string;                       // destination object, e.g. 'p_customer'
  schema?: Record<string, unknown>;
  manageSchema?: false | 'properties' | 'full';
  handler(ctx: SyncHandlerContext): unknown | Promise<unknown>;
}): SyncDefinition

worker.sync(source: SourceDefinition, {
  // same options; id defaults to the source name and handler is optional
}): SyncDefinition
```

Moves data into a destination object. The second call shape takes a source from `defineSource`: `id` defaults to the source name, the handler becomes optional (omitted, it defaults to one that just reports success), and the source definition rides into the Worker's manifest. The row pipeline that would ingest source rows automatically — batched writes into the destination object, paced through the portal's shared rate budget — is not live yet, so a sync that actually moves data still does the work in its handler. `schedule` is likewise recorded in the manifest; nothing fires it on a clock yet, so runs are driven explicitly through the Worker's `/sync/<id>/run` endpoint (`hs-x dev invoke` locally). `manageSchema` decides how much portal schema HS-X owns: `'full'` manages the object and its properties, `'properties'` manages properties on an existing object, `false` touches nothing. Sync handlers get `ctx.sync` for cursor reads and writes. The whole journey is the [syncs guide](/docs/guides/syncs).

#### `worker.use` and `worker.manifest`

```ts
worker.use(...capabilities: CapabilityDefinition[]): WorkerDefinition
worker.manifest(): WorkerManifest
```

`use` registers capabilities built elsewhere, which is how a Worker splits across files: the package also exports standalone `tool`, `action`, `cardBackend`, `trigger`, and `sync` helpers that build the same definitions without registering them. `manifest` is what build and deploy call to produce the Worker's capability manifest; it throws on duplicate ids, and since `tool` and `action` share one namespace, a tool and an action with the same id collide.

#### `usesScopes`

```ts
usesScopes<T>(scopes: readonly string[], run: () => T): ScopeUsage<T>
```

A declaration marker: wrapping a call records the HubSpot scopes it needs, for analysis that cannot see inside a raw `fetch`. The analysis is narrower than the marker today: `hs-x check` warns when it spots a raw `fetch` against a HubSpot API (pointing you at `ctx.hubspot` or this wrapper) and checks card scopes against the declared list, but it does not yet read `usesScopes` declarations or infer scopes from `ctx.hubspot` calls. Declaring them now means your code is already accurate when that inference lands.

## Where sync rows come from

#### `defineSource` and `defineSource.push`

```ts
// Pull: fetch pages on a schedule.
defineSource({
  name: string;
  auth?: { type: 'bearer' | 'oauth2' | 'basic' | 'hmac'; /* credential fields */ };
  fetch(ctx: { cursor?: TCursor; http: SourceHttpClient }):
    { rows: { key: string; data: TData }[]; cursor?: TCursor };
}): PullSourceDefinition

// Push: rows arrive on a webhook.
defineSource.push({
  name: string;
  auth?: { type: 'bearer' | 'oauth2' | 'basic' | 'hmac'; /* credential fields */ };
  receive(ctx: { event: TEvent }):
    { rows: { key: string; data: TData }[] };
}): PushSourceDefinition
```

A source describes an external system in two verbs. A pull source implements `fetch`: it receives the cursor persisted from the previous run and returns a page of `{ key, data }` rows plus the cursor for the next one; a page with no cursor ends the run. A push source implements `receive`, turning each delivered event into rows; the manifest reserves a webhook path for it at `/webhooks/<name>`. Know where the line sits today: a source is declared contract riding in the manifest, and the runtime pipeline that would drive it — paging `fetch`, serving the `/webhooks/<name>` endpoint, verifying the declared auth before `receive` runs — is not live yet. Either kind attaches to a worker with `worker.sync(source, {...})`. Pull and push are walked end to end in the [syncs guide](/docs/guides/syncs).

## What a handler returns

#### `ok`, `failContinue`, `failStop`, `retryLater`, `block`

```ts
type ActionResult =
  | { status: 'ok'; output?: Record<string, unknown> }
  | { status: 'fail-continue'; message: string; output?: Record<string, unknown> }
  | { status: 'fail-stop'; message: string }
  | { status: 'retry-later'; message: string; retryAfterSeconds?: number }
  | { status: 'block'; message: string };

ok(output?)                              // success, optionally with output fields
failContinue(message, output?)           // record the failure, let the workflow advance
failStop(message)                        // halt enrollment for this record
retryLater(message, retryAfterSeconds?)  // transient failure, with a retry-delay hint
block(message)                           // policy stop, distinct from a failure
```

Five constructors, one for each `ActionResult` status, and the runtime maps each onto HubSpot's execution contract on the inline action path: `ok` returns the output fields with a `200`; `fail-continue` returns them with `hs_execution_state: FAIL_CONTINUE`, so the action is recorded as failed and the workflow advances; `block` returns `hs_execution_state: BLOCK`, pausing the enrollment until the block expires (HubSpot's default is one week); `fail-stop` answers `400`, a failure HubSpot does not retry; and `retry-later` answers `429` with `retryAfterSeconds` as a `Retry-After` header (or `503` without a hint), which HubSpot requeues with exponential backoff for up to three days. The full result also lands in the run checkpoint. An uncaught throw still surfaces as a runtime error rather than a tidy failure result, so return explicit results either way. The [workflow actions guide](/docs/guides/workflow-actions) maps each status to what HubSpot shows.

## What every handler receives

#### `HandlerContext`

```ts
interface HandlerContext<TInput, TEnv> {
  input: TInput;                  // typed from the capability's input fields
  enrolledObject: {               // the CRM record the capability runs against
    id: string;
    objectType: string;
    properties: Record<string, unknown>;
  };
  install: InstallContext;        // id, portalId, state, config()
  env: TEnv;                      // Worker environment bindings
  hubspot: HubSpotClient;         // authenticated, rate-limited HubSpot client
  appObjects: AppObjectsContext;  // get / create / update / archive
  appEvents: AppEventsContext;    // send / sendBatch
  http: HttpClient;               // plain outbound HTTP
  billing?: BillingContext;       // recordUsage / recordCredit / current
  sync?: SyncCursorContext;       // cursor() / setCursor(), sync handlers only
  flags?: FlagsContext;           // getBoolean / getString / getNumber / getJson
  logger: Logger;                 // debug / info / warn / error, structured fields
  request: Request;               // the raw incoming Request
}
```

**Every capability handler gets the same context, so code moves between capability kinds without rewiring.** `input` is typed from the declared field map. `enrolledObject` is the record the capability was invoked against. `install` identifies the installing portal and exposes `config()` for per-install configuration. `hubspot` is the authenticated client scoped to that install, already paced against the portal's rate budget; `http` is for everything that is not HubSpot.

Three members are optional because they depend on what the project declares. `billing` exists on platform-linked deploys whose app declares a billing catalog, and is `undefined` otherwise; the [billing guide](/docs/guides/billing) covers both modes. `sync` is the cursor store, present in sync handlers. `flags` reads feature flags from the KV snapshot in your own account, each getter failing safe to the default you pass; the [feature flags guide](/docs/guides/feature-flags) explains the evaluation model. To poke at any of this locally, `hs-x dev invoke` runs a capability through the production router with fixture context, per the [local dev guide](/docs/guides/dev-mode).

## Objects and events the app owns

#### `appObject`

```ts
appObject(id: string, {
  name: string;
  label: string;
  singularForm: string;
  pluralForm: string;
  primaryDisplayLabelPropertyName: string;
  properties: Record<string, AppObjectPropertyDefinition>;
  // plus optional: uid, description, appPrefix, settings, requiredProperties,
  // searchableProperties, propertyGroups, defaultCreateFormFields,
  // secondaryDisplayLabelPropertyNames
}): AppObjectDefinition
```

Declares an app object: a CRM object type your app owns, created in the portal at install time. Pass the result in `defineApp({ objects: [...] })`. Each property is a [field definition](#fields) with object-specific extras like `hasUniqueValue` and `readOnlyValue`. Handlers then work with typed records through `ctx.appObjects.get`, `create`, `update`, and `archive`, where the property types flow from the declaration. All three declarations in this section take an optional `uid`, the portal-side identity of the type: omit it and the SDK derives one from the id as UPPER_SNAKE (`flag-rollout` becomes `FLAG_ROLLOUT`). HubSpot keys the installed type by `uid`, so pick it once and keep it stable. The [app objects guide](/docs/guides/app-objects) builds one from scratch.

#### `appObjectAssociation`

```ts
appObjectAssociation(id: string, {
  fromObjectType: string;
  toObjectType: string;
  name?: string;
  label?: string;
  inverseLabel?: string;
  cardinality?: 'ONE_TO_ONE' | 'ONE_TO_MANY' | 'MANY_TO_ONE' | 'MANY_TO_MANY';
  uid?: string;
}): AppObjectAssociationDefinition
```

Declares an association between an app object and another object type, with an optional internal `name`, labels for both directions, and a cardinality. Registered in `defineApp({ objectAssociations: [...] })`; `uid` follows the same derive-from-id rule as `appObject`.

#### `appEvent`

```ts
appEvent(id: string, {
  name: string;
  label: string;
  objectType: string;            // which CRM object the event attaches to
  properties: Record<string, AppEventPropertyDefinition>;
  // plus optional: uid, description, supportsCustomObject, headerTemplate, detailTemplate
}): AppEventDefinition
```

Declares an app event type that handlers emit onto CRM records with `ctx.appEvents.send(event, occurrence)` or `sendBatch`. `send` auto-batches by install at the app's configured `appEvents.batching` size and delay. An occurrence targets a record by `objectId` (or `email`/`utk` for contacts) and carries typed `properties` from the declaration. The [app events guide](/docs/guides/app-events) covers templates, timelines, and triggering on your own events.

## Typed inputs, outputs, and properties

#### `FieldDefinition` and `FieldType`

```ts
type FieldType = 'string' | 'number' | 'bool' | 'boolean' | 'enumeration' | 'datetime' | 'json';

interface FieldDefinition {
  type: FieldType;
  label?: string;
  description?: string;
  required?: boolean;
  default?: unknown;
  options?: readonly string[] | { value: string; label: string }[];
  fieldType?: string;            // HubSpot UI control, e.g. 'select'
  valueSource?: 'static' | 'property' | 'both';
  supportedValueTypes?: ('STATIC_VALUE' | 'OBJECT_PROPERTY' | 'FIELD_DATA')[];
  isRequired?: boolean;          // HubSpot's spelling of required
}

interface OutputFieldDefinition {
  type: FieldType;
  label?: string;
  description?: string;
  fieldType?: string;
}
```

One field shape serves capability `input`/`output` maps, app-object properties, and app-event properties. The choice list type is `'enumeration'` and its choices live in `options`, as plain strings or `{ value, label }` pairs; there is no `'enum'` type and no `values` key. Legacy uppercase HubSpot spellings (`'STRING'`, `'ENUMERATION'`, and friends) are accepted and normalized at codegen time.

The last three members exist for the workflow-action form. `supportedValueTypes` tells HubSpot how the field may be filled — typed in statically, mapped from an object property, or wired from another field's data — and codegen defaults it to static-only; `valueSource` declares the same intent in SDK terms. `isRequired` is HubSpot's spelling of `required`: codegen reads either, and a field with neither is required. Output maps need none of that, so `OutputFieldDefinition` is the narrower shape — type, label, description, control.

Field maps drive typing end to end: `InferInput` turns a declared map into the handler's `input` type, so a `number` field arrives as `number` and an `enumeration` with literal options narrows to a union of those strings. Two helpers back the normalization and are exported for tooling: `normalizeHubSpotFieldType` maps any accepted spelling to its canonical lowercase token (via the `HUBSPOT_FIELD_TYPE_MAP` table), and `defaultHubSpotFieldType` picks the HubSpot UI control a type gets when `fieldType` is omitted.

## Feature flags at the edge

#### `evaluateFlag` and friends

```ts
evaluateFlag(snapshot, context, defaultValue): FlagEvaluationResult
evaluateBooleanFlag(snapshot, context, defaultValue): FlagEvaluationResult<boolean>
evaluateStringFlag(snapshot, context, defaultValue): FlagEvaluationResult<string>
evaluateNumberFlag(snapshot, context, defaultValue): FlagEvaluationResult<number>
evaluateJsonFlag<T>(snapshot, context, defaultValue): FlagEvaluationResult<T>

evaluateFlags(
  snapshots: ReadonlyMap<string, FlagSnapshot | undefined>,
  context,
  defaults: ReadonlyMap<string, unknown>,
): Record<string, FlagEvaluationResult>
```

The pure flag evaluator: no IO, no control-plane call. The first argument is the `FlagSnapshot` from KV — the compiled flag, not the authoring-side definition — and the default is required because every failure mode resolves to it: a missing snapshot, a dangling variation reference, a type-mismatched typed getter. The return is never a bare value; read `.value` off the `FlagEvaluationResult`, which also carries the winning `variation` and a `reason`. `evaluateFlags` batches one context across many snapshots, keyed by the defaults map. This is the same evaluator `ctx.flags` runs against the KV snapshot in your Worker, exported so tests and tooling can evaluate flags without a runtime.

#### `createHsxOpenFeatureProvider`

```ts
createHsxOpenFeatureProvider(options: HsxOpenFeatureProviderOptions): HsxOpenFeatureProvider
```

An OpenFeature-compatible provider over the same evaluator, for code already standardized on the OpenFeature client API. Resolution reasons and error codes follow the OpenFeature spec. Authoring flags, targeting rules, and the kill switch are the [feature flags guide](/docs/guides/feature-flags).

## Redaction

#### `redact`

```ts
import { redact } from '@hs-x/sdk';

logger.info('verification result', redact({
  email: 'mia@example.com',
  note: 'customer name',
  status: 'deliverable',
}, { fields: ['note'] }));
```

The helper mirrors the runtime's logger redactor: token-like keys, bearer/PAT/HS-X token strings, email addresses, and phone-shaped strings become `[redacted]`; `fields` force-redacts app-specific keys the generic rules cannot infer. Add `redaction: { fields: [...] }` to `defineApp(...)` when the runtime should apply the same forced field list to every handler logger payload.

## Inside the card iframe

**Card code imports from `@hs-x/sdk/ui`, never from `@hs-x/sdk`**: the main barrel is backend-only, and `ui` is the one entry point built to run inside a HubSpot UI-extension iframe. The surface is small by design — a logger, an explicit dev-mode override for it, and a feature-flag client for cards that have no backend. `@hubspot/ui-extensions` is an optional peer dependency that the subpath imports dynamically, so a Node consumer pulling it for types alone resolves cleanly without the peer installed.

#### `logger`

```ts
import { logger } from '@hs-x/sdk/ui';

logger.info('rendered');   // debug / info / warn / error

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface Logger {
  debug(message: string): void;
  info(message: string): void;
  warn(message: string): void;
  error(message: string): void;
}
```

The API mirrors the `@hubspot/ui-extensions` logger exactly: four levels, one string argument. Each call does two things. It always delegates to HubSpot's logger when the peer is present, so production logs batch and ship to HubSpot's log viewer with trace ids and rate limits handled on HubSpot's side. In local dev it additionally forwards the call to the `hs-x dev` log sidecar, which is how card logs land in your terminal next to the backend's.

Dev detection keys off the module's own origin. HubSpot's local dev server serves card bundles from localhost and production bundles come from HubSpot's CDN, so a bundle loaded from a localhost origin forwards and a CDN bundle does not. Forwarding mirrors HubSpot's own batching shape — up to 100 entries per POST on a roughly 5-second cadence, flushed with `sendBeacon` when the page hides so the last batch survives closing the card. Every send is fire-and-forget; a logging call never throws, whatever the network does.

#### `enableHsxDev`, `disableHsxDev`, `DEFAULT_HSX_DEV_LOG_URL`

```ts
enableHsxDev(url?: string): void   // url defaults to DEFAULT_HSX_DEV_LOG_URL
disableHsxDev(): void

const DEFAULT_HSX_DEV_LOG_URL = 'http://127.0.0.1:9099/__hsx/log';
```

The explicit override for when auto-detection reads the situation wrong in either direction: a bundler that rewrites `import.meta.url` hides dev mode, and a test build served from localhost fakes it. `enableHsxDev()` switches forwarding on, optionally at a non-default sidecar URL; `disableHsxDev()` switches it off, auto-detection included. The sidecar is its own listener inside the `hs-x dev` process — port 9099 by default (`--hsx-log-port`), separate from the dev server's HTTP API — and it is loopback-only by design. `enableHsxDev` throws on any URL whose host is not `localhost`, `127.0.0.1`, or `::1`, and the logger re-validates the URL on every call, so free-form log messages (which may carry record data or secrets) cannot be pointed off-host.

#### `createFlagsClient`

```ts
createFlagsClient(options: {
  endpoint: string;                            // tenant Worker origin; must be in permittedUrls.fetch
  targetingContext?: Record<string, unknown>;  // overlay applied to every evaluation
  timeoutMs?: number;                          // forwarded to hubspot.fetch (its own default: 15s)
  fetchImpl?: HubSpotFetchLike;                // transport override, primarily a test seam
}): FlagsClient

interface FlagsClient {
  getBoolean(key: string, defaultValue: boolean): Promise<boolean>;
  getString(key: string, defaultValue: string): Promise<string>;
  getNumber(key: string, defaultValue: number): Promise<number>;
  getJson<T>(key: string, defaultValue: T): Promise<T>;
  evaluate(
    keys: readonly string[],
    targetingContext?: Record<string, unknown>,
  ): Promise<Record<string, FlagClientResult>>;
}

interface FlagClientResult { value: unknown; variation?: string; reason: string }
```

Feature flags for pure-UI cards. A card with a backend should read flags in the backend handler through `ctx.flags`; a card without one resolves them over the wire, against the same Worker the project deploys:

```http
POST <endpoint>/_hsx/flags/evaluate
{ "flagKeys": ["new-card"], "targetingContext": { "portalId": "555000" } }

200 { "ok": true, "flags": { "new-card": { "value": true, "variation": "on", "reason": "targeting_match" } } }
```

The endpoint reads the KV snapshot in your account and runs the same pure evaluator behind `ctx.flags`; neither the control plane nor HubSpot is in the path. The Worker's own configuration supplies the identity dimensions (account, project, environment, app id), the client's `targetingContext` overlays per-user targeting on top, and `evaluate`'s second argument overlays again per call. `reason` is the standard evaluation reason: `targeting_match`, `rollout`, `default`, `disabled`, `flag_not_found`, or `error`.

Inside a real card iframe the transport is `hubspot.fetch`, the sandbox's only sanctioned egress — which is why the Worker origin must be listed in `permittedUrls.fetch`, and why the request body travels as a plain object with no custom headers (`hubspot.fetch`'s calling convention; `HubSpotFetchLike` is its minimal contract, and a custom `fetchImpl` matches it). Outside the iframe, in tests and Node scripts, the client adapts the same call onto native `fetch`.

Every getter fails safe to the default you pass. A missing transport, a non-200, a malformed body, a thrown fetch, a key the Worker doesn't know, and a boolean, string, or number value that doesn't match its typed getter all resolve to the default; `getJson` checks only that the value is non-null before casting. **A flag read can never break a render.** There is no client-side cache, so each getter call is a full round trip, and a card that reads several flags should make one `evaluate([...keys])` call and pull values from the returned map. The [UI extensions guide](/docs/guides/ui-extensions) wires a card end to end, the [feature flags guide](/docs/guides/feature-flags) covers authoring and targeting, and the [local dev guide](/docs/guides/dev-mode) shows the sidecar loop.

## Declared but gated

#### `@hs-x/sdk/experimental`

```ts
import {
  featureFlagAppObject,
  flagDefinitionToAppObjectRecord,
  flagToCompanyAssociation,
  flagToContactAssociation,
} from '@hs-x/sdk/experimental';
```

The experimental subpath holds surface that is declared but not yet delivered end to end: today, the feature-flag CRM projection. `featureFlagAppObject` declares the flag app object (with `FEATURE_FLAG_APP_OBJECT_TYPE` and `FEATURE_FLAG_APP_OBJECT_SCOPES` constants), `flagDefinitionToAppObjectRecord` maps a flag definition to a record, the two association helpers link flags to companies and contacts, and `flagDestinationSourceId` / `flagDestinationMatchKey` derive sync identities. All of it is pure functions, but the push-to-CRM delivery path is gated in v1, so treat these as experimental: they can change or graduate to the main barrel without a major version.

#### Type re-exports

Back on the main barrel, `@hs-x/sdk` also re-exports the types its functions produce and consume, so your own helpers can be typed without reaching into internals: `AppDefinition`, `WorkerManifest`, `CapabilityManifest`, `CardDefinition`, `SourceManifest`, the billing declaration family (`AppBillingDeclaration`, `ProjectBillingPlan`, `BillingPricing`, and the rest), and the `HubSpotClient` / `HttpClient` types behind `ctx.hubspot` and `ctx.http`. `SDK_VERSION` is the package version as a constant.

**When a declaration misbehaves**

Run `hs-x check` first: it validates every declaration on this page (ids, field types, schemas, scopes) and reports findings with file and line before anything deploys. For handler behavior, `hs-x dev invoke <capability-id>` runs the capability through the production router locally.

## Where the exports lead

- [Getting started](/docs/guides/getting-started) — `defineApp`, the scaffold, and the first deploy, narrated.
- [Workflow actions](/docs/guides/workflow-actions) — `worker.tool`, result statuses, and the 20-second budget.
- [Agent tools](/docs/guides/agent-tools) explains the `agent` block and Breeze exposure.
- [Triggers](/docs/guides/triggers) — `worker.trigger`, signatures, dedup, and queueing.
- [Syncs](/docs/guides/syncs) — `defineSource`, `worker.sync`, cursors, and schema management.
- [UI extensions](/docs/guides/ui-extensions) — `card`, card backends, and `permittedUrls`.
- [App objects](/docs/guides/app-objects) and [app events](/docs/guides/app-events) — the CRM types your app owns.
- [Feature flags](/docs/guides/feature-flags) covers `ctx.flags` and the edge evaluation model.
- [Billing](/docs/guides/billing) pairs the billing declaration with `ctx.billing`.
- The [CLI reference](/docs/cli) lists the commands that validate, run, and deploy what you declare here.

---

*Last updated: July 24, 2026. Reflects `@hs-x/sdk` v0.3.0 (`SDK_VERSION`); shapes mirror the package's typed barrel. Refreshed whenever the surface changes.*

