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 simctlhas 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.71+ and React 18+
- Zustand and React Navigation are optional — they only gate store instrumentation and route tracking respectively. Everything else works without them.
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 (~/.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
npm install -g squiggle-clibunx squiggle-cli --versionShell completions
Generated from the command tree rather than written, so they cannot drift from the binary that dispatches them:
squiggle completions zsh --install2 · 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.
bun add @squiggle/react-nativeThe one import that has to be first
@squiggle/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 — the FIRST line of the bundle, before anything elseimport '@squiggle/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:
{ "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:
import { Squiggle } from '@squiggle/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
Optional, and the difference between a component name and a clickable source location:
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/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 dev agent, as an Expo config plugin
The in-process agent is what makes Squiggle fast — AX reads, in-process taps and typing, the animation-idle probe. Add @squiggle/host-inject as a devDependency and list it in your Expo config; there is no hand-authored native code to write:
// app.json{ "expo": { "plugins": [ "expo-router", "@squiggle/host-inject" ] }}On every expo prebuild the plugin adds a #if DEBUG loader to your AppDelegate that dlopens the bundled dylib on launch, plus a build phase that rebuilds and copies it into the app. It survives prebuild --clean and pod install, so there is nothing to re-add by hand.
Three independent guards keep it out of anything shippable: the #if DEBUG is compiled out of Release, and the build phase checks both CONFIGURATION = Debug and PLATFORM_NAME = iphonesimulator before it stages the dylib at all.
Metro, in a monorepo
Only if your app lives in a workspace. Squiggle's packages export raw TypeScript with no build step, so Metro has to watch the workspace root and resolve from both node_modules trees:
// 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 Expo plugin changes the native project, so neither reaches a running app through Fast Refresh. Regenerate and rebuild once:
npx expo prebuild # regenerates ios/ with the agent build phasenpx expo run:ios # compiles the dev client and installs itOptional: 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:
import { agentHint, defineAction, identify, tag } from '@squiggle/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
taginherits the live interaction chain, so it enriches the tap it happened inside. StampappVersionin the traits and the trend charts get release markers for free. agentHint({ destructive: true })- A declaration an agent cannot misread. It rides
accessibilityHintbehind asquiggle: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.
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:
squiggle activate --trialsquiggle activatesquiggle licenseThe 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.
- 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:
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 launchinjects 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:
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.yamlThe 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:
squiggle # a grouped command picker, on a terminalsquiggle --help # 28 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 documentProject 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 … | jqsafe. 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:
squiggle explain E_NOT_INJECTEDsquiggle explain 5The CLI documents all sixty-three 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 75 tools and, with the kit, the methodology for using them.
Register the server
claude mcp add squiggle -- squiggle mcpOr commit it, so everyone on the project gets the same server without a setup step:
// .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
squiggle agent kit --installThis 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. squiggle_status reports dylib freshness and a map of what is unavailable and why — an agent should read it before concluding a tool is broken.
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.