view .md
Reference · SDK

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.

Time
≈ 10 min read
Outcome
You know what each export declares, the shape of its signature, and which guide walks the journey around it.

The 30-second answer

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:

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.

ExportWhat it declares
defineAppThe app: name, distribution, auth, scopes, cards, billing, permitted URLs
cardA UI-extension app card, passed to defineApp
mcpServerA HubSpot Breeze MCP server component, passed to defineApp
defineWorkerA named Worker that collects capabilities
worker.tool / worker.actionA workflow action, optionally exposed to Breeze agents
worker.cardBackendA Worker endpoint behind an app card
worker.triggerA handler for a HubSpot webhook event
worker.syncA scheduled or event-driven data sync
worker.use / worker.manifestRegister prebuilt capabilities; build the deploy manifest
defineSourceA pull or push source feeding a sync
appObject / appObjectAssociationAn app-owned CRM object and its associations
appEventAn app-defined CRM event type
ok / failContinue / failStop / retryLater / blockActionResult constructors for handler returns
HandlerContextWhat every handler receives
FieldDefinition / FieldTypeTyped input, output, and property fields
evaluateFlag familyPure edge feature-flag evaluation
createHsxOpenFeatureProviderOpenFeature provider over the same evaluator
usesScopesScope declarations static analysis cannot infer
redactRedact secrets, PII, and configured fields before logging
@hs-x/sdk/uiThe card-iframe surface: logger, dev-mode toggles, and local flag snapshots
@hs-x/sdk/ui/reactOptional React integration: FlagsProvider, useFlags
@hs-x/sdk/experimentalDeclared but gated surface (flag-to-CRM projection)

What HubSpot installs

defineApp

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;
    };
    metrics?: false | { enabled?: boolean };
  };
  alerts?: {                                // control-plane alert rules (linked deploys)
    notify?: string | string[];
    rules: {
      name: string;
      expr: string;
      window: string;
      severity?: 'info' | 'warning' | 'critical';
      notify?: string | string[];
    }[];
  };
  redaction?: { fields?: string[] };        // force-redacted logger keys, see §09
  install?: { successUrl: string };         // browser destination after a successful OAuth install
  cards?: CardDefinition[];                 // from card()
  mcpServers?: McpServerDefinition[];       // from mcpServer()
  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 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, iframe for embeddable pages, and img for image sources. hs-x deploy always adds the deployed Worker's own origin to fetch, so you only list it yourself for a custom domain or a second origin. The UI extensions guide shows where each one bites.

Three smaller blocks round out the app. alerts declares control-plane alert rules for linked deploys (a notify destination and a list of rules, each with an expr over the checkpoint stream, a window, and a severity). redaction.fields names the logger keys the runtime force-redacts in every handler's log payload, the same list redact accepts (§09). install.successUrl is where the browser lands after a successful direct or external OAuth install; without it, the runtime falls back to the portal's connected-apps page.

mcpServer

mcpServer(id: string, {
  name: string;
  description: string;
  mcpUrl: string;
  mcpClientId: string;
  uid?: string;                            // defaults to id
  version?: string;                        // defaults to '1.0.0'
  requiredScopes?: string[];               // emits [] when omitted
  logoUrl?: string;
  enabled?: boolean;                       // defaults to true
  websiteUrl?: string;
  privacyPolicyUrl?: string;
}): McpServerDefinition

Declares a first-class HubSpot Breeze MCP server project component (not the @hs-x/mcp authoring server). Pass it in defineApp({ mcpServers: [...] }). This HubSpot surface is beta: codegen requires auth: 'oauth' and platformVersion: '2026.09-beta' or newer. mcpUrl must be a public HTTPS URL; HS-X rejects localhost, loopback, link-local, and private IPs, but does not claim to prove DNS reachability at build time.

card

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;
  backend?: string;          // required when flags is 'server'
  flags?: 'server';          // backend-evaluated snapshot only
}): 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. flags: 'server' declares that the card consumes a backend-evaluated snapshot and therefore requires backend. The validator and codegen reject any other flag mode or a missing backend.

Capabilities live on a Worker

defineWorker

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; 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

worker.tool(id: string, {
  label: string;
  description?: string;
  objectType: string;
  input?: FieldMap;
  output?: FieldMap;
  agent?: { description: string; expose: string[] };
  billing?: CapabilityBillingDeclaration;
  handler(ctx: HandlerContext<InferInput<Input>>): 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; agent exposure in the agent tools guide.

worker.cardBackend

worker.cardBackend(id: string, {
  label?: string;
  objectTypes?: (string | AppObjectDefinition)[];
  input?: FieldMap;          // typed ctx.input; required fields are enforced before dispatch
  output?: FieldMap;
  billing?: CapabilityBillingDeclaration;
  handler(ctx: HandlerContext<InferInput<Input>>): 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 (or the generated _hsx-backend.ts client, when the card declares backend), and the response envelope is { ok, capabilityId, result } with result carrying whatever the handler returned. Whatever the card sends must sit under input in the request body; the runtime decodes the body as the standard dispatch payload and drops other top-level keys. An input field map types ctx.input and, for fields marked required, makes the runtime reject a dispatch that omits them with 400 missing_required_input, exactly as it does for tools. This is where card code does network IO, secret-bearing requests, and HubSpot API work through ctx.hubspot. The UI extensions guide wires a full card to its backend.

worker.trigger

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

Runs when HubSpot delivers a webhook batch. 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), and invokes the handler once per delivery, inline, before answering. ctx.input is { triggerId, deliveryId, events }, where events is the array HubSpot posted (one or many events per delivery), so a handler iterates input.events rather than assuming one. An uncaught throw becomes an HTTP 500 and HubSpot retries the delivery; the generated entrypoint wires no trigger queue, so nothing is deferred. One limitation applies on this path today: the webhook payload binds a portal but no install id, so ctx.hubspot cannot resolve a stored install token from a trigger handler; look the install up yourself or hand the work to a capability invoked with an install. 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 covers signatures, dedup trade-offs, and queueing.

worker.sync

worker.sync(id: string, {
  label?: string;
  schedule: string;                    // interval ('5m'), cron expression, 'event', or 'manual'
  into?: string;                       // destination object, e.g. 'p_customer'
  idProperty?: string;                 // unique HubSpot property each row key carries; required with a source
  schema?: Record<string, unknown>;    // source field -> HubSpot property name, see below
  manageSchema?: false | 'properties' | 'full';   // defaults to false
  billing?: CapabilityBillingDeclaration;
  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 reports success), and the source definition rides into the Worker's manifest.

For a source-backed sync the runtime owns the row pipeline: after the handler runs, it batch-upserts the source's rows into into through HubSpot's batch endpoint, keyed on idProperty, so a redelivered row updates rather than duplicates. idProperty names the HubSpot unique property (for example email on contacts) whose value each row's key carries; a source declared without it fails closed at run time. Rows that fail schema validation are set aside in a poison-row queue instead of failing the page. At runtime schema maps source field names (keys) to HubSpot property names (values): each row's data[key] is written to that property, and without a schema every scalar field passes through under its own name. The deploy-time schema plan reads the same map as property name to type, so the two readings only agree when a source field and its HubSpot property share a name; keep them identical until that is reconciled.

schedule is honored on the deployed Worker. hs-x deploy normalizes each interval or cron cadence to a Cloudflare cron trigger and generates a scheduled handler that fans each fire out to its syncs; 'event' is for push sources, which run when their webhook fires, and 'manual' means only an explicit signed call to /sync/<id>/run (or hs-x dev invoke locally) starts a run. manageSchema decides how much portal schema HS-X owns: 'full' manages the object and its properties, 'properties' manages properties on an existing object, false (the default) touches nothing. Sync handlers get ctx.sync for cursor reads and writes. The runtime side of all this is the sync section of the runtime HTTP reference; the whole journey is the syncs guide.

Schedules are exact-or-reject. worker.sync itself throws a ScheduleValidationError at import time for any cadence a Cloudflare cron trigger cannot honor uniformly: sub-minute intervals, minute counts that do not divide 60 (7m, 90m), hour counts that do not divide 24 (5h), multi-day intervals, and cron expressions outside the standard five-field subset. It also enforces the push/pull contract: a push source must declare schedule: 'event', and nothing else may. Nothing is ever rounded to a cadence the platform can run.

Per-capability billing

interface CapabilityBillingDeclaration {
  requires?: string | readonly string[];                 // entitlement feature(s) the install must hold
  meter?: string | { id: string; quantity?: number };    // usage meter recorded per dispatch (quantity defaults to 1)
  onLimit?: 'block' | 'warn' | 'allow';                  // defaults to 'block'
  deniedMessage?: string;
}

Every capability kind accepts a billing block that binds it to the app's billing catalog: requires gates dispatch on an entitlement, meter records usage automatically, and onLimit decides what happens at the limit. A blocked dispatch answers 402 billing_gated on the invoke, card, and sync routes, and the BLOCK execution state on the workflow-action route. normalizeCapabilityBilling is the exported normalizer that turns the sugar (a bare feature or meter id) into the NormalizedCapabilityBilling shape the manifest and runtime use; the billing guide covers the catalog it binds to.

worker.use and worker.manifest

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 within a kind. Since tool and action share the tool kind, a tool and an action with the same id collide; a tool and a trigger with the same id do not.

usesScopes

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

// Pull: fetch pages on a schedule.
defineSource({
  name: string;
  auth?: SourceAuthDefinition;
  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?: SourceAuthDefinition;
  receive(ctx: { event: TEvent }):
    { rows: { key: string; data: TData }[] };
}): PushSourceDefinition
 
type SourceAuthDefinition =
  | { type: 'bearer'; token?: unknown }
  | { type: 'oauth2'; clientId?: unknown; clientSecret?: unknown }
  | { type: 'basic'; username?: unknown; password?: unknown }
  | { type: 'hmac'; secret?: string };   // push sources: the NAME of the Worker secret binding holding the shared key

For hmac, secret is the name of a Worker secret binding (STRIPE_WEBHOOK_SECRET), never the literal key: the deployed Worker binds that name and the runtime reads it at request time. The manifest carries only auth.type; credential values never serialize. A push source's manifest entry also records its webhookPath (/webhooks/<name>).

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, and the deployed Worker serves its webhook at POST /webhooks/<name>: the request is verified against the source's declared hmac auth before receive is called, and the rows it returns feed the same durable pipeline as a scheduled run. A pull source is paged by the runtime from the last safe cursor on each scheduled or manual run. Either kind attaches to a worker with worker.sync(source, {...}), and a push source must declare schedule: 'event'. Pull and push are walked end to end in the syncs guide.

What a handler returns

ok, failContinue, failStop, retryLater, block

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 maps each status to what HubSpot shows.

What every handler receives

HandlerContext

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()
  execution?: ExecutionContext;   // workflowId, source, callbackId, actionDefinitionId
  env: TEnv;                      // Worker environment bindings
  hubspot: HubSpotClient;         // authenticated, rate-limited HubSpot client
  appObjects: AppObjectsContext;  // get / create / update / archive
  appEvents: AppEventsContext;    // send / sendBatch
  store: StoreContext;            // get / put / delete / list, install-scoped durable KV
  http: HttpClient;               // plain outbound HTTP
  billing?: BillingContext;       // recordUsage / recordCredit / current / upgradeUrl
  sync?: SyncCursorContext;       // cursor / setCursor / checkpoint / checkpoints, 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. The client resolves its token from the install the request bound, so on the webhook path (which binds a portal but no install id) it cannot resolve one yet; see worker.trigger. store is durable, install-scoped key/value storage in the project's tenant database: get, put, delete, and list(prefix?, { limit? }), for state a handler needs to keep between invocations.

execution is present when the invoking surface supplies it: a workflow action carries the workflowId, the actionDefinitionId, and, on the batched route, the callbackId the completion is keyed on. Three more 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. It records usage and credits (recordUsage(meterId, quantity, { idempotencyKey }), recordCredit(..., { idempotencyKey, reason })), reads the install's plan through current() (a BillingCurrentState with plan, status, has(feature), limit(meterId), and usage(meterId)), and mints a Stripe Checkout URL through upgradeUrl(); the billing guide covers both modes. sync is the cursor store, present in sync handlers: cursor() and setCursor() move the safe cursor a page at a time, and checkpoint(chunkId, state?) / checkpoints() let an interrupted run resume mid-page. 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 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.

Conversations on ctx.hubspot

The install-scoped client includes the two Conversations branches shipped by the pinned official SDK: Custom Channels (channel registration, channel accounts, and messages) and visitor identification. The exported request types let helpers stay aligned with that SDK surface:

import type {
  HubSpotClient,
  HubSpotConversationMessageCreateInput,
  HubSpotVisitorIdentificationTokenInput,
} from '@hs-x/hubspot';
 
export function publishCustomChannelMessage(
  hubspot: HubSpotClient,
  channelId: number,
  input: HubSpotConversationMessageCreateInput,
) {
  return hubspot.conversations.customChannels.messages.create(channelId, input);
}
 
export function identifyVisitor(hubspot: HubSpotClient, email: string) {
  const input = { email } satisfies HubSpotVisitorIdentificationTokenInput;
  return hubspot.conversations.visitorIdentification.generateToken(input);
}

These types are projections of HubSpot['conversations'], not a hand-maintained copy of its wire contract. This coverage does not add general inbox or thread list/read/write APIs. integrationThreadId is available on a Custom Channels message input, but it remains an external correlation field rather than a HubSpot thread-CRUD client.

Objects and events the app owns

appObject

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 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 builds one from scratch.

appObjectAssociation

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

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 covers templates, timelines, and triggering on your own events.

Typed inputs, outputs, and properties

FieldDefinition and FieldType

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 that sets neither is optional, HubSpot's own default. Set required: true to make the form require it and to have the runtime reject dispatches that omit it (400 missing_required_input). json fields are typed unknown in the handler but emitted to HubSpot as string. 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

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

createHsxOpenFeatureProvider({
  resolveSnapshot: (flagKey: string) => Promise<FlagSnapshot | undefined> | FlagSnapshot | undefined, // your KV/store read
  staticContext?: Partial<FlagEvaluationContext>,   // portalId / installId / actor identity merged into every evaluation
  name?: string,                                    // provider metadata name
}): HsxOpenFeatureProvider
 
interface HsxOpenFeatureProvider {
  metadata: { name: string };
  resolveBooleanEvaluation(flagKey, defaultValue, context?): Promise<OpenFeatureResolutionDetails<boolean>>;
  resolveStringEvaluation(flagKey, defaultValue, context?): Promise<OpenFeatureResolutionDetails<string>>;
  resolveNumberEvaluation(flagKey, defaultValue, context?): Promise<OpenFeatureResolutionDetails<number>>;
  resolveObjectEvaluation<T>(flagKey, defaultValue, context?): Promise<OpenFeatureResolutionDetails<T>>;
}

An OpenFeature-compatible provider over the same evaluator, for code already standardized on the OpenFeature client API. You supply resolveSnapshot, the read that fetches a flag's compiled snapshot (a KV get, an in-memory map); the provider maps the OpenFeature context (targetingKey plus attributes) onto the HS-X evaluation context, merges staticContext underneath it, and runs the pure evaluator. It never imports @openfeature/*, so it registers with your own OpenFeature SDK by structural typing or works standalone. The provider never throws: a missing snapshot, a dangling variation, or a type mismatch resolves to the default with an OpenFeature ERROR reason and a FLAG_NOT_FOUND, TYPE_MISMATCH, or GENERAL error code. Resolution reasons follow the OpenFeature spec (TARGETING_MATCH, SPLIT, DISABLED, DEFAULT, ERROR). Authoring flags, targeting rules, and the kill switch are the feature flags guide.

Redaction

redact

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, and maxDepth (default 8) bounds how deep the walk goes into nested values. 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 entry point built to run inside a HubSpot UI-extension iframe. The base surface is small by design: a logger, an explicit dev-mode override, and a local reader for server-evaluated flag snapshots. It exports no flag transport or grant bootstrap. React bindings are isolated under @hs-x/sdk/ui/react, so importing the base /ui entry does not require React. Both @hubspot/ui-extensions and React are optional peer dependencies.

logger

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. Batches ship through navigator.sendBeacon when the browser offers it (a keepalive fetch otherwise), and a pagehide listener flushes whatever is queued so the last batch survives closing the card. The queue caps at 10,000 entries and drops on overflow. Every send is fire-and-forget; a logging call never throws, whatever the network does.

enableHsxDev, disableHsxDev, DEFAULT_HSX_DEV_LOG_URL

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.

createFlagsSnapshotProvider

interface FlagClientResult { value: unknown; variation?: string; reason: string }
type UiFlagsSnapshot = Readonly<Record<string, FlagClientResult>>;
 
createFlagsSnapshotProvider(snapshot: UiFlagsSnapshot): LocalFlagsProvider;
 
interface LocalFlagsProvider {
  evaluate(keys: readonly string[]): Record<string, FlagClientResult>;
  getBoolean(key: string, defaultValue: boolean): boolean;
  getString(key: string, defaultValue: string): string;
  getNumber(key: string, defaultValue: number): number;
  getJson<T>(key: string, defaultValue: T): T;
}

The launch contract is server-only. A card declaring flags: 'server' must also declare a backend; that trusted handler evaluates through ctx.flags and returns an inert snapshot in its signed response. createFlagsSnapshotProvider is synchronous and in-memory. Missing keys and typed mismatches fail safe to caller defaults. Its private brand prevents an arbitrary transport-backed object from satisfying LocalFlagsProvider accidentally.

FlagsProvider and useFlags

import type { ReactNode } from 'react';
import type { FlagClientResult, LocalFlagsProvider, UiFlagsSnapshot } from '@hs-x/sdk/ui';
import { FlagsProvider, useFlags } from '@hs-x/sdk/ui/react';
 
interface FlagsProviderProps {
  provider?: LocalFlagsProvider;
  initialSnapshot?: UiFlagsSnapshot;
  children?: ReactNode;
}
 
interface UseFlagsResult<TDefaults, TKey extends keyof TDefaults> {
  flags: Readonly<{ [Key in TKey]: TDefaults[Key] }>;
  details: Readonly<Partial<Record<TKey, FlagClientResult>>>;
  isLoading: boolean;
}
 
function useFlags<
  const TDefaults extends Readonly<Record<string, unknown>>,
  const TKey extends Extract<keyof TDefaults, string>,
>(keys: readonly TKey[], defaults: TDefaults): UseFlagsResult<TDefaults, TKey>;

FlagsProvider owns one deterministic in-memory cache epoch. useFlags reads seeded values, returns typed defaults for misses, and records fail-safe details without network retries. initialSnapshot is the normal path; provider accepts only the branded local provider created by createFlagsSnapshotProvider. A new provider or snapshot object creates a new epoch. Calling the hook without a provider throws a configuration error. The feature flags guide has the complete server-snapshot example.

Declared but gated

@hs-x/sdk/experimental

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 capability definition family (ToolDefinition, CardBackendDefinition, TriggerDefinition, SyncDefinition, CapabilityDefinition, WorkerDefinition), the source family (PullSourceDefinition, PushSourceDefinition, SourceAuthDefinition, SourceRow, SourcePage, SourceHttpClient), the field helpers (FieldMap, FieldOption, InferInput), the billing declaration family (AppBillingDeclaration, ProjectBillingPlan, BillingPricing, CapabilityBillingDeclaration, NormalizedCapabilityBilling, and the rest), the HubSpotClient / HttpClient types behind ctx.hubspot and ctx.http, and the context types (InstallContext, ExecutionContext, StoreContext, StoreEntry, BillingContext, BillingCurrentState, SyncCursorContext, SyncChunkCheckpoint, FlagsContext, Logger, SyncHandlerContext). A few helpers used by codegen and the runtime are exported for tooling: normalizeSyncSchedule and ScheduleValidationError (the schedule parser and its error; NormalizedSyncSchedule is the parsed shape), isDefaultSyncHandler, normalizeCapabilityBilling, and the REDACTED constant the redactor substitutes. 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 starteddefineApp, the scaffold, and the first deploy, narrated.
  • Workflow actionsworker.tool, result statuses, and the 20-second budget.
  • Agent tools — the agent block and Breeze exposure.
  • Triggersworker.trigger, signatures, dedup, and queueing.
  • SyncsdefineSource, worker.sync, cursors, and schema management.
  • UI extensionscard, card backends, and permittedUrls.
  • App objects and app events — the CRM types your app owns.
  • Feature flagsctx.flags and the edge evaluation model.
  • Billing — the billing declaration paired with ctx.billing.
  • CLI reference — the commands that validate, run, and deploy what you declare here.

Last updated: August 27, 2026. Reflects the current @hs-x/sdk release (SDK_VERSION reports the exact version at runtime); shapes mirror the package's typed barrel. Refreshed whenever the surface changes.