SDK reference

For AI agents: this page is Markdown at https://squiggle.sh/docs/reference/sdk.md. Every docs page is listed at https://squiggle.sh/llms.txt, and all of them are one file at https://squiggle.sh/llms-full.txt.

Everything your app can import from @squiggle.sh/react-native. Only the import is required; the rest tells Squiggle things it can't work out on its own.

The import

Terminal
import '@squiggle.sh/react-native';

It must be the first line of your entry file, before anything loads React. squiggle init puts it there. See Set up your app.

withSquiggle

From @squiggle.sh/react-native/metro. It wraps your Metro config so that a development build times each module your app evaluates while it starts. The report card uses those times to name the screens your app loads at startup though it doesn’t open on them. It adds nothing to a production bundle. squiggle init adds it.

metro.config.js
const { getDefaultConfig } = require('expo/metro-config');const { withSquiggle } = require('@squiggle.sh/react-native/metro');const config = getDefaultConfig(__dirname);module.exports = withSquiggle(config);

Wrap the config your file exports last. It changes the config you pass and returns it, so it works inside or outside other with… wrappers. After you add it, restart Metro with --clear. Until the bundle is rebuilt, the report card’s startup row stays pending.

<Squiggle>

Wrap your root component in it. It tracks the current screen, feeds the heatmap every touch, and adds the on-device overlay in development builds.

app/_layout.tsx
import { Squiggle } from '@squiggle.sh/react-native';import { Stack, useNavigationContainerRef } from 'expo-router';export default function RootLayout() {  return (    <Squiggle navigationRef={useNavigationContainerRef()}>      <Stack />    </Squiggle>  );}
PropWhat it does
navigationRefYour navigation container ref: useNavigationContainerRef() with Expo Router, or your NavigationContainer ref with React Navigation. Without it, Squiggle can’t tell which screen is showing.
overlayShow the on-device overlay. Default true. It only ever draws in a development build.
touchCaptureSend every touch to the heatmap. Default true.
touchDetectorsCheck every touch for rage taps and dead zones, not only touches on gesture-handler controls. A touch counts as a tap only if it lifts where it went down, so scrolls never count. Default true.

Keep GestureHandlerRootView at your root as usual, with <Squiggle> inside it.

defineAction

Names something a user does. Each call is timed until it finishes, and recorded as succeeded or failed. Named actions show up in feature usage and funnels, and an agent can call one by name.

TypeScript
import { defineAction } from '@squiggle.sh/react-native';const addToCart = defineAction('add_to_cart', (product: Product) =>  cart.add(product));

Call addToCart(product) wherever you called the original. Errors are passed through unchanged.

identify and tag

identify sets who is using the app, for the users and retention views. tag adds key-value data to whatever the user is doing right now. Add appVersion (or release) to the traits to get release markers on the trend charts and to compare releases with squiggle product state --drift.

TypeScript
import { identify, tag } from '@squiggle.sh/react-native';identify('user_8f2a', { plan: 'pro', appVersion: '1.4.2' });tag({ experiment: 'checkout-v2', variant: 'B' });

Traits and tags must be plain data under 8 KB. Larger ones are dropped, with a warning in development.

agentHint

Says what a control is, in props it spreads onto the component. The most useful one is destructive: an agent can’t press a destructive control without asking you first.

TypeScript
import { agentHint } from '@squiggle.sh/react-native';<Pressable {...agentHint({ id: 'delete-account', destructive: true })}>  <Text>Delete account</Text></Pressable>;
FieldWhat it does
idBecomes the testID, so flows can find the control by it.
rolebutton, link, tab, switch, checkbox, radio or menuitem, when the component doesn’t have one.
labelThe accessibility label.
destructiveThe control logs out, deletes or pays. Agents need your approval to press it.
flowThe journey the control belongs to, such as checkout. Groups controls by purpose.

logEvent

Writes one structured event. A flow can then assert on a field of the event, which keeps working when the wording of a log message changes.

TypeScript
import { logEvent } from '@squiggle.sh/react-native';logEvent('order.submitted', { orderId: 'ord_2291', total: 48.5 });

For fields on every event, or your own output, use createRunLogger and consoleJsonSink from the same package.

Save and restore your app’s state

squiggle save checkout-3-items records the screen your app is on and the data behind it. squiggle load checkout-3-items puts both back, in under a second, instead of tapping through screens to rebuild them. In a debug build this needs no code: Squiggle captures the navigation state, every Zustand and Legend State store, and every MMKV instance. A release build is captured only when it was built with EXPO_PUBLIC_SQUIGGLE_STATE_ANALYTICS=1 and its user hasn’t withdrawn consent. In Studio, the device panel’s Capture menu saves and loads the same slots; see Drive the simulator.

  • A save holds plain data. Functions, class instances, Date values and cycles are left out, and squiggle save lists each one it skipped.
  • State that lives inside a component, such as useState, a scroll position or a running timer, is not saved.
  • MMKV data is copied on the simulator itself, so it loads only on the simulator that took it. Loading the save on another simulator, or after you reset the app’s data, restores the screen and the stores, leaves every MMKV instance as it was, and reports the load as failed, naming the MMKV instance. Saving MMKV needs react-native-mmkv 4 or later (createMMKV). An encrypted MMKV instance is never copied, and squiggle save lists it as left out.
  • A save is checked against your app before any of it is restored. If the app has changed since, such as a store with a renamed or added top-level key, a store that no longer exists, or a screen that was removed, squiggle load refuses the whole save, names what changed, and leaves your app as it was. Save again.
  • Stores are saved under their names (see Store names). A save that names a store by its creation order, such as store:zustand-2, is refused once the Babel plugin names that store. Save again.

Saves live in .squiggle/agent/fixtures/ in your project and never leave your machine.

Store names

Saves and state samples name each store. With the Babel plugin, a store declared at the top level of a module is named after its variable: export const useCart = create(…) is zustand:useCart, and a Legend State store is legend: and its variable. The name stays the same from release to release and across a Fast Refresh.

  • A store the plugin can’t name, such as one created inside a component or in a build without the plugin, is named by the order it was created in, such as store:zustand-3. That name moves when you add a store.
  • Two stores declared under the same variable name in different files share it: the second one created is useCart~2.
  • After you upgrade Squiggle, restart Metro with --clear. Until the bundle is rebuilt, stores keep their store:zustand-3 names.

Turn on state samples

State samples feed squiggle product state, --where and friction --state. Your app sends them only when you build it with EXPO_PUBLIC_SQUIGGLE_STATE_ANALYTICS=1 (or true), in a debug build as much as a release one. Saves don’t need it in a debug build.

The value is read when the bundle is built. After you change it, clear the bundler’s cache for the next release build, for example npx expo export --clear: without it, the build can keep the value it was built with before.

With the variable set, also tell Squiggle what each of your users chose. The default is allowed, and Squiggle stops capturing the moment you pass false:

TypeScript
import { setStateAnalyticsConsent } from '@squiggle.sh/react-native';// Call it wherever your app records the user's analytics choice.setStateAnalyticsConsent(settings.analyticsAllowed);
ConsentWhat it means
offThe app was built without the variable. Nothing is sampled, whatever you pass.
grantedSamples are sent. The default with the variable set.
revokedYou passed false. Nothing is sampled until you pass true, and a release build stops capturing saves too.

setStateAnalyticsConsent returns the consent now in force, getStateAnalyticsConsent() reads it, and onStateAnalyticsConsent(listener) calls you when it changes and returns a function that stops it.

With consent, Squiggle sends a sample of your app’s state when the screen changes, and half a second after your stores stop changing, or 2 seconds after they started if they never stop. It sends at most two a second, and only when something changed. A sample holds shapes and counts, never values: the current route, how many items an array holds, how many keys an object has, whether a boolean is true, a number’s sign and order of magnitude, and a string’s length rounded down to a power of two. It reads two levels into each store.

  • A field whose name looks like a secret, such as password, accessToken, sessionId or pin, is left out entirely.
  • An object keyed by data, such as emails or ids, is sent as its number of keys only.
  • MMKV is sent as each instance’s number of keys.

Each value becomes a path, such as zustand:useCart.items#len. See State paths for every kind.

To send the values of a field such as a theme or a plan tier, list it by its store name. Each call replaces the list:

TypeScript
import { setStateAnalyticsAllow } from '@squiggle.sh/react-native';// export const useSettings = create(() => ({ theme: 'dark', plan: 'pro' }));setStateAnalyticsAllow(['zustand:useSettings.theme', 'zustand:useSettings.plan']);

A listed field stops sending values for the rest of the session once it has shown more than 16 different ones, and a value longer than 64 characters is never sent; in both cases its length is sent instead.

registerFixtureSource

Adds a store Squiggle can’t see on its own to every save, or replaces how one is saved: a source you register wins over Squiggle’s own source of the same name. It returns a function that unregisters the source.

TypeScript
import { registerFixtureSource } from '@squiggle.sh/react-native';registerFixtureSource('cart', {  capture: () => cartStore.getState().items,  restore: (items) => cartStore.setState({ items }),  misfit: (items) =>    Array.isArray(items) ? undefined : 'the cart is no longer a list',});

misfit is optional. Return a reason when a save no longer fits your store, for example one taken before you changed its shape, and undefined when it does. Squiggle asks every source before restoring any, so one misfit refuses the whole save. Keep the name stable: a save that names a source your app no longer registers is refused the same way.

FieldWhat it does
captureReturns the store’s data as plain data, or a promise of it. Return undefined to leave this source out of this save.
restorePuts saved data back. Return false or throw to refuse it; the load reports that source as failed.
misfitOptional. Why saved data no longer fits, or undefined when it does.
skippedOptional. The paths the last capture left out. squiggle save lists each one as name:path.

defineTunable

Names a number an agent may change while the app runs, such as a page size or a debounce, and the range it may take. An agent lists and sets it with the tune tool, so a value can be chosen by measuring it instead of by feel. useTunable reads it in a component and re-renders when it changes; get() reads it anywhere else.

TypeScript
import { defineTunable, useTunable } from '@squiggle.sh/react-native';const pageSize = defineTunable('feed.pageSize', {  default: 20,  min: 5,  max: 100,  step: 5,  description: 'rows fetched per page',});function Feed() {  const n = useTunable(pageSize);  // …}

defineTunable throws when step isn’t positive, when min is above max, or when the default is outside the range or off the grid. The app refuses any name it didn’t declare, and any value that isn’t a finite number, is outside the range or is off the step grid, and the agent sees why, such as tune: 'feed.pageSize': 7 is not on the step-5 grid from 5. A value an agent sets is dropped on reload. A Fast Refresh that declares the tunable again keeps the value only while the new range allows it. Nothing else in your app can be changed this way; see Safety and approvals.

tokens lists the values your design system already uses for the number, such as your spacing scale. A layout repair (--repair) moves each value it chooses onto the nearest token that still works, and flags one it can’t. Each token must be a value the tunable allows, or defineTunable throws.

Environment variable

VariableWhat it does
EXPO_PUBLIC_SQUIGGLE_WS_URL=ws://localhost:<port>Where the app connects to Studio or the CLI. Unset, an iOS simulator app connects to ws://localhost:7777. Set it when you move the port (squiggle uses the same port from port in config.jsonc or SQUIGGLE_PORT), or to your Mac’s address for a physical device.
EXPO_PUBLIC_SQUIGGLE_WS_URL=offStop the app connecting to Studio or the CLI on its own, so the on-device overlay can run. Studio and squiggle flow still connect when they launch the app.
EXPO_PUBLIC_SQUIGGLE_STATE_ANALYTICS=1Send state samples, in any build, subject to each user's choice, and allow saves in a release build. Debug builds capture state for saves either way.

Libraries Squiggle instruments

If your app uses one of these, Squiggle records it automatically. You never need to install one for Squiggle.

LibraryWhat Squiggle records
zustand (4+)Store changes.
@react-navigation/nativeThe current screen.
@tanstack/react-query (5+)Query state.
react-native-gesture-handler (2+)Gestures. A pan, pinch or rotation also keeps its touch path (up to 32 points), and a pan says what it looked like: a swipe to dismiss, a pull to refresh, a drag to reorder or a scrub.
react-native-worklets (0.8+)Gestures that run on the UI thread, with their touch path kept and scored there rather than sent to JavaScript on every move.
react-native-mmkv (2+)Reads and writes. Needs the Babel plugin. Saves need 4+.
@react-native-async-storage/async-storageReads and writes. Needs the Babel plugin.
op-sqlite, expo-sqliteQueries. Needs the Babel plugin.
@legendapp/state (3+)Observable reads and writes. Needs the Babel plugin.

Probes in a development build

A development build also runs probes: rules you write in squiggle probe when that the app checks while you use it, with no rebuild. There is nothing to set up, and a release build never runs one. See Watch for a condition inside the app.