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 optional
edge: "bottom" | "top" | "left" | "right" field that flags every
event in the chain as a screen-edge system gesture; bottom
engages iOS's home-indicator recognizer (live home / app-switcher
preview as the touches stream); top engages the status-bar
recognizer (live lock-screen cover sheet from a top-left drag,
Notification Center from a top-right drag). Omit edge for
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-switcher fires two home
presses ~150 ms apart (SpringBoard's own multitasking recipe);
swipe-to-app-switcher is the slow drag-and-hold variant on
the gesture path; swipe-to-home is the fast edge-flick → Home;
pull-down-to-lock-screen and pull-down-to-notification-center
drag down from top-left and top-right respectively.
key (single keystroke) and type (US-ASCII string). CLI:
baguette key --code KeyA --modifiers shift,command --duration 0.2
and baguette type --text "hello". code is a W3C
KeyboardEvent.code; modifiers are shift | control | option | command.
paste — arbitrary unicode into the focused field via the sim's
pasteboard + Cmd+V (the path around type's US-ASCII limit; not a
HID-only path — shells out to xcrun simctl pbcopy). Wire:
{"type":"paste","text":"…","press":false?} on the stream WS
(replies paste_result) and input stdin. CLI: baguette paste --udid <X> --text "…" [--no-press]; plus baguette clipboard get
(print the sim's pasteboard raw) and baguette clipboard sync
(host Mac pasteboard → sim, full-fidelity — images included).
Needs a booted device. See
docs/features/paste.md.
copy — the sim→host interactive mirror of paste: 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 (replies
copy_result) and input stdin — press:false skips 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.
See docs/features/paste.md.
describe-ui — dump the on-screen accessibility tree as JSON
(per-node role, label, value, identifier, frame in
device points, recursive children). CLI:
baguette describe-ui --udid <X> (full tree) or
baguette describe-ui --udid <X> --x <px> --y <px> (hit-test).
Frames are in the same units as tap / swipe wire fields, so
reading frame.x + frame.width/2, frame.y + frame.height/2
back into a tap envelope 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.json and
POST /simulators/<X>/interface (any subset, answers the resulting
state). Backed by xcrun simctl ui (not a HID path). A read on a
device that isn't booted answers unknown and exits 0 — a state to
check for, not a failure; unsupported means the runtime lacks the
setting. Neither can be set. Pairs with describe-ui: change the
conditions, re-dump the tree, compare. See
docs/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 on WS /simulators/<X>/logs?level=&style=&predicate=&bundleId=
emits {"type":"log","line":"..."} text frames per entry.
Levels: only default | info | debug (iOS-runtime narrow — host
notice / error / fault are 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's
AVCaptureVideoPreviewLayer / AVCapturePhotoOutput /
UIImagePickerController. No CLI; use the WS at
WS /simulators/<UDID>/camera — camera_list / camera_start
(with source: webcam | image | video) / camera_stop /
camera_set_flags upstream, camera_devices / camera_state
downstream (phase = idle | streaming, plus live fps and active
source). Image/video files upload first via
POST /simulators/<UDID>/camera-source?name=<file>. Frames flow
through /tmp/SimCam.bgra
(24-byte LE header + BGRA pixels) into VirtualCamera.dylib
loaded inside the simulator via DYLD_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 to xcrun simctl install /
addmedia (not a HID path). serve exposes one entry point —
POST /simulators/<X>/files?name=<filename> with the raw bytes as the
body — and routes by extension (app → install, .zip carrying one
top-level .app → extract via ditto -x -k + install, media →
Photos); a file with no home on a simulator returns 415. The
browser focus page accepts drag-and-drop onto the device, including
a bare .app directory — it's packed into a stored zip in-page
and posted as <Name>.app.zip. See
docs/features/file-upload.md.
location — set the device's simulated GPS position (not a HID path;
shells out to xcrun 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 (driving
CLLocation.course); baguette location clear --udid <X> restores live
location. Position/waypoints are lat,lon tokens (e.g.
37.3318,-122.0312); a token whose latitude starts with - must
follow a -- separator. serve: POST /simulators/<X>/location with a
{latitude,longitude} point, {waypoints:[…],speed?} route, or
{latitude,longitude,bearing,speed} walk body, and DELETE to clear.
Out-of-range, <2-waypoint, or speed-less-walk bodies return 400.
Browser focus page has a Location card (map-pin toolbar button) with
a Leaflet map and a Walk joystick — the stick steers absolute, while
W/S drive along the current heading and A/D turn 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), and course is
derived on a flat lat/lon grid so diagonal bearings skew by
1/cos(latitude) (~6.5° at lat 37; cardinals are exact). See
docs/features/location.md.
motion — make the device's apps read CoreMotion: CMMotionActivity
(walking / running / cycling / automotive), CMPedometer counters, and
CMMotionManager samples. Not a simctl path — all three are
unavailable in a stock simulator, so baguette injects
VirtualMotion.dylib. baguette motion start --udid <X> [--activity <kind>] [--speed <m/s>] arms it (plain start = walking);
baguette motion set --udid <X> --activity <kind> changes it;
baguette motion stop --udid <X> parks it stationary and disarms.
serve: POST /simulators/<X>/motion with {"activity":"running"} or
just {"speed":6} (classified server-side), GET to read back
{ok,active,activity,steps,metres,speed} — an inactive device answers
{"ok":true,"active":false} and nothing more — DELETE to stop. An
unknown udid is 404 on 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 with
xcrun simctl launch --terminate-running-process <X> <bundle-id>.
Floor counting and the magnetometer are deliberately still unavailable.
See docs/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 the dnctl/pfctl rules under it
are system-wide, so baguette injects VirtualNetwork.dylib to 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; plain
baguette 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>/network with
{"profile":"3g"} / {"latencyMs":300,"bandwidthKbps":400,"lossPercent":5}
/ {"offline":true}, GET to read back, DELETE to clear. Browser: a
Network card, with an amber toolbar dot whenever conditioning is on.
Same relaunch rule as motion — only apps launched after network set
are conditioned; changing it afterwards needs no relaunch.
Three limits to state rather than discover: only URLSession-shaped
traffic is conditioned — URLSessionWebSocketTask is (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 --offline will 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. See
docs/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. Use baguette paste --udid <X> --text "…" (or the paste wire 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" — crashes backboardd via every known path.
Refused by the CLI.
- Physical-device routes (
/devices.json,
WS /devices/:udid/stream) — the host side exists but no companion
app ships yet, so nothing ever connects; gestures on a device stream
are rejected with device control is not wired yet. Simulators
only for now. See docs/features/device-twin.md.
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 home then baguette 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 each baguette command.
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.
1---2name: baguette3description: 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.4---56# baguette — programmatic iOS simulator control78`baguette` is a macOS CLI that drives iOS simulators directly via Apple's9private `SimulatorHID` (the same path Xcode uses internally). It works on10**iOS 26.4 + Xcode 26 + Apple Silicon** and is faster + more reliable than11`idb` / `AXe` / `simctl io` for input.1213This skill is for **agents that need to interact with a running simulator**14(taps, swipes, screenshots, gesture sequences). Humans wanting a "play the15simulator in a browser" UI should be pointed at `baguette serve` and16`http://localhost:8421/simulators/<udid>` — but agents drive the CLI.1718## The agent's happy path1920Most automation jobs follow the same shape:2122```bash23# 1. Find a booted device.24baguette list # human-readable25baguette list --json # machine-readable: {running, available}2627# 2. Boot one if nothing is running.28baguette boot --udid <UDID>2930# 3. Get the screen size — you need this for every gesture.31baguette chrome layout --udid <UDID> # → {composite:{width,height}, screen:{width,height}, ...}3233# 4. Drive it.34baguette tap --udid <UDID> --x 219 --y 478 --width 438 --height 9543536# 5. Verify what happened (capture one JPEG of the framebuffer).37baguette screenshot --udid <UDID> --output /tmp/frame.jpg38```3940Steps 3–4 are the part that bites — see "The coordinate footgun" below.4142## The coordinate footgun (read this)4344**All `x` / `y` / `startX` / `endX` / `x1` / `x2` / `cx` / `cy` are in45device points** — same units as the `width` / `height` you pass alongside.4647A "tap at the centre of an iPhone 17 Pro Max" is `x:219, y:478` (half of48**438×954**). It is **not** `x:0.5, y:0.5` (normalized). It is **not**49`x:1206, y:2622` (raw pixels). The HID adapter normalises internally.5051To get the right `width` / `height` for a UDID:5253```bash54baguette chrome layout --udid <UDID> | jq '.screen | {width, height}'55# → {"width": 438, "height": 954}56```5758Always use the values from `chrome layout` — different devices have59different point sizes, and hardcoding "438×954" only works for iPhone 1760Pro Max.6162## One-shot vs streaming gestures6364Two ways to send input. Pick by frequency:6566- **One-shot** (`baguette tap / swipe / pinch / pan / press`) — separate67 process per gesture. Right for a handful of distinct interactions in a68 shell script. Each invocation pays the SimulatorHID setup cost69 (~50–100ms).7071- **Streaming** (`baguette input --udid <UDID>`) — long-running process72 reading newline-delimited JSON from stdin, writing `{"ok":true}` /73 `{"ok":false,"error":…}` to stdout per line. Right for sequences of74 many gestures (drags, multi-finger choreography, demo playback) where75 per-gesture latency matters. Same wire format the WebSocket uses.7677```bash78# One-shot.79baguette tap --udid X --x 219 --y 478 --width 438 --height 9548081# Streaming (open the pipe once, send many).82( echo '{"type":"tap","x":219,"y":478,"width":438,"height":954,"duration":0.05}'83 echo '{"type":"swipe","startX":219,"startY":760,"endX":219,"endY":190,"width":438,"height":954,"duration":0.3}'84) | baguette input --udid X85```8687For the full wire-format spec (every gesture type with examples), read88`references/wire-protocol.md`.8990## Visual verification — let the agent see what happened9192After driving a UI flow, the agent usually needs to confirm state.93The right tool is `baguette screenshot` — a one-shot JPEG of the94simulator's framebuffer with no streaming session involved:9596```bash97baguette screenshot --udid <UDID> --output /tmp/frame.jpg98baguette screenshot --udid <UDID> > /tmp/frame.jpg # stdout works too99baguette screenshot --udid <UDID> --quality 0.6 --scale 2 > thumb.jpg100```101102Defaults: `--quality 0.85`, `--scale 1` (native). `--scale 2` halves103each dimension; useful when you only need a quick visual check.104105Equivalent HTTP route during `baguette serve`:106`GET http://localhost:8421/simulators/<UDID>/screenshot.jpg[?quality=][?scale=]`.107108Important: SimulatorKit only emits a frame when something on screen109changes. A booted-but-idle simulator (lock screen with no second hand)110may not produce one within the 2 s timeout — `baguette screenshot`111exits non-zero and prints `Failure.timeout`. Wake the device with a112gesture first if you're capturing a static state:113114```bash115baguette tap --udid <UDID> --x 1 --y 1 --width "$W" --height "$H" # nudge116sleep 0.2117baguette screenshot --udid <UDID> --output /tmp/frame.jpg118```119120Then `Read /tmp/frame.jpg` to inspect (Claude Code's Read tool handles121images).122123For a snapshot while a `baguette serve` WebSocket is already open,124send `{"type":"snapshot"}` on that channel — the server emits a125keyframe immediately. Use this only when the WS is already live; for126fresh captures `baguette screenshot` is one HTTP-free command.127128For a presentation image on a 3D device model, use `render-3d`:129130```bash131baguette render-3d --udid <UDID> \132 --variant finish=deep-blue --rotation=-8,18,0 \133 --size 1200x1200 --output /tmp/device.png134```135136An existing image can be rendered with137`--screen <image> --device <model-id>`. The HTTP equivalents are138`GET /simulators/<UDID>/3d-model.json` for public model/variant metadata and139`POST /simulators/<UDID>/render-3d.png` for the PNG. This is a one-shot140presentation surface; gestures still target the live 2D stream.141142## What's wired vs what isn't143144Wired (use freely):145- `tap`, `swipe`, `touch1-{down,move,up}`, `touch2-{down,move,up}`,146 `pinch`, `pan`, `scroll`. `touch1-*` events accept an optional147 `edge: "bottom" | "top" | "left" | "right"` field that flags every148 event in the chain as a screen-edge system gesture; `bottom`149 engages iOS's home-indicator recognizer (live home / app-switcher150 preview as the touches stream); `top` engages the status-bar151 recognizer (live lock-screen cover sheet from a top-left drag,152 Notification Center from a top-right drag). Omit `edge` for153 ordinary interior touches.154- `button`: `home`, `lock`, `power`, `volume-up`, `volume-down`,155 `action`, `app-switcher`, `swipe-to-app-switcher`, `swipe-to-home`,156 `pull-down-to-lock-screen`, `pull-down-to-notification-center`.157 Optional `--duration` / `"duration"` for long-press semantics158 (action button "Hold for Ring", power → Siri / SOS, …). The five159 virtual buttons land iOS gesture recognition without any160 client-side stream management. `app-switcher` fires two home161 presses ~150 ms apart (SpringBoard's own multitasking recipe);162 `swipe-to-app-switcher` is the slow drag-and-hold variant on163 the gesture path; `swipe-to-home` is the fast edge-flick → Home;164 `pull-down-to-lock-screen` and `pull-down-to-notification-center`165 drag down from top-left and top-right respectively.166- `key` (single keystroke) and `type` (US-ASCII string). CLI:167 `baguette key --code KeyA --modifiers shift,command --duration 0.2`168 and `baguette type --text "hello"`. `code` is a W3C169 `KeyboardEvent.code`; modifiers are `shift | control | option | command`.170- `paste` — arbitrary unicode into the focused field via the sim's171 pasteboard + Cmd+V (the path around `type`'s US-ASCII limit; not a172 HID-only path — shells out to `xcrun simctl pbcopy`). Wire:173 `{"type":"paste","text":"…","press":false?}` on the stream WS174 (replies `paste_result`) and `input` stdin. CLI: `baguette paste175 --udid <X> --text "…" [--no-press]`; plus `baguette clipboard get`176 (print the sim's pasteboard raw) and `baguette clipboard sync`177 (host Mac pasteboard → sim, full-fidelity — images included).178 Needs a booted device. See179 [`docs/features/paste.md`](../../docs/features/paste.md).180- `copy` — the sim→host interactive mirror of `paste`: press Cmd+C181 sim-side (focused field copies its selection), then ferry the182 pasteboard onto the host Mac's clipboard, full-fidelity — images183 included (`xcrun simctl pbsync <udid> host`). Wire:184 `{"type":"copy","press":false?}` on the stream WS (replies185 `copy_result`) and `input` stdin — `press:false` skips the186 keystroke for a pure ferry. Browser **Cmd+C / Ctrl+C** while the187 screen has focus sends it. CLI: `baguette clipboard copy --udid <X>`188 is a pure ferry (no keystroke). Browser copy targets the machine189 running baguette (local-dev happy path). Needs a booted device.190 See [`docs/features/paste.md`](../../docs/features/paste.md).191- `describe-ui` — dump the on-screen accessibility tree as JSON192 (per-node `role`, `label`, `value`, `identifier`, `frame` in193 device points, recursive `children`). CLI:194 `baguette describe-ui --udid <X>` (full tree) or195 `baguette describe-ui --udid <X> --x <px> --y <px>` (hit-test).196 Frames are in the same units as `tap` / `swipe` wire fields, so197 reading `frame.x + frame.width/2`, `frame.y + frame.height/2`198 back into a `tap` envelope just works.199- `interface` — the accessibility-display family: light / dark200 appearance, Increase Contrast, and content size (Dynamic Type,201 including the five accessibility sizes). CLI: `baguette interface202 appearance|contrast|text-size --udid <X> [<value>]` — no value reads,203 a value sets. HTTP: `GET /simulators/<X>/interface.json` and204 `POST /simulators/<X>/interface` (any subset, answers the resulting205 state). Backed by `xcrun simctl ui` (not a HID path). **A read on a206 device that isn't booted answers `unknown` and exits 0** — a state to207 check for, not a failure; `unsupported` means the runtime lacks the208 setting. Neither can be set. Pairs with `describe-ui`: change the209 conditions, re-dump the tree, compare. See210 [`docs/features/interface.md`](../../docs/features/interface.md).211- `logs` — stream the booted simulator's unified log line-by-line212 to stdout. CLI: `baguette logs --udid <X> [--level info|debug|default]213 [--style default|compact|json|ndjson|syslog] [--predicate ...]214 [--bundle-id <id>]`. SIGINT (Ctrl-C) tears down cleanly. WS215 variant on `WS /simulators/<X>/logs?level=&style=&predicate=&bundleId=`216 emits `{"type":"log","line":"..."}` text frames per entry.217 Levels: only `default | info | debug` (iOS-runtime narrow — host218 `notice / error / fault` are rejected at the wire).219- `camera` — pipe a camera source (a live Mac webcam, an uploaded220 still image, or a looping uploaded video) into the iOS app's221 `AVCaptureVideoPreviewLayer` / `AVCapturePhotoOutput` /222 `UIImagePickerController`. No CLI; use the WS at223 `WS /simulators/<UDID>/camera` — `camera_list` / `camera_start`224 (with `source: webcam | image | video`) / `camera_stop` /225 `camera_set_flags` upstream, `camera_devices` / `camera_state`226 downstream (phase = `idle | streaming`, plus live `fps` and active227 `source`). Image/video files upload first via228 `POST /simulators/<UDID>/camera-source?name=<file>`. Frames flow229 through `/tmp/SimCam.bgra`230 (24-byte LE header + BGRA pixels) into `VirtualCamera.dylib`231 loaded inside the simulator via `DYLD_INSERT_LIBRARIES`. Apps232 launched *before* arming don't load the dylib — relaunch them.233 Browser UI lives under the Camera card on `/simulators/<UDID>`.234- `install` / `add-media` — add a file to the device. `baguette install235 --udid <X> <path>` installs an `.ipa` / `.app`; `baguette add-media236 --udid <X> <path>` adds an image / video (`png jpg jpeg gif heic heif237 mov mp4 m4v`) to Photos. Both shell out to `xcrun simctl install` /238 `addmedia` (not a HID path). `serve` exposes one entry point —239 `POST /simulators/<X>/files?name=<filename>` with the raw bytes as the240 body — and routes by extension (app → install, `.zip` carrying one241 top-level `.app` → extract via `ditto -x -k` + install, media →242 Photos); a file with no home on a simulator returns `415`. The243 browser focus page accepts drag-and-drop onto the device, including244 a bare `.app` **directory** — it's packed into a stored zip in-page245 and posted as `<Name>.app.zip`. See246 [`docs/features/file-upload.md`](../../docs/features/file-upload.md).247- `location` — set the device's simulated GPS position (not a HID path;248 shells out to `xcrun simctl location`). `baguette location set --udid249 <X> <lat,lon>` pins a point; `baguette location start --udid <X>250 [--speed <m/s>] [--distance <m>] [--interval <s>] <lat,lon> <lat,lon>…`251 runs a moving route; `baguette location walk --udid <X> --bearing <deg>252 --speed <m/s> <lat,lon>` heads off along a compass bearing (driving253 `CLLocation.course`); `baguette location clear --udid <X>` restores live254 location. Position/waypoints are `lat,lon` **tokens** (e.g.255 `37.3318,-122.0312`); a token whose latitude starts with `-` must256 follow a `--` separator. `serve`: `POST /simulators/<X>/location` with a257 `{latitude,longitude}` point, `{waypoints:[…],speed?}` route, or258 `{latitude,longitude,bearing,speed}` walk body, and `DELETE` to clear.259 Out-of-range, <2-waypoint, or speed-less-walk bodies return `400`.260 Browser focus page has a **Location** card (map-pin toolbar button) with261 a Leaflet map and a **Walk** joystick — the stick steers absolute, while262 `W`/`S` drive along the current heading and `A`/`D` turn it (tank263 controls); **Replay** retraces the walked trail as a route.264 **Two iOS-26 limits:** `CLHeading` (compass) is unavailable in the265 simulator entirely (`headingAvailable() == false`), and `course` is266 derived on a flat lat/lon grid so diagonal bearings skew by267 `1/cos(latitude)` (~6.5° at lat 37; cardinals are exact). See268 [`docs/features/location.md`](../../docs/features/location.md).269- `motion` — make the device's apps read CoreMotion: `CMMotionActivity`270 (walking / running / cycling / automotive), `CMPedometer` counters, and271 `CMMotionManager` samples. **Not a simctl path** — all three are272 unavailable in a stock simulator, so baguette injects273 `VirtualMotion.dylib`. `baguette motion start --udid <X> [--activity274 <kind>] [--speed <m/s>]` arms it (plain `start` = walking);275 `baguette motion set --udid <X> --activity <kind>` changes it;276 `baguette motion stop --udid <X>` parks it stationary and disarms.277 `serve`: `POST /simulators/<X>/motion` with `{"activity":"running"}` or278 just `{"speed":6}` (classified server-side), `GET` to read back279 `{ok,active,activity,steps,metres,speed}` — an inactive device answers280 `{"ok":true,"active":false}` and nothing more — `DELETE` to stop. An281 unknown udid is `404` on every one of them. Browser: a **Drive282 motion sensors** toggle on the Location card — once on, the walk283 joystick and route speeds already being posted drive the activity.284 **The one thing that surprises people: only apps launched _after_285 arming see anything** (dyld inserts at exec time) — relaunch with286 `xcrun simctl launch --terminate-running-process <X> <bundle-id>`.287 Floor counting and the magnetometer are deliberately still unavailable.288 See [`docs/features/motion.md`](../../docs/features/motion.md).289- `network` — condition what the device's apps see of the network:290 latency, downlink bandwidth, request loss, hard offline. **Not a simctl291 path** — Network Link Conditioner and the `dnctl`/`pfctl` rules under it292 are system-wide, so baguette injects `VirtualNetwork.dylib` to scope it293 to one simulator. `baguette network set --udid <X> --profile 3g` (or294 `--latency <ms> --bandwidth <kbps> --loss <percent>`, or `--offline`) —295 **exactly one** of those three per invocation; `network clear --udid <X>`296 stops it, including for apps already running; plain297 `baguette network --udid <X>` reports what's applied.298 Presets are NLC's: `wifi | dsl | lte | 3g | edge | very-bad-network |299 100-loss`. `serve`: `POST /simulators/<X>/network` with300 `{"profile":"3g"}` / `{"latencyMs":300,"bandwidthKbps":400,"lossPercent":5}`301 / `{"offline":true}`, `GET` to read back, `DELETE` to clear. Browser: a302 **Network** card, with an amber toolbar dot whenever conditioning is on.303 **Same relaunch rule as motion** — only apps launched after `network set`304 are conditioned; changing it afterwards needs no relaunch.305 **Three limits to state rather than discover:** only URLSession-shaped306 traffic is conditioned — `URLSessionWebSocketTask` is (latency, loss,307 offline; not bandwidth), but an SDK that opens its own socket is not, so308 Ably's ably-cocoa and Starscream are unreached and `--offline` will not309 feel offline to them; **`WKWebView` / Safari page loads are not310 conditioned**, since WebKit loads them in its own networking process, so311 a hybrid app is throttled natively but not in its web content; and loss312 is request-level rather than packet-level. See313 [`docs/features/network.md`](../../docs/features/network.md).314315NOT wired (skill should NOT propose these):316- **Non-ASCII text** through `type` — IME / Pinyin / accented / emoji317 isn't on the host-HID keystroke path. Use `baguette paste --udid318 <X> --text "…"` (or the `paste` wire verb) for those strings — it319 rides the pasteboard instead of keystrokes.320- **F-keys, Page Up/Down, Home/End** through `key` — outside the321 phase-1 supported code set. Most iOS apps don't use them anyway.322- `button: "siri"` — crashes `backboardd` via every known path.323 Refused by the CLI.324- **Physical-device routes** (`/devices.json`,325 `WS /devices/:udid/stream`) — the host side exists but no companion326 app ships yet, so nothing ever connects; gestures on a device stream327 are rejected with `device control is not wired yet`. Simulators328 only for now. See `docs/features/device-twin.md`.329330## Composing flows — the smoke-test pattern331332```bash333#!/usr/bin/env bash334set -euo pipefail335UDID="$1"336337# Resolve screen size once; reuse for every gesture.338read W H < <(baguette chrome layout --udid "$UDID" \339 | jq -r '.screen | "\(.width) \(.height)"')340341# Wake / unlock.342baguette press --udid "$UDID" --button lock # toggle (sleep if awake)343sleep 0.5344baguette press --udid "$UDID" --button lock # back on345346# Home → tap Settings.347baguette press --udid "$UDID" --button home348sleep 0.4349baguette tap --udid "$UDID" --x $((W * 75 / 100)) --y $((H * 55 / 100)) \350 --width "$W" --height "$H"351352# Capture proof.353baguette stream --udid "$UDID" --format mjpeg --fps 1 \354 | head -c 200000 > /tmp/settings.jpg355```356357Note `width`/`height` reuse: every gesture pays the same coordinate358convention, so resolving once and re-passing avoids the footgun.359360## Pairing with Claude Code361362The natural loop when an agent edits a SwiftUI app:3633641. Edit code → ⌘B in Xcode (or `xcodebuild`) → app reloads on the sim.3652. Agent uses `baguette press --button home` then `baguette tap …` to366 navigate to the screen it just changed.3673. Agent captures a frame (above), `Read`s the JPEG, and confirms the368 pixels match intent.369370If the human wants to follow along visually, also point them at371`http://localhost:8421/simulators/<udid>` (after starting `baguette serve`)372— that's a focused single-tab view of the sim, no Xcode window juggling.373374## Reference files375376- `references/wire-protocol.md` — every gesture type with copy-pasteable377 JSON examples + the coordinate convention restated.378- `references/cli.md` — full subcommand list, flags, and exit/output379 format for each `baguette` command.380381Read these on demand — don't pull both into context unless the task382actually needs the breadth (e.g., authoring a long input pipeline →383read `wire-protocol.md`; debugging which subcommand to use → read384`cli.md`).385386## Install (only when missing)387388```bash389brew install baguette390baguette --version391```392393Requires Xcode 26 + Apple Silicon. If `baguette` already works, skip394this — agents shouldn't reinstall on every invocation.