TechFabricTechFabricHarness
Building Agents

React applications

Build agent conversations and finite-run interfaces with the public Fabric client protocol.

@fabric-harness/react provides FabricProvider, useFabricClient, useFabricAgent, and useFabricJob. The package talks only to the public authenticated HTTP protocol, so the same UI can target local development, a Node artifact, Databricks Apps, or another compatible deployment.

pnpm add @fabric-harness/react @fabric-harness/client react

Configure the provider

Create one client for the application. Authentication headers stay in the application transport and are not placed in agent messages or model context.

src/main.tsx
import { FabricProvider } from '@fabric-harness/react';

export function Root({ children }: { children: React.ReactNode }) {
  return (
    <FabricProvider options={{
      baseUrl: '/api',
      headers: { authorization: `Bearer ${window.fabricToken}` },
    }}>
      {children}
    </FabricProvider>
  );
}

For server-rendered frameworks, pass a request-scoped client through client. Hooks use stable external-store server snapshots and start network observation only after the component mounts.

Agent conversations

useFabricAgent catches up from offset zero, streams text, reasoning, and tool-argument deltas over SSE, replaces transient parts with the canonical durable assistant message, deduplicates reconnect replay, resets stale projections when the stream incarnation changes, and applies truncation records. Render the stable messages projection; transcript remains available for diagnostics and compatibility. Optimistic messages remain separate in pending until their submission-correlated input is observed.

isWorking and activeSubmissionIds track admitted submissions and observed durable work until submission_settled, independently of status (the connection/send lifecycle). Replayed entries rebuild this projection. They do not discover queued work that has not appeared in this client's receipt or conversation stream; use the server submission status interface for that case.

import { useFabricAgent } from '@fabric-harness/react';

function SupportChat({ customerId }: { customerId: string }) {
  const chat = useFabricAgent({ agent: 'support', id: customerId, session: 'default' });

  async function send(text: string) {
    const submissionId = await chat.send(text);
    const settled = await chat.wait(submissionId, { timeoutMs: 30_000 });
    console.log(settled.outcome);
  }

  return (
    <>
      {chat.messages
        .filter((message) => message.display === 'visible')
        .map((message) => (
          <p key={message.id}>
            {message.parts
              .filter((part) => part.type === 'text')
              .map((part) => part.text)
              .join('')}
          </p>
        ))}
      {chat.pending.map((message) => (
        <button key={message.id} disabled={message.status !== 'failed'} onClick={() => chat.retry(message.id)}>
          {message.status}: retry
        </button>
      ))}
      <button onClick={() => send('Check account status')}>Send</button>
      <button onClick={() => chat.refresh()}>Refresh</button>
      <button onClick={() => chat.abort()}>Abort</button>
    </>
  );
}

While a tool call is still streaming, its message part has { type: 'dynamic-tool', state: 'input-streaming', inputText }. Treat inputText as untrusted, partial JSON; only input-available contains the provider's completed parsed input.

Send image attachments with the normal delivered-message shape.

await chat.send({
  kind: 'user',
  body: 'Inspect this screenshot',
  attachments: [{ type: 'image', mimeType: 'image/png', data: base64 }],
});

Retry-safe agent admission

sendMessage(message, options) returns the complete SendReceipt immediately after durable admission: submissionId, uid, acceptedAt, conversation URLs, offset, and deduplicated when a key was supplied. It doesn't wait for generation. Existing send() still returns only the submission ID.

// Create and persist this key once per logical user action, before the first attempt.
const key = crypto.randomUUID();
const receipt = await chat.sendMessage('Check account status', {
  idempotencyKey: key,
  signal: controller.signal,
});
const settled = await chat.wait(receipt.submissionId, { timeoutMs: 30_000 });

Keys contain 1–256 visible ASCII characters; use opaque random IDs, never account numbers or secrets. Reuse the same key and unchanged message, creation data, and incarnation precondition after an uncertain network outcome. The server scopes the key to the authenticated actor, tenant, agent, instance, and named session. Exact retries return the original submission and pre-admission offset; conflicting reuse returns HTTP 409. Current authentication/instance checks still run on replay. Keys are hashed before durable storage. Deduplication lasts while the submission record is retained; instance deletion or store cleanup ends that protection.

retry() retains the original message/options, including the key, but starts a fresh HTTP attempt without the previous AbortSignal. Caller mutation cannot change this saved request. Keys are opt-in: without one, retry can admit duplicate work. Persist your key outside the hook if retries must survive a page reload. Cancelling admission HTTP does not cancel work that the server already accepted; use status/wait and the explicit agent abort operation to resolve the outcome.

Call dismissFailed(id) when abandoning one failed message, or dismissFailed() for all failures, to release retained messages and retry data. Cache eviction also clears private retry payloads; retrying an evicted store fails closed and requires the original request/key from application storage.

Use the matching current Node or Cloudflare runtime for keyed sends. Custom mounted backends must implement atomic keyed admission and declare supportsIdempotentAdmission: true; the router otherwise returns 501 for a keyed request. Older servers may ignore the header, so upgrading only the browser package does not provide deduplication. Durable restart protection requires durable persistence (for example SQLite or Postgres), not the dev server's default in-memory store. Generated Cloudflare routes without a durable runtime return 501 for keyed sends. The built-in Cloudflare token authenticates a shared service principal. Applications requiring per-user isolation must use their own authenticated host boundary and pass a distinct trusted actor to the Durable Object host; sharing a service token doesn't create separate end-user identities. A conversation metadata read failure stops admission so the server cannot issue a misleading receipt cursor.

Finite runs

useFabricJob admits an asynchronous run, follows its offset events, publishes the terminal run envelope, and exposes retry and abort. Starting a newer invocation supersedes the previous observation, so a late terminal response from the old run cannot replace the new run's state. Use invokeSync only for short request/response work.

const report = useFabricJob<{ accountId: string }, { url: string }>('account-report');

await report.invoke(
  { accountId: 'account-42' },
  { idempotencyKey: 'account-report:account-42' },
);

console.log(report.status, report.run?.output?.url);
await report.abort();

Agent observation reconnects from the latest durable offset with bounded exponential backoff and resets the delay after receiving an update. Set live: 'poll' in createFabricAgentStore when SSE is intentionally unavailable; the default is automatic SSE with polling fallback. Stores with no subscribers or in-flight work are evicted, which keeps long-lived applications with dynamic agent and job addresses bounded.

Runnable application

examples/react-chat starts fh dev --mock and Vite with one pnpm dev command. It includes multiple persistent agent instances, image attachments, optimistic retry, abort, canonical transcript streaming, and a finite job panel. Its UI renders the public conversation-message projection, including streaming text and tool states. The example consumes @fabricorg/ui for shared Fabric semantic tokens and operator controls; this UI package remains an application dependency and is not required by the Harness SDK or runtime. Its automated browser check captures desktop/mobile light and dark layouts and fails on browser errors or mobile horizontal overflow.