PPactDocs
Mobile & SDKs

@pact/sdk-mobile — JavaScript, React Native, Capacitor

Use Pact from any JavaScript runtime: React Native, Capacitor, browsers, Node. Typed entity hooks, offline writes, white-label theming.

@pact/sdk-mobile is the official Pact client for JavaScript runtimes. It works out of the box on:

  • React Native (Expo or bare).
  • Capacitor (Pact's own native shell, plus any other Capacitor-based app).
  • Browsers (vanilla JS, or any modern bundler — the published bundle is framework-agnostic).
  • Node ≥ 18.18 (for server-side automation and the daily_briefing cron pattern).

The React surface (hooks, PactProvider, PactThemeProvider) is exposed via the @pact/sdk-mobile/react subpath so Node consumers don't pay the React cost.

Install

bash
npm install @pact/sdk-mobile
# or
pnpm add @pact/sdk-mobile

Bundle size: ESM index is 0.8 KB + a shared 24 KB core chunk (post-tree-shake); the React layer adds 7 KB. Both numbers are validated in CI via the bundle job under web-ci.yml.

Quickstart — Node

ts
import { PactClient, accounts, agents } from "@pact/sdk-mobile";

const pact = new PactClient({
  token: process.env.PACT_TOKEN!,        // pact_test_… or pact_live_…
  tenantId: process.env.PACT_TENANT,     // optional when the key is tenant-bound
});

const list = await accounts.list(pact, { limit: 5 });
console.log(list.data.data, "consent_filtered:", list.consentFiltered);

const briefing = await agents.fire(pact, {
  agentId: "daily_briefing",
  input: { prompt: "weekly review" },
});
console.log(briefing.cost);  // { actual_cents, predicted_cents, drift_pct, charged }

Quickstart — React Native

tsx
import {
  PactProvider,
  PactThemeProvider,
  useContacts,
  useAgent,
  useLogActivity,
  usePactOnline,
} from "@pact/sdk-mobile/react";

export default function App() {
  return (
    <PactThemeProvider overrides={{ colors: { accent: { ember: "#FF9F1C" } } }}>
      <PactProvider token={process.env.EXPO_PUBLIC_PACT_TOKEN!} tenantId="demo">
        <Dashboard />
      </PactProvider>
    </PactThemeProvider>
  );
}

function Dashboard() {
  const online = usePactOnline();
  const contacts = useContacts({ limit: 25 });
  const briefing = useAgent("daily_briefing");
  const { log } = useLogActivity();
  // …render contacts.data, briefing.cost, etc.
}

The full sample lives in packages/sdk-mobile/examples/react-native-quickstart — drop it into npx create-expo-app and add EXPO_PUBLIC_PACT_TOKEN.

Auth + tenancy

Authenticate with either:

  • A scoped Pact API key (pact_test_… for sandbox, pact_live_… for production). Mint under Settings ▸ Developers ▸ API keys.
  • An OAuth access token (recommended for partner apps). See OAuth scopes.

Tenant resolution:

  • When the token is tenant-bound (the common case), the server uses the bound tenant and the X-Tenant-ID header is optional.
  • When the token is service-level (multi-tenant partner apps), pass the active tenantId on PactProvider or new PactClient({ tenantId }). The SDK forwards it as X-Tenant-ID on every call.
  • Consent. Every read result carries consentFiltered: boolean | undefined. When true, the server filtered out at least one row for a withdrawn consent — surface a hint to the user so a short list is explicable.
  • BYOK. Agent results expose byok: true and cost_cents: 0 when the tenant has supplied their own LLM credentials. The cost.charged field is the canonical did Pact bill for this? boolean.
  • Audit. Call client.emitAudit(type, payload) to write a Wave AT event; the SDK silently ignores failures so audit never blocks the caller. The built-in entity helpers don't emit on your behalf — that's intentional, so you decide what's audit-worthy.

Offline writes

Mutations issued while client.setOnline(false) is in effect park in an in-memory (or storage-backed) FIFO queue and replay automatically the next time you call setOnline(true). Every queued write carries a client-side UUID forwarded as Idempotency-Key, so a network retry never produces a duplicate row.

ts
import { OfflineQueue, type OfflineStorage } from "@pact/sdk-mobile";

const asyncStorageAdapter: OfflineStorage = {
  load:  async () => JSON.parse((await AsyncStorage.getItem("pact.q")) ?? "[]"),
  save:  async (items) => AsyncStorage.setItem("pact.q", JSON.stringify(items)),
};

const pact = new PactClient({
  token: API_TOKEN,
  offlineStorage: asyncStorageAdapter,
});

// Wire your network observer:
NetInfo.addEventListener((s) => pact.setOnline(!!s.isConnected));

Real-time events

ts
import { subscribeEvents } from "@pact/sdk-mobile";

const sub = subscribeEvents(pact, (event) => {
  console.log(event.type, event.id);
}, { types: ["agent.run.completed"] });

// later:
sub.close();

On RN, pass WebSocketImpl: WebSocket since EventSource isn't shimmed out of the box. The subscriber tracks Last-Event-ID so a dropped network doesn't lose audit frames on reconnect.

White-label theming

ts
import { resolveTokens } from "@pact/sdk-mobile";

const tenantOverrides = { colors: { accent: { ember: tenant.brandColor } } };
const tokens = resolveTokens(tenantOverrides);
//   tokens.colors.text.primary, tokens.spacing[4], …

Or, in React, drop <PactThemeProvider overrides={…}> at the embed root and read usePactTokens() anywhere inside.

API reference

SurfacePurpose
new PactClient(opts) / createPactClient(opts)Constructor.
accounts, contacts, deals, activities, tasks, notes, meetings, agentsEntity helpers, all (client, …args) → APIResponse<T>.
subscribeEvents(client, onEvent, opts)Wave AT event tail.
OfflineQueue, uuidv4()Building blocks for custom offline strategies.
defaultTokens, resolveTokens(overrides)Wave V design tokens (JS).
<PactProvider>, <PactThemeProvider>React context providers.
usePactClient, usePactTokens, usePactOnlineContext readers.
useAccounts, useContacts, useDeals, useActivities, useTasks, useNotes, useUpcomingMeetingsEntity hooks.
useAgent, useLogActivity, useBookMeetingMutation / fire hooks.
usePactEventsReal-time tail hook.

Full TypeDoc lives in the source; the .d.ts ships in the published package.