engine.commands
Every command the app has, with its menu path, shortcut, parameters, and whether it can run right now (and why not).
Returns a list of { id, label, menu, shortcut, params, enabled, disabled_reason? }.
Developers
The Mac app can be controlled from outside, over a connection that never leaves your computer. Run any command, press any button, read what happened. Use it from a shell script, a test, or an AI assistant.
LightBites listens on a port on your own Mac when you ask it to. You send one line of JSON, you get one line back. Three kinds of request cover everything a person can do in the app.
Everything the app does to your photos is a named command: rate, flag, move, export, adjust a slider. Run any of them by name.
engine.execute
Open a screen, a sheet or a photo, and read what the app is showing: the selection, the screen, timings.
ui.set · ui.inspect
Find any button, toggle, field, row or photo in a window by its name, press it, type into it, or choose a menu item.
ui.tree · ui.press
The controls layer reads the app’s own accessibility information, the same names VoiceOver reads. It works inside the app, so it sends no clicks or keystrokes to the system and needs no permission. The better a control is named for VoiceOver, the better it can be found here.
Start the app with the port switched on, then talk to it.
LIGHTCRAFT_DEMO=1 LIGHTCRAFT_CONTROL_PORT=18731 \
/Applications/LightBites.app/Contents/MacOS/LightBitesecho '{"id":1,"method":"host.state"}' | nc -w 2 127.0.0.1 18731The reply comes back as one line.
{"id":1,"ok":true,"result":{"active":21,"selectionCount":1,"visibleCount":24,"undo":null,"redo":null,…}}import json, socket
def call(method, params=None, port=18731):
with socket.create_connection(("127.0.0.1", port)) as s:
s.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode())
return json.loads(s.makefile().readline())
print(call("ui.press", {"label": "4 stars"}))Plain text over a local TCP connection. One JSON object per line, in both directions.
{"id": 7, "method": "ui.press", "params": {"label": "4 stars"}}{"id": 7, "ok": true, "result": {"label": "4 stars", "path": "0.0.2.3", "role": "AXButton"}}id is yours. It comes back unchanged, so you can match replies to requests. It can be a number or a string.method is the name of the request; params is an object, and may be left out.A failed request has "ok": false, a short code, and an error in plain words.
{"id": 8, "ok": false, "code": "ui", "error": "no element named “Nothing here”"} Commands report their own codes, such as unknownCommand, disabled (the command can't run now) and badParams. Requests about controls use ui.
Every action on your photos is a named command, the same one the menu and the shortcuts run. This is the most exact way in: the parameters are plain values, and nothing depends on what is on screen.
Every command the app has, with its menu path, shortcut, parameters, and whether it can run right now (and why not).
Returns a list of { id, label, menu, shortcut, params, enabled, disabled_reason? }.
Run one command by id, exactly as the menu or a shortcut would. Photo edits land on the undo stack like any other.
{"method": "engine.execute", "params": {
"command": "develop.set",
"params": {"control": "light.exposure", "value": 0.8}
}}A snapshot of the library: the active photo, how many are selected, how many are shown, and the names of the next undo and redo steps.
Returns { active, selectionCount, visibleCount, undo, redo, unsaved, … }.
Ask engine.commands what exists. Some you will use often: develop.set, develop.adjust and develop.get for the sliders, library.select for the selection, and edit.undo.
Anything you can click in a window can be found by name and used. This is for what commands don't cover: choosing a photo, an album or a tab, flipping a switch, filling in a field, or choosing a menu item.
The controls a person can operate in a window, each with its name, value, whether it is enabled, and where it is. Texts and groups are left out unless you ask.
Returns { window, count, unnamed, controls: [{ path, label, role, value?, enabled, frame }] }. frame is in points from the window's top left; unnamed counts the controls that have no name.
{"method": "ui.tree", "params": {"filter": "stars"}}
→ {"count": 5, "unnamed": 0, "window": "All Photos", "controls": [
{"label": "1 stars", "role": "AXButton", "path": "0.0.2.0", "enabled": true, "frame": [272, 969, 20, 24]},
{"label": "2 stars", "role": "AXButton", "path": "0.0.2.1", "enabled": true, "frame": [293, 969, 20, 24]},
…
]}Press a button, flip a toggle, choose a row in the sidebar, choose a photo, pick a tab. Name the control by label, or by the path from ui.tree.
Returns the control that was pressed, or an error that says why not (disabled, not found, no such action).
Set a slider to a number, or replace the text in a field, typed the way the keyboard would.
Type where the keyboard focus is. Press a text field first (ui.press on it focuses it).
Choose an item in the menu bar, as the menu would, if it is enabled.
Move the app around, and check what it shows.
Go to a screen, open a sheet or a photo, or change a view option. Pass only what should change.
What the app is showing now: the screen, the photo, background activity, and timings of the last render.
Returns { screen, photo, activity, perf, … }.
A keyboard shortcut, as the app takes it right now. Says whether it meant anything.
Returns { handled, key }.
The window as a PNG, as the screen draws it, toolbar and sidebar included.
Returns { path, width, height }.
An assistant that speaks the Model Context Protocol can use LightBites through lightcraft-cli, the command-line tool of the open-source LightCraft engine. It connects to the port you opened and turns its requests into tools.
claude mcp add lightbites -- lightcraft-cli mcp --connect 127.0.0.1:18731{
"mcpServers": {
"lightbites": {
"command": "lightcraft-cli",
"args": ["mcp", "--connect", "127.0.0.1:18731"]
}
}
} The assistant gets tools to take a screenshot, list the controls on screen (list_widgets, with all and window), press one (click, by id or by label, with an optional action), set a slider or a field (set_value), choose a menu bar item (menu_item), press keys, type text, move between screens (set_ui), and run any command (run_command, get_develop, set_develop, query_photos, export and more). Start LightBites first; the connection is made on the first request, and again if the app restarts.
To run a fixed list of requests without a connection, put them in a file, one per line, and point the app at it.
{"method": "ui.set", "params": {"screen": "grid"}}
{"method": "ui.press", "params": {"label": "Portfolio"}}
{"sleep": 500}
{"method": "ui.press", "params": {"label": "3 stars"}}
{"method": "ui.screenshot", "params": {"path": "portfolio.png"}}LIGHTCRAFT_DEMO=1 LIGHTCRAFT_SCRIPT=/tmp/tour.jsonl \
/Applications/LightBites.app/Contents/MacOS/LightBites
# replies are appended to /tmp/tour.out, one line per request{"sleep": 500} waits half a second. A relative path in a request, for a screenshot, is next to the script.
Short requests that do one thing. Each was run against the app.
The rating bar is a row of buttons named “1 stars” to “5 stars”.
{"method": "ui.press", "params": {"label": "4 stars"}}Photos are controls named by title. Pressing chooses one; confirm opens it.
{"method": "ui.press", "params": {"label": "Lagoon", "action": "confirm"}}The sidebar's rows are named by album.
{"method": "ui.press", "params": {"label": "Portfolio"}}A develop slider is a control with an id. Commands take its real value.
{"method": "engine.execute", "params": {"command": "develop.set",
"params": {"control": "light.exposure", "value": 0.8}}}The develop sliders listen for increment and decrement. List them with ui.tree and all.
{"method": "ui.press", "params": {"label": "Exposure", "action": "increment"}}Useful after a change, to check what the app shows.
{"method": "ui.screenshot", "params": {"path": "/tmp/lightbites.png"}}LIGHTCRAFT_CONTROL_PORT is set.127.0.0.1, not on your network. Other devices can't reach it.What it can't do yet.
Escape with ui.key. The menu bar itself works with ui.menuItem.all: true, as plain elements, and show no current value. Read values with develop.get and set them with develop.set or develop.adjust.ui.tree to see what they are called today.path from ui.tree is good while the screen holds still. Prefer label in anything you keep.