API reference
Entry points, CLI, components, hooks, server adapters, HTTP API, and styling.
- Entry points
- CLI
- Components:
WorkflowRunsDashboard·RunsTable·RunDetail·StatusSummary·FilterBar·Pagination·LiveToggle·StatusBadge WorkflowRunsProvider- Hooks:
useWorkflowRuns·useWorkflowRun·useWorkflowRunStats·useRunActions·useRunFilters·useWorkflowRunsClient - Helpers
createFetchClient- Server adapters
- HTTP API
- Security
- Styling & customization
- Architecture
Entry points
Client code never imports the server entries, so a browser bundle does not pull in the engine.
| Import | Contents | Runs |
|---|---|---|
@pg-workflows/ui | Components, hooks, provider, helpers, and a re-export of createFetchClient and its types | client |
@pg-workflows/ui/client | createFetchClient and types, no React | client or server |
@pg-workflows/ui/server | createWorkflowRunsApi, toFetchHandler, toNodeHandler, HttpError, toErrorResponse | server only |
@pg-workflows/ui/next | createAppRouterHandler, createPagesApiHandler, createRouteHandlers | server only |
@pg-workflows/ui/tailwind | Tailwind preset for a subset of the pgw-* color tokens | build |
@pg-workflows/ui/styles.css | CSS variables (light and dark), Tailwind @theme tokens, and component styles | client |
pg-workflows-ui (bin) | Standalone localhost dashboard | CLI |
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]| Flag | Default | Description |
|---|---|---|
--database-url | DATABASE_URL, else postgres://localhost:5432/postgres | Postgres connection string |
--port | 3777 | Port to listen on |
-h, --help | Print usage |
- The server binds to
127.0.0.1only. It has no authentication and noresolveContext, 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 withWorkflow <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-runsand 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.
| Prop | Type | Default | Description |
|---|---|---|---|
baseUrl | string | Prefix of the mounted API routes. Mutually exclusive with client. Read once on mount. | |
client | WorkflowRunsClient | A client, usually from createFetchClient. Mutually exclusive with baseUrl. Read once on mount. | |
pollIntervalMs | number | 5000 while Live, 0 while paused | Refresh interval. When set, it overrides the Live toggle: the button still switches, but the interval stays fixed. |
selectedRunId | string | null | Controlled selection. Pair with onSelectRun and your router for deep links. When omitted, the dashboard tracks selection itself. | |
onSelectRun | (id: string | null) => void | Called 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
| Prop | Type | Default | Description |
|---|---|---|---|
runs | WorkflowRun[] | Rows to render, in order. | |
onSelectRun | (id: string) => void | Called when a row is clicked, or on Enter or Space. | |
selectedRunId | string | null | Highlights the matching row. | |
isLoading | boolean | false | While 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().
| Prop | Type | Default | Description |
|---|---|---|---|
runId | string | Run to load. | |
onBack | () => void | Renders 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-100display ashigh,normal, andlow. - 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 ispaused. 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
| Prop | Type | Default | Description |
|---|---|---|---|
counts | Partial<Record<WorkflowRunStatus, number>> | Counts by status, usually useWorkflowRunStats().data. | |
onSelectStatus | (status: WorkflowRunStatus) => void | Called when a count is clicked. | |
trailing | ReactNode | Rendered 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
| Prop | Type | Description |
|---|---|---|
filters | RunFilters | Current filters, from useRunFilters. |
hasActiveFilters | boolean | Enables the Clear control. |
workflowIds | string[] | Options for the workflow filter. |
onFiltersChange | (partial: Partial<RunFilters>) => void | Called with the changed fields. Reset startingAfter and endingBefore here, or the next request stays on a cursor from the previous filter. |
onClear | () => void | Called by the Clear control. |
Style state: { active: boolean }.
Pagination
| Prop | Type | Default | Description |
|---|---|---|---|
hasPrev | boolean | Enables Prev. | |
hasNext | boolean | Enables Next. | |
onPrev | () => void | Called by Prev. | |
onNext | () => void | Called by Next. | |
isFetching | boolean | false | Disables both buttons while a page loads. |
Style state: { hasPrev, hasNext, fetching }.
LiveToggle
A Base UI Toggle.
| Prop | Type | Default | Description |
|---|---|---|---|
isLive | boolean | Pressed state. | |
isFetching | boolean | Sets data-fetching while a request is in flight. | |
onToggle | () => void | Called on press. Pass pollIntervalMs={isLive ? 5000 : 0} to the provider. | |
nativeButton | boolean | true | Set to false when render is not a <button>. |
Style state: Base UI toggle state (pressed, disabled). Also sets data-pressed and data-fetching.
StatusBadge
| Prop | Type | Description |
|---|---|---|
status | WorkflowRunStatus | 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled' |
Sets data-status. Style state: { status }.
WorkflowRunsProvider
| Prop | Type | Default | Description |
|---|---|---|---|
client | WorkflowRunsClient | Used by every hook below. | |
pollIntervalMs | number | 5000 | Refresh interval for every query under the provider. 0 turns polling off. |
children | ReactNode |
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>.
| Param | Type | Description |
|---|---|---|
limit | number | Required. Page size, at most 100. |
statuses | WorkflowRunStatus[] | Filter by status. |
workflowId | string | Filter by workflow. |
startingAfter | string | Cursor for the next page. |
endingBefore | string | Cursor 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.
| Action | Variables | Engine 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:
| Field | Type | Applied |
|---|---|---|
limit | number | server |
startingAfter, endingBefore | string | server |
statuses | WorkflowRunStatus[] | server |
workflowId | string | server |
search | string | client (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:
| Field | Description |
|---|---|
filters | The 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. |
hasActiveFilters | true 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,
)| Export | Description |
|---|---|
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.
| Option | Type | Description |
|---|---|---|
baseUrl | string | Prefix of the mounted routes. A trailing slash is ignored. |
fetch | typeof fetch | Custom 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
| Export | From | Description |
|---|---|---|
createWorkflowRunsApi({ engine, basePath?, resolveContext? }) | /server | Returns 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) | /server | Turns an API object, or a (request) => Promise<Response> function, into one Fetch function. |
toNodeHandler(source) | /server | Node (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) | /server | The 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) | /next | Default 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, notgetEngine(). 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))basePathmust match the mount.toNodeHandlerreadsreq.originalUrl, which keeps the prefix that Express strips fromreq.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 path | Engine call |
|---|---|
GET / | getRuns (query: starting_after, ending_before, limit up to 100, workflow_id, statuses, repeatable) |
GET /stats | getStats (query: workflow_id) |
GET /:id | getRun |
POST /:id/cancel | cancelWorkflow |
POST /:id/pause | pauseWorkflow |
POST /:id/resume | resumeWorkflow |
POST /:id/fast-forward | fastForwardWorkflow (body: { data? }) |
POST /:id/trigger | triggerEvent (body: { eventName, data? }) |
Errors return { error, message?, issues? }. issues lists validation failures. An in_progress error also carries workflowId and runId.
| Status | error | Cause |
|---|---|---|
400 | validation | Invalid query, body, or JSON, or an engine validation error |
401 | unauthorized | resolveContext threw |
404 | not_found | Unknown run or path |
405 | method_not_allowed | Wrong method for the path |
409 | in_progress | WorkflowRunInProgressError (a singleton slot is taken) |
409 | conflict | Any other engine error, such as an illegal transition |
500 | internal | Anything 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. PassresolveContextwhen 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.
classNameandstyletake a value, or a function of the component's state (listed with each component above).renderreplaces 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 (
statonStatusSummary). 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.