pg-workflows

API reference

Entry points, CLI, components, hooks, server adapters, HTTP API, and styling.

Entry points

Client code never imports the server entries, so a browser bundle does not pull in the engine.

ImportContentsRuns
@pg-workflows/uiComponents, hooks, provider, helpers, and a re-export of createFetchClient and its typesclient
@pg-workflows/ui/clientcreateFetchClient and types, no Reactclient or server
@pg-workflows/ui/servercreateWorkflowRunsApi, toFetchHandler, toNodeHandler, HttpError, toErrorResponseserver only
@pg-workflows/ui/nextcreateAppRouterHandler, createPagesApiHandler, createRouteHandlersserver only
@pg-workflows/ui/tailwindTailwind preset for a subset of the pgw-* color tokensbuild
@pg-workflows/ui/styles.cssCSS variables (light and dark), Tailwind @theme tokens, and component stylesclient
pg-workflows-ui (bin)Standalone localhost dashboardCLI

Peer dependencies: react >= 18, react-dom >= 18, @tanstack/react-query >= 5, tailwindcss ^4, pg-workflows >= 0.13.0. pg-workflows in turn needs pg.

CLI

npx @pg-workflows/ui [--database-url=<url>] [--port=3777]
FlagDefaultDescription
--database-urlDATABASE_URL, else postgres://localhost:5432/postgresPostgres connection string
--port3777Port to listen on
-h, --helpPrint usage
  • The server binds to 127.0.0.1 only. It has no authentication and no resolveContext, so anyone who can reach the port can read and change every run. Do not expose it.
  • It starts an engine with no registered workflows. engine.start() runs migrations if needed. Because no workflows are registered, the runs table shows step progress only from each run's timeline.
  • engine.start() also starts queue workers on the shared run queue. A job those workers pick up fails with Workflow <id> not found, because the CLI has no definitions. Prefer it for local databases, and embed the components in your app when it runs against a live queue.
  • It serves the API under /workflow-runs and the prebuilt dashboard on every other path.

Components

Every component takes className, style, and render (see Styling & customization) and forwards a ref to its root element. StatusSummary is the exception when it renders nothing.

WorkflowRunsDashboard

Self-contained dashboard. It creates its own QueryClient (with query retries off) and WorkflowRunsProvider. Pass exactly one of baseUrl or client.

PropTypeDefaultDescription
baseUrlstringPrefix of the mounted API routes. Mutually exclusive with client. Read once on mount.
clientWorkflowRunsClientA client, usually from createFetchClient. Mutually exclusive with baseUrl. Read once on mount.
pollIntervalMsnumber5000 while Live, 0 while pausedRefresh interval. When set, it overrides the Live toggle: the button still switches, but the interval stays fixed.
selectedRunIdstring | nullControlled selection. Pair with onSelectRun and your router for deep links. When omitted, the dashboard tracks selection itself.
onSelectRun(id: string | null) => voidCalled when a row is opened (id) or the detail view is closed (null).

The workflow filter lists the workflow IDs on the current page. Style state: { selected: boolean }.

RunsTable

PropTypeDefaultDescription
runsWorkflowRun[]Rows to render, in order.
onSelectRun(id: string) => voidCalled when a row is clicked, or on Enter or Space.
selectedRunIdstring | nullHighlights the matching row.
isLoadingbooleanfalseWhile runs is empty, shows "Loading…" instead of "No runs".

Columns: Workflow, Run ID (copyable), Resource ID, Status, Started, Completed, Duration. The Workflow cell shows step progress for running, paused, and failed runs, using the larger of the timeline's step count and totalSteps. Style state: { empty: boolean, loading: boolean }.

RunDetail

Must render under WorkflowRunsProvider. It calls useWorkflowRun(runId) and useRunActions().

PropTypeDefaultDescription
runIdstringRun to load.
onBack() => voidRenders a back control that calls this. When omitted, there is no back control.

It renders:

  • A header with the workflow ID, run ID, and step progress.
  • A details grid: workflow, resource ID, status, timestamps, duration, retries, priority, job, and error. Priority 100, 0, and -100 display as high, normal, and low.
  • The actions: Cancel, Pause, Resume, Fast-forward, and Trigger. All are disabled once the run is terminal. Pause is enabled only while the run is running, and Resume only while it is paused. Each action shows a success or error message.
  • The step timeline, and input and output JSON. A failed run's error is shown above the steps.

Fast-forward sends no data. The engine completes the current wait step only when the run is paused on one, and otherwise returns the run unchanged. Trigger always sends an event named resume with no data. To send another event, call useRunActions().trigger yourself.

Style state: { phase: 'loading' | 'error' | 'ready', status?: string }.

StatusSummary

PropTypeDefaultDescription
countsPartial<Record<WorkflowRunStatus, number>>Counts by status, usually useWorkflowRunStats().data.
onSelectStatus(status: WorkflowRunStatus) => voidCalled when a count is clicked.
trailingReactNodeRendered after the counts.
stat{ className?, style?, render? }Style hooks for each count button. They receive that button's state.

Renders one button per status with a count above zero. When every count is 0, it renders nothing, so there is no element for ref. Style state: { empty: boolean } (always false while rendered).

FilterBar

PropTypeDescription
filtersRunFiltersCurrent filters, from useRunFilters.
hasActiveFiltersbooleanEnables the Clear control.
workflowIdsstring[]Options for the workflow filter.
onFiltersChange(partial: Partial<RunFilters>) => voidCalled with the changed fields. Reset startingAfter and endingBefore here, or the next request stays on a cursor from the previous filter.
onClear() => voidCalled by the Clear control.

Style state: { active: boolean }.

Pagination

PropTypeDefaultDescription
hasPrevbooleanEnables Prev.
hasNextbooleanEnables Next.
onPrev() => voidCalled by Prev.
onNext() => voidCalled by Next.
isFetchingbooleanfalseDisables both buttons while a page loads.

Style state: { hasPrev, hasNext, fetching }.

LiveToggle

A Base UI Toggle.

PropTypeDefaultDescription
isLivebooleanPressed state.
isFetchingbooleanSets data-fetching while a request is in flight.
onToggle() => voidCalled on press. Pass pollIntervalMs={isLive ? 5000 : 0} to the provider.
nativeButtonbooleantrueSet to false when render is not a <button>.

Style state: Base UI toggle state (pressed, disabled). Also sets data-pressed and data-fetching.

StatusBadge

PropTypeDescription
statusWorkflowRunStatus'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled'

Sets data-status. Style state: { status }.

WorkflowRunsProvider

PropTypeDefaultDescription
clientWorkflowRunsClientUsed by every hook below.
pollIntervalMsnumber5000Refresh interval for every query under the provider. 0 turns polling off.
childrenReactNode

The hooks use TanStack Query, so the provider must render inside a QueryClientProvider.

Hooks

Every hook must run under WorkflowRunsProvider.

useWorkflowRuns(params)

List query. Returns UseQueryResult<ListRunsResult>.

ParamTypeDescription
limitnumberRequired. Page size, at most 100.
statusesWorkflowRunStatus[]Filter by status.
workflowIdstringFilter by workflow.
startingAfterstringCursor for the next page.
endingBeforestringCursor for the previous page.

data is { items, nextCursor, prevCursor, hasMore, hasPrev }. The hook refetches while pollIntervalMs > 0 and keeps the previous page on screen while the next one loads. Each item is a WorkflowRun from pg-workflows, plus totalSteps? when the workflow is registered on the server's engine.

useWorkflowRun(id)

Single run. Returns UseQueryResult<WorkflowRun>. The query is disabled while id is empty. It polls on the provider interval and stops once the status is terminal (completed, failed, or cancelled).

useWorkflowRunStats(params?)

Counts by status. Returns UseQueryResult<Record<WorkflowRunStatus, number>>. params is { workflowId?: string }. Polls on the provider interval.

useRunActions()

Returns { cancel, pause, resume, fastForward, trigger }. Each is its own UseMutationResult<WorkflowRun>, so isPending and error are tracked per action. On success, the run and every runs list are invalidated.

ActionVariablesEngine call
cancel{ id }cancelWorkflow
pause{ id }pauseWorkflow
resume{ id }resumeWorkflow
fastForward{ id, data? }fastForwardWorkflow: completes the current wait step with data when the run is paused on one
trigger{ id, eventName, data? }triggerEvent

useRunFilters(initial?)

Local filter state. initial: Partial<RunFilters> is merged over the defaults { limit: 20, sort: 'createdAt', dir: 'desc' }.

RunFilters:

FieldTypeApplied
limitnumberserver
startingAfter, endingBeforestringserver
statusesWorkflowRunStatus[]server
workflowIdstringserver
searchstringclient (matches run ID, workflow ID, resource ID)
datePreset'all' | '1h' | '24h' | '7d' | '30d' | '90d'client
durationPreset'any' | 'lt-10s' | 'lt-30s' | 'lt-1m' | 'gt-30s' | 'gt-1m' | 'gt-5m' | 'gt-10m'client
sort'id' | 'workflowId' | 'createdAt' | 'status' | 'duration'client
dir'asc' | 'desc'client

Client-side fields apply to the current page only, because the engine paginates by cursor. Apply them with applyClientFilters and sortRuns.

Returns:

FieldDescription
filtersThe full RunFilters object.
serverParams{ limit, startingAfter, endingBefore, statuses, workflowId }. Pass it to useWorkflowRuns.
setFilters(partial)Merges a partial update.
replaceFilters(next)Replaces the whole object.
clearFilters()Resets to the defaults above, not to initial.
toggleSort(key)Sorts by key, starting with desc. Calling it again on the same key flips between desc and asc.
hasActiveFilterstrue when status, workflow ID, date, duration, or search is set.

useWorkflowRunsClient()

Returns { client, pollIntervalMs } from the provider. Throws outside WorkflowRunsProvider.

Helpers

Exported from @pg-workflows/ui. <WorkflowRunsDashboard/> uses them to apply the client-side filters:

const rows = sortRuns(
  applyClientFilters(runs.data?.items ?? [], {
    search: filters.search,
    datePreset: filters.datePreset,
    durationPreset: filters.durationPreset,
  }),
  filters.sort,
  filters.dir,
)
ExportDescription
applyClientFilters(runs, filters)Filters a page of runs. filters is ClientFilters: { search?, datePreset?, durationPreset?, from?, to?, minDurationMs?, maxDurationMs? }. from and to are ISO strings compared with createdAt, and override datePreset. minDurationMs and maxDurationMs override durationPreset.
sortRuns(runs, key, dir)Returns a sorted copy.
computeDurationMs(run)Run duration in ms, or null for a pending run.
formatDuration(ms)'1m 5s' style string.
timeAgo(date, now?)'7m ago' style string, or 'in 7m' for a future date.
isTerminalStatus(status)true for completed, failed, cancelled.
DATE_PRESETS, DURATION_PRESETS{ value, label }[] option lists used by FilterBar.
datePresetToFrom(preset, now?)ISO lower bound for a date preset, or undefined for 'all'.
durationPresetToBounds(preset){ minDurationMs?, maxDurationMs? } for a duration preset.

createFetchClient(options)

From @pg-workflows/ui or @pg-workflows/ui/client (no React). Returns a WorkflowRunsClient that calls the HTTP API.

OptionTypeDescription
baseUrlstringPrefix of the mounted routes. A trailing slash is ignored.
fetchtypeof fetchCustom fetch, for example to add auth headers. Defaults to globalThis.fetch.
interface WorkflowRunsClient {
  listRuns(params: ListRunsParams): Promise<ListRunsResult>
  getRun(id: string): Promise<WorkflowRun>
  getStats(params?: { workflowId?: string }): Promise<WorkflowRunStats>
  cancelRun(id: string): Promise<WorkflowRun>
  pauseRun(id: string): Promise<WorkflowRun>
  resumeRun(id: string): Promise<WorkflowRun>
  fastForwardRun(id: string, body?: { data?: Record<string, unknown> }): Promise<WorkflowRun>
  triggerEvent(id: string, body: { eventName: string; data?: Record<string, unknown> }): Promise<WorkflowRun>
}

Any non-2xx response throws an Error with the status code in its message. To back the components with something other than HTTP, implement the interface yourself.

Server adapters

ExportFromDescription
createWorkflowRunsApi({ engine, basePath?, resolveContext? })/serverReturns fetch(request), which routes by method and path, plus one handler per endpoint (listRuns(req), getRun(req, id), getStats(req), cancelRun(req, id), and so on). basePath defaults to /workflow-runs. resolveContext(req) returns { resourceId? } or throws (see Security).
toFetchHandler(source)/serverTurns an API object, or a (request) => Promise<Response> function, into one Fetch function.
toNodeHandler(source)/serverNode (req, res) adapter for Express, Fastify (req.raw / reply.raw), Nest, or node:http. Takes an API object or a Fetch function.
HttpError, toErrorResponse(err)/serverThe error mapping the adapter uses (see HTTP API). Use them in your own routes to return the same status codes.
createAppRouterHandler(source)/next{ GET, POST } for an App Router catch-all. source is { engine, basePath?, resolveContext? }, where engine is an engine or a function that returns one (sync or async). The first request awaits engine.start() and builds the API once. source can also be an existing API object or Fetch function.
createPagesApiHandler(source)/nextDefault export for a Pages Router catch-all. Takes the same source and starts the engine the same way.
createRouteHandlers(source)/next{ list, detail, cancel, pause, resume, fastForward, trigger } for a route.ts per path, for example to wrap mutations in extra auth. Takes the same source. Each entry is the same path-based dispatcher, so each file must sit at the path it serves.

engine only needs the methods the API calls (getRuns, getRun, getStats, cancelWorkflow, pauseWorkflow, resumeWorkflow, fastForwardWorkflow, triggerEvent) and optionally workflows, which supplies totalSteps. A WorkflowEngine has all of them.

The adapters use Web Request / Response and send no CORS headers. Serve the dashboard and the API from the same origin, for example with a Vite dev-server proxy.

Next.js App Router

Add it to your app covers the App Router setup: an engine singleton in lib/engine.ts and one catch-all route.

  • Pass getEngine, not getEngine(). The handler calls it on every request, so it must return the same engine each time.
  • The first request awaits engine.start(), and later requests reuse that promise.
  • <WorkflowRunsDashboard/> can be imported from a Server Component. The components carry their own 'use client' directives.

Next.js Pages Router

Set basePath to the public URL Next puts on the request:

// pages/api/workflow-runs/[[...path]].ts
import { createPagesApiHandler } from '@pg-workflows/ui/next'
import { getEngine } from '@/lib/engine'

export default createPagesApiHandler({ engine: getEngine, basePath: '/api/workflow-runs' })
<WorkflowRunsDashboard baseUrl="/api/workflow-runs" />

Express

import express from 'express'
import { createWorkflowRunsApi, toNodeHandler } from '@pg-workflows/ui/server'
import { engine } from './engine'

const runsApi = createWorkflowRunsApi({ engine, basePath: '/workflow-runs' })

const app = express()
app.use('/workflow-runs', toNodeHandler(runsApi))
  • basePath must match the mount. toNodeHandler reads req.originalUrl, which keeps the prefix that Express strips from req.url.
  • Don't run express.json() on this path. It consumes the body before the handler can read it.

Hono

app.all('/workflow-runs', (c) => runsApi.fetch(c.req.raw))
app.all('/workflow-runs/*', (c) => runsApi.fetch(c.req.raw))

TanStack Start

The list path and the splat are separate files, because /workflow-runs/$ does not match GET /workflow-runs.

// src/routes/workflow-runs.index.ts
export const Route = createFileRoute('/workflow-runs')({
  server: { handlers: { GET: ({ request }) => runsApi.fetch(request) } },
})

// src/routes/workflow-runs/$.ts
const handle = ({ request }: { request: Request }) => runsApi.fetch(request)
export const Route = createFileRoute('/workflow-runs/$')({
  server: { handlers: { GET: handle, POST: handle } },
})

Bun and Deno

Bun.serve({ fetch: (request) => runsApi.fetch(request) })
Deno.serve((request) => runsApi.fetch(request))

Requests outside basePath get a 404 from the adapter.

HTTP API

All paths are under basePath (default /workflow-runs). Responses are JSON.

Method and pathEngine call
GET /getRuns (query: starting_after, ending_before, limit up to 100, workflow_id, statuses, repeatable)
GET /statsgetStats (query: workflow_id)
GET /:idgetRun
POST /:id/cancelcancelWorkflow
POST /:id/pausepauseWorkflow
POST /:id/resumeresumeWorkflow
POST /:id/fast-forwardfastForwardWorkflow (body: { data? })
POST /:id/triggertriggerEvent (body: { eventName, data? })

Errors return { error, message?, issues? }. issues lists validation failures. An in_progress error also carries workflowId and runId.

StatuserrorCause
400validationInvalid query, body, or JSON, or an engine validation error
401unauthorizedresolveContext threw
404not_foundUnknown run or path
405method_not_allowedWrong method for the path
409in_progressWorkflowRunInProgressError (a singleton slot is taken)
409conflictAny other engine error, such as an illegal transition
500internalAnything else

Security

The adapter leaves authentication to your app, so put the routes behind your own middleware.

For scoping, use resolveContext. The resourceId it returns is passed to every read and every action, so a caller only sees and changes their own runs. The adapter never reads resourceId from the request.

createWorkflowRunsApi({
  engine,
  // Return the caller's tenant. Throw to respond with 401.
  resolveContext: async (req) => ({ resourceId: await getTenantId(req) }),
})

getTenantId stands for your own session lookup.

Open by default. Without resolveContext, the adapter reads and changes every run in the database. That is fine for a single-tenant dashboard behind your auth. Pass resolveContext when several tenants share the database.

Styling & customization

The components are Base UI parts with a default theme: square corners, a 1px ink border, neutral surfaces, and a hard offset shadow. Status color is the only chroma.

  • className and style take a value, or a function of the component's state (listed with each component above).
  • render replaces the root element instead of wrapping it. The part's props and behavior move to the element you pass.
  • State is also written as data-* attributes, so plain CSS can target it.
  • Style the public root and documented parts (stat on StatusSummary). Markup inside a part may change between releases.

Recolor with CSS variables. Override any --pgw-* token on :root or a closer scope:

:root {
  --pgw-accent: #6d28d9;
  --pgw-accent-fg: #fff;
  --pgw-border: #e5e5e5;
  --pgw-radius: 0.5rem;
  --pgw-font: 'IBM Plex Sans', sans-serif;
  --pgw-focus: #6d28d9;
  --pgw-status-running: #2563eb;
}

The other tokens are --pgw-bg, --pgw-fg, --pgw-card, --pgw-muted, --pgw-muted-fg, --pgw-hover, --pgw-active, --pgw-disabled, --pgw-shadow, --pgw-on-status, --pgw-status-completed, --pgw-status-failed, --pgw-status-paused, --pgw-status-cancelled, and --pgw-status-pending.

Dark mode follows prefers-color-scheme. There is no toggle yet.

Restyle one component with the style hooks:

<StatusBadge
  status={run.status}
  className={(state) => (state.status === 'failed' ? 'ring-2 ring-red-600' : undefined)}
/>

<StatusSummary counts={counts} stat={{ className: 'uppercase tracking-wide' }} />
.pgw-badge[data-status='failed'] { color: #b91c1c; }
.pgw-live[data-pressed] { border-color: var(--pgw-status-running); }

Use the tokens in your own markup. styles.css registers them with Tailwind v4 @theme: bg-pgw-bg, text-pgw-fg, border-pgw-border, text-pgw-status-running, shadow-pgw, font-pgw, and more.

<span className="bg-pgw-status-running px-2 text-pgw-on-status">running</span>

Add pgw-root to your container when you compose the components yourself. It sets the background, foreground, and font. <WorkflowRunsDashboard/> already applies it. The stable classes .pgw-button, .pgw-badge, .pgw-input, .pgw-popup, .pgw-filters, .pgw-stat, and .pgw-live live in @layer components, so your utilities override them.

With a JS Tailwind config instead of @source, add the preset and the package to content. The preset covers a subset of the color tokens. It omits pgw-card, pgw-accent-fg, and pgw-disabled, and adds no shadow-pgw or font-pgw.

import pgwPreset from '@pg-workflows/ui/tailwind'

export default {
  presets: [pgwPreset],
  content: ['./node_modules/@pg-workflows/ui/dist/**/*.js'],
}

Architecture

flowchart LR
  UI["Components and hooks"] --> Client["createFetchClient"]
  Client --> HTTP["HTTP /workflow-runs"]
  HTTP --> API["createWorkflowRunsApi"]
  API --> Engine["WorkflowEngine"]
  Engine --> DB[("PostgreSQL")]

The components call the hooks, the hooks call the HTTP API through the client, and the server adapter calls WorkflowEngine. Only the server connects to Postgres.

On this page