Getting started

Twenty minutes end to end, most of it a native rebuild you start and walk away from. The order matters: the SDK has to be in the app build before any other surface can see anything.

Before you start

Squiggle drives a real iOS simulator from a native host process, so the requirements are hard rather than nominal:

An Apple Silicon Mac on macOS 15 or later
The host injects into simulator processes and drives them over real HID. That is Apple-Silicon-only by construction, not a packaging choice we can relax.
Xcode
You are driving the iOS simulator, so you already have it — but it is a requirement, not a suggestion. xcrun simctl has to work.
A React Native app you can rebuild
The SDK ships native code, so this has to be an Expo development build (or a bare RN app you compile yourself). Expo Go cannot load it.
React Native 0.76+ and React 18.3+
The only two required peers, and what the SDK's peer ranges enforce.
Nothing else — six libraries are optional

Each one gates the instrumentation it is for, and nothing else. You never install one for Squiggle: if your app already uses it, the adapter turns on; if not, it no-ops and the rest of the graph is unaffected. They are declared as optional peers, so your package manager neither installs them nor warns about them.

  • zustand (≥4) — store mutations
  • @react-navigation/native — route and screen tracking
  • react-native-mmkv (≥2) — storage read/write spans
  • @react-native-async-storage/async-storage — storage read/write spans
  • @tanstack/react-query (≥5) — query state
  • react-native-gesture-handler (≥2) — gestures

1 · Install the tooling

The desktop app and the CLI are the same build: the app bundles the squiggle binary inside Squiggle.app, so installing the app installs both. Pick whichever door suits you — they share one licence and one project store.

The desktop app

Download the DMG from Download and drag it to Applications. First launch asks for nothing; the Studio, the Runtime Inspector, the Performance Center and the Agent Center are all there before you have a project.

Install CLI in PATH…

If you have the app, this is the shortest route to the terminal. Open Settings (⌘ ,) and press Install beside Command line. It symlinks the bundled binary into ~/.local/bin/squiggle — no npm, no Homebrew tap, no curl-to-shell.

A symlink rather than a copy, deliberately: an app update moves the CLI with it instead of leaving a stale binary answering --version with last month's. And it targets ~/.local/bin rather than /usr/local/bin, because the latter is root-owned and writing there from a GUI app means an authorization prompt and a privileged helper — a great deal of security-relevant machinery to save you one line in a shell profile.

npm, if you only want the terminal

Terminal
npm install -g @squiggle.sh/clibunx @squiggle.sh/cli --version

Shell completions

Generated from the command tree rather than written, so they cannot drift from the binary that dispatches them:

Terminal
squiggle completions zsh --install

2 · Add the SDK to your app

This is the part that actually matters. Everything else in Squiggle is a view over the graph your app produces, and nothing produces it until the SDK is in the bundle.

Terminal
bun add @squiggle.sh/react-native

This also pulls in react-native-nitro-modules at 0.35.x — a required peer your package manager installs automatically. The SDK's native accelerator ships as a prebuilt binary ABI-matched to that minor, and an iOS build without the package fails at pod install. Yarn 1 is the exception that only warns instead of installing: add react-native-nitro-modules@^0.35.9 yourself there. On web and in Expo Go nothing links it and the SDK runs on its pure-JS fallback automatically.

The one import that has to be first

@squiggle.sh/react-native is a side-effecting import. It installs the React commit-hook shim, intercepts Zustand stores and stands up the transport — and it has to run before React loads, or the commits that happen during startup are simply missed.

index.js
// index.js — the FIRST line of the bundle, before anything elseimport '@squiggle.sh/react-native';// …then your normal entry. Expo Router:import 'expo-router/entry';// …or a plain React Native app:// import { registerRootComponent } from 'expo';// import App from './App';// registerRootComponent(App);

Point main at that file rather than at expo-router/entry, which is what most Expo apps ship with:

package.json
{  "main": "index.js"}

That import alone is the whole required setup. No provider, no config file, nothing to register, and idle CPU is approximately zero while no consumer is attached.

Wrap your root in <Squiggle>

Strictly optional, and strongly recommended. It composes route tracking, the app-wide touch probe that feeds the heatmap, and the on-device overlay in the right order, so you wire one thing instead of three:

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>  );}

Every piece self-gates — the overlay draws nothing outside dev, on the web, or when a WebSocket consumer is configured — so it is safe to leave in a production tree. Turn parts off with overlay={false} or touchCapture={false}. Keep GestureHandlerRootView at your root as usual; <Squiggle> sits inside it.

The navigationRef is the one thing that cannot be inferred — React Navigation only exposes it through a hook. Omit it and your components still appear, grouped under (no screen) instead of under the route they rendered in.

The Babel plugin

Strictly optional, but it is the difference between a component name and a clickable source location — and, if you use MMKV v4, between storage spans and none. It is its own package, installed as a devDependency:

Terminal
bun add -d @squiggle.sh/babel-plugin

Then list it as a plugin — and keep babel-preset-expo as the sole preset, because that is what wires up react-native-worklets/plugin for Reanimated:

babel.config.js
module.exports = function (api) {  api.cache(true);  return {    // Keep babel-preset-expo as the SOLE preset: it wires up    // react-native-worklets/plugin, which Reanimated 4 requires.    presets: ['babel-preset-expo'],    plugins: ['@squiggle.sh/babel-plugin'],  };};
Source metadata
Injects { file, line, col } onto component definitions, which is what makes tap-to-source work in the Inspector. React 19 dropped _debugSource, so there is no longer a runtime source for this.
The bundler-agnostic Zustand transform
Rewrites import { create } from 'zustand' so stores are instrumented under any module format. Without it, store instrumentation falls back to a runtime in-place export patch, which throws under read-only ESM exports.
The storage transforms
The same rewrite for the four libraries that hand out a storage handle — react-native-mmkv (createMMKV, useMMKV), AsyncStorage (the default import), op-sqlite (open) and expo-sqlite (openDatabaseAsync, openDatabaseSync, useSQLiteContext) — so every read, write and query emits a span. For these it is the only path there is: each exports its entry point in a form no runtime patch can reassign, whether a re-exported factory (MMKV v4's createMMKV among them) or a read-only default binding. You write plain library code; there is nothing Squiggle-specific in your storage layer.

One gap worth knowing: every react-native-mmkv hook that takes an optional instance reads through the library's own internal default instance when you don't pass one, and nothing on the consumer side can reach it. Pass an instance — one that came from createMMKV() — to close it. The position differs by hook: the value hooks take it second (useMMKVString('k', storage), useMMKVNumber, useMMKVBoolean, useMMKVBuffer, useMMKVObject), while useMMKVKeys(storage) takes it first and useMMKVListener(fn, storage) takes it after the listener. useMMKV() itself is fine either way — the plugin rewrites it, so what it hands back is already instrumented.

The dev agent — nothing to install

The in-process agent is what makes Squiggle fast — AX reads, in-process taps and typing, the animation-idle probe. It ships inside the CLI, not as an npm package: squiggle up relaunches your simulator app with the agent injected and then proves the injection took (it checks the loaded library's Mach-O UUID, so a stale or missing agent is a loud failure, not a slow mystery).

Terminal
squiggle up

It is simulator-only and per-launch by construction — nothing is added to your project, your native code, or anything shippable. Launching your app any other way (Xcode, expo run:ios) simply runs it without the agent; flows still work over real HID input, just slower.

Because per-launch means exactly that, the reliable move is to stop typing it. Put it in the script you already run, so every rebuild ends with an injected app instead of one you have to remember to relaunch:

package.json
{  "scripts": {    "ios": "expo run:ios && squiggle up",    "up": "squiggle up"  }}

A forgotten squiggle up does not fail loudly — the app launches, your flows still pass over real HID, and the only symptoms are a settle-dependent waitFor timing out and roughly an extra two and a half seconds per run. That is a slow mystery to debug and a one-line script to prevent.

Metro, in a monorepo

Only if your app lives in a workspace and hoists dependencies to the root: Metro resolves from the app directory by default, so let it watch the workspace root and both node_modules trees:

metro.config.js
// metro.config.js — ONLY needed in a monorepoconst { getDefaultConfig } = require('expo/metro-config');const path = require('path');const projectRoot = __dirname;const workspaceRoot = path.resolve(projectRoot, '../..');const config = getDefaultConfig(projectRoot);config.watchFolders = [workspaceRoot];config.resolver.nodeModulesPaths = [  path.resolve(projectRoot, 'node_modules'),  path.resolve(workspaceRoot, 'node_modules'),];module.exports = config;

Rebuild, don't reload

The SDK changes what is in the bundle and the native accelerator changes the native project, so neither reaches a running app through Fast Refresh. Regenerate and rebuild once:

Terminal
npx expo prebuild        # regenerates ios/ with the SquiggleNitro podnpx expo run:ios         # compiles the dev client and installs it

Optional: tell Squiggle what it cannot infer

None of this is needed for the graph — the adapters instrument everything automatically. It is for the things only your code knows:

enrichment
import { agentHint, defineAction, identify, tag } from '@squiggle.sh/react-native';// A named user action: timed to settlement, emitted as an `action` event, and// addressable by an agent BY NAME rather than by tapping at coordinates.const addToCart = defineAction('add_to_cart', () => cart.getState().add(product));identify('user-123', { plan: 'pro', appVersion: '1.4.2' });tag({ experiment: 'checkout-v2', variant: 'A' });// A declaration always beats an inference. `destructive` registers the control// with the intent runtime's deny rail the moment it mounts.<Pressable {...agentHint({ id: 'delete-account', destructive: true })} />;
defineAction(name, fn)
Wraps a named user action, timed to settlement. It is the feature-usage and funnel-step signal behind the Product surface — and an agent can target it by name instead of by tapping.
identify(id, traits) · tag(attrs)
Session identity and key-value enrichment. A tag inherits the live interaction chain, so it enriches the tap it happened inside. Stamp appVersion in the traits and the trend charts get release markers for free.
agentHint({ destructive: true })
A declaration an agent cannot misread. It rides accessibilityHint behind a squiggle: prefix — no new prop, no wrapper — and registers the control with the deny rail on mount.
registerFixtureSource(name, { capture, restore })
Lets a program restore app state as a precondition instead of driving eight steps to reach it. Only your app knows where its state lives, which is why this is a registration seam rather than an inference.
logEvent(name, fields)
One structured line per app event, which is what lets a flow assert on a field of an event rather than on the wording of a log message. Nothing to install or configure — createRunLogger and consoleJsonSink are exported from the same package when you want base fields or your own sink. See structured app events.

3 · Activate your licence

Activation writes a signed grant to ~/.squiggle/license.json that the CLI, the MCP server and the desktop app all read. One key, three front doors:

Terminal
squiggle activate --trialsquiggle activatesquiggle license

You never type the key into the command — activate asks for it. Run squiggle activate on its own and it prompts, with the key masked as you paste it. The key itself arrives by email when you buy, and lives in your Polar receipt; activation exchanges it for the grant and then forgets it, keeping four characters so squiggle license has something to show you.

Passing it inline as squiggle activate KEY does work, and warns, because a key given as an argument is written to ~/.zsh_history in plain text — where it outlives the terminal, syncs to backups, and is read by anything that greps your history. For scripts, pipe it in with squiggle activate - or point at a file with --key-file; both keep it off the command line.

The grant is honoured offline until it expires, so a licence check never adds latency to a command and never fails a run that worked a minute ago on a train. squiggle license prints which of the four states you are in — entitled, trial, expired, unlicensed — and the reason.

In CI, use a token rather than an activation: a runner is a new machine on every build, and the grant is machine-bound.

CI
- run: squiggle flow flows/login.yaml --events  env:    # Not machine-bound: a runner is a new machine on every build.    SQUIGGLE_LICENSE_TOKEN: ${{ secrets.SQUIGGLE_LICENSE_TOKEN }}

4 · Prove the plane is live

This is the step people skip, and it is the one that saves the afternoon. doctor checks every link in the chain and names a remedy for anything broken:

Terminal
squiggle doctor
simulator booted
There is a device to drive, and only one claiming the port.
app installed & running
The bundle ID it expects is actually on that device.
agent injected
The app was relaunched through the host with the agent dylib loaded — proven by the dylib's Mach-O UUID, not assumed. A bare simctl launch injects nothing.
host reachable
The Swift helper that owns real HID, the accessibility tree and in-process delivery is answering.

squiggle up is the command that gets you from "the app is installed" to "the app is injected": it relaunches through the host and proves the injection rather than assuming it.

5 · Run your first flow

Rather than writing YAML from a blank file, drive the journey by hand and let the recorder draft it:

Terminal
squiggle record --out login.sqrec# … drive the journey by hand, then stop the recording …squiggle flow --from-recording login.sqrec --out flows/login.yamlsquiggle flow flows/login.yaml

The draft is commented with what it could and could not identify — including a list of taps that nothing durable named, which is a to-do list for testIDs rather than a failure. Writing flows takes it from there.

Setting up the CLI

The binary works the moment it is on your PATH. These are the four things worth doing once, before you have thirty flows and a CI job.

Discovery, without a document that can go stale

Every command is a typed leaf of one tree, and every listing below is rendered from that tree rather than written beside it:

Terminal
squiggle                  # a grouped command picker, on a terminalsquiggle --help           # 30 commands, grouped; globals; exit codessquiggle perf --help      # a subtree's leavessquiggle perf sql --help  # one leaf's arguments, defaults and examplessquiggle --schema-json    # the WHOLE tree as one JSON document

Project profiles

A .squiggle/config.jsonc at your project root holds named environments; --profile <name> selects one on any command. The same directory is where flows, recordings, the perf store, run history and bench history live — it belongs to the project, and is found by walking up from wherever you happen to be, so running from a subdirectory finds the same store.

Two output modes, for two audiences

--json
stdout becomes a machine channel: the payload alone, every human line moved to stderr. That is what makes squiggle perf sql … | jq safe. It is declared only on commands that honour it — advertising it elsewhere would be a lie the help text tells.
--events
One NDJSON line per phase and step, on stderr. Humans get the spinner, CI gets the stream, both from one source.

Branch on integers, not on prose

The exit codes are frozen and published in --schema-json: 0 success, 1 the run under test failed, 2 usage, 3 licence, 4 timeout, 5 device unavailable. Any of them —or an error code like E_NOT_INJECTED — turns into an explanation plus a runnable fix:

Terminal
squiggle explain E_NOT_INJECTEDsquiggle explain 5

The CLI documents all seventy-eight leaves, the output rules and the full exit-code contract.

Setting up MCP for your agent

The MCP server is the same build again — squiggle mcp starts it over stdio. Your agent gets 80 tools and, with the kit, the methodology for using them.

Register the server

Terminal
claude mcp add squiggle -- squiggle mcp

Or commit it, so everyone on the project gets the same server without a setup step — squiggle init --mcp writes exactly this, and merges into an existing .mcp.json rather than replacing it:

.mcp.json
// .mcp.json, at your project root{  "mcpServers": {    "squiggle": { "command": "squiggle", "args": ["mcp"] }  }}

The server resolves .squiggle/ from the project root by walking up from its working directory, so it shares your flows, recordings, perf store and history with the CLI rather than keeping a second copy.

Install the agent kit

Terminal
squiggle agent kit --install

This writes four skills (squiggle-setup, squiggle-drive, squiggle-perf, squiggle-triage), four subagents (qa, perf, a11y, explore) and four /squiggle:* commands into .claude/. Hooks are deliberately not installed — merging them edits settings.json and arms a bench-on-stop that runs a real device replay, so squiggle agent kit without the flag writes the whole twenty-one-file bundle to .squiggle/kit for you to read first.

Clients that are not Claude Code get the same guidance as MCP resources — squiggle://skill/{id} — read on demand over the same connection. A resource costs nothing until it is read.

Run squiggle up before the agent drives

The drive and intent tools assume an app relaunched with the agent injected. An uninjected app does not fail; it silently falls back, and the agent sees a waitFor timeout rather than a missing capability. status reports dylib freshness and a map of what is unavailable and why — an agent should read it before concluding a tool is broken. This is the failure the "ios": "expo run:ios && squiggle up" script above exists to make impossible.

Licensing refuses inside the protocol

An unlicensed server still serves the full catalogue and refuses at call time, rather than exiting. An MCP client that gets no tool list has no way to tell you why; one that gets a tool list and a clear refusal does. Agents & MCP covers the intent runtime, the gates and every tool family.

Where to go next

Writing flows
Tighten that draft up: selectors, every step verb, assertions, and how self-healing decides a moved control is still the same control.
Speed & delivery
Why an injected app is dramatically faster, and the three flags that matter once your suite is longer than one flow.
Devices & Studio
The desktop app — one-click bring-up, driving the simulator from a live canvas, and the overlay modes.
Agents & MCP
Why one compiled program beats one inference per tap, and why the loop gets cheaper the more you run it.