Not required to use Tiny Engineer. Extra macOS companion: lock the screen → robot sleep, unlock → wakeup. Primary integrations stay REST and Cursor (integration.md, api.md).
Robot must already be on the same Wi-Fi. Base URL: http://tiny-engineer.local.
A small Swift CLI listens for macOS screen lock / unlock and POSTs /anim:
| Event | Request |
|---|---|
| Screen locked | curl -X POST "http://tiny-engineer.local/anim?name=sleep" |
| Screen unlocked | curl -X POST "http://tiny-engineer.local/anim?name=wakeup" |
Verified responses look like:
{"ok":true,"animation":"sleep"}
{"ok":true,"animation":"wakeup"}The program observes these macOS distributed notifications:
com.apple.screenIsLockedcom.apple.screenIsUnlocked
On each event it runs /usr/bin/curl via Foundation Process, then stays alive on RunLoop.main.
A user LaunchAgent (RunAtLoad + KeepAlive) starts it after login.
These notification names are commonly used for this purpose but are not a formally documented public Apple API. Treat them as an implementation detail that could change in a future macOS version.
| Path | Role |
|---|---|
~/screenwatch/screenwatch.swift |
Source |
~/screenwatch/screenwatch |
Compiled binary |
~/Library/LaunchAgents/local.screenwatch.plist |
LaunchAgent |
launchd does not expand ~. The plist ProgramArguments must be the absolute path to the compiled binary (for example the output of echo "$HOME/screenwatch/screenwatch"). Do not put ~ in the plist.
mkdir -p ~/screenwatchSave this as ~/screenwatch/screenwatch.swift:
import Darwin
import Foundation
setbuf(stdout, nil)
setbuf(stderr, nil)
func postAnim(name: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/curl")
process.arguments = [
"-X", "POST",
"http://tiny-engineer.local/anim?name=\(name)",
]
do {
try process.run()
process.waitUntilExit()
print("posted \(name) status=\(process.terminationStatus)")
fflush(stdout)
} catch {
fputs("curl failed: \(error)\n", stderr)
fflush(stderr)
}
}
let center = DistributedNotificationCenter.default()
center.addObserver(
forName: Notification.Name("com.apple.screenIsLocked"),
object: nil,
queue: nil
) { _ in
print("screen locked")
fflush(stdout)
postAnim(name: "sleep")
}
center.addObserver(
forName: Notification.Name("com.apple.screenIsUnlocked"),
object: nil,
queue: nil
) { _ in
print("screen unlocked")
fflush(stdout)
postAnim(name: "wakeup")
}
print("screenwatch listening for lock/unlock")
fflush(stdout)
RunLoop.main.run()Compile:
swiftc -o ~/screenwatch/screenwatch ~/screenwatch/screenwatch.swift
chmod +x ~/screenwatch/screenwatchTo hide curl’s response body, add -s -o /dev/null to process.arguments (before -X). Keep them off until you have confirmed the endpoints.
Confirm the robot answers before installing a LaunchAgent:
curl -X POST "http://tiny-engineer.local/anim?name=sleep"
curl -X POST "http://tiny-engineer.local/anim?name=wakeup"Expect JSON like {"ok":true,"animation":"sleep"} / {"ok":true,"animation":"wakeup"}. If the hostname stalls, try curl -4 or the IP on the OLED (api.md).
Then run the binary in a terminal (not as a service):
~/screenwatch/screenwatchYou should see screenwatch listening for lock/unlock. Lock the screen (Control-Command-Q, or Apple menu → Lock Screen), then unlock. Terminal should print screen locked / screen unlocked and posted sleep / posted wakeup. The robot should sleep and wake. Ctrl-C to stop.
Do not load the LaunchAgent until this works.
Copy the plist below to ~/Library/LaunchAgents/local.screenwatch.plist. Replace /ABSOLUTE/PATH/TO/screenwatch with the expanded path from echo "$HOME/screenwatch/screenwatch".
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>local.screenwatch</string>
<key>ProgramArguments</key>
<array>
<string>/ABSOLUTE/PATH/TO/screenwatch</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/screenwatch.log</string>
<key>StandardErrorPath</key>
<string>/tmp/screenwatch-error.log</string>
</dict>
</plist>Validate:
plutil -lint ~/Library/LaunchAgents/local.screenwatch.plistLoad as the logged-in user. Do not use sudo with launchctl bootstrap gui/$(id -u) … — that targets the user GUI domain and fails or loads into the wrong session.
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.screenwatch.plistUnload:
launchctl bootout gui/$(id -u)/local.screenwatchStatus:
launchctl print gui/$(id -u)/local.screenwatchLogs:
tail -f /tmp/screenwatch.log /tmp/screenwatch-error.logAfter login (or immediately, because RunAtLoad is true) you should see screenwatch listening for lock/unlock in /tmp/screenwatch.log.
launchctl print gui/$(id -u)/local.screenwatchshowsstate = running(not onlyspawn scheduled).- Lock, then unlock.
/tmp/screenwatch.logshould containscreen locked/screen unlockedandposted sleep/posted wakeup. - Robot plays sleep then wakeup. Independent check:
curl http://tiny-engineer.local/animSuccessful POSTs return JSON such as {"ok":true,"animation":"sleep"} and {"ok":true,"animation":"wakeup"}.
state = spawn scheduled
launchd has accepted the job but has not kept a running process. Usual causes: wrong ProgramArguments path, binary not executable, or the process exiting immediately and being retried. Check path =, state =, and last exit status in launchctl print. Confirm:
ls -l "$(echo "$HOME/screenwatch/screenwatch")"
~/screenwatch/screenwatchThe foreground run must stay up until you Ctrl-C.
last exit code = 78 (EX_CONFIG)
launchd could not configure/exec the job. Almost always a bad executable path in the plist (missing file, still using ~, or a directory instead of the binary). ProgramArguments[0] must be an existing executable file. Re-lint the plist, fix the path, bootout, then bootstrap again.
Bootstrap failed: 5: Input/output error
The job is usually already loaded, or you mixed user/root domains. Do not retry with sudo. Print, then bootout, then bootstrap:
launchctl print gui/$(id -u)/local.screenwatch
launchctl bootout gui/$(id -u)/local.screenwatch
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.screenwatch.plistAlso happens if the plist path is wrong or unreadable by the user.
Incorrect executable path in the plist
launchd does not expand ~. Use echo "$HOME/screenwatch/screenwatch" and paste that absolute path as the only ProgramArguments string. After editing:
plutil -lint ~/Library/LaunchAgents/local.screenwatch.plist
launchctl bootout gui/$(id -u)/local.screenwatch
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.screenwatch.plistEmpty log output (stdout buffering)
If lock/unlock works but /tmp/screenwatch.log stays empty, Swift’s stdout is block-buffered when not a TTY. The sample calls setbuf(stdout, nil) / fflush. Rebuild if you omitted those. StandardErrorPath still catches fputs failures.
Verify curl endpoints independently
If the helper runs but the robot does not move, bypass Swift:
curl -v -X POST "http://tiny-engineer.local/anim?name=sleep"
curl -v -X POST "http://tiny-engineer.local/anim?name=wakeup"A reachable board returns {"ok":true,"animation":"sleep"} / {"ok":true,"animation":"wakeup"}. Failures here are network, mDNS, or API auth — not launchd. If access_token is set, curl needs Authorization: Bearer … (api.md).
launchctl bootout gui/$(id -u)/local.screenwatch
rm ~/Library/LaunchAgents/local.screenwatch.plistOptional: rm -r ~/screenwatch and the log files under /tmp/. After bootout the helper is gone until you bootstrap again; deleting the plist is enough to keep it from returning at login.
- Optional extra. Tiny Engineer does not need this to work with Cursor, REST, or the web UI.
com.apple.screenIsLocked/com.apple.screenIsUnlockedare widely used but unofficial. They may change in a future macOS.- Lock via Control-Command-Q (or Lock Screen) is the event this watches. Closing the lid may sleep the Mac before the helper can POST.
- To discard curl bodies: add
-s-o/dev/nullto theProcessarguments. - Same-network, port 80. Optional Bearer token:
api.md.