Docs

Public exports of @fastyoke/sdk — provider, data hooks, realtime client, FsmTimeline / FsmViewer, WorkflowHistory, SmartField, and the typed resource clients.

SDK Reference

The @fastyoke/sdk package is the public surface every host, extension, and external integration works against. TypeScript types in the package are authoritative — when this page and the .d.ts disagree, trust the types.

Current version: 0.2.0. See the CHANGELOG for the 0.1.x → 0.2.0 delta.

<FastYokeProvider>

Mounts the SDK context. The host app mounts one at the root with its auth-aware fetcher and the current tenant; extensions see the same context when loaded into the host, so extension code never mounts its own provider.

import { FastYokeProvider } from '@fastyoke/sdk';

<FastYokeProvider
  tenantId="11111111-1111-1111-1111-111111111111"
  fetcher={apiFetch}
>
  <App />
</FastYokeProvider>

Props

PropTypeRequiredDescription
tenantIdstringyesCurrent tenant UUID. Every request the SDK issues is scoped by this — see the Multi-tenancy mandate.
fetcher(input, init?) => Promise<Response>yesCredential-aware fetch. The host wires its apiFetch (auto-attaches the JWT, handles 401 redirects); tests inject a mock.
projectIdstring | nullnoWhen set, narrows list queries and project-scoped mutations.
baseUrlstringnoAbsolute API origin. Defaults to the current origin — extensions loaded into the admin shell never set this.
realtimebooleannoDefault true. When false, no WebSocket is opened and every realtime-aware hook behaves as if { realtime: false } were passed individually. Useful for SSR and tests.
socketFactory(url: string) => WebSocketnoOverride the WebSocket constructor. Tests inject a controllable fake; production leaves this unset.

useFastYoke()

Returns the current SDK context value. The six typed resource clients hang off it plus realtime (the shared WebSocket client) and the raw transport inputs.

const {
  tenantId,
  projectId,
  fetcher,
  schemas,
  jobs,
  entities,
  pages,
  files,
  extensions,
  realtime, // RealtimeClient | null
} = useFastYoke();

Throws if called outside a <FastYokeProvider>. Client instances are stable across renders unless tenantId / projectId / fetcher / baseUrl change.

Resource clients

Every client method returns a typed Promise. Non-2xx responses throw ApiError with the parsed body; network failures throw TypeError (the platform default for fetch).

entities

entities.list(kind, params?): Promise<PagedEntityResponse>
entities.get(kind, id): Promise<EntityResponse>
entities.create(kind, dataPayload): Promise<EntityResponse>
entities.patch(kind, id, dataPayload): Promise<EntityResponse>
entities.delete(kind, id): Promise<void>
entities.exportPdf(kind, id): Promise<Blob>

delete is a hard delete — no soft-tombstone. Admins who need audit history run the entity kind through an FSM and read event_log.

jobs

jobs.list(params?): Promise<JobResponse[]>
jobs.get(id): Promise<JobResponse>
jobs.create(input): Promise<JobResponse>
jobs.transition(id, input): Promise<JobResponse>
jobs.cancel(id, input): Promise<JobResponse>   // admin-only
jobs.history(id): Promise<EventLogEntry[]>

cancel is the FSM administrative override — it bypasses guard evaluation and stamps an __admin_cancel__ row on event_log with the supplied reason.

schemas, pages, files, extensions

Identical shape (list / get / create / patch / delete as applicable). See the .d.ts files in the installed package for per-method signatures.

React data hooks

Thin async wrappers over the clients, useful when the surrounding component wants { loading, error, data } state without hand-rolling effects.

Two shapes:

  • Read hooks return { data, loading, error, refetch }. Fire on mount and whenever dependencies change; AbortSignal-gated cancel means post-unmount or stale results never update state.
  • Action hooks return { <verb>, loading, error, result }. <verb> is a stable callback you invoke from a handler; result holds the last successful response, error the last thrown ApiError.

No optimistic updates — loading stays true until the round-trip completes. Zustand coupling is deliberately absent so extensions don't see the host's store choices.

Read hooks

HookReturnsRealtime match
useEntities(kind, filters?, options?)PagedEntityResponseentity_mutation where entity_name == kind
useEntity(kind, id, options?)EntityResponseentity_mutation where entity_name == kind && record_id == id
useJobs(params?, options?)JobResponse[]any transition
useJob(id, options?)JobResponsetransition where job_id == id
useJobHistory(id, options?)EventLogEntry[]transition where job_id == id
useSchemas(params?)SchemaResponse[]— no realtime (schemas don't broadcast)
useSchema(id)SchemaResponse
useActiveSchemas(params?)SchemaResponse[] (filtered)

options is { realtime?: boolean } (default true). See Realtime below for how auto-refetch works.

Action hooks

HookVerbArgsResult
useCreateEntity()createEntity{ kind, dataPayload }EntityResponse
useUpdateEntity()updateEntity{ kind, id, dataPayload }EntityResponse
useDeleteEntity()deleteEntity{ kind, id }true on success
useSpawnJob()spawnJobCreateJobInputJobResponse
useTransitionJob()transitionJob{ id, input }JobResponse
useCancelJob()cancelJob{ id, input }JobResponse

Worked examples

Paginated entity list with optimistic-free create button:

import { useEntities, useCreateEntity } from '@fastyoke/sdk';

function WidgetsPanel() {
  const { data, loading, error, refetch } = useEntities('widget', {
    page: 1,
    pageSize: 20,
  });
  const { createEntity, loading: creating } = useCreateEntity();

  if (loading) return <p>Loading widgets…</p>;
  if (error) return <p>Failed: {error.message}</p>;

  return (
    <section>
      <ul>
        {data?.records.map((r) => (
          <li key={r.id}>{String(r.data_payload.name)}</li>
        ))}
      </ul>
      <button
        disabled={creating}
        onClick={async () => {
          await createEntity({
            kind: 'widget',
            dataPayload: { name: 'New widget' },
          });
          refetch(); // or rely on realtime auto-refresh — see below
        }}
      >
        {creating ? 'Creating…' : 'Add widget'}
      </button>
    </section>
  );
}

Live workflow timeline via useJob + <WorkflowHistory>:

import { useJob, WorkflowHistory } from '@fastyoke/sdk';

function JobPage({ id }: { id: string }) {
  const { data: job } = useJob(id);
  return (
    <>
      <h1>{job?.current_state ?? '…'}</h1>
      <WorkflowHistory jobId={id} />
    </>
  );
}

Both the header and the history refresh automatically when the backend broadcasts a transition for id — no polling, no manual refetch.

Transition button:

import { useTransitionJob } from '@fastyoke/sdk';

function ApproveButton({ jobId }: { jobId: string }) {
  const { transitionJob, loading, error } = useTransitionJob();
  return (
    <>
      <button
        disabled={loading}
        onClick={() =>
          transitionJob({ id: jobId, input: { eventType: 'approve' } })
        }
      >
        Approve
      </button>
      {error && <span role="alert">{error.message}</span>}
    </>
  );
}

<WorkflowHistory>

Drop-in table over useJobHistory. Humanizes __created__ / __admin_cancel__ sentinels, em-dashes missing actor/reason, and ships with inline styling so iframe-isolated extensions render without the host stylesheet.

<WorkflowHistory jobId="b9c..." />

Props

PropTypeDescription
jobIdstringRequired. Drives useJobHistory(jobId) internally.
formatTimestamp(iso: string) => stringOverride the default toLocaleString().
classNamestringApplied to the outer <table>.
styleReact.CSSPropertiesMerged with the inline defaults.

<FsmTimeline> + <FsmViewer>

Drop-in workflow visualizers for ISVs embedding FastYoke flows. Two physically-separate exports so tree-shaking is deterministic — FsmTimeline ships with zero reactflow / elkjs imports.

<FsmTimeline> — standalone

Pure HTML / Tailwind utility classes. Mobile-friendly; right-sized for L1 support and field-tech surfaces.

import { FsmTimeline, useJob, useJobHistory } from '@fastyoke/sdk';

const { data: job } = useJob(jobId);
const { data: history } = useJobHistory(jobId);
const { data: schema } = useSchema(job?.schema_id);

<FsmTimeline
  schema={schema?.schema_json ?? { initial_state: 'pending' }}
  entity={job ? { current_state: job.current_state, history } : undefined}
  onTransitionRequest={async (target) => {
    // Translate target state → event_type via the schema's transitions list,
    // then fire the host's transition mutation.
  }}
/>

<FsmViewer> — composed shell

Renders the timeline plus a React.lazy-imported reactflow canvas. Smart-default mode: entity supplied → operator (timeline only); entity omitted → engineer (canvas only). Pass mode='dual' for both surfaces side-by-side, or mode='operator' | 'engineer' to lock the surface and hide the built-in mode switcher.

<FsmViewer schema={schema} entity={entity} onTransitionRequest={doTransition} />

Composable types

The viewer is transition-agnostic: the host composes EntityState = { current_state, history? } from /tenant/jobs/:id + the event-log endpoint and translates the chosen target state to a backend event_type itself. The viewer never fetches and never writes.

ExportPurpose
EntityState{ current_state, history? } shape the viewer reads.
EntityHistoryEntrySubset of EventLogEntry the viewer displays.
ViewerSchemaSubset of SchemaDefinition (initial state + transitions).
ViewerTransition{ from, to, event_type }.
TransitionRequestHandler(targetState, payload?) => Promise<void>.

Audit-diff disclosures

FsmTimeline accepts a renderHistoryDetail(entry, index) => ReactNode | null render-prop that renders an inline <details> disclosure under each history row. Pair with <FsmAuditDiff /> and the matchAuditEntry() helper to surface payload before/after diffs straight from the audit ledger:

import {
  FsmTimeline,
  FsmAuditDiff,
  matchAuditEntry,
  useJobAudit,
  useJobHistory,
  useJob,
  useSchema,
} from '@fastyoke/sdk';

function MyJobView({ jobId }: { jobId: string }) {
  const { data: job } = useJob(jobId);
  const { data: history } = useJobHistory(jobId);
  const { data: audit } = useJobAudit(jobId);
  const { data: schema } = useSchema(job?.schema_id ?? '');

  if (!job || !schema) return null;

  return (
    <FsmTimeline
      schema={schema.schema_json}
      entity={{ current_state: job.current_state, history: history ?? [] }}
      renderHistoryDetail={(entry) => {
        const row = matchAuditEntry(audit ?? [], entry);
        return row ? <FsmAuditDiff audit={row} changesOnly /> : null;
      }}
    />
  );
}

FsmAuditDiff renders a flat key-by-key shallow diff (added / removed / changed / unchanged). Pass changesOnly to hide unchanged keys. Returns a friendly placeholder when the audit row's payloads are null (job had no context_record_id) or when the snapshots are equal.

Bundle hygiene

Importing only FsmTimeline does not pull in reactflow or elkjs — verified at build time by our automated bundle-size checks. ISVs on strict bundle budgets (Vercel Pro frontends, mobile-first hosts) can ship the timeline at near-zero cost.

Realtime

@fastyoke/sdk ships with a built-in WebSocket client that multiplexes two event streams — FSM transitions and entity CRUD — over a single connection per provider. Extensions don't open their own sockets; they subscribe to the shared one.

How hooks use it

Every realtime-aware read hook accepts { realtime?: boolean } (default true). When enabled, the hook registers a listener on the provider's shared client and calls refetch() when a matching event arrives — no polling, no backend changes from your side.

// Default: refetches on any mutation to the 'widget' kind.
useEntities('widget');

// Opt out — only updates via explicit refetch().
useEntities('widget', undefined, { realtime: false });

// Narrow to a single record.
useEntity('widget', id);

RealtimeClient directly

For rare cases where you want the raw event stream (e.g. a debug overlay, a metrics push), import RealtimeClient from the package:

import { RealtimeClient, type RealtimeEvent } from '@fastyoke/sdk';

const client = new RealtimeClient({ tenantId });
const unsubscribe = client.subscribe((ev: RealtimeEvent) => {
  if (ev.kind === 'transition') {
    console.log(ev.job_id, ev.from_state, '→', ev.to_state);
  } else if (ev.kind === 'entity_mutation') {
    console.log(ev.op, ev.entity_name, ev.record_id);
  }
});

// later…
unsubscribe();
client.close();

Envelope shapes

Every message is a kind-tagged envelope. Route by kind, not field presence.

type TransitionRealtimeEvent = {
  kind: 'transition';
  tenant_id: string;
  job_id: string;
  schema_id: string;
  event_type: string;
  from_state: string | null;
  to_state: string;
};

type EntityMutationRealtimeEvent = {
  kind: 'entity_mutation';
  tenant_id: string;
  entity_name: string;
  record_id: string;
  op: string; // "create" | "update" | "delete"
};

Reconnection

Exponential backoff (1s → 2s → 4s …, capped at 30s). The delay resets on a successful connection. The client doesn't replay missed events from the disconnect window — hooks refetch on reconnect via their own dependency arrays, which is idempotent.

Extension registry

Host-side helpers for loading, activating, and rendering tenant-uploaded extension bundles. Extension authors don't typically touch these.

<ExtensionProvider>

import { ExtensionProvider, ExtensionErrorBoundary } from '@fastyoke/sdk';

<ExtensionProvider>
  <ExtensionErrorBoundary>
    <AdminShell />
  </ExtensionErrorBoundary>
</ExtensionProvider>

useExtensionRegistry()

interface ExtensionRegistryValue {
  loaded: LoadedExtension[];
  componentsByBlockType: Map<string, ComponentType>;
  pagesByPath: Map<string, ComponentType>;
  customBlocks: CustomBlockDescriptor[];
  loading: boolean;
  refresh: () => void;
}

Host surfaces call refresh() after the admin installs / activates / deactivates an extension so the Page Designer dropdown and custom page routes pick up the new set without a reload.

ApiError

Thrown from any client method or action hook on a non-2xx response.

import { ApiError } from '@fastyoke/sdk';

try {
  await jobs.transition(id, { eventType: 'bad' });
} catch (err) {
  if (err instanceof ApiError) {
    console.log(err.status, err.body); // server's parsed error body
  }
  // else: TypeError (network failure, CORS, etc.)
}

Action hooks surface the same error via their error field while still re-throwing from the returned promise — you can branch on err.status at the call site or let the effect that reads error handle it.

Zod schemas

Every wire DTO has a matching Zod schema exported alongside the TypeScript type:

import {
  SchemaResponseZ,
  JobResponseZ,
  EventLogEntryZ,
  EntityResponseZ,
  PageResponseZ,
  ExtensionManifestZ,
  ExtensionResponseZ,
  MintTokenResponseZ,
  SchemaDefinitionZ,
} from '@fastyoke/sdk';

const parsed = JobResponseZ.parse(await response.json());

The SDK uses these internally on every deserialize path. Re-exported so extension code that talks to custom backends can validate against the same contract.

FileRef discriminator

data_payload fields that reference uploaded files use a JSON-level tagged marker. Helpers for the convention:

import { isFileRef, extractFileId, type FileRef } from '@fastyoke/sdk';

if (isFileRef(val)) {
  const id = extractFileId(val); // uuid of the file row
}

<SmartField /> (LCAP)

Single React component that renders one annotated entity field — picks the input from the closed 9-type vocabulary, applies display formatting from ui_config_json, runs zod validation on change. Used by Forms v2, the Page Designer entity_field block, the CRUD scaffold's emitted bundles, and any @fastyoke/next consumer.

import { SmartField, type EntityFieldAnnotation } from '@fastyoke/sdk';

<SmartField
  annotation={annotation}                    // EntityFieldAnnotation
  value={record.data_payload[field_key]}     // current value
  onChange={(next) => update(field_key, next)}
  mode="edit"                                // 'edit' | 'display'
  density="comfortable"                      // 'compact' | 'comfortable' | 'spacious'
  uiConfigOverride={{ '@ui/date_format': 'yyyy' }}  // optional
  currentTier="team"                         // 'hobby'|'pro'|'team'|'enterprise'|'fleet'
/>

Props:

PropTypeRequiredNotes
annotationEntityFieldAnnotationyesThe row from /tenant/entities/:name/annotations. Drives every resolution decision.
valueunknownyesCurrent field value. Caller owns the source of truth.
onChange(next: unknown) => voidyesCalled on every change. Caller debounces / batches as desired. No-op on mode="display".
mode'edit' | 'display'optionalDefaults to 'edit'. 'display' short-circuits to a <span> with the formatted value — no input mounts.
density'compact' | 'comfortable' | 'spacious'optionalForwarded to the catalog component.
uiConfigOverrideRecord<string, unknown>optional@ui/* keys merged AFTER annotation.ui_config_json. Lets one block specialize one rendering surface.
currentTiertier stringoptionalDefaults to 'team'. Below Team, expression keys (@ui/visible_when, etc.) short-circuit with a console warn.
className, id, exprContextvariousoptionalStandard pass-through props.

Imports:

import {
  SmartField,
  type SmartFieldProps,
  type SmartFieldMode,
  type SmartFieldDensity,
  // Pure helpers — testable without React.
  resolveSmartField,
  resolveFieldType,
  type ResolvedFieldType,
  type ResolvedSmartField,
  // Annotation row + zod adapter.
  type EntityFieldAnnotation,
  type LcapFieldType,
  entityAnnotationToZod,
} from '@fastyoke/sdk';

See the LCAP section for the full annotation model + matrix. <SmartField /> is framework-agnostic React-DOM; the @fastyoke/next SDK adds an SSR wrapper for App-Router consumers.

entityAnnotationToZod()

Build a zod object schema from an array of annotation rows. Used by the CRUD scaffold's emitted bundles to validate form input client-side before the write hook fires.

import { entityAnnotationToZod } from '@fastyoke/sdk';

const schema = entityAnnotationToZod(annotations);
const result = schema.safeParse(formValues);
if (!result.success) showErrors(result.error);

The helper honors required / max_length / min / max / options_json / field_type / ui_config_json and produces the right zod leaf per type. See field types for the full refinement table.