@palisadelabs/agent-sdk-react-native
v0.2.2
Published
Palisade in-app agent for React Native / Expo apps: assistant thread, nudges, home card, driver, outbox delivery. Headless core with theme + component injection; all copy, policy and capabilities are decided server-side.
Readme
@palisadelabs/agent-sdk-react-native
The Palisade in-app agent for React Native / Expo apps: the assistant thread (pushed route and global sheet), proactive nudge popups, support-reply delivery, the personalized home card, the agent driver (navigate / seed the pack builder / toast), and the agent-outbox claim. Ported verbatim from the CatchBack integration; everything host-specific arrives through one provider.
Design rules the package holds to:
- Dumb pipes. Copy, policy, allowlists and deep links are decided server-side
(via
@palisadelabs/agent-relayin the host's Next app). New message kinds and capabilities never require an SDK release. - Custody. The SDK never holds client credentials. Session auth, storage, navigation, analytics and the pack builder are host callbacks.
- Version skew. Unknown driver action types, unknown deep-link paths, unknown outbox kinds and unresolved destinations all no-op silently. The compiled screen deny set ships inside the SDK and no remote config or provider prop can override it.
Install
The package ships compiled JS (JSX preserved, Metro transpiles it) plus
.d.ts in lib/.
npm install @palisadelabs/agent-sdk-react-nativePeer dependencies (all optional in package.json so a non-RN workspace install
does not drag them in; the host app already has every one of these):
react, react-native, @react-navigation/native,
react-native-safe-area-context, react-native-reanimated, expo-haptics,
expo-crypto, @expo/vector-icons.
Glue 1: the provider mount
Mount <PalisadeAgent> once, inside the NavigationContainer, wrapping the
navigator (the same site where the reference app mounted its assistant
watchers). It writes the environment seam and renders the three global hosts
(nudge watcher, driver host, assistant modal host) after {children}.
import {
PalisadeAgent,
type AgentStorage,
} from '@palisadelabs/agent-sdk-react-native';
import { navigationRef } from './navigation/ref';
import { supabase } from './lib/supabase';
import { diskCache } from './lib/diskCache';
import { getBuilderState, getLastBuiltPack, clearLastBuiltPack } from './lib/builderState';
import { getRenderImageUrl } from './lib/images';
import { trackEvent } from './lib/catchbackPixel';
import MessageBubble from './components/messages/MessageBubble';
import GlassButton from './components/GlassButton';
import DragDismissSheet from './components/DragDismissSheet';
// Storage adapter over the existing disk cache. The SDK builds the SAME key
// strings the app used before (catchback_assistant_thread_v1_<userId>, etc.,
// via keyPrefix 'catchback'), so existing users' threads, A/B assignments and
// nudge cooldowns survive the migration byte for byte.
const agentStorage: AgentStorage = {
getItem: (key) => diskCache.getString(key),
setItem: (key, value) => diskCache.setString(key, value),
removeItem: (key) => diskCache.remove(key),
};
<PalisadeAgent
apiBaseUrl="https://catchback.app"
enabled={process.env.EXPO_PUBLIC_ASSISTANT_ENABLED !== 'false'}
getSession={async () => {
const { data } = await supabase.auth.getSession();
const s = data.session;
return s ? { userId: s.user.id, accessToken: s.access_token } : null;
}}
storage={agentStorage}
keyPrefix="catchback"
navigationRef={navigationRef}
assistantScreenName="AssistantChat"
onEvent={(name, props) => trackEvent(name, props)}
resolveImageUrl={(url, opts) => getRenderImageUrl(url, opts)}
resolveDestination={(destination, params) => {
// The driver's compiled semantic destinations. Only the host knows which
// routes they map to. Return true = handled; false = the SDK falls back
// (or no-ops, for the compiled trio). See "Destinations" below.
if (!navigationRef.isReady()) return false;
switch (destination) {
case 'card':
if (!params.card_id) return false;
navigationRef.navigate('CardDetail', { cardId: String(params.card_id) });
return true;
case 'packs':
navigationRef.navigate('MainTabs', { screen: 'Packs' });
return true;
case 'market':
navigationRef.navigate('MainTabs', {
screen: 'Market',
params: params.query ? { query: String(params.query) } : undefined,
});
return true;
default:
return false; // unknown destinations fall through to the SDK's fallbacks
}
}}
onSeedCustomPack={(seed) => {
// The driver solved a custom pack; open the builder seeded with it (the
// reference app's agentPack param shape). Return true = handled.
navigationRef.navigate('MainTabs', {
screen: 'Packs',
params: { openCustom: true, agentPack: seed },
});
return true;
}}
builderContext={{
// Read seam onto the host's pack-builder state; chat sends attach these
// to page_context so the agent sees the pack on screen.
getBuilderState,
getLastBuiltPack,
clearLastBuiltPack,
}}
theme={catchbackAgentTheme /* glass fills, Inter/Laira faces, brand colors */}
components={{
MessageBubble, // the shared DM bubble; props are a superset of AgentMessageBubbleProps
Button: GlassButton, // props are a superset of AgentButtonProps
Sheet: DragDismissSheet, // buys back finger-drag dismiss on the nudge sheet
}}
>
<RootNavigator />
</PalisadeAgent>Notes:
enabledis the master build-time kill switch, the SDK equivalent of the reference app'sEXPO_PUBLIC_ASSISTANT_ENABLED !== 'false'gate. False (or omitted) mounts nothing assistant-shaped; every exported lib function no-ops. Every feature on top stays behind the server's remote flags.themeandcomponentsare partial: pass only what you override, neutral defaults fill the rest. A bare<PalisadeAgent>renders a clean generic chat with no host components at all.components.Sheetis optional. Without it the SDK uses its internal bottom sheet (same motion, no finger-drag dismiss). The injected component receivesAgentSheetProps(exported), which is a compatible subset of the CatchBackDragDismissSheetprops, so the real component drops straight in.resolveImageUrl(url, { width })is the host's CDN image resizer (for CatchBack,lib/imagesgetRenderImageUrl). The SDK calls it at every product/pack-art render and prefetch site (reply card thumbnails atwidth: 300, the home card's pack fan) so thumbnails never download full-size originals. Omitted, images load at their raw URLs — correct, but a real bandwidth and scroll-perf hit: wire it whenever the host has a resizing CDN.
Glue 2: modal provider mounts
AssistantModalProvider holds the shared open/close state behind every Ask
button. <PalisadeAgent> already provides one as a fallback, and the provider
is nest-safe (an inner one defers to an outer one). Mount your own in two
places, exactly like the reference app:
- The App root, ABOVE
<PalisadeAgent>, so screens outside the navigator subtree share the same modal state. - Any standalone native surface tree that renders its own React root (for
CatchBack: the rip-screen overlay tree). Each separate React root needs its
own provider or
useAssistantModalthrows.
import { AssistantModalProvider, useAssistantModal } from '@palisadelabs/agent-sdk-react-native';
// any Ask button, in any header:
const { openModal } = useAssistantModal();Glue 3: screen registration
Register the pushed thread route under the name AssistantChat (the deep-link
fallback and the nudge sheet's support-reply tap navigate to this name; a
different name also works if you pass it as assistantScreenName):
import { AssistantChatScreen } from '@palisadelabs/agent-sdk-react-native';
<Stack.Screen
name="AssistantChat"
component={AssistantChatScreen}
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>Also register the Ask button's SHEET surface (same thread, sheet-presented;
openModal() navigates here). Deliberately a navigator screen and not a
React Native <Modal>: Fabric can orphan a Modal's host view as an invisible
full-screen touch interceptor when its dismissal races the driver's
navigation, freezing every tap in the app (root-caused live). The navigator's
native sheet has no such failure mode. A different name works if you pass it
as assistantSheetScreenName:
import { AssistantSheetScreen } from '@palisadelabs/agent-sdk-react-native';
<Stack.Screen
name="AssistantChatSheet"
component={AssistantSheetScreen}
options={{ presentation: 'modal' }}
/>The pinned conversation row in Messages stays host code (it is glue, not SDK);
it builds its preview from loadAssistantPreview() and navigates to
AssistantChat.
Glue 4: navigation call sites
Two functions wire the SDK into the host's navigation layer:
// NavigationContainer.onStateChange: hand over the deepest focused route.
// Feeds the nudge's browsing signal; keep your own analytics next to it.
onStateChange={(state) => {
const route = resolveActiveRoute(state); // the host's existing resolver
recordNavigation(route.name, route.params, route.key);
}}
// The push / in-app deep-link resolver: give the SDK first refusal.
// routeAgentLink handles '/assistant' links ("the team replied" pushes) and
// returns false for everything else, so the host's own switch keeps working.
export function navigateToLink(link: string, data?: Record<string, unknown>) {
if (routeAgentLink(link, data)) return;
// ...the host's existing cases...
}Destinations
resolveDestination(destination, params) gets first refusal on every
navigation the SDK wants to make. Destinations the SDK asks for today:
| Destination | Params | Fired by | SDK fallback when unhandled |
|---|---|---|---|
| card | { card_id } | reply card taps, driver navigate | navigate('CardDetail', { cardId }) (silent no-op without card_id) |
| packs | {} | driver navigate | navigate('MainTabs', { screen: 'Packs' }) |
| market | { query? } | driver navigate | navigate('MainTabs', { screen: 'Market', params: { agentQuery } }) |
| assistant | push data | routeAgentLink('/assistant') | navigate(assistantScreenName) |
| assistant_chat | {} | support-reply popup tap | navigate('AssistantChat') |
| custom_pack_builder | {} | nudge CTA without driver actions | navigate('MainTabs', { screen: 'Packs', params: { openCustom: true } }) |
Unknown destination names return false from your resolver and the SDK treats them per the table (everything falls back to the reference route names, and an unknown route name is a React Navigation no-op).
Kill switches
Layered, outermost first:
EXPO_PUBLIC_ASSISTANT_ENABLEDat app build time, passed as the provider'senabledprop. Off = the stock app, nothing mounts.ASSISTANT_ENABLEDon the relay (server env). Off = every relay route 404s and the SDK degrades to its fail-soft defaults.- The remote
agent_configflags, resolved per user by the relay's experiment endpoint and read at runtime on every surface:header_button,dm_row,nudge,driver_actions,screen_reporting,home_card,pack_reorder,clear_chat. Each gates exactly one surface or capability; all default off (ASSISTANT_FLAGS_OFF) until an assignment resolves, and a relay too old to send flags gets the legacy per-arm gating. - Remote config copy and behavior knobs (greeting, nudge copy and cooldowns,
telemetry sampling,
driver_extra_destinations) are served per request; absent config renders the compiled defaults, which are the shipped literals.
Version-skew contract
A newer server must never crash an older app:
- Unknown driver action types are dropped by the parser, never thrown on.
- Deep-link paths the SDK does not know return false from
routeAgentLink. - Unknown outbox message kinds render as plain text or are skipped.
navigatedestinations outside the compiled set are only honored when the remote config allowlists them, and then only as bare route names (unknown names are a React Navigation no-op).- The compiled
DENIED_SCREENSset is a code floor: no remote config, allowlist or provider prop can make the driver open those screens. onSeedCustomPackabsent or returning false drops the seed silently.
Cache compatibility
With keyPrefix: 'catchback' and a storage adapter over the existing disk
cache, the SDK reads and writes the exact key strings the reference app used:
catchback_assistant_thread_v1_<userId | anon>(the on-device thread)catchback_assistant_assignment_v2_<userId>(the resolved A/B assignment)catchback_assistant_nudge_shown_v1_<userId>(nudge cooldown stamp)
Existing users keep their threads and assignments across the migration. Other adopters set their own prefix once and never change it.
Public surface
See src/index.ts for the full export list. The main groups: the provider
(PalisadeAgent), surfaces (AssistantThread, AssistantChatScreen,
AssistantHomeCard/AgentHomeCard), modal state (AssistantModalProvider,
useAssistantModal), hooks (useAgentSession, useAnonAssistant,
useAssistantArm, useAgentPackOrder), navigation glue (routeAgentLink,
recordNavigation), the chat/driver/config lib functions the host's screens
call directly, and the theme/component injection types (AgentTheme,
AgentComponents, AgentMessageBubbleProps, AgentButtonProps,
AgentSheetProps, AgentBubbleMessage).
The host API bridge (host_api_call)
The general seam for agent capabilities that act on the host app's own API (balance purchases, pack ripping, buyback sells, listing management). One glue callback, written once:
const agentApiBridge = async (path, init) => {
const s = await getSession();
if (!s) return null;
const res = await fetch(`${ENV.API_URL}${path}`, {
method: init?.method ?? 'GET',
headers: { ...(init?.headers ?? {}), authorization: `Bearer ${s.accessToken}` },
body: init?.body,
});
return { status: res.status, ok: res.ok, bodyText: await res.text().catch(() => '') };
};
<PalisadeAgent apiBridge={agentApiBridge} ... />From then on, new capabilities are server-side toggles: the agent's reply
carries {type: 'host_api_call', method, path, body, confirm, ...} and no
client release is needed.
Safety is compiled into the SDK, not the glue, and none of it is config-overridable:
- Same-origin relative paths only (no schemes, no
//, no traversal, encoded or not), so a hostile config can never point the user's token at another origin. - A compiled deny floor (
HOST_API_DENIED_PATTERNS): money rails in/out, transfers, gifts, auth/password, account deletion, admin. Never callable. - The remote allowlist
behavior.api_call_allowlist(path prefixes) defaults to[]: the whole action kind ships dark until the dashboard arms it. - Mutations always confirm: any non-GET renders a native confirm sheet (server-composed title/body/line items, config fallbacks) and runs only on a fresh user tap. Declining also drops the rest of that reply's actions. No sheet mounted = fail closed.
- Outcomes are narrated (success/failure toasts) and reported through the
host_api_calltelemetry event (extend the ingest vocabulary server-side before relying on it).
