# routinectl Create and manage Claude Desktop local routines from a shell, in one command, without touching the mouse, raising a window, or stealing focus. ``` ./install.sh ``` That builds from source, puts `routinectl` in `~/bin`, and drops a skill into `~/.claude/skills/routinectl` so a Claude Code session knows how to drive it. ## Requirements **macOS.** The tool talks to one macOS application through the Accessibility API. **Claude Desktop, running, with a window open.** Not merely running. The app keeps its process alive after you close every window, so `pgrep` says yes and every command still exits 3. **Xcode command line tools**, for `swiftc`. `install.sh` checks for it and prints the one command that installs it. It builds from source, so Apple silicon and Intel both work. **Accessibility permission, granted to the app that runs the binary** rather than to the binary. That is iTerm2, Terminal, Ghostty, BetterTouchTool, or Claude Desktop itself when the session driving it lives in the app's own terminal pane. Fully quit and relaunch the host after granting; a running process does not pick up a new grant. `routinectl doctor` reports whether it took. **The Claude CLI, optionally.** If `claude` is on your PATH, routinectl repairs itself when the app is redesigned: see "When the interface changes" below. Without it everything still works, and a redesign leaves you a file to fix by hand instead of a fixed file. ## What it does not do It does not schedule anything. It fills in the Claude Desktop routine form and reads the saved values back; the app runs the routines. It does not create folders. `--folder` picks from the recently-used list the app already offers, and there is no path field to type into. Open a session in a folder once through the app and it becomes available. It does not touch anything outside the Claude Desktop UI. No cursor movement, no window raise, no app activation, no clipboard, no files of yours. The one place it writes is `~/.routinectl`, which holds its selector overlay and its failure records and nothing else; `ROUTINECTL_HOME` moves that. It does not survive every app redesign on its own. It repairs a control that was renamed, which is the common case, and it cannot repair a control that stopped existing or a form that grew a new required step. It stands on three facts about one application's accessibility tree; §7 names them and §9 says exactly which kinds of change it can and cannot absorb. ## When the interface changes This tool drives somebody else's application, and that application will be redesigned. So it is built to be repaired rather than to be lucky. Every control it touches is named in one table with a list of candidate labels, and a JSON overlay in `~/.routinectl/selectors.json` overrides that table without a rebuild. When a lookup fails, routinectl writes down the whole window as it actually is, hands that to the Claude CLI if you have one, accepts nothing back but corrected labels, validates them, and retries once. When you do not have the CLI, it writes the same record and tells you which file to edit. ``` routinectl doctor # names any control that no longer resolves, before anything fails routinectl repair # what self-repair has changed, and what it rejected routinectl repair --run # fix everything the open window says is out of date routinectl repair --reset # discard it all, back to the shipped table ``` Turn it off with `--no-repair` or `ROUTINECTL_AUTOREPAIR=0`. Full detail, including what it deliberately cannot fix, is in §9. ## Uninstall ``` ./install.sh --uninstall ``` --- ## Agent reference AUDIENCE: a Claude Code session, not a human. Read top to bottom before first use. ```yaml binary: ./bin/routinectl source: ./routinectl.swift build: swiftc -O routinectl.swift -o bin/routinectl platform: macOS, Apple silicon or Intel (rebuild from source on a different arch) targets: Claude Desktop, bundle com.anthropic.claudefordesktop output: JSON on stdout, always, including on failure side_effects: none outside the Claude Desktop UI foreground_required: no cursor_moved: no clipboard_used: no ``` ## 1. Preflight - run this first, every session ```bash routinectl doctor ``` Proceed only when `ok: true`. Decision table: | doctor says | meaning | do this | |---|---|---| | `accessibility_permission: false` | the host process cannot use the AX API | see §6 PERMISSION | | `app_running` errors, exit 3 | Claude Desktop is not launched | start Claude Desktop, retry | | `web_ax_tree: false`, low `nodes` | Chromium has not exposed web content | quit and reopen Claude Desktop, retry; see §6 OPAQUE_TREE | | `code_mode: false` | app is in Home mode | nothing - `create` switches it | | `axpress_returns_success: true` | informational only | ignore; see §5 | ## 2. Create a routine Single invocation. Every field is set and verified before the next one starts. ```bash routinectl create \ --name "Nightly repo health sweep" \ --description "Checks the project for uncommitted work and reports drift" \ --instructions "Run a health pass over the project. Report anything uncommitted. Change no files." \ --permissions "Bypass permissions" \ --model "Opus 5" \ --folder "$HOME/projects/example" \ --schedule custom \ --cron "0 1,2 * * *" ``` Equivalent, preferred when the instructions are long or contain shell metacharacters: ```bash routinectl create --spec /path/to/spec.json routinectl create --name N --description D --instructions-file ./instructions.md ... ``` ```json { "name": "Nightly repo health sweep", "description": "Checks the project for uncommitted work and reports drift", "instructions": "Run a health pass...", "permissions": "Bypass permissions", "model": "Opus 5", "folder": "/path/to/project", "worktree": false, "schedule": "custom", "cron": "0 1,2 * * *" } ``` CLI flags override `--spec` keys. `--dry-run` prints the resolved plan and exits 0 without touching the UI - use it to echo a spec back to the user for approval. ### Flags | flag | required | default | notes | |---|---|---|---| | `--name` | yes | - | the app title-cases it for display; match case-insensitively afterwards | | `--description` | yes | - | | | `--instructions` / `--instructions-file` | no | empty | the routine's prompt | | `--permissions` | no | form default (Manual) | exact string from §3 | | `--model` | no | form default (Default) | exact string from §3 | | `--folder` | no | unset | MUST already appear in the folder menu - see §6 FOLDER_MISSING | | `--worktree` | no | off | boolean flag | | `--schedule` | no | `daily` | `manual\|hourly\|daily\|weekdays\|weekly\|custom` | | `--cron` | with `custom` | - | standard 5-field cron | | `--at` | no | form default 09:00 | 24-hour `HH:MM`, ignored when schedule is `custom` | | `--dry-run` | no | off | resolve and print, change nothing | ### Cron The app runs the expression as written and renders its own reading back. Verify against the `repeats` field in the result, which is the app's interpretation, not the string you sent. | intent | cron | |---|---| | 1am and 2am daily | `0 1,2 * * *` | | every day 9am | `0 9 * * *` | | weekdays 7:30am | `30 7 * * 1-5` | | Sundays 4:30am | `30 4 * * 0` | | every 15 min | `*/15 * * * *` | Scheduled runs carry a randomized delay of several minutes; `1:08 AM` for a `0 1` cron is correct, not a bug. ## 3. Discover valid field values Never guess a permissions/model/folder string. Enumerate: ```bash routinectl options ``` Values as of app 1.26832.0 - re-run `options` rather than trusting this table: ```yaml permissions: [Settings default, Manual, Accept edits, Plan, Auto, Bypass permissions] model: [Default, Fable 5, Opus 5, Sonnet 5, Haiku 4.5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6] schedule: [Manual, Hourly, Daily, Weekdays, Weekly, Custom] folder: recently-used folders only, machine-specific ``` ## 4. Read and remove ```bash routinectl list # every routine: name, schedule, status routinectl show "" # one routine's saved fields routinectl delete "" --yes # irreversible; refuses without --yes ``` `list` reads the Routines page, so it returns all routines, not the sidebar's subset. Name matching is exact first, then prefix, case-insensitive. `show` returns the whole of each value. The detail page splits a long value across sibling `AXStaticText` nodes, so `show` joins the fragments in tree order, and a group holding several separate facts is read as facts rather than concatenated. Taking only the longest fragment gives output that is plausible, specific and wrong: an `instructions` value that starts partway in, a `folder` cut at its first separator, a `status` that is really the next-run line. If a value ever reads back shorter than what you wrote, suspect the reader before the stored routine; `create` already blocked until the field read back equal. **Every command needs Claude Desktop running WITH AN OPEN WINDOW**, not merely running. The app keeps its process alive with all windows closed, so `pgrep` says yes and every command still exits 3 with `Claude Desktop has no open window`. That is a precondition, not a fault: an unattended run that needs to read or write a routine has to check for it and degrade rather than treat it as a defect. `routinectl show "" --dump` prints the raw AX subtree behind each field: role, value, description, child count, in tree order. Reach for it when a value reads back wrong after an app update: the split points are a rendering detail and they will move again. ## 5. Exit codes - branch on these, not on stdout text | code | name | meaning | recovery | |---|---|---|---| | 0 | ok | action completed AND its postcondition held | - | | 1 | usage | bad or missing arguments | fix the command | | 2 | notFound | a required control is absent from the tree | wrong page or opaque tree; run `doctor` | | 3 | appMissing | app not running / no window | launch Claude Desktop | | 4 | noPermission | Accessibility not granted to the host | §6 PERMISSION | | 5 | postcondition | the action fired and the UI did not change | §6 - this is the important one | | 6 | optionMissing | menu does not offer that value; `available` lists what it does | pick from `available` | Exit 5 is the load-bearing code. This tool never reports success from a return value alone - it re-reads the UI and requires the change to be visible. A tool's own success line is not evidence. ## 6. Troubleshooting ### PERMISSION - exit 4, or every action silently does nothing Accessibility permission belongs to the process that **runs** the binary, not to the binary. Grant it in System Settings > Privacy & Security > Accessibility to whichever of these is the host: - iTerm2 / Terminal / Ghostty, if invoked from a terminal - **Claude Desktop itself**, if invoked from a session running inside the app's own terminal pane - the app driving the app still needs its own checkbox - BetterTouchTool, if invoked from a trigger After granting, fully quit and relaunch the host. The grant is not picked up by a running process. ### OPAQUE_TREE - exit 2 everywhere, `doctor` shows few nodes Chromium builds its web accessibility tree only when an assistive client asks, via `AXManualAccessibility` on the application element. routinectl sets it on startup and waits. If the tree stays opaque, another AX client may have reset it: quit Claude Desktop fully (not just close the window) and reopen. ### POSTCONDITION on Create - exit 5, "the routine did not save" The form is still open, so a value the app rejects is present. Check in order: 1. `--name` or `--description` empty after the write 2. cron expression malformed - the app validates it and blocks Create 3. an inline validation message the tool cannot see Recover by reading the form back, or press Cancel in the UI and retry with `--dry-run` first. ### MENU_NO_HIGHLIGHT - exit 5, "could not move the menu highlight" The menu opened but never reported `AXFocused` on any item, meaning it is not taking key events from this process. Almost always PERMISSION for the host, or the app is mid-update. Run `doctor`, then retry. ### FOLDER_MISSING - exit 6 on `--folder` The Folder control lists **recently-used folders only**; there is no path entry field. If the folder you want is not in `available`, it has never been opened in Claude Desktop. Open a session in that folder once through the app, then re-run. Do not attempt to type the path. ### NAME_MISMATCH - exit 2 on `show`/`delete` The app title-cases displayed names. Matching is case-insensitive, so this means the name genuinely differs. Read `available` in the error payload. ### After a Claude Desktop update, everything breaks at once Expected failure mode; see §7. Run `doctor` first, then a `--dry-run`, then one real create against a throwaway name, then `delete --yes` it. ## 7. Why this tool exists / what will break it Three facts hold the tool up. If a future app build changes any of them, this is where to look: 1. **`AXPress` is dead for web content.** As of Claude Desktop 1.26832.0 (measured 2026-08-10), `AXUIElementPerformAction(kAXPressAction)` on any control inside the Chromium web view returns `.success` and nothing happens - foreground and background alike. **Native** window controls on the same process and build still act on it (`AXMinimizeButton` minimizes correctly), so the fault is in the web AX bridge, not the application. A synthetic `CGEvent` mouse click posted to the pid at the control's rect also does nothing. What works: set `AXFocused = true` on the element, then post a Return key event to the process. `doctor` reports whether `AXPress` has started working again. 2. **Menu highlight is `AXFocused` on the `AXMenuItem`**, not the value string and not the item you focus yourself. Focusing a menu item does NOT select it. Open the control, post `down`, re-read which item carries `AXFocused`, repeat until the wanted item has it, then post Return. Arrow counts are never hardcoded: the highlight starts at the current selection on some menus and unhighlighted on others. 3. **`AXValue` writes still work** on text fields and text areas, and are the only route that never regressed. Controls are resolved by **role + label, never by tree path**. Index paths shift with window size, sidebar state, virtualized list scroll position and app version. This is also why window geometry is irrelevant: verified working with the window at full width and at half width, unchanged. The labels themselves are the part that moves, so they are not scattered through the code: every one of them is in `SELECTORS`, each with a list of candidates, and §9 covers the repair path that rewrites that list when a build renames something. ## 8. Efficient use from an agent loop - One `create` call does the whole form. Do not drive the UI step by step. - `--dry-run` to confirm intent with the user, then the same command without it. - `options` once per session if you need to offer choices; cache the result. - Branch on exit code, parse `error` and `hint` only for the message to the user. - Do not poll `list` in a loop; `create` already asserts the routine saved. --- MIT licensed, see `LICENSE`. Part of the [auto.relay70](https://auto.relay70.dev/) automation registry. ## 9. Self-repair, and what it can and cannot fix Everything this tool touches is named in one table, `SELECTORS` in the source, and each entry carries an ordered list of candidate labels. The first that resolves wins, so a control renamed in a new app build keeps working as long as one of its names is in the list. A JSON overlay overrides individual entries at run time, so a rename is fixable **without a rebuild**. ```bash routinectl repair # what is in force, what was rejected, recent failures routinectl repair --run # resolve every selector against the open window and fix the first that is missing routinectl repair --reset # discard the overlay, back to the built-in table ``` ### What happens on a drift Exit 2 (a control is not in the tree) and exit 5 (the control is there and the action did not take) are the two shapes "the interface moved" takes. On either, against a named control: 1. A record is written to `~/.routinectl/failures/-.json`: the lookup that failed, the app version, the argv, and every named element in the window as `role / subrole / label`. **This happens whether or not a repair agent exists**, and on a machine with no agent that file plus the printed command is the whole fix. 2. If a repair agent is reachable, it is given that record and asked for one JSON object: corrected labels for the one key that failed. 3. The answer is validated before it is used. Unknown keys, empty labels, lists longer than eight and anything that is not the expected shape are dropped by name with a reason, which `routinectl repair` prints. **The agent never touches the Swift source and never runs a command**; the only thing it can change is a list of strings in one file. 4. On a valid fix the overlay is written and the original command is re-executed once, in a fresh process. A second failure is a real failure: the recursion guard is an environment variable and there is no third attempt. `doctor` reports this surface before anything fails: `selectors_resolve` names every selector that does not resolve against the current window, and `self_repair` says whether an agent was found and which overlay is loaded. ### Configuration | Variable | Default | What it does | |---|---|---| | `ROUTINECTL_HOME` | `~/.routinectl` | Everything this tool writes lives here, and nothing outside it is written | | `ROUTINECTL_SELECTORS` | `$ROUTINECTL_HOME/selectors.json` | The overlay file | | `ROUTINECTL_AUTOREPAIR` | on | `0` to record the failure and stop there | | `ROUTINECTL_REPAIR_CMD` | the Claude CLI, found on `PATH` or in the usual install locations | Any executable taking `-p ` and printing the JSON object | | `ROUTINECTL_REPAIR_MODE` | `headless` | `terminal` opens an interactive session instead of running one silently | `--no-repair` on any command does the same as `ROUTINECTL_AUTOREPAIR=0` for that invocation. ### What it cannot fix Self-repair changes **labels**, nothing else. It handles a renamed control, a retitled button, a menu entry that changed wording. It does not handle a control that stopped existing, a field that moved to a different page, a new required step in the form, or a change in how the application responds to keyboard input. Those need the source, and the failure record is written for exactly that reason: it is the evidence a person or an agent needs to make the real change. Exit 1, 3, 4 and 6 are never sent to repair. A usage error, a closed application, a missing Accessibility grant and a menu that does not offer a value are all things reading the window cannot fix.