@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_briefingcron 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
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
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
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-IDheader is optional. - When the token is service-level (multi-tenant partner apps), pass the
active
tenantIdonPactProviderornew PactClient({ tenantId }). The SDK forwards it asX-Tenant-IDon every call.
Consent + BYOK + audit
- Consent. Every read result carries
consentFiltered: boolean | undefined. Whentrue, 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: trueandcost_cents: 0when the tenant has supplied their own LLM credentials. Thecost.chargedfield is the canonicaldid 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.
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
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
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
| Surface | Purpose |
|---|---|
new PactClient(opts) / createPactClient(opts) | Constructor. |
accounts, contacts, deals, activities, tasks, notes, meetings, agents | Entity 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, usePactOnline | Context readers. |
useAccounts, useContacts, useDeals, useActivities, useTasks, useNotes, useUpcomingMeetings | Entity hooks. |
useAgent, useLogActivity, useBookMeeting | Mutation / fire hooks. |
usePactEvents | Real-time tail hook. |
Full TypeDoc lives in the source;
the .d.ts ships in the published package.