@fabric-platform/sdk
v0.19.1
Published
TypeScript SDK for the Fabric AI workflow platform
Readme
@fabric-platform/sdk
TypeScript SDK for the Fabric AI workflow platform. Designed for Next.js (server components, API routes, client-side) with zero runtime dependencies.
Install
npm install @fabric-platform/sdkRequires Node.js 18+.
Quick Start
import { FabricClient } from '@fabric-platform/sdk';
const fabric = new FabricClient({
baseUrl: 'https://api.fabric.ai',
auth: { type: 'api-key', key: 'fab_...' },
organizationId: 'your-org-id',
});
// Submit a workflow and stream live progress
const run = await fabric.workflows.runs.submitAndWait('research/deep_research', {
input: { query: 'AI trends in 2026' },
onEvent(event) {
console.log(`${event.kind}: ${event.node_key}`);
},
});
console.log(run.status); // "completed"
console.log(run.output); // workflow resultAuthentication
The SDK is stateless — it does not store tokens. Your application owns the session.
API Key (server-side)
const fabric = new FabricClient({
auth: { type: 'api-key', key: process.env.FABRIC_API_KEY! },
});Bearer Token (client-side)
const fabric = new FabricClient({
auth: { type: 'bearer', token: supabaseSession.access_token },
});Dynamic Token (Next.js server components)
import { cookies } from 'next/headers';
const fabric = new FabricClient({
auth: async () => {
const token = cookies().get('fabric_access_token')?.value;
if (!token) throw new Error('Not authenticated');
return token;
},
});OAuth Client Credentials (service-to-service)
const fabric = new FabricClient({
auth: { type: 'oauth', clientId: '...', clientSecret: '...' },
});The OAuth strategy auto-exchanges credentials for an access token and caches it until expiry.
Auth Endpoints
The auth resource exposes login/signup methods that return tokens without storing them:
// No auth needed for these
const fabric = new FabricClient({ baseUrl: 'https://api.fabric.ai' });
const { access_token, refresh_token, user } = await fabric.auth.login('[email protected]', 'password');
const { access_token: newToken } = await fabric.auth.refresh(refresh_token);
await fabric.auth.signup('[email protected]', 'password');
await fabric.auth.magicLink('[email protected]');
await fabric.auth.forgotPassword('[email protected]');
await fabric.auth.resetPassword(recoveryToken, 'new-password');
// Social login — returns a redirect URL
const url = fabric.auth.socialLoginUrl('google');
// MFA
const factor = await fabric.auth.mfa.enroll('totp');
const challenge = await fabric.auth.mfa.challenge(factor.id);
const verified = await fabric.auth.mfa.verify(factor.id, challenge.id, '123456');Configuration
interface FabricConfig {
/** Base URL of the Fabric API. Default: http://localhost:3001 */
baseUrl?: string;
/** Authentication strategy or dynamic token provider. */
auth?: AuthStrategy | (() => Promise<string>);
/** Default organization ID for scoped requests. */
organizationId?: string;
/** Default team ID for scoped requests. */
teamId?: string;
/** Request timeout in milliseconds. Default: 30000 */
timeout?: number;
/** Custom fetch implementation (e.g., Next.js enhanced fetch). */
fetch?: typeof globalThis.fetch;
}Next.js Fetch Caching
Pass Next.js's extended fetch for cache control:
const fabric = new FabricClient({
auth: { type: 'api-key', key: process.env.FABRIC_API_KEY! },
fetch: fetch, // Next.js enhanced fetch
});Workflows
Registry (create, list, manage)
// Create a workflow definition
const entry = await fabric.workflows.registry.create({
name: 'my-pipeline',
language: 'python',
source: 'custom',
entry_point: 'main.py',
description: 'My custom pipeline',
input_schema: { type: 'object', properties: { query: { type: 'string' } } },
});
// List workflows (hierarchical: team > org > global)
const { items, pagination } = await fabric.workflows.registry.list();
const { items: orgWorkflows } = await fabric.workflows.registry.list({
organization_id: 'org-uuid',
limit: 50,
});
// Get a specific workflow by name
const workflow = await fabric.workflows.registry.get('research/deep_research');
// Delete a workflow
await fabric.workflows.registry.delete('my-pipeline');
// Compose multiple workflows into a pipeline
const composed = await fabric.workflows.compose({
name: 'research-to-hooks',
steps: ['research/deep_research', 'hooks/generate'],
description: 'Research a topic then generate hooks',
});Discovering a workflow's schema
Fabric derives JSON Schemas from each workflow's Pydantic task
types at server boot and stores them in the registry. Use the
dedicated getSchemas method to fetch just the contract:
const schemas = await fabric.workflows.registry.getSchemas('research/trends');
// Workflow-level pair — use these to render a submit form.
if (schemas.input_schema) {
renderForm(schemas.input_schema);
}
if (schemas.output_schema) {
console.log('This workflow returns:', schemas.output_schema);
}
// Per-task breakdown (excludes internal :capture / :finalize shims).
for (const task of schemas.task_schemas ?? []) {
console.log(task.task_id, task.description, task.source);
}Workflows that don't declare Pydantic task types come back with
null schemas — the call still succeeds, the consumer just learns
there's no machine-readable contract yet. See the
Typed Workflows guide
for the full pattern.
Runs (execute, monitor, control)
// Submit a run (returns immediately)
const run = await fabric.workflows.runs.submit('research/deep_research', {
input: { query: 'AI trends' },
metadata: { source: 'dashboard' },
priority: 75,
idempotencyKey: 'unique-request-id', // prevent double-submissions
validate: true, // opt in to server-side JSON Schema validation
});
// Get run status
const status = await fabric.workflows.runs.get(run.id);
console.log(status.status); // "pending" | "running" | "completed" | "failed" | ...
console.log(status.progress); // { total_tasks: 5, completed_tasks: 3, percentage: 60, ... }
// List runs
const { items: runs } = await fabric.workflows.runs.list({ limit: 20 });
// Cancel a run
await fabric.workflows.runs.cancel(run.id, 'No longer needed');
// Pause / Resume
await fabric.workflows.runs.pause(run.id, 'Waiting for approval');
await fabric.workflows.runs.resume(run.id);Submit and Wait
// Submit and block until completion, with live progress callbacks
const result = await fabric.workflows.runs.submitAndWait('research/deep_research', {
input: { query: 'AI trends' },
timeoutMs: 300_000, // 5 minutes
onEvent(event) {
if (event.kind === 'workflow.node.completed') {
console.log(`Step done: ${event.node_key}`);
}
},
});
console.log(result.output);Real-Time Events (SSE)
Stream events for a workflow run
const stream = fabric.workflows.runs.streamEvents(run.id, {
onEvent(event) {
switch (event.kind) {
case 'workflow.run.started':
console.log('Workflow started');
break;
case 'workflow.node.started':
console.log(`Node ${event.node_key} running...`);
break;
case 'workflow.node.progress':
console.log(`Progress: ${JSON.stringify(event.payload)}`);
break;
case 'workflow.node.completed':
console.log(`Node ${event.node_key} done`);
break;
case 'workflow.run.completed':
console.log('Workflow finished!');
break;
case 'workflow.run.failed':
console.error(`Failed: ${event.payload?.error}`);
break;
}
},
onError(err) {
console.error('Stream error:', err);
},
});
// Cancel the stream
stream.abort();
// Wait for the stream to finish
await stream.done;Features:
- Replays existing events on connect (no missed events)
- Auto-reconnects via
Last-Event-IDheader - Auto-closes on terminal events (
completed,failed,cancelled) - Works in Node.js, browsers, and Edge Runtime
Stream all events
const stream = fabric.events.stream({
onEvent(event) { console.log(event.kind); },
});WebSocket (for dashboards)
const ws = fabric.events.connectWebSocket({
onEvent(event) { console.log(event); },
onError(err) { console.error(err); },
onClose() { console.log('Disconnected'); },
});
ws.subscribeToRun('run-uuid'); // Filter to a specific run
ws.unsubscribe(); // Receive all events
ws.close(); // DisconnectBuilt-in event logger
import { logEvents } from '@fabric-platform/sdk';
// Pretty-prints events to the console with timing
fabric.workflows.runs.streamEvents(run.id, { onEvent: logEvents });React Component Example
'use client';
import { useEffect, useState } from 'react';
import { FabricClient, type DomainEvent } from '@fabric-platform/sdk';
function WorkflowProgress({ runId, token }: { runId: string; token: string }) {
const [events, setEvents] = useState<DomainEvent[]>([]);
useEffect(() => {
const fabric = new FabricClient({
baseUrl: process.env.NEXT_PUBLIC_FABRIC_URL,
auth: { type: 'bearer', token },
});
const stream = fabric.workflows.runs.streamEvents(runId, {
onEvent(event) {
setEvents(prev => [...prev, event]);
},
});
return () => stream.abort(); // Cleanup on unmount
}, [runId, token]);
return (
<ul>
{events.map((e) => (
<li key={e.id}>{e.kind}: {e.node_key}</li>
))}
</ul>
);
}Streaming workflow runs in the browser
The SDK's SSE layer uses fetch + ReadableStream, not the browser's native
EventSource, which means watch() and streamEvents() work directly from
the browser with custom Authorization headers — no proxy required.
Auth: use a dynamic token provider, never ship an API key
Browsers must never carry a long-lived fab_... API key. Configure the
client with a dynamic token provider that fetches a short-lived token from
your own auth backend:
'use client';
import { FabricClient } from '@fabric-platform/sdk';
export const fabric = new FabricClient({
baseUrl: 'https://api.fabric.example.com',
auth: async () => {
// Your server issues a short-lived JWT scoped to this user.
const res = await fetch('/api/fabric-token', { credentials: 'include' });
const { token } = await res.json();
return token;
},
});The provider is re-invoked on every fetch call, so tokens refresh naturally on reconnect. Your backend only needs to implement one route: mint a short-lived Fabric token for the logged-in user.
useWorkflowRun — React hook with typed state
@fabric-platform/sdk/react exports a hook that wraps watch() and tracks
node progress for you. It accepts the FabricClient you constructed above,
and a nullable runId so you can render it before the run is submitted:
'use client';
import { useState } from 'react';
import { useWorkflowRun } from '@fabric-platform/sdk/react';
import { fabric } from './fabric-client';
export function RunTrigger({ workflowName }: { workflowName: string }) {
const [runId, setRunId] = useState<string | null>(null);
const run = useWorkflowRun(fabric, runId);
return (
<div>
<button
disabled={runId !== null && !run.isComplete}
onClick={async () => {
// submit() is a normal POST — works in the browser with the same
// dynamic token provider, no proxy needed.
const r = await fabric.workflows.runs.submit(workflowName, {
input: { topic: 'quarterly trends' },
});
setRunId(r.id);
}}
>
Run workflow
</button>
<progress value={run.progressPercent} max={100} />
<p>{run.currentStep ?? (runId ? 'waiting…' : 'idle')}</p>
{run.error && <p>Error: {run.error.message}</p>}
{run.isComplete && <p>Done: {run.runStatus}</p>}
</div>
);
}State surface:
| Field | Type | Description |
|-------------------|---------------------------------------------------|-----------------------------------------------|
| nodes | PipelineNode[] | All nodes seen so far with current status |
| currentStep | string \| null | Key of the node currently running |
| progressPercent | number | 0–100 based on terminal node count |
| isComplete | boolean | true once a terminal run event arrives |
| runStatus | 'completed' \| 'failed' \| 'cancelled' \| null | Final status, null while running |
| connected | boolean | Whether the SSE stream is currently connected |
| error | Error \| null | Most recent stream error |
| abort | () => void | Manually tear down the stream |
The hook cleans up on unmount automatically. Authorization errors (403 on
connect, expired token, etc.) surface on error rather than throwing.
Direct usage without the hook
If you prefer to drive watch() yourself:
const watcher = fabric.workflows.runs.watch(run.id, {
onNodeStarted: (key) => console.log(`${key} started`),
onNodeCompleted: (key, payload) => console.log(`${key} done`, payload),
onRunCompleted: (payload) => console.log('run done', payload),
onError: (err) => console.error(err),
});
// Later — e.g. on route change:
watcher.abort();Choosing between the two React hooks
| Hook | Transport | Auth | Use when |
|-------------------------|--------------------------------|--------------------------------------------------|-------------------------------------------------------------------------|
| useWorkflowRun | fetch + ReadableStream | Bearer token from dynamic provider | You want direct browser → Fabric streaming, no proxy hop |
| usePipelineProgress | Native EventSource | Cookies / same-origin only (no headers possible) | You already proxy SSE through your own server via createSSEResponse() |
Both are legitimate. useWorkflowRun is strictly newer; reach for it when
starting fresh.
Deployment checklist
- CORS: your Fabric API must include the caller's origin in
CORS_ALLOWED_ORIGINS, and theallow_headerslist must coverAuthorization,Cache-Control, andLast-Event-ID. The defaulttower_http::corsconfig that ships withfabric servealready does this. - Token endpoint: implement
GET /api/fabric-token(or equivalent) on your own backend that returns{ token: "..." }scoped to the logged-in user. Keep token TTL short (default is 2 hours). - Long-running streams: if a stream stays open past the token's lifetime without disconnecting, the server may or may not terminate it depending on your Fabric deployment's auth policy. On reconnect the provider is re-invoked, so the token refreshes automatically after any drop.
Node Logs
Fetch stdout/stderr excerpts for completed workflow nodes:
const attempts = await fabric.workflows.runs.getNodeAttempts(run.id, 'my-node-key');
for (const attempt of attempts) {
console.log('stdout:', attempt.stdout_excerpt);
console.log('stderr:', attempt.stderr_excerpt);
console.log('duration:', attempt.duration_ms, 'ms');
}Organizations
const org = await fabric.organizations.create({ name: 'Acme Corp' });
const { items: orgs } = await fabric.organizations.list({ limit: 20 });
const details = await fabric.organizations.get(org.id);
// Members and teams
const { items: members } = await fabric.organizations.members(org.id);
const { items: teams } = await fabric.organizations.teams(org.id);
// Usage and audit
const usage = await fabric.organizations.usage(org.id);
const { items: logs } = await fabric.organizations.auditLogs(org.id);
// Budget
const budget = await fabric.organizations.getBudget(org.id);
await fabric.organizations.setBudget(org.id, 500.00);
// Secrets
await fabric.organizations.createSecret(org.id, { name: 'OPENAI_KEY', value: 'sk-...' });
const secrets = await fabric.organizations.listSecrets(org.id);
await fabric.organizations.deleteSecret(org.id, 'OPENAI_KEY');Teams
const team = await fabric.teams.create({
organization_id: org.id,
name: 'Engineering',
});
const details = await fabric.teams.get(team.id);Assets
// Upload a file
const asset = await fabric.assets.upload(fileBlob, {
contentType: 'image/png',
filename: 'avatar.png',
});
// List assets
const { items: assets } = await fabric.assets.list();
// Get a signed download URL
const { url } = await fabric.assets.signedUrl(asset.id, 3600); // 1 hour expiryGalleries
const gallery = await fabric.galleries.create({
name: 'Portraits',
kind: 'portrait',
tags: ['professional'],
});
const { items: galleries } = await fabric.galleries.list();
// Add/list/remove items
const item = await fabric.galleries.addItem(gallery.id, {
asset_id: 'asset-uuid',
label: 'Professional Woman #1',
tags: ['female', 'business'],
});
const { items } = await fabric.galleries.listItems(gallery.id);
await fabric.galleries.removeItem(item.id);
await fabric.galleries.delete(gallery.id);API Keys
const { id, secret } = await fabric.apiKeys.create({
description: 'CI/CD key',
scopes: ['workflows:execute', 'assets:read'],
});
// `secret` is only returned at creation time
const { items: keys } = await fabric.apiKeys.list();
const key = await fabric.apiKeys.get(id);
await fabric.apiKeys.disable(id);
const rotated = await fabric.apiKeys.rotate(id); // new secret issued
await fabric.apiKeys.delete(id);Invitations
const invite = await fabric.invitations.create({
organization_id: org.id,
email: '[email protected]',
role: 'member',
});
await fabric.invitations.accept(invite.id);
await fabric.invitations.revoke(invite.id);Permissions
// Check a single permission
const { allowed } = await fabric.permissions.check({
resource: `organization:${org.id}`,
action: 'update',
});
// Batch check
const results = await fabric.permissions.checkBatch([
{ resource: `organization:${org.id}`, action: 'read' },
{ resource: `organization:${org.id}`, action: 'delete' },
]);
// Grant / revoke
const perm = await fabric.permissions.grant({
principal_id: 'user-uuid',
resource_type: 'organization',
resource_id: org.id,
action: 'update',
});
await fabric.permissions.revoke(perm.id);Webhooks
const webhook = await fabric.webhooks.create(org.id, {
url: 'https://example.com/webhook',
event_filter: ['workflow.run.completed', 'workflow.run.failed'],
});
const { items: webhooks } = await fabric.webhooks.list(org.id);
await fabric.webhooks.update(webhook.id, { active: false });
const { items: deliveries } = await fabric.webhooks.deliveries(webhook.id);
await fabric.webhooks.delete(webhook.id);Service Accounts
const sa = await fabric.serviceAccounts.create({ name: 'ci-bot' });
const { items: accounts } = await fabric.serviceAccounts.list();
const apiKey = await fabric.serviceAccounts.createApiKey(sa.id);
const keys = await fabric.serviceAccounts.listApiKeys(sa.id);
await fabric.serviceAccounts.rotateApiKey(sa.id, apiKey.id);
await fabric.serviceAccounts.revokeApiKey(sa.id, apiKey.id);
await fabric.serviceAccounts.disable(sa.id);
await fabric.serviceAccounts.enable(sa.id);OAuth Clients
const client = await fabric.oauth.createClient({ client_name: 'My App' });
const clients = await fabric.oauth.listClients();
const tokens = await fabric.oauth.token(client.client_id, 'client-secret');
const refreshed = await fabric.oauth.refresh(tokens.refresh_token);
await fabric.oauth.revoke(tokens.access_token);
await fabric.oauth.deleteClient(client.id);Current User
const me = await fabric.me.get();
const myOrgs = await fabric.me.organizations();
const myTeams = await fabric.me.teams();
const myPerms = await fabric.me.permissions();Admin
// Dev bootstrap (creates initial org + admin)
await fabric.admin.bootstrap();
// Concurrency limits
const limits = await fabric.admin.listConcurrencyLimits();
await fabric.admin.setConcurrencyLimit('org:uuid', 10);Pagination
All list endpoints return PaginatedResponse<T>:
const { items, pagination } = await fabric.organizations.list({ limit: 20 });
// pagination: { count: 20, has_more: true, next_cursor: "20" }Auto-pagination
import { paginate, paginateAll } from '@fabric-platform/sdk';
// Async iterator — page by page
for await (const page of paginate((params) => fabric.organizations.list(params))) {
for (const org of page) {
console.log(org.name);
}
}
// Collect everything into one array
const allOrgs = await paginateAll((params) => fabric.organizations.list(params));Error Handling
All errors are typed with status code, error code, request ID, and trace ID:
import {
FabricError,
FabricAuthError,
FabricNotFoundError,
FabricConflictError,
FabricRateLimitError,
} from '@fabric-platform/sdk';
try {
await fabric.organizations.create({ name: 'Acme' });
} catch (err) {
if (err instanceof FabricConflictError) {
console.log('Slug already taken');
} else if (err instanceof FabricAuthError) {
console.log('Re-authenticate');
} else if (err instanceof FabricRateLimitError) {
console.log(`Retry after ${err.retryAfterMs}ms`);
} else if (err instanceof FabricNotFoundError) {
console.log('Not found');
} else if (err instanceof FabricError) {
console.log(`${err.code}: ${err.message} (request: ${err.requestId})`);
}
}Error hierarchy:
| Class | Status |
|-------|--------|
| FabricValidationError | 400, 422 |
| FabricAuthError | 401 |
| FabricForbiddenError | 403 |
| FabricNotFoundError | 404 |
| FabricConflictError | 409 |
| FabricRateLimitError | 429 |
| FabricServerError | 5xx |
All extend FabricError which extends Error.
Event Types
type EventKind =
// Run lifecycle
| 'workflow.run.created' | 'workflow.run.queued' | 'workflow.run.promoted'
| 'workflow.run.started' | 'workflow.run.completed'
| 'workflow.run.failed' | 'workflow.run.cancelled'
// Node lifecycle
| 'workflow.node.ready' | 'workflow.node.claimed'
| 'workflow.node.started' | 'workflow.node.progress'
| 'workflow.node.completed'| 'workflow.node.failed'
| 'workflow.node.retried' | 'workflow.node.skipped'
| 'workflow.node.cancelled'| 'workflow.node.waiting_for_event'
| 'workflow.node.resumed';Next.js Integration Patterns
Server-side client factory
// lib/fabric.ts
import { cookies } from 'next/headers';
import { FabricClient, FabricAuthError } from '@fabric-platform/sdk';
export function getFabricClient() {
return new FabricClient({
baseUrl: process.env.FABRIC_URL,
auth: async () => {
const token = cookies().get('fabric_access_token')?.value;
if (!token) throw new FabricAuthError({
code: 'no_session', status: 401, message: 'Not authenticated',
});
return token;
},
});
}Login API route
// app/api/auth/login/route.ts
import { FabricClient } from '@fabric-platform/sdk';
import { cookies } from 'next/headers';
export async function POST(req: Request) {
const { email, password } = await req.json();
const fabric = new FabricClient({ baseUrl: process.env.FABRIC_URL });
const auth = await fabric.auth.login(email, password);
cookies().set('fabric_access_token', auth.access_token, {
httpOnly: true,
secure: true,
maxAge: auth.expires_in,
});
cookies().set('fabric_refresh_token', auth.refresh_token, {
httpOnly: true,
secure: true,
maxAge: 30 * 86400,
});
return Response.json({ user: auth.user });
}SSE proxy route
// app/api/workflows/[runId]/events/route.ts
import { cookies } from 'next/headers';
export async function GET(req: Request, { params }: { params: { runId: string } }) {
const token = cookies().get('fabric_access_token')?.value;
const upstream = await fetch(
`${process.env.FABRIC_URL}/v1/workflow-runs/${params.runId}/events`,
{ headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' } },
);
return new Response(upstream.body, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
});
}Development
npm run build # Build with tsup (ESM + CJS + declarations)
npm run typecheck # Type-check without emitting
npm test # Run tests with vitest
npm run dev # Watch mode