Baguette
Drive iOS simulators programmatically via the `baguette` CLI — taps, swipes, multi-finger gestures, hardware buttons (Home / Lock / Volume / Action / Power), ASCII keyboard text, and frame capture, all without opening Xcode. Use when: (1) an agent needs to drive a booted iOS simulator from a script — tap a coordinate, swipe, type text; (2) building a smoke test, demo, or end-to-end UI flow on a simulator; (3) pairing iOS dev with Claude Code to verify on-screen state after a code change; (4) the user asks to "automate iPhone gestures", "control iOS sim programmatically", or "drive simulator without Xcode"; (5) the user names `baguette`, `baguette input`, `baguette tap`, `baguette serve`, or `baguette stream`; (6) a SwiftUI verification needs to touch the running app, not just inspect static code. Avoid for plain "open the iOS Simulator" / "install Xcode" questions — those are about Xcode itself, not driving a sim.From its SKILL.md
npx -y skills add tddworks/baguette --skill baguetteAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
19.7 KB, as published. Nobody here has run it
baguette — programmatic iOS simulator control
baguette is a macOS CLI that drives iOS simulators directly via Apple's
private SimulatorHID (the same path Xcode uses internally). It works on
iOS 26.4 + Xcode 26 + Apple Silicon and is faster + more reliable than
idb / AXe / simctl io for input.
This skill is for agents that need to interact with a running simulator
(taps, swipes, screenshots, gesture sequences). Humans wanting a "play the
simulator in a browser" UI should be pointed at baguette serve and
http://localhost:8421/simulators/<udid> — but agents drive the CLI.
The agent's happy path
Most automation jobs follow the same shape:
# 1. Find a booted device.
baguette list # human-readable
baguette list --json # machine-readable: {running, available}
# 2. Boot one if nothing is running.
baguette boot --udid <UDID>
# 3. Get the screen size — you need this for every gesture.
baguette chrome layout --udid <UDID> # → {composite:{width,height}, screen:{width,height}, ...}
# 4. Drive it.
baguette tap --udid <UDID> --x 219 --y 478 --width 438 --height 954
# 5. Verify what happened (capture one JPEG of the framebuffer).
baguette screenshot --udid <UDID> --output /tmp/frame.jpg
Steps 3–4 are the part that bites — see "The coordinate footgun" below.
The coordinate footgun (read this)
All x / y / startX / endX / x1 / x2 / cx / cy are in
device points — same units as the width / height you pass alongside.
A "tap at the centre of an iPhone 17 Pro Max" is x:219, y:478 (half of
438×954). It is not x:0.5, y:0.5 (normalized). It is not
x:1206, y:2622 (raw pixels). The HID adapter normalises internally.
To get the right width / height for a UDID:
baguette chrome layout --udid <UDID> | jq '.screen | {width, height}'
# → {"width": 438, "height": 954}
Always use the values from chrome layout — different devices have
different point sizes, and hardcoding "438×954" only works for iPhone 17
Pro Max.
One-shot vs streaming gestures
Two ways to send input. Pick by frequency:
-
One-shot (
baguette tap / swipe / pinch / pan / press) — separate process per gesture. Right for a handful of distinct interactions in a shell script. Each invocation pays the SimulatorHID setup cost (~50–100ms). -
Streaming (
baguette input --udid <UDID>) — long-running process reading newline-delimited JSON from stdin, writing{"ok":true}/{"ok":false,"error":…}to stdout per line. Right for sequences of many gestures (drags, multi-finger choreography, demo playback) where per-gesture latency matters. Same wire format the WebSocket uses.
# One-shot.
baguette tap --udid X --x 219 --y 478 --width 438 --height 954
# Streaming (open the pipe once, send many).
( echo '{"type":"tap","x":219,"y":478,"width":438,"height":954,"duration":0.05}'
echo '{"type":"swipe","startX":219,"startY":760,"endX":219,"endY":190,"width":438,"height":954,"duration":0.3}'
) | baguette input --udid X
For the full wire-format spec (every gesture type with examples), read
references/wire-protocol.md.
Visual verification — let the agent see what happened
After driving a UI flow, the agent usually needs to confirm state.
The right tool is baguette screenshot — a one-shot JPEG of the
simulator's framebuffer with no streaming session involved:
baguette screenshot --udid <UDID> --output /tmp/frame.jpg
baguette screenshot --udid <UDID> > /tmp/frame.jpg # stdout works too
baguette screenshot --udid <UDID> --quality 0.6 --scale 2 > thumb.jpg
Defaults: --quality 0.85, --scale 1 (native). --scale 2 halves
each dimension; useful when you only need a quick visual check.
Equivalent HTTP route during baguette serve:
GET http://localhost:8421/simulators/<UDID>/screenshot.jpg[?quality=][?scale=].
Important: SimulatorKit only emits a frame when something on screen
changes. A booted-but-idle simulator (lock screen with no second hand)
may not produce one within the 2 s timeout — baguette screenshot
exits non-zero and prints Failure.timeout. Wake the device with a
gesture first if you're capturing a static state:
baguette tap --udid <UDID> --x 1 --y 1 --width "$W" --height "$H" # nudge
sleep 0.2
baguette screenshot --udid <UDID> --output /tmp/frame.jpg
Then Read /tmp/frame.jpg to inspect (Claude Code's Read tool handles
images).
For a snapshot while a baguette serve WebSocket is already open,
send {"type":"snapshot"} on that channel — the server emits a
keyframe immediately. Use this only when the WS is already live; for
fresh captures baguette screenshot is one HTTP-free command.
For a presentation image on a 3D device model, use render-3d:
baguette render-3d --udid <UDID> \
--variant finish=deep-blue --rotation=-8,18,0 \
--size 1200x1200 --output /tmp/device.png
An existing image can be rendered with
--screen <image> --device <model-id>. The HTTP equivalents are
GET /simulators/<UDID>/3d-model.json for public model/variant metadata and
POST /simulators/<UDID>/render-3d.png for the PNG. This is a one-shot
presentation surface; gestures still target the live 2D stream.
What's wired vs what isn't
Wired (use freely):
tap,swipe,touch1-{down,move,up},touch2-{down,move,up},pinch,pan,scroll.touch1-*events accept an optionaledge: "bottom" | "top" | "left" | "right"field that flags every event in the chain as a screen-edge system gesture;bottomengages iOS's home-indicator recognizer (live home / app-switcher preview as the touches stream);topengages the status-bar recognizer (live lock-screen cover sheet from a top-left drag, Notification Center from a top-right drag). Omitedgefor ordinary interior touches.button:home,lock,power,volume-up,volume-down,action,app-switcher,swipe-to-app-switcher,swipe-to-home,pull-down-to-lock-screen,pull-down-to-notification-center. Optional--duration/"duration"for long-press semantics (action button "Hold for Ring", power → Siri / SOS, …). The five virtual buttons land iOS gesture recognition without any client-side stream management.app-switcherfires two home presses ~150 ms apart (SpringBoard's own multitasking recipe);swipe-to-app-switcheris the slow drag-and-hold variant on the gesture path;swipe-to-homeis the fast edge-flick → Home;pull-down-to-lock-screenandpull-down-to-notification-centerdrag down from top-left and top-right respectively.key(single keystroke) andtype(US-ASCII string). CLI:baguette key --code KeyA --modifiers shift,command --duration 0.2andbaguette type --text "hello".codeis a W3CKeyboardEvent.code; modifiers areshift | control | option | command.paste— arbitrary unicode into the focused field via the sim's pasteboard + Cmd+V (the path aroundtype's US-ASCII limit; not a HID-only path — shells out toxcrun simctl pbcopy). Wire:{"type":"paste","text":"…","press":false?}on the stream WS (repliespaste_result) andinputstdin. CLI:baguette paste --udid <X> --text "…" [--no-press]; plusbaguette clipboard get(print the sim's pasteboard raw) andbaguette clipboard sync(host Mac pasteboard → sim, full-fidelity — images included). Needs a booted device. Seedocs/features/paste.md.copy— the sim→host interactive mirror ofpaste: press Cmd+C sim-side (focused field copies its selection), then ferry the pasteboard onto the host Mac's clipboard, full-fidelity — images included (xcrun simctl pbsync <udid> host). Wire:{"type":"copy","press":false?}on the stream WS (repliescopy_result) andinputstdin —press:falseskips the keystroke for a pure ferry. Browser Cmd+C / Ctrl+C while the screen has focus sends it. CLI:baguette clipboard copy --udid <X>is a pure ferry (no keystroke). Browser copy targets the machine running baguette (local-dev happy path). Needs a booted device. Seedocs/features/paste.md.describe-ui— dump the on-screen accessibility tree as JSON (per-noderole,label,value,identifier,framein device points, recursivechildren). CLI:baguette describe-ui --udid <X>(full tree) orbaguette describe-ui --udid <X> --x <px> --y <px>(hit-test). Frames are in the same units astap/swipewire fields, so readingframe.x + frame.width/2,frame.y + frame.height/2back into atapenvelope just works.interface— the accessibility-display family: light / dark appearance, Increase Contrast, and content size (Dynamic Type, including the five accessibility sizes). CLI:baguette interface appearance|contrast|text-size --udid <X> [<value>]— no value reads, a value sets. HTTP:GET /simulators/<X>/interface.jsonandPOST /simulators/<X>/interface(any subset, answers the resulting state). Backed byxcrun simctl ui(not a HID path). A read on a device that isn't booted answersunknownand exits 0 — a state to check for, not a failure;unsupportedmeans the runtime lacks the setting. Neither can be set. Pairs withdescribe-ui: change the conditions, re-dump the tree, compare. Seedocs/features/interface.md.logs— stream the booted simulator's unified log line-by-line to stdout. CLI:baguette logs --udid <X> [--level info|debug|default] [--style default|compact|json|ndjson|syslog] [--predicate ...] [--bundle-id <id>]. SIGINT (Ctrl-C) tears down cleanly. WS variant onWS /simulators/<X>/logs?level=&style=&predicate=&bundleId=emits{"type":"log","line":"..."}text frames per entry. Levels: onlydefault | info | debug(iOS-runtime narrow — hostnotice / error / faultare rejected at the wire).camera— pipe a camera source (a live Mac webcam, an uploaded still image, or a looping uploaded video) into the iOS app'sAVCaptureVideoPreviewLayer/AVCapturePhotoOutput/UIImagePickerController. No CLI; use the WS atWS /simulators/<UDID>/camera—camera_list/camera_start(withsource: webcam | image | video) /camera_stop/camera_set_flagsupstream,camera_devices/camera_statedownstream (phase =idle | streaming, plus livefpsand activesource). Image/video files upload first viaPOST /simulators/<UDID>/camera-source?name=<file>. Frames flow through/tmp/SimCam.bgra(24-byte LE header + BGRA pixels) intoVirtualCamera.dylibloaded inside the simulator viaDYLD_INSERT_LIBRARIES. Apps launched before arming don't load the dylib — relaunch them. Browser UI lives under the Camera card on/simulators/<UDID>.install/add-media— add a file to the device.baguette install --udid <X> <path>installs an.ipa/.app;baguette add-media --udid <X> <path>adds an image / video (png jpg jpeg gif heic heif mov mp4 m4v) to Photos. Both shell out toxcrun simctl install/addmedia(not a HID path).serveexposes one entry point —POST /simulators/<X>/files?name=<filename>with the raw bytes as the body — and routes by extension (app → install,.zipcarrying one top-level.app→ extract viaditto -x -k+ install, media → Photos); a file with no home on a simulator returns415. The browser focus page accepts drag-and-drop onto the device, including a bare.appdirectory — it's packed into a stored zip in-page and posted as<Name>.app.zip. Seedocs/features/file-upload.md.location— set the device's simulated GPS position (not a HID path; shells out toxcrun simctl location).baguette location set --udid <X> <lat,lon>pins a point;baguette location start --udid <X> [--speed <m/s>] [--distance <m>] [--interval <s>] <lat,lon> <lat,lon>…runs a moving route;baguette location walk --udid <X> --bearing <deg> --speed <m/s> <lat,lon>heads off along a compass bearing (drivingCLLocation.course);baguette location clear --udid <X>restores live location. Position/waypoints arelat,lontokens (e.g.37.3318,-122.0312); a token whose latitude starts with-must follow a--separator.serve:POST /simulators/<X>/locationwith a{latitude,longitude}point,{waypoints:[…],speed?}route, or{latitude,longitude,bearing,speed}walk body, andDELETEto clear. Out-of-range, <2-waypoint, or speed-less-walk bodies return400. Browser focus page has a Location card (map-pin toolbar button) with a Leaflet map and a Walk joystick — the stick steers absolute, whileW/Sdrive along the current heading andA/Dturn it (tank controls); Replay retraces the walked trail as a route. Two iOS-26 limits:CLHeading(compass) is unavailable in the simulator entirely (headingAvailable() == false), andcourseis derived on a flat lat/lon grid so diagonal bearings skew by1/cos(latitude)(~6.5° at lat 37; cardinals are exact). Seedocs/features/location.md.motion— make the device's apps read CoreMotion:CMMotionActivity(walking / running / cycling / automotive),CMPedometercounters, andCMMotionManagersamples. Not a simctl path — all three are unavailable in a stock simulator, so baguette injectsVirtualMotion.dylib.baguette motion start --udid <X> [--activity <kind>] [--speed <m/s>]arms it (plainstart= walking);baguette motion set --udid <X> --activity <kind>changes it;baguette motion stop --udid <X>parks it stationary and disarms.serve:POST /simulators/<X>/motionwith{"activity":"running"}or just{"speed":6}(classified server-side),GETto read back{ok,active,activity,steps,metres,speed}— an inactive device answers{"ok":true,"active":false}and nothing more —DELETEto stop. An unknown udid is404on every one of them. Browser: a Drive motion sensors toggle on the Location card — once on, the walk joystick and route speeds already being posted drive the activity. The one thing that surprises people: only apps launched after arming see anything (dyld inserts at exec time) — relaunch withxcrun simctl launch --terminate-running-process <X> <bundle-id>. Floor counting and the magnetometer are deliberately still unavailable. Seedocs/features/motion.md.network— condition what the device's apps see of the network: latency, downlink bandwidth, request loss, hard offline. Not a simctl path — Network Link Conditioner and thednctl/pfctlrules under it are system-wide, so baguette injectsVirtualNetwork.dylibto scope it to one simulator.baguette network set --udid <X> --profile 3g(or--latency <ms> --bandwidth <kbps> --loss <percent>, or--offline) — exactly one of those three per invocation;network clear --udid <X>stops it, including for apps already running; plainbaguette network --udid <X>reports what's applied. Presets are NLC's:wifi | dsl | lte | 3g | edge | very-bad-network | 100-loss.serve:POST /simulators/<X>/networkwith{"profile":"3g"}/{"latencyMs":300,"bandwidthKbps":400,"lossPercent":5}/{"offline":true},GETto read back,DELETEto clear. Browser: a Network card, with an amber toolbar dot whenever conditioning is on. Same relaunch rule as motion — only apps launched afternetwork setare conditioned; changing it afterwards needs no relaunch. Three limits to state rather than discover: only URLSession-shaped traffic is conditioned —URLSessionWebSocketTaskis (latency, loss, offline; not bandwidth), but an SDK that opens its own socket is not, so Ably's ably-cocoa and Starscream are unreached and--offlinewill not feel offline to them;WKWebView/ Safari page loads are not conditioned, since WebKit loads them in its own networking process, so a hybrid app is throttled natively but not in its web content; and loss is request-level rather than packet-level. Seedocs/features/network.md.
NOT wired (skill should NOT propose these):
- Non-ASCII text through
type— IME / Pinyin / accented / emoji isn't on the host-HID keystroke path. Usebaguette paste --udid <X> --text "…"(or thepastewire verb) for those strings — it rides the pasteboard instead of keystrokes. - F-keys, Page Up/Down, Home/End through
key— outside the phase-1 supported code set. Most iOS apps don't use them anyway. button: "siri"— crashesbackboarddvia every known path. Refused by the CLI.
Composing flows — the smoke-test pattern
#!/usr/bin/env bash
set -euo pipefail
UDID="$1"
# Resolve screen size once; reuse for every gesture.
read W H < <(baguette chrome layout --udid "$UDID" \
| jq -r '.screen | "\(.width) \(.height)"')
# Wake / unlock.
baguette press --udid "$UDID" --button lock # toggle (sleep if awake)
sleep 0.5
baguette press --udid "$UDID" --button lock # back on
# Home → tap Settings.
baguette press --udid "$UDID" --button home
sleep 0.4
baguette tap --udid "$UDID" --x $((W * 75 / 100)) --y $((H * 55 / 100)) \
--width "$W" --height "$H"
# Capture proof.
baguette stream --udid "$UDID" --format mjpeg --fps 1 \
| head -c 200000 > /tmp/settings.jpg
Note width/height reuse: every gesture pays the same coordinate
convention, so resolving once and re-passing avoids the footgun.
Pairing with Claude Code
The natural loop when an agent edits a SwiftUI app:
- Edit code → ⌘B in Xcode (or
xcodebuild) → app reloads on the sim. - Agent uses
baguette press --button homethenbaguette tap …to navigate to the screen it just changed. - Agent captures a frame (above),
Reads the JPEG, and confirms the pixels match intent.
If the human wants to follow along visually, also point them at
http://localhost:8421/simulators/<udid> (after starting baguette serve)
— that's a focused single-tab view of the sim, no Xcode window juggling.
Reference files
references/wire-protocol.md— every gesture type with copy-pasteable JSON examples + the coordinate convention restated.references/cli.md— full subcommand list, flags, and exit/output format for eachbaguettecommand.
Read these on demand — don't pull both into context unless the task
actually needs the breadth (e.g., authoring a long input pipeline →
read wire-protocol.md; debugging which subcommand to use → read
cli.md).
Install (only when missing)
brew install baguette
baguette --version
Requires Xcode 26 + Apple Silicon. If baguette already works, skip
this — agents shouldn't reinstall on every invocation.