Skip to content

Developers

Drive LightBites from code.

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.

Overview

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.

Commands

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

Screens

Open a screen, a sheet or a photo, and read what the app is showing: the selection, the screen, timings.

ui.set · ui.inspect

Controls

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.

Quick start

Start the app with the port switched on, then talk to it.

1. Start LightBites with a port

Terminal
LIGHTCRAFT_DEMO=1 LIGHTCRAFT_CONTROL_PORT=18731 \
  /Applications/LightBites.app/Contents/MacOS/LightBites

2. Send a request

Terminal
echo '{"id":1,"method":"host.state"}' | nc -w 2 127.0.0.1 18731

The reply comes back as one line.

Reply
{"id":1,"ok":true,"result":{"active":21,"selectionCount":1,"visibleCount":24,"undo":null,"redo":null,…}}

3. Or from Python

Python
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"}))

Protocol

Plain text over a local TCP connection. One JSON object per line, in both directions.

Request
{"id": 7, "method": "ui.press", "params": {"label": "4 stars"}}
Reply
{"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 connection can send many requests, one after another. Each is answered in turn.
  • A request that takes time (an export, say) is answered when it is done, up to two minutes.

Errors

A failed request has "ok": false, a short code, and an error in plain words.

Reply
{"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.

Commands

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.

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? }.

engine.execute

Run one command by id, exactly as the menu or a shortcut would. Photo edits land on the undo stack like any other.

command
The command id, such as develop.set or library.select.
params?
The command's parameters (see engine.commands).
{"method": "engine.execute", "params": {
  "command": "develop.set",
  "params": {"control": "light.exposure", "value": 0.8}
}}

host.state

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.

Controls

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.

ui.tree

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.

window?
main (default), settings, second, sheet (the one open over a window), or part of a window title.
filter?
Keep controls whose name or role contains this text.
all?
true lists every element, including texts, groups and the develop sliders.
nested?
true returns the tree as it is, not a flat list.

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]},
    …
  ]}

ui.press

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.

label?
The control's name. An exact match wins, then one that contains it.
path?
The path from ui.tree (also accepted as id). It is good while the screen holds still.
role?
Narrow a label to one kind of control, such as AXButton.
index?
Which one, counting from 0, when several controls share the label.
action?
confirm opens a photo (a double click), showMenu, increment and decrement step a slider. Default: a press.
window?
As for ui.tree.

Returns the control that was pressed, or an error that says why not (disabled, not found, no such action).

ui.setValue

Set a slider to a number, or replace the text in a field, typed the way the keyboard would.

label | path
The control, as for ui.press.
value
A number for a slider, text for a field.

ui.text

Type where the keyboard focus is. Press a text field first (ui.press on it focuses it).

text
What to type.

ui.menuItem

Choose an item in the menu bar, as the menu would, if it is enabled.

path
The titles leading to it, such as ["View", "Show Sidebar"].

Screens and state

Move the app around, and check what it shows.

ui.set

Go to a screen, open a sheet or a photo, or change a view option. Pass only what should change.

screen?
grid, photo, edit, crop, masking, remove, redEye, presets, info, commands, people, map.
photo?
A photo id to show.
sheet?
albums, filter, settings, export, sync, history, versions, keywords, about, or none.
span?
all, months or years for the grid.
windowSize?
[width, height] in points.

ui.inspect

What the app is showing now: the screen, the photo, background activity, and timings of the last render.

Returns { screen, photo, activity, perf, … }.

ui.key

A keyboard shortcut, as the app takes it right now. Says whether it meant anything.

key
How a shortcut is written: 3, Shift+X, Cmd+A, Right.

Returns { handled, key }.

ui.screenshot

The window as a PNG, as the screen draws it, toolbar and sidebar included.

path
Where to save it.
window?
As for ui.tree.

Returns { path, width, height }.

AI assistants (MCP)

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 Code

Terminal
claude mcp add lightbites -- lightcraft-cli mcp --connect 127.0.0.1:18731

Any MCP client

JSON
{
  "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.

Scripts

To run a fixed list of requests without a connection, put them in a file, one per line, and point the app at it.

tour.jsonl
{"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"}}
Terminal
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.

Recipes

Short requests that do one thing. Each was run against the app.

Rate the selected photo

The rating bar is a row of buttons named “1 stars” to “5 stars”.

{"method": "ui.press", "params": {"label": "4 stars"}}

Open a photo

Photos are controls named by title. Pressing chooses one; confirm opens it.

{"method": "ui.press", "params": {"label": "Lagoon", "action": "confirm"}}

Go to an album

The sidebar's rows are named by album.

{"method": "ui.press", "params": {"label": "Portfolio"}}

Set exposure exactly

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

Nudge a slider the way the arrow keys do

The develop sliders listen for increment and decrement. List them with ui.tree and all.

{"method": "ui.press", "params": {"label": "Exposure", "action": "increment"}}

Take a picture of the window

Useful after a change, to check what the app shows.

{"method": "ui.screenshot", "params": {"path": "/tmp/lightbites.png"}}

Safety

  • Off unless you ask. Nothing listens until LIGHTCRAFT_CONTROL_PORT is set.
  • This computer only. The port is open on 127.0.0.1, not on your network. Other devices can't reach it.
  • Web pages can't use it. A request that doesn't start with a JSON object closes the connection before anything runs, so a web page can't send commands to it.
  • Any program you run can use it. While the port is open, a program on this Mac can do what you can do in the app. Open it only while you need it.
  • No synthetic input. It never moves your pointer or presses keys at the system level, so it can't act on another app, or on whatever window is in front.

Limits

What it can't do yet.

  • Mac only. The commands work on every platform; the controls layer is built for the Mac app. iPhone and iPad need a different approach.
  • Pop-up menus. The items of a menu button, once it is open, aren't listed. Run what the item does as a command, or press Escape with ui.key. The menu bar itself works with ui.menuItem.
  • Develop sliders. They appear with all: true, as plain elements, and show no current value. Read values with develop.get and set them with develop.set or develop.adjust.
  • Icon-only buttons. A few still carry the name of their symbol (“Gear Shape”) until they get a proper label. Use ui.tree to see what they are called today.
  • Paths change. A path from ui.tree is good while the screen holds still. Prefer label in anything you keep.