A queue for unattended coding-agents that runs on your Computer
macOS on Apple silicon · Windows x64 and ARM64 (beta)
DispatchQ takes the work you would otherwise babysit in a terminal and runs it while you are away, most usefully overnight, once your usage allowance has reset. A task is a prompt plus the folder it runs in, with a time it may start. A background daemon runs it headless through Claude Code, Codex or opencode, and in the morning the result is waiting: the full transcript in the Log, any file the run produced in Artifacts, and a notification on your phone if you asked for one.
It began as a way to spend the nightly Claude Code allowance, and the rate limit is still where it does most of its thinking: a run that hits the limit is paused, and the same session continues when the window reopens, or another provider takes the task over. Around that, the queue has grown into a small workflow engine:
- Several agents, one queue. Any number of providers and accounts, each with its own allowance. A task names the one it runs on, or is asked of several at once, with a further task that joins the answers.
- Tasks that make tasks. A run can queue follow-up work, publish a report
or an export, and send you a message, all through the bundled
dispatchqCLI. A prompt can say "if you find something worth fixing, queue it instead of doing it now." - Built for 3 a.m. Every run is told it is unattended. Prompts are checked against this Computer before they are queued. Hung runs are killed, orphaned runs recovered, and the Computer is held awake while a run is in flight; on macOS it is also woken for timed work.
- Nothing leaves the machine. Tasks, settings and history are plain files
under
~/Library/Application Support/dispatchq(on Windows%APPDATA%\dispatchq), and the daemon listens on loopback only. The agent CLIs talk to their own services; DispatchQ itself contacts nothing but GitHub's release API for update checks, and feedback is filed by you, in your browser.
- Queue the big refactor at lunch, let it run after midnight on the fresh allowance, and review the branch over coffee.
- Run a watcher every fifteen minutes that checks a feed, a mailbox or a build. Write it as a script job and it costs no usage at all, keeps running while the allowance is gone, and queues a real agent job the moment it finds something. Quiet history keeps its successes out of the Log; a real change reaches your phone as a push notification.
- Ask Claude and Codex the same question, with a third task that waits for both and merges the answers into one report.
- Publish a nightly PDF or HTML report to Artifacts and open it on your phone straight from the notification.
- Turn a backlog into tasks: one run reads the list and queues one task per item, so each gets its own session, log and outcome.
- Keep a list of nice-to-have work for allowance that would otherwise expire: a backfill task runs only while a provider is so far behind on its week that part of it would go unused, and waits otherwise.
- Hand a task to a colleague as a
.dispatchqfile. They import it, adjust the folder, and get the same job on their Computer. - Just ask. "Why did last night's run fail?", "show me the newest report", "every weekday at 7, summarise the open TODOs in this repo": the assistant answers in a sentence, opens what you asked for, and drafts the job for you to confirm.
Getting started
Doing more with it
- Providers
- Provider pools
- Command-line interface
- From another tool or agent
- What every run is told
- Letting a task queue follow-up work
- Asking several providers at once
- Letting a task publish artifacts
- Letting a task send a notification
- Script jobs
- Inline or from a file
- Quiet history for frequent jobs
- Backfill tasks
- Sharing tasks as files
- The prompt review
- Asking DispatchQ
- Sending feedback
Reference
- Features
- How scheduling and the limit gate behave
- When DispatchQ notifies
- Data on disk
- Uninstall
- Build from source
- Architecture
- Requirements
- License
DispatchQ is two pieces that share a file-based store:
- A background daemon (
dispatchqd) installed as a per-user launchd LaunchAgent on macOS, where it starts at login and restarts if it exits; on Windows it starts from the per-user Run key at sign-in and is not restarted if it exits. It does all the work: scheduling, running tasks through the provider's CLI, tracking the rate limit, planning wake-ups, pruning history, sending notifications, and serving the dashboard on127.0.0.1. Exactly one daemon owns a data directory: a second one started against the same store says so and exits, so a queue is never scheduled twice. - A native app window (
dispatchqapp) that wraps that dashboard in a WKWebView window on macOS and a WebView2 window on Windows. The app is just the UI — closing it never stops scheduling, and the daemon keeps running with the window shut.
The nightly cycle looks like this:
- You queue tasks during the day. Each task is a prompt plus the folder it runs in (its repo/context), with an optional trigger time. A task can also be a script job, where that text is a program instead of a prompt — see Script jobs. Either text can live in a file of yours instead, read at the start of every run — see Inline or from a file.
- The daemon watches the queue and starts due tasks headless, through the CLI of the provider each task names (Claude Code by default), one at a time unless a task is marked parallel. A script job is started the same way, but as a program: no provider, no model, no allowance spent.
- If a run hits the rate limit, DispatchQ pauses the whole queue and, once the limit clears, resumes the same Claude session — no work is lost. The pause is visible while it lasts: a banner names the time the queue continues, the run is marked rescheduled in the Log with its resume time, and you can drop that resume there so the task does not start again.
- On macOS, to run a timed task past a scheduled sleep, it wakes the Mac with
pmset, and keeps the Mac from idle sleep (caffeinate -i) while a run is in flight. That does not stop the sleep of a closed lid: a MacBook on battery with its lid shut sleeps anyway, and macOS wakes it only for a minute or so about every quarter of an hour, often without network. An agent task due in such a wake waits for the network instead of failing, and a notification that could not go out is sent once the Mac is online. On Windows it holds the PC awake while a run is active but does not wake it from sleep. - Results — success, failure, rate-limit wait, or auth problem — land in the Log with the full run output, optionally with a notification.
These steps install DispatchQ on macOS. For Windows, see Windows (beta).
- Download the latest
dispatchq-<version>-macos-arm64.pkgfrom the Releases page (older releases name itdispatchq-<version>.pkg). It runs on Apple silicon Macs only; there is no Intel build. - Open it and follow the installer.
The package installs DispatchQ to /Applications, sets up a per-user
LaunchAgent so the daemon starts at login, and opens DispatchQ when it is
done, so you can start adding tasks right away. Installing over an existing
version works the same way: the installer closes the open DispatchQ window first,
replaces the daemon and reopens the new version at the end, so an update takes
effect without a manual restart. A task that is running during the update keeps
running: the new daemon takes the run back (see Runs survive a daemon restart).
The installer verifies the hand-over instead of assuming it: it waits until the
daemon that answers on 127.0.0.1:10765 reports the version it just installed,
retries once (dropping a stale LaunchAgent that still points at an old copy of
the app), and reports the install as failed if the new daemon never takes
over — rather than finishing green while the machine keeps running the old
version. It resolves the logged-in user through macOS SystemConfiguration,
the console device and console login records, validating that the user's GUI
session exists. If user detection fails while a daemon is running, the
install fails instead of silently skipping the update. Before stopping
processes it unloads the LaunchAgent, so it cannot restart an outdated daemon
during the update. It replaces stale agent files, re-enables the DispatchQ service
and explicitly starts it. Remaining daemons are stopped only for the logged-in
user. An install cannot succeed while an outdated daemon of that user remains
running, or if the bundled daemon cannot report its version. It also lists any
other DispatchQ.app copies it finds.
If installation fails, open Window →
Installer Log in macOS Installer, select Show All Logs and look for the
dispatchq: messages. Closing the app window alone does not stop its daemon.
The package is not notarized, so on first launch macOS may warn that it is from an unidentified developer. Right-click DispatchQ → Open, then confirm — or allow it under System Settings → Privacy & Security.
You'll also see two normal macOS prompts by design: Allow notifications? on first launch, and allow access to your Documents? the first time a task's folder is in a protected location (Documents, Desktop, Downloads). Allow both so unattended runs aren't blocked.
macOS, not DispatchQ, decides how long a notification stays on screen. DispatchQ asks for the Alerts style, which waits until you click it — but if macOS already knows the app (or overrides it), set it under System Settings → Notifications → DispatchQ → Alerts. Settings has a button that opens that pane directly.
To run tasks past a scheduled sleep, DispatchQ schedules wakes with pmset, which
needs one sudoers entry (the daemon prints the exact line on install, and the
dashboard shows it if a wake ever fails):
echo "$USER ALL=(root) NOPASSWD: /usr/bin/pmset" | sudo tee /etc/sudoers.d/dispatchqDispatchQ runs natively on Windows 11 PCs with an x64 or ARM64 processor. It
needs no WSL. Every release, from 2.1.0 on, attaches two Windows installers next
to the macOS package, under the same version:
dispatchq-<version>-windows-x64-beta.exe and
dispatchq-<version>-windows-arm64-beta.exe. Only the file name marks them as
beta; the version number and the release are the same as for macOS.
- Check your processor under Settings > System > About > System type, then
download the matching installer from the
Releases page:
- x64-based processor (Intel or AMD):
dispatchq-<version>-windows-x64-beta.exe - ARM-based processor (for example Snapdragon):
dispatchq-<version>-windows-arm64-beta.exe. The x64 installer also runs there, with its programs emulated.
- x64-based processor (Intel or AMD):
- Run it. Setup installs for your user only and needs no administrator rights. The installer is not signed, so SmartScreen warns that the publisher is unknown: choose More info, then Run anyway.
- Finish the wizard. Open DispatchQ on its last page starts the window.
Setup installs under %LOCALAPPDATA%\Programs\DispatchQ, registers the daemon
to start at every sign-in, adds the CLI to your user PATH, and installs the
Microsoft Edge WebView2 Runtime only when the window would not find a usable
one: dispatchqd webview2-status repeats the window's own lookup and loads the
runtime's library the way the window does, in a separate process ended after 20
seconds (setup ends the check after a minute). When the runtime is still not
confirmed usable after Microsoft's bootstrapper, setup shows the bootstrapper's
result and what the check found, and finishes the rest of the installation; it
does not repair a damaged runtime.
Each installer holds programs for one architecture. The installer itself is a 32-bit x86 program for both (NSIS has no ARM64 stub), which Windows on ARM runs emulated. The ARM64 installer refuses to run on a non-ARM PC; the x64 one runs on 64-bit Windows, including Windows 11 on ARM.
To update, use About > Check for updates or run a newer installer over the
existing one. Uninstall from Settings > Apps, the Start menu, or
dispatchq uninstall (/PURGE also deletes %APPDATA%\dispatchq). Installing,
upgrading and uninstalling stop only this installation's window and the daemon
that has your data directory's lock file open, as Windows reports it, after
checking that it runs from the installation folder (or, when installing, from
the folder the previous installer recorded). Another copy of DispatchQ, or a
daemon for a different DISPATCHQ_HOME, keeps running. When such a copy uses
your data directory, or its owner cannot be identified, setup and the
uninstaller stop before changing anything, show which process it is and offer
Retry once you have quit it; dispatchqd install and uninstall fail the same
way. Setup never runs the dispatchqd it is about to replace; it uses a copy of
the new one. The step-by-step notes and a first-start checklist are in
INSTALL-WINDOWS.txt in this
repository; the installer does not copy them to the PC.
The beta has had little testing on real Windows machines. The table below lists what differs from macOS and what is not available; the end of this section says what has been verified.
It reuses the same daemon, queue and dashboard. What differs on Windows:
| Area | Windows beta |
|---|---|
| Window | WebView2, the same dashboard |
| Background service | dispatchqd.exe, started from the per-user Run key at sign-in; runs only while you are signed in |
| Data | %APPDATA%\dispatchq; the daemon log is dispatchqd.log there |
| Providers | native claude, codex, opencode found in the usual install folders, npm and scoop shims, then PATH; an npm or scoop .cmd shim is resolved to the program it starts; a Claude login is read from .credentials.json |
| Cancel | every run starts suspended, joins a Windows Job Object and is then resumed, so even a child started at once belongs to it; cancelling, the idle watchdog or the daemon dying ends the whole process tree, and a run whose job cannot be set up does not start. The assistant, prompt reviews, readiness probes and the Codex limits reading start the same way, so a cancelled or timed-out request also ends every tool its provider started |
| Script jobs | run as Windows PowerShell, written as UTF-8 with a byte order mark so non-ASCII text survives; a #! line is refused |
| Agent jobs | the built-in guidance tells the agent to call the CLI in PowerShell through $env:DISPATCHQ_BIN (a bare dispatchq when it is unset) and names Windows paths; a provider in WSL gets the Linux shell version |
| Keep awake | held while a run is active (the display may still sleep) |
| Folder and save dialogs | the native Windows dialogs, opened by the daemon through PowerShell |
| Providers in WSL | optional, per provider (see below); a PC without WSL is unaffected |
| Notifications | Windows toasts, sent through Windows PowerShell 5.1 under the Start menu entry's app identity, which the daemon registers at start; a link opens in the default browser. Pushover, ntfy and webhooks work as on macOS. Whether a toast without a link stays in the notification center and what clicking it does is not yet verified on Windows |
| Shortcuts and wording | every shortcut hint shows Ctrl where this README says ⌘ (Ctrl+K, Ctrl+J, Ctrl+1–Ctrl+8, Ctrl+S, Ctrl+Enter); ⌘N and ⌘R come from the macOS menu bar and have no Windows equivalent. Settings speak of this computer and name Windows' own places (Settings > System > Notifications, Installed apps) instead of System Settings or the macOS Installer |
| Updates | About > Check for updates (and dispatchq update check) looks for the newest release with an installer for this PC's architecture, dispatchq-<version>-windows-<x64|arm64>-beta.exe or the same name without -beta; for one version the name without -beta wins. Download & install saves it to Downloads and opens setup; when you continue there, setup stops and replaces this installation |
| Not available | wake from sleep, continue in a terminal, uninstall button, appearance pinning, script jobs in WSL |
A provider can run inside a Linux distribution of the Windows Subsystem for
Linux instead of in Windows. It is a property of one provider, chosen when you
add it (Settings > Providers > Add provider > Runs in: Linux (WSL), or
dispatchq provider add ID --kind claude-code --wsl-distro Ubuntu [--wsl-user dm])
and fixed afterwards: to run elsewhere, add another provider. So a Windows
Claude and a Linux Claude sit side by side with their own sessions, readiness
and limits, and a task, pool or fallback picks between them like any two
providers.
Native Windows stays the default and needs no WSL. Every existing and new
provider runs in Windows unless you choose Linux for it. Nothing starts
wsl.exe at start-up, for scheduling, readiness, models, limits or asides of a
Windows provider; the dashboard asks for the distribution list only after you
pick Linux. A test installs the WSL service with a recording fake wsl.exe
and fails if a native provider's readiness, models, run, aside or folder check
starts it. On macOS the choice does not exist.
| Linux (WSL) provider | |
|---|---|
| Login and settings | the Linux ones: install the CLI and run claude auth login (or the Codex or OpenCode login) inside Linux as that user; nothing is copied between Windows and Linux |
| Paths | binary and configuration directory are Linux paths (~ is the Linux home); empty binary finds the CLI on the Linux login PATH, never a Windows .exe. Provider executables must be readable; Windows programs are rejected for explicit paths and symlinks too. The Windows CLI bridge for follow-ups remains available. |
| Task folders | stored as Windows sees them: C:\repo (opened as /mnt/c/repo, slower) or \\wsl.localhost\Ubuntu\home\you\repo (fast). A Linux path typed for a task that runs in exactly one distribution is stored as that share. A pool or fallback skips a provider that cannot open the task's folder; a provider named directly that cannot is refused when the task is saved |
| How it runs | wsl.exe --distribution D [--user U] --cd / --exec <helper> <operation>, with nothing else on the command line. The program's arguments, environment (including API keys) and input go to DispatchQ's Linux helper as frames on its standard input, never on a command line, in a file or in WSLENV |
| Helper | dispatchq-wsl, a static Linux program shipped for x86-64 and arm64 in both installers; the distribution's own processor picks it (an x64 installer on Windows on Arm still runs arm64 distributions), and its ELF machine is checked before it starts |
| Cancel and watchdog | the helper stops the whole Linux process tree, then the Windows side ends wsl.exe |
| Daemon gone | the helper stops the tree when its channel closes or when it hears nothing for 15 s (the daemon pings every 3 s). If the helper itself was killed, the next daemon start stops the trees of this installation whose helper is provably gone, checked by process start time; nothing else in Linux is touched, and DispatchQ never stops a distribution or WSL |
| Follow-up jobs, artifacts, notifications | the run calls DispatchQ for Windows through WSL interop ($DISPATCHQ_BIN); Linux paths given to --dir and --file become Windows ones. The run's guidance uses Linux shell commands; DISPATCHQ_HOME belongs to the Windows daemon and must be translated with wslpath for direct file access. No network port is opened. With interop off in the distribution, runs work but these calls fail |
| Not supported | script jobs in WSL (scripts run in Windows; call wsl.exe from one), WSL 1 (warned), drive mounting off (the helper cannot be reached; the error says so) |
Nothing of this has run on a real WSL installation yet. The transport, the
helper's process-tree handling, cancel, daemon-death and clean-up are tested on
macOS by running the real helper code behind a fake wsl.exe with real
processes; whether wsl.exe forwards end of file and exit codes as documented,
and how long a distribution takes to start, are checked on the PC
(INSTALL-WINDOWS.txt, "Providers in
WSL"). A build older than 2.1.0 ignores a provider's [providers.execution]
table and drops it when it rewrites the configuration.
Platform layer: the Windows beta builds on a platform abstraction layer
(cmd/dispatchqd/platform.go) that keeps operating-system specifics out of the
shared daemon. platform_windows.go supplies the Run-key service
manager, the termination of this installation's daemon (the process Windows reports as having
the data directory's lock file open, checked by process instance and executable path,
never by process name),
the PowerShell dialogs, the Windows version and the
no-console log setup; internal/system supplies the sleep inhibitor (the thread-pinned
SetThreadExecutionState hold on Windows, caffeinate -i on macOS). Both the daemon and
dispatchq run-now take it from system.DefaultSleepInhibitor. Linux and other systems
keep the explicit unsupported fallback (platform_other.go).
The Windows code has been cross-compiled and its portable parts (shim resolution, the installer PATH script, the build's architecture handling) are unit-tested on macOS; the Windows-only Job Object tests are compiled but have not run. An x64 build runs on one Windows PC; the ARM64 build has not been run on Windows on ARM hardware (see docs/windows/PLAN.md for the verification gaps).
- Ask DispatchQ (⌘J) and describe the job in plain words; the assistant drafts it and you confirm it on its card (see Asking DispatchQ). Or open the task form with the + next to it (⌘N) — give it a prompt, choose the job type (Agent, the default, or Script — see Script jobs), write the prompt or script Inline or switch it to File and pick the file it is read from on every run with Choose… (see Inline or from a file), pick the working folder (prefilled from Settings → General → Default working directory, if you set one), and choose a trigger (as-soon-as-possible, earliest start, or cron; a cron schedule is validated as you type, with a preview of its next three runs). Optionally pick the provider it runs on (only ones that can actually run are offered), override the model or reasoning effort or permissions, or enable parallel / notify on result. The folder dialog starts at the folder currently set for the task; if that folder no longer exists it opens at the nearest existing parent, and at your home folder when nothing is set — so a task whose folder was deleted or renamed can always be pointed somewhere new.
- Leave it queued. The daemon runs it at the scheduled time (or overnight when the allowance resets).
- Check the Log for the outcome, open a run to read the full log, or replay it. A finished one-shot task leaves the queue but stays in history; recurring tasks remain queued for their next occurrence. Its id is free again: a task added later under the same id is a new task and runs.
- The Dashboard shows how much of each provider's allowance is left; Usage shows what DispatchQ consumed over the last 14 days.
The complete list, one line each. Every item links to the section that explains it.
Scheduling
- Three triggers per task: as soon as possible, earliest start at a fixed date and time, or a 5-field cron schedule that is validated as you type, with a preview of its next three runs. See How scheduling and the limit gate behave.
- Priority is list order, top first. Reorder in the app or with
dispatchq move. - One at a time by default. A task marked parallel runs alongside other parallel tasks. A task that runs alone and has waited five minutes behind parallel work gets its turn: nothing new starts until it has run.
- Pause everything with one switch in Settings: a run in flight finishes, nothing new starts, and the Computer is not woken.
Providers
- Three harnesses, one queue: Claude Code, Codex and opencode. Same queue, same history, same limit handling. See Providers.
- Any number of accounts. Two providers of the same kind with separate configuration directories are two accounts with two allowances.
- How much is left, at a glance. The Dashboard shows each account's 5-hour and weekly window with its reset time, read without spending any of it. See How much is left.
- API key instead of a login. A provider can carry environment variables;
an
ANTHROPIC_API_KEYon a Claude provider runs it on API billing, and its cost shows in Usage. See Environment variables. - Checked before the night. A provider whose CLI is missing or logged out blocks its tasks with the reason instead of failing them; they start by themselves once it works again. See Is it ready?.
- Rate limit per provider. A run that hits the limit is paused and its session resumed when the limit clears; only that provider's tasks wait, and the pause is visible as rescheduled with the time it continues. See When the limit is reached.
- A fallback for the limit. A provider can name another one to take its tasks while its allowance is used up, so the night carries on.
- Pools spread the work. Put two subscriptions in a pool and give the task the pool: every run goes to the account whose weekly allowance would otherwise expire unused soonest, weighted by plan size. See Provider pools.
- Auth errors are their own outcome, notified, never silently retried.
Tasks that do more
- Two kinds of job. An agent job sends its prompt to a model; a script job runs it as a program — deterministic, free, and never held up by a rate limit. See Script jobs.
- Inline or from a file. Either kind of job can keep its prompt or script in a file of yours, read fresh at the start of every run, so the job follows the file as it changes. See Inline or from a file.
- Per-task overrides for provider, model, reasoning effort, permission handling, notification and quiet history, layered over global defaults.
- Built-in run framing. Every run is told it is headless and unattended, so it finishes or hands on instead of waiting for someone. See What every run is told.
- Custom system prompt appended to every run after the built-in one.
- Prompt review. Before a task is queued, Claude reads the prompt against this Computer and names what would go wrong at 3 a.m.; one click applies the rewrite. See The prompt review.
- Self-queueing. A run schedules follow-up tasks with
dispatchq queue. See Letting a task queue follow-up work. - Fan-out and join. One job per provider, and a job that waits for all of them and combines the results. See Asking several providers at once.
- Artifacts. A run publishes a file with
dispatchq publish; it lands in the Artifacts view with an in-app viewer for HTML, PDF, images, Markdown and text. See Letting a task publish artifacts. - Own notifications. A run sends a message with
dispatchq notify, optionally with a link that opens on click. See Letting a task send a notification. - Quiet history for frequent watchers: successful runs stay out of the Log (two days on hand behind Show hidden runs), failures show as usual. See Quiet history for frequent jobs.
- Backfill tasks soak up allowance that would otherwise expire: they run only while a provider's urgency is above a threshold you set on the Dashboard. See Backfill tasks.
- Share a task as a
.dispatchqfile; the importer adjusts folder and prompt before it is queued. See Sharing tasks as files.
Unattended by design
- Hung runs are killed after a configurable idle timeout, whole process group included.
- Runs cut off by a restart are recorded and notified. A run whose process
ended while no daemon watched it (a crash, a power loss, an update that killed
the daemon) gets its outcome at the next start: the one its session transcript
shows when the turn had ended (Claude Code), otherwise failed as
interrupted. A turn that ended on an API error is read the way a live run's
is: a usage limit pauses the provider until the window the error names and the
session resumes then, a login problem is recorded as
auth_error, any other error as failed. This holds for quiet-history runs too, which have no history row until they end; such a run is recorded as a normal, visible row, not a hidden quiet one. It is notified like any other failure, and the daemon log names each run (run … ended while no daemon watched it). - An interrupted one-shot continues once. When DispatchQ itself cuts off a one-shot task's run (the 30-second shutdown grace runs out, the daemon dies under it, or a run taken back after a restart ends without a readable outcome), the task is not retired half done and not started over: its next run continues the same session, so the model sees what it already did. It is not handed the task's prompt again but told that it was cut off, to check what is already done and finish only what is missing. The run's reason says so, and so does a note in the next run's log. A limit that pauses the continuation turns it into an ordinary limit resume. Cut off a second time, even after such a pause, the task is retired. A task deleted meanwhile is not continued. This needs a session the harness itself named: one it reported before the cut, or a Claude Code transcript on disk. Without one (a Codex or opencode run taken back or ended while DispatchQ was down, a harness stopped before it started), the task is retired as before. Recurring tasks start their next occurrence fresh as before, and scripts are not continued.
- Runs survive a daemon restart. A run's process does not stop when the
daemon does (an update, a crash). The next daemon takes the run back: it
shows as running with its process id, its task is not started a second
time, Cancel and Continue in Terminal stop it. When it ends, its
outcome and answer are read from its session transcript (Claude Code);
where that cannot tell (another harness, a turn that had not ended), it is
recorded as failed with the reason that its exit status and the output
written after the restart could not be captured, and a one-shot task
continues its session once, as above. The daemon log says
took back run …. A run an older version already wrote off as interrupted is taken back the same way when its headless harness is still running with that session (Claude Code, within seven days). The idle timeout does not apply to a run taken back. - Wake and stay awake. On macOS,
pmsetwakes the Mac at each task's time plus an hourly heartbeat,caffeinate -ikeeps it from idle sleep through a run, and a run frozen across a long sleep is not mistaken for a hung one. Closing the lid on battery still sleeps the Mac; see No network, no start for the short wakes that follow. A broken wake setup is shown in the dashboard. On Windows the PC is held awake during a run but not woken from sleep. - File-access prompts up front. Task folders are read at login and on edit, so macOS asks for Documents access while you are present.
- Bounded history. Old runs are pruned to a configurable count; an unread failure stays until you have read it.
- No run record is lost to a bad moment on disk. A write to history, or the
write that takes a finished one-shot task out of the queue, is tried again
for a few seconds before it counts as failed. A run record history still
refuses is kept in
runs/<run-id>.pending.json, written to history ahead of the next record, and at the latest at the next daemon start (wrote N run record(s) to history …in the daemon log). A one-shot task whose removal from the queue failed after it ran is taken out of it at the next start (removed task … from the queuein the daemon log), unless a run of it is still going or its session waits to continue.
Seeing what happened
- Notifications natively on macOS and, optionally, to Pushover, an ntfy topic, or any incoming webhook (Slack, Discord, Home Assistant, n8n) with a JSON body you template. Failures always notify, with high priority on the phone, once per failing streak, and again when the task works; successes only if the task opts in. A notification a channel could not take (no network in a short wake) is tried again for a day. Every notification also lands in the app's Notifications view, and clicking the macOS banner marks it read there and opens what it is about — the artifact, the run's log, the link. See When DispatchQ notifies.
- Full history of every run with its complete log as a chat transcript or raw output, and replay.
- Continue in Chat… opens Terminal in the task's folder and resumes the run's session interactively, with the harness and account that ran it (a run a rate-limit fallback took over reopens on the fallback's account). On a running task the same button takes it over: the unattended run is stopped, and its chat reopens in Terminal so you can write to it.
- Usage: tokens, runs and API-equivalent cost per day over the last 14 days.
Platform
- Scriptable. The bundled
dispatchqCLI does everything the window does, with--jsonoutput for tools and agents. See Command-line interface. - Native macOS: its own window, Dock icon and menu bar. Light or dark and the accent colour follow macOS live, and either can be pinned in Settings → Appearance.
- Keyboard first: a command palette (⌘K) for every view, action, task and recent run, ⌘1–⌘8 for the views, ⌘F to search a list.
- Automatic updates from GitHub releases, one click to install. A version whose daemon never took over is flagged with a Finish update button. A development build can Reinstall the latest release, as long as that release still reads the data the build left behind.
- An assistant that operates DispatchQ. Ask about runs and artifacts, have
it open them, or describe a job and confirm the draft. It knows every
dispatchqcommand and can do whatever the command line can, each change on a card you confirm. See Asking DispatchQ. - Feedback that writes itself. Describe a bug or a wish, Claude drafts the GitHub issue, and you file it yourself. See Sending feedback.
- Local and private. Human-readable TOML/JSON under your Library folder; the API is loopback-only.
The dashboard (and the native window that wraps it) has eight views. Six sit at
the top of the sidebar; Settings and Feedback (below)
sit at the bottom, out of the way of the work. Above them are Ask DispatchQ
(⌘J, the assistant), a + that opens the task form
directly (⌘N), and the command palette (⌘K): type to jump to a view, add or import a task,
pause or resume all runs, switch the appearance, open a task (or run it now)
or a recent run's log. ⌘1 to ⌘8 switch views in sidebar order. In a
narrow window the sidebar folds to a column of icons, each named in a tooltip;
its toggle lays the full sidebar over the view until a view is picked. Every
view fits a window about 850 px wide as well: a view's header puts its
controls on a line of their own when they would crowd the title, Settings
lists its sections in a wrapping row above the section, a Log entry moves its
duration and tokens under the task name, and a notification keeps its title
whole while the task name beside it gives way. Tasks
are added and edited in a panel that slides in from the right, so the list stays
visible. Its sections run What (name, group, job type), When, Prompt or
Script, Where, How it runs, so the schedule is in view before the long prompt;
Group picks an existing queue group or starts one by typing a name (an edit
changes the group only when this field is changed). A one-line summary above
the footer says what saving will do ("Runs every weekday at 07:00 on Claude
Code · Opus"). Recurring starts from 0 3 * * *, nightly at 03:00, which is
also the first preset. ⌘↵ saves, and closing with unsaved changes asks
first. The window opens on the Dashboard:
- Dashboard — what needs attention at a glance. On top, the allowance
strip: one line per enabled provider, with exhausted allowance or refused API
credit sorted last and lightly dimmed. Both groups are sorted alphabetically
by name. Each line shows its logo, what is left of each
window (for Claude the 5-hour and the weekly window, for Codex the windows its
account has) and when each resets; a bar turns orange at 25% left and red at
10%. At the right end of each line sits the provider's urgency on a small
gauge with the backfill threshold as a tick. In a narrow
window each allowance window moves to a line of its own under the
provider's name. The label shows
only the urgency value, without a running-task count; it turns
green while the provider is above it. It is read without spending any usage
(see How much is left); the strip says how old the
reading is and Read now asks again. An account on an API key has no
balance anyone can read, so DispatchQ proves its credit instead: once a day
it sends the account a one-word ping (Reply with the single word pong, on
Haiku, a fraction of a cent when it answers and nothing when it is refused),
and every run on the account counts as a proof too. A ping that cannot reach
the account at all (no network at login, say) is no test and is tried again
an hour later. The line says what the latest proof found, on the right where
a plan account says week used up: credit available, or credit used up in red; its tooltip says which proof and when. An account
refused with Credit balance is too low is treated exactly like a used-up
week: it takes no work until a ping gets an answer again, also across a
restart of the app, its tasks go to the next pool member or fallback at once,
and no banner announces it. The next day's ping checks it
again; after topping up the credit, Read now pings every API-key account
at once and unlocks the account straight away. Opening the Dashboard and
⌘R do not ping. A billing refusal
that does not name the credit balance (a spend limit set on the account,
say) pauses the provider the same way and also reads credit used up.
dispatchq provider limitsshows the same latest proof. Right under it, the Pool card puts every pool on one line, its members in the order a run started now would take them. Below that, cards in a fixed order: what is running now (and runs a rate limit paused, with their resume time, counted apart as N waiting; a run whose provider has no API credit left says only No credit left, without a resume time), what is up next (due tasks first, with the reason a task is held; tasks with quiet history and one-time tasks that already ran are left out), runs that failed in the last 24 hours, and the last 7 days (runs, success rate, tokens and cost). Each entry opens its log or its task. Running now lists five runs and its +N more unfolds the rest in place; on the other cards it opens the Log filtered to failures, or the Queue. These four cards sit in two columns where the view is wide enough, and one under the other at full width where it is not. - Queue — the pending tasks in priority order. Add, edit, delete, enable/pause,
reorder, or run now (a manual test run, independent of the trigger). Each
row carries its task's name, its schedule in words (Every day at 03:00,
Mondays at 06:30, Yearly on 1 Jan at 00:00) and when it is next due (a
date once it is more than a week away), what it runs on (the provider and the
model that provider will use for it), and how its last run ended — a dot and
last: failed 1 h ago; clicking it opens that run's log. ⌘F searches the
tasks by name, group, provider or model, schedule and label; groups without a
match are hidden, and dragging is off while a search is active. Run now
keeps you on the Queue; its toast offers Open log (from the command
palette it opens the log straight away). Enabling or pausing a task with its
switch takes effect without a toast. Run now and the edit button
appear when the pointer is on a row; the … menu holds the rest, including
Move up / Move down and Move to group, so everything dragging does
also works from the keyboard. The All / Active switch in the toolbar hides
the paused tasks; moving a task up then puts it above the next visible one,
skipping the hidden tasks in between. Tasks can be filed into groups: grab a row by its handle and drag
it onto a group's header to move it there, between two rows to reorder it, or
onto Drop here to start a new group to make a group on the spot — it is
named as it is created. A group header folds its section shut and says how
many tasks are inside; whether it is open or folded is remembered across
restarts. Dragging a header moves the whole section, so the groups can be put
in the order the work happens in, and the pencil on a header renames the group
— typing the name of a group that already exists merges the two, after asking. Dragging the last task out of a group removes the group, since a
group is nothing but the tasks that name it. While the global pause switch is on, a yellow banner sits above the
list (with a Resume runs button) and Run now is disabled on every
row. A running one-shot task moves to
Log; a recurring task stays here with a running badge; hovering its
cron expression shows the next occurrence and when it last ran. Underneath
each task sits a badge for every option it has switched on: script (it runs
as a program, without a model), file (it reads its prompt or script from a
file on every run; the tooltip names it), parallel, granted (orange, the task skips
permission prompts), notifies (blue), and silent (quiet history). A script
job has no provider or model to name; its script badge says so. A task the rate limit interrupted carries a
rescheduled badge (orange) whose tooltip names when its interrupted session
continues, and a task whose provider cannot run it carries a red blocked
badge naming what is wrong. A task that is due but did not start carries an
orange waiting badge with how long it has been due (waiting 12 min, counted
from the daemon's start if it restarted meanwhile); its
tooltip gives the scheduler's reason, such as the pause, a task that runs
alone, or an account out of allowance until a given time. A wait of five
minutes or more is also written to
dispatchqd.out.logwith its reason, once, so it can still be explained after the badge is gone. Export in a row's … menu saves the task as a.dispatchqfile via the native save panel, and Import… in the toolbar opens such a file in the task sheet for review. Whenever a prompt is written or changed, Claude checks it against this Computer and shows what it found in a purple banner under the prompt box, with Apply to take the rewrite; just opening a sheet again shows the earlier finding and costs nothing. A script job is reviewed as the program it is, and the sheet hides the provider, model and permission settings it cannot have. See The prompt review. - Log — every run, newest first. A failed or canceled run shows its error
under the task name (the whole text on hover).
Open a run to see the live/finished log as a chat view or raw output, along
with the prompt. The chat view pairs every tool call with its result and folds
a run of tool calls into one line, renders the assistant's text with its
headings, lists and code blocks, follows a live run as it writes (scrolling up
stops that), and has copy buttons for messages, code and the whole log;
the artifacts the run published sit at the top and open in the viewer, and
Edit task opens the task as it is now in the queue (off once it was
deleted). A script run shows its script and Output | Raw instead; a running task can be stopped from there with Cancel task
(its process is terminated and the run is recorded as
canceled); a run the rate limit paused shows as rescheduled with the time it continues, and Cancel resume — on the row and in its log view — drops that plan: the interrupted session is discarded, the run is recorded ascanceled, and a one-shot task leaves the queue instead of starting again (a recurring task keeps its schedule and starts fresh at its next occurrence); a finished run offers Continue in Chat…, and so does a running one in its log view, where it takes the run over: after a confirmation the unattended process is stopped, the run is recorded ascanceled("taken over in Terminal"), and only once it is gone does the session reopen. Continue in Chat… opens Terminal in the task's folder and resumes the run's session interactively — with the harness that owns it (claude --resume,codex resumefor a Codex run,opencode --sessionfor an opencode run) and the same permission mode the run had (a skip-permissions task resumes with--dangerously-skip-permissions); the button needs the session to still exist — Claude Code prunes old sessions after ~30 days; Show more loads older runs 25 at a time; and replay a task. Above the list sits the same filter bar as in Artifacts: a search field (⌘F jumps to it, Escape clears it) where every word has to occur somewhere in the run — task, folder, harness, model, group, outcome or error —, All | Running | Failed (Running shows only the runs executing right now, Failed the failed ones and auth errors), and Filter, which holds the queue group, the task, the provider, the outcome (success, failed, rate limited, …), the start date (Any time, Today, 7 days, 30 days, or a From/To day) and Show hidden runs. Its badge counts what is set; each active filter also shows as a chip next to it, × removes that one, and Reset in the popover clears them all. A quiet history task's successful runs are hidden: the list shows its runs that did not succeed (and the one in flight) without any filter, and Show hidden runs adds the successes still kept (up to 48 hours, see Quiet history), each marked Hidden with an eye icon. The switch is temporary: it holds while the window stays open (like the other filters, it survives a trip to another view) and is off again after a reload or a restart. - Artifacts — files your tasks published, newest first.
The list is grouped by day (Today, Yesterday, then the date), one card per
artifact with an icon and colour for its file type (the switch next to
Notify me turns the cards into a compact list; the choice is kept for
this window): its title, its
description, the file name and size, a type tag, who made it, and when. "Who made it" is the parent job when
another job created the publisher (a watcher that filed a review job), else
the publisher itself; hovering it spells out the whole chain, clicking it
opens the producing run's log (while that run is still in history). Clicking a card opens the
artifact in a full-window viewer; ← and → step to the previous and
next artifact of the list, like Quick Look: HTML, PDF, images, and text are previewed
inline, anything else (archives, binaries) shows a short note instead of a
preview. A web link clicked inside an HTML artifact opens in your default
browser, since the preview cannot load outside pages; links to a spot within
the page still scroll there. Markdown files (
.md) are shown rendered, with headings, tables and checklists; their web links open in your default browser too, and images show only their alt text because the preview loads nothing from outside. The viewer names the file and its size under the title and offers Open externally, which hands the file to your browser to open or save, and — when the run that published the artifact can still be resumed — Continue in Chat…, the same interactive resume as in a run's log. Opening an artifact either way also marks the notification about it read. Deleting one (which removes the stored copy) is on its card while the pointer is there. Notify me in the toolbar is the same switch as Settings → Notifications → Notify about new artifacts: off, new artifacts arrive here quietly, without a notification. Above the list sits a filter bar. It starts with a search field (⌘F jumps to it, Escape clears it): every word you type has to occur somewhere in the artifact — title, description, file name, task, parent, group or file type — so "adr 0050" finds the ADR-0050 review and "seo pdf" the PDF of the SEO job. Next to it, as in the Log, Filter holds the publish date, the queue group and the parent job, with each active one shown as a removable chip; the list shows 25 artifacts at a time with a footer holding the count and Show more. The group narrows the list to one queue group, and the parent to the artifacts one top-level job produced, itself or through the jobs it created (see where an artifact comes from). The parent menu lists only jobs at the root of a chain, never the one-off jobs a watcher filed. Clicking the notification of a newly published artifact opens it in the viewer over whatever view is showing. - Notifications — every notification DispatchQ sent, newest first, with an
unread badge. It is the one view with a read state; the Log and Artifacts
carry no badges, since what matters in them arrives here as well: a run's outcome, a new artifact, a provider that stopped or
resumed running tasks or is paused for hours, a task that has waited long
without starting, and whatever a task said with
dispatchq notify. A task that keeps failing has one row that counts its failures ("failing since 08:00 (5 times)") and moves to the top with each. Each row shows the notification as it was shown on screen — a glyph for its kind, the title, the message — and when it was sent; the list is grouped by day and grows with Show more like Artifacts; ↑ and ↓ move between rows. A row does what clicking the macOS notification does: it turns read, and what it is about opens — the artifact in the viewer, the run's log, a task's link in the browser. A message does not repeat the task's name, which stands next to the title. The other way round, clicking a macOS notification marks it read here before it opens its target; a notification with no target (a provider's state) lands in this view. Opening an artifact or a run's log from its own view also reads the notification about it, and Mark all read in the toolbar does what it says. Above the list: a search field (⌘F; every word has to occur in the title, message, task or kind), All | Unread, and Filter with the kind (run succeeded, run failed, new artifact, provider, from a task), shown as a removable chip once set. The daemon keeps the last 1000 notifications; older ones make room, except an unread run failure, which stays until you have read it. Notifications macOS is set to hide still arrive here, so nothing is lost while the banner is off. - Usage — tokens, runs and cost for the period chosen in the header: 7, 14 (the default), 30 or 90 days, or All. A bar chart per day (per week beyond 45 days) with a Tokens | Runs | Cost switch; hovering a bar (or the arrow keys) shows all three for it. Above it the period's totals with the all-time figures beside them, below it one row per provider with its runs, outcomes and runtime in that period, and under each provider one row per model that ran (as the harness reported it, so an alias and its full id share a row; else as the task named it, Harness default when neither is known). The rows follow the chart's switch: the bar, the figure on the right and the order show the chosen measure. A provider's bar is its share of the period, a model's bar its share of that provider's. Tokens count fresh input and output: Codex's cached input is left out, as Claude Code reports cache reads apart, so providers compare fairly (Codex runs recorded by older versions still include it).
- Settings — global defaults and integrations (below). The custom system prompt gets the same review banner as a task prompt. A red badge here means an update is available.
The views keep themselves current: every few seconds the window re-reads the queue, the log, the artifacts, the notifications, the usage and the limits the daemon last read, including the views that are not open, so a view you click shows the present state. Coming back to the window re-reads them at once. View → Refresh (⌘R) in the menu bar re-reads every view and the providers' limits on demand; it leaves an unsaved Settings form alone.
The dashboard is also reachable in a normal browser at
http://127.0.0.1:10765 while the daemon is running.
Settings lists its sections on the left — General, Appearance, Execution, Notifications, Providers, Pools, Assistant, System and About; ↑/↓ step through them. An available update puts a red dot on About, which shows the app's claim, "A queue for unattended coding-agents that runs on your computer", and is where the update banner and button live.
Switches, menus and pickers apply the moment they change. Typed text (the custom system prompt and the notification channels) waits for the bar that appears at the bottom — Unsaved changes · Discard · Save, or ⌘S — and every group says which of the two it does (Applies instantly or Needs Save). A section with unsaved text carries a dot in the list, and leaving Settings keeps the draft for the session.
| Section | Group | Setting | What it does |
|---|---|---|---|
| General | Custom system prompt | Custom system prompt | Extra instructions appended to every run after the built-in prompt. Reviewed like a task prompt. |
| Default working directory | Choose folder… | The folder a new task's working directory starts at. Still editable per task; Clear leaves the field blank. | |
| Appearance | Appearance | System / Light / Dark | System follows macOS; Light or Dark keeps the window and the dashboard that way whatever the Mac is doing. Stored as appearance in config.toml, so a browser tab shows the same scheme. |
| Accent colour | System, a preset, or your own | System follows the accent colour set in macOS, live. A preset or the colour picker pins another one for buttons, selections and highlights; text on it switches between white and black so it stays readable. Stored as accent (#rrggbb, empty = follow macOS). |
|
| Execution | Global pause | Pause all runs | Global stop switch: nothing starts while it is on, not even Run now; a run already in flight keeps going. |
| Runs | Stop a run with no output for | Idle-timeout watchdog: kills a hung run (default 30 min; a working run keeps streaming and is unaffected; Off disables it). | |
| Keep run history | How many runs (and their logs) to retain before pruning (default 3000; Unlimited keeps everything). An unread run that did not succeed (failed, auth error, canceled, interrupted, waiting on a rate limit) is kept past the limit until you read it. Pruning runs in batches: history may grow a tenth past the limit before it is cut back to it. The hidden successes of quiet-history tasks do not count; they have their own retention (see Quiet history). | ||
| Scheduler | Check for due tasks every | How often the daemon wakes to look for work (15 min – 6 h; also the wake safety-net interval). | |
| Notifications | On this computer | Keep alerts on screen | Opens System Settings → Notifications, where DispatchQ's alert style lives: Banners disappear on their own, Alerts stay until you click them. A warning sits on top while macOS hides DispatchQ's notifications. |
| Notify about new artifacts | On by default; the same switch sits as Notify me in the Artifacts toolbar. Off: a published artifact still lands in Artifacts, but raises no notification on any channel and no entry in Notifications. Switching it back on does not announce what was published in the meantime. | ||
| Pushover | Send to Pushover | Toggle plus API token and user key for phone push. A stored secret shows as set, with Replace and Remove; it is never shown again. | |
| ntfy | Send to ntfy | Toggle, server (empty = ntfy.sh), topic, and an optional access token for a protected topic. | |
| Webhook | Post to a webhook | Toggle, endpoint URL, and a JSON body template using {{title}}, {{message}}, {{url}} (empty = DispatchQ's own body). |
|
| Providers | Backfill threshold | Slider | Below the providers: the urgency above which backfill tasks run, with each provider's urgency next to it. Applies on release. |
| One card per provider | Its settings | Name and type, a health dot with Check, its current limits (the same bars as the Dashboard, when the harness reports any), the binary and configuration directory in use, the names of its environment variables, the default model, and the provider that takes over when the limit is reached (and the model it uses). The switch turns it on or off at once; Edit opens a dialog that saves that provider alone; Make default and Remove sit in its … menu. See Providers. | |
| Add provider | Asks for an id, a type, a name and optionally a configuration directory. | ||
| Pools | One card per pool | Its settings | Name, members and their weights, which member the next run goes to and why. Edit opens a dialog with, per provider of the pool's type, a switch for membership and a weight (empty = the plan size the provider reports, e.g. auto · 20×). Remove is refused while a task uses the pool. Add pool asks for an id, a name and the members. See Provider pools. |
| Assistant | Prompt review | Check prompts with Claude | Whether Claude reviews a prompt against this Computer before the task is queued (on by default). |
| Review provider / Review model | Which provider answers the review, and with which model. Only providers that can hold an aside are offered; Default follows the global default provider, and the model falls back to the reviewing provider's own default. | ||
| Assistant | Assistant provider / Assistant model | Which provider answers in Ask DispatchQ and drafts the GitHub issue on the Feedback page, and with which model. Left at DispatchQ's choice, on Claude Code the chat uses a mid-size model (Sonnet) and feedback a small one (Haiku), rather than whatever you picked for real tasks; other harnesses use their own default. Only providers that can hold an aside are offered. | |
| System | Danger zone | Remove DispatchQ from this computer | Uninstall… asks you to type uninstall, then opens the uninstaller in the macOS Installer. Your tasks, history and settings are kept unless you select them under Customize. See Uninstall. |
| About | Software updates | Version / Check for updates | Current version, when the last check ran, and a manual Check for updates (dispatchq update check does the same from a terminal; the running app shows the result within a minute); an available update shows its notes with Download & install, Dismiss and All releases. On a development build, Reinstall downloads the latest release and opens its installer; it stays disabled, with the reason, while that release is older than the oldest one that can read this build's data (2.0.0 for now). |
A provider is one configured agent harness — the CLI a task is actually run
by. Every installation has one, claude, which is the
Claude Code CLI; an existing configuration is
migrated into it automatically, keeping the binary path and default model it
already used.
Three harnesses are supported:
| Type | CLI | Notes |
|---|---|---|
Claude (claude-code) |
Claude Code | Passes a task's reasoning effort through Claude Code's --effort flag. Reports token counts and cost. Its only authority settings are "ask" and "skip every prompt". |
Codex (codex) |
Codex | Takes a reasoning effort and a real sandbox mode, so read-only and workspace-write actually mean something. Tasks may use a working folder that is not itself a Git repository. Reports tokens but no cost — DispatchQ never invents one. |
opencode (opencode) |
opencode | Runs whatever model opencode is configured for, including local ones (it was brought up against LM Studio). Takes a reasoning effort as opencode's variant. Permission handling is all or nothing: the CLI's own prompts, or none. Reports tokens and cost as opencode states them. It cannot answer DispatchQ's own questions yet, so the prompt review and the feedback assistant are not offered on it, and its errors do not tell a rate limit from any other failure, so a limited run fails instead of pausing. |
You can configure as many instances as you like, including two of the same kind: give each its own configuration directory and they are two accounts, with their own sessions and their own rate limit. A limit on one does not hold up the other.
A provider has:
| Field | What it is |
|---|---|
| Id | The stable name a task selects it by (claude). Fixed once created. |
| Type | Which harness it runs — Claude, Codex or opencode. Fixed once created. The app says type and names the harness; the adapter kind it maps to (claude-code) is what the file stores. |
| Name | The label shown in the app and in run messages. |
| Binary | Absolute path to the CLI. The background daemon can't see your shell PATH, so a full path is safest; empty auto-detects and the card offers what it found. |
| Configuration directory | Where that CLI keeps its account and sessions. Empty uses the CLI's own (~/.claude, ~/.codex, ~/.config/opencode), which the field shows as its placeholder. Two providers with separate directories are two separate accounts. |
| Environment variables | Variables the CLI is started with on every run, readiness check and terminal continuation, on top of DispatchQ's own environment (and winning over it). See Environment variables. |
| Default model | Used for tasks on this provider that name no model of their own. |
| When the limit is reached | Another provider that takes this one's tasks while its allowance is used up. Empty means they wait for the window to reopen, which is what DispatchQ did before. |
| Model there | The model those substituted runs are given. Empty (Decided by DispatchQ) keeps the task's own model between two accounts of the same CLI and takes the substitute's default model otherwise. Only shown once a fallback is chosen, and cleared with it. |
| Enabled | Off keeps the provider and its tasks, but runs nothing on it. |
Both paths must be absolute; a leading ~ is expanded and stored resolved, so
the file says what is actually used.
Each provider can carry environment variables for its CLI. The usual reason is
an API key: put ANTHROPIC_API_KEY on a Claude provider and its runs are billed
per token through the API instead of drawing on the login's plan.
- Set them in Settings → Providers, in Add provider or a provider's
Edit dialog (Add variable, or the shortcut for the harness's key
variable), or with
dispatchq provider edit ID --env NAME=VALUE(repeatable;--unset-env NAMEremoves one). The list in the dialog is the whole set: a variable you remove there is removed. - Write-only. The values are secrets. They are kept in
config.toml(readable by your user only), never sent back to the app or printed by the CLI; the card,provider showandprovider list --jsonshow the names only. In the dialog a stored value reads Stored · type to replace and stays as it is unless you type a new one. Prefer the dialog: a value typed on the command line is visible to your shell history. - Where they apply. Every run, the readiness check, the limit reading, the prompt review and the assistant, and Continue in terminal. For the terminal, the values are handed to the shell in a temporary file it deletes at once, not on the visible command line.
- Not allowed: names another field owns (
CLAUDE_CONFIG_DIR,CODEX_HOME,XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_CACHE_HOME— use the configuration directory) and names starting withDISPATCHQ_orCLAUDEQ_. - Limits and Usage. A Claude provider with an
ANTHROPIC_API_KEY(orANTHROPIC_AUTH_TOKEN) shows its credit proof instead of plan windows: the windows of a login it may also hold are not what it spends, and the key's prepaid credit balance cannot be read. DispatchQ pings it once a day and on Read now; an account refused for credit pauses until a ping gets an answer (see When the limit is reached). Its runs are counted in Usage like any other, with the cost Claude Code reports, which for a key is what is actually billed. Subscription runs show the API-equivalent cost.
One provider is the default: tasks that name none run on it. It cannot be switched off or removed while it holds that role — make another one the default first — because most tasks name no provider and would all stop at once.
Settings → Providers manages all of this. The providers are listed under their harness (Claude, Codex, opencode), each group headed by its logo; the default provider always comes first. Each provider gets its own card, headed by its name, with an on/off switch that applies at once and Edit, which opens a dialog for the name, binary, configuration directory, environment variables, default model and fallback and saves that provider alone. Check, Make default and Remove are actions and take effect at once. Below them sits Add provider, which opens a dialog asking for the two things that cannot be changed afterwards — the id and the type — plus an optional configuration directory, which is what makes the new one a second account, and optional environment variables.
dispatchq provider … does exactly the same things, and both refuse the same
changes for the same reasons — removing an instance a task still names, for
instance, which names the tasks that have to change first.
Both harnesses run tasks like Claude Code does, with two limits worth knowing.
Codex: what a real exhausted ChatGPT allowance looks like in its output has not been observed yet. A rate-limited Codex run is paused and resumed on a fixed delay rather than at the time the provider names, because in the controlled test it named none.
opencode: its error reporting is the weakest of the three. Running it locally, every failure came back in the same generic shape, so DispatchQ cannot tell an exhausted allowance or a logged-out account from any other error. A failed opencode run is therefore recorded as failed, not paused and resumed, and the readiness check can only confirm that the binary runs.
Editing a task changes its provider only when you say so. Every other edit — the prompt, the folder, the schedule — goes through whatever state the current provider is in, since that edit may well be how you are fixing it.
Before a task starts, DispatchQ checks that its provider can actually run it. The check costs no model usage — it looks at the binary, asks for its version, and asks the CLI whether anyone is logged in — and it reports one of:
| Status | Meaning |
|---|---|
| Ready | Jobs can start. |
| Not installed | The CLI was not found, or the configured path cannot be run. |
| Not logged in | The CLI is there, but no account is signed in (claude auth login). |
| Invalid configuration | The provider's own settings cannot be used — an unreachable configuration directory, say. |
| Switched off | You disabled the provider. |
| Could not be checked | The check itself did not reach a verdict, so DispatchQ does not assume either way. |
A fresh installation therefore shows the real state of Claude Code rather than assuming it is there because it is the default provider.
What follows from an unready provider:
- New tasks are refused — in the app and from the CLI — with the reason. A job that is known in advance to fail is not worth filing. Could not be checked is the exception: "I could not ask" is not "it does not work", so the task is filed and simply waits, rather than a running job losing the follow-up it just queued because one probe timed out.
- Tasks that already exist stay queued. They are not started, they take no concurrency slot, and nothing about their schedule advances: a one-shot task is not marked done, a cron task keeps its next occurrence. The Queue row shows a red blocked badge with the reason.
- When the provider works again, they start by themselves on the next check. Nothing has to be re-queued.
- You are told once. A provider that breaks raises one notification, and one more when it recovers — not one per scheduler tick, and not again after a daemon restart.
A rate limit is not a provider problem: that pauses the run and resumes it, as it always did — and it pauses only the provider that hit it. The other providers keep working, because the allowance belongs to one account.
A provider can name a fallback: the provider its tasks run on while its own allowance is used up. Without one — the default — those tasks simply wait, as before.
- It applies to the rate limit, and to the account blocks that count as one (a Claude organisation that disallows subscription access, a used-up API credit balance or another billing refusal), and to nothing else. A provider that is missing, logged out or switched off is still never answered by running the work somewhere else; those tasks stay queued and say why.
- The chain is followed as far as it reaches. A provider in it that cannot take the work right now — rate-limited itself, switched off, not installed, not logged in, or with a last allowance reading that shows a window used up until a reset still ahead — is stepped over and its own fallback is asked. When nothing in the chain can work, the tasks wait: the queue is honest about being stuck.
- The interrupted session does not travel. A session belongs to the account that issued it, so the substitute starts the task again from the beginning and says so in the run's log, which also names the provider it came from. Once that run finishes the paused session is dropped rather than resumed later: the work has been done, and doing it twice is worse than losing a conversation. The paused run stays in the Log as the record of what happened.
- The model can be chosen with the fallback ("Model there"). Left open, the task's own model travels only between two accounts of the same harness, and a fallback of a different type runs its own default model instead of a name it would not understand.
- A fallback must name another configured provider, and the chain may not close into a circle; removing a provider that is somebody's fallback is refused by name, exactly like removing one a task still uses.
- The waiting banner names both: Claude → Second account, and says the queue keeps running rather than that nothing starts. With several providers blocked it says which part of the work carries on.
Set it on the provider's block in Settings → Providers ("When the limit is
reached", plus "Model there"), or with
dispatchq provider edit ID --fallback OTHER --fallback-model MODEL; none
clears either flag.
The Dashboard and each provider's block in Settings → Providers show the account's allowance windows: how much of each is used and when it resets. Reading them costs no tokens and none of the allowance.
| Harness | Where the figures come from | Windows shown |
|---|---|---|
| Claude | The usage endpoint Claude Code's own /usage screen reads, called with the login the CLI stored for that provider's configuration directory |
5 hours, week |
| Codex | The CLI's own codex app-server, asked account/rateLimits/read with the provider's CODEX_HOME |
whichever windows the account reports, one or two (5 hours, week) |
| Claude on an API key | nothing: the prepaid credit balance of an ANTHROPIC_API_KEY cannot be read without an admin key, so a daily one-word ping (and Read now) proves it |
none; the latest proof instead, Credit OK or No credit left, with its time (Credit balance unknown before the first ping) |
| opencode | nothing; it runs whatever backend it is pointed at | none, shown as No limit |
The daemon reads them when the Dashboard opens, on View → Refresh (⌘R), every 15 minutes on its own, and right after every run on that provider. A request that comes within 30 seconds of the last reading reuses it, so switching views does not hammer the provider. The window's regular polling only shows what was last read; it never asks the provider itself.
When a reading fails, the last good figures stay up with their age and the
reason underneath, for example an expired Claude login: DispatchQ never renews a
login itself (that would rotate the CLI's token and log it out), so the figures
come back with the next run on that account or after claude auth login. The
Claude endpoint is not a documented API; if Anthropic changes it, the Dashboard
says the limits cannot be read and nothing else is affected.
The same figures are on the command line, read right then rather than from the daemon's memory, so the command works with the daemon stopped too:
dispatchq provider limits # every provider, one line per window
dispatchq provider limits claude --json # one provider, in the shape the API servesID WINDOW USED RESETS URGENCY NOTE
claude 5 hours 10% Sat 01:19 (in 4h 36m) 0.3
claude Week 85% Mon 02:59 (in 2d 6h)
codex Week 20% Tue 09:00 (in 3d 2h) 1.8 backfill
opencode - - - - No limit
The urgency column is the one the Dashboard shows (see
Backfill tasks); backfill marks a provider above the
threshold. Read from the CLI, it does not know about runs in flight, which
share the daemon's figure. A running job can ask before it hands work to
another account: used_percent per window, state (ok, unavailable,
unsupported, disabled), urgency and backfill are in the JSON.
DispatchQ stores no passwords, tokens or API keys. It stores the path of a
CLI's configuration directory; the CLI owns what is inside it. Readiness checks
read only whether a login exists, never whose it is, and neither the daemon log
nor the API ever carries account details. The one exception to "never reads
the login" is the Claude limits reading above: the daemon reads the access
token Claude Code stored (from the macOS keychain, through the same security
tool the CLI uses, so no keychain prompt appears) and sends it to Anthropic
only, the same place the CLI sends it. It is held for that one request and is
never written, logged or served over the API.
A pool is a set of providers of one type, two Claude subscriptions for example, that a task can be given instead of a single provider. DispatchQ decides at every start which member runs it, so the allowances are used up before they reset instead of one account running dry while the other's expires unused.
How a member is chosen, at the moment a run starts:
-
Members that cannot take work right now are out: switched off, not ready, or waiting out a rate limit.
-
Members whose 5-hour window is at 90% or more, or whose week is used up, go to the back. They are still tried if nothing else can run.
-
Among the rest, the highest urgency × weight wins. Urgency is the provider's own, the figure the Dashboard shows next to it:
urgency = free share of the week × hours in a week ÷ hours until the week resets ÷ (1 + runs already on it)1 means spending evenly from now on uses up exactly what is left; 2 means it would take twice that pace, so half of what is left is at risk of expiring unused. 10% left that resets in 4 hours (urgency 4.2) beats 60% left for another five days (0.84). The weight is the plan's size: DispatchQ reads it from the Claude login (Max 20x = 20, a Team seat on Max 5x = 5, Pro = 1), so 10% of a large plan counts for more than 10% of a small one. Codex does not say how its plans compare, so a Codex member counts as 1 unless you set its weight.
-
Runs already going on a member share its urgency. Two pool tasks that start in the same tick land on two accounts rather than both on the first.
-
A member with no limit reading yet ranks after the ones with a reading.
Sessions. A pool task that hit a rate limit continues its session on the same member as soon as that member is open again, because the session only exists there. While that member is still limited, the task starts over on another member instead of waiting, which is the point of the pool. The run's log says so.
Seeing the choice. Every run of a pool task opens its log with the choice
and the reasons, for example Pool Claude chose Claude Max: Claude Max (14% of
week left, resets in 2 d); Claude Team (100% of 5 hours used). The Log names
the pool next to the provider. The Dashboard and dispatchq pool show list the
members in the order the next run would take them, with each one's weight
and the reason for its place (the commands add the urgency); dispatchq pool next names the member the
next run goes to. Both commands ask the running daemon, so they show exactly
what the Dashboard shows, rate-limit pauses included. With no daemon running
they read the members' limits themselves and say so; a member waiting out a
rate limit is then not recognised as such.
Rules.
- Every member must be of one type, so the model a task names means the same on each. The model list in the task sheet is the first member's.
- A pool has its own id, which no provider may share. Tasks choose it in the
same Provider field as a provider (listed under Pools), or with
--pool IDonadd,editandqueue. A task names a provider or a pool, never both; naming one clears the other. - A member's own rate-limit fallback is not used inside a pool: the pool is the fallback.
- A pool is offered and a task on it is accepted as long as at least one member can run. The queue marks the task blocked only when none can.
- A provider cannot be removed while it is a member, and a pool cannot be removed while a task uses it.
- Jobs a pool task queues inherit the pool, not the member the parent landed
on. A task exported as a
.dispatchqfile leaves its pool behind, like its provider.
dispatchq pool add claude-pool --name Claude --member claude --member claude-team
dispatchq add --id nightly --prompt "…" --dir ~/code/app --pool claude-pool
dispatchq pool next claude-pool
dispatchq pool show claude-poolEverything the app does is also available on the command line. You can list the queue, read and change a task's prompt and settings, edit the global settings, trigger a test run, and read run history. This section is written to stand on its own, so another tool or agent needs nothing else to drive DispatchQ. See From another tool or agent.
Both binaries ship inside the app bundle, and neither is on your PATH:
/Applications/DispatchQ.app/Contents/MacOS/dispatchq # the control CLI
/Applications/DispatchQ.app/Contents/MacOS/dispatchqd # the daemon
Call them by full path:
/Applications/DispatchQ.app/Contents/MacOS/dispatchq list…or put the directory on your PATH once:
export PATH="/Applications/DispatchQ.app/Contents/MacOS:$PATH"The rest of this section writes them as plain dispatchq and dispatchqd. Both work
on the store under ~/Library/Application Support/dispatchq directly (override it
with DISPATCHQ_HOME), so they work whether or not the app window is open. The
running daemon re-reads the store on its next tick, so a change never needs a
restart.
dispatchqd run [--interval 5s] [--no-wake] [--addr 127.0.0.1:10765]
dispatchqd install # install & start the LaunchAgent (autostart at login)
dispatchqd uninstall # stop & remove the LaunchAgent
dispatchqd --version
dispatchqd webview2-status # Windows: will the window find a usable WebView2 Runtime?
The installer runs install for you; you rarely need these directly. On Windows,
webview2-status prints one line with the answer and the registry entries behind
it (profile folders shortened) and exits 0 when the runtime is usable, 10 when it
is not or could not be confirmed (for example a library whose loading did not
finish). It changes nothing.
dispatchq list [--json] # show the queue
dispatchq show ID [--json] # one task in full, prompt included
dispatchq add --id ID (--prompt P | --source-file PATH) --dir DIR [--name N] [--group G]
[--kind agent|script] # script = a program, no model
# --source-file: read the text from PATH on every run
[--trigger asap|fixed|cron] [--at RFC3339] [--cron EXPR]
[--provider ID] [--model M] [--reasoning-effort E] [--parallel]
[--skip-permissions] [--notify] [--quiet-history]
[--backfill] # run only on allowance that would go unused
dispatchq edit ID # open the whole task in $EDITOR
dispatchq edit ID [--name N] [--prompt P | --prompt-file PATH | --source-file PATH] [--dir DIR]
[--group G] # "" takes it out of its group
[--kind agent|script] # switching to script drops the model settings
[--trigger asap|fixed|cron] [--at RFC3339] [--cron EXPR]
[--provider ID] [--model M] [--reasoning-effort E]
[--parallel=BOOL] [--enabled=BOOL]
[--skip-permissions=BOOL] [--notify=BOOL] [--quiet-history=BOOL]
[--backfill=BOOL]
dispatchq queue (--prompt P | --source-file PATH) [--at RFC3339 | --in DUR | --cron EXPR] [--dir DIR] [--name N]
[--group G] [--kind agent|script] # a queued job is an agent job unless told
[--provider ID] [--model M] [--reasoning-effort E]
[--parallel=BOOL] [--skip-permissions=BOOL]
[--notify=BOOL] [--quiet-history=BOOL] [--backfill=BOOL]
[--depends-on JOBID]... # wait for these jobs to finish
[--include-results] # and put their answers in the prompt
# (a script reads them on stdin)
[--json] # print the new job, id included
dispatchq publish --file PATH [--title T] [--description D] # publish a file as an artifact
dispatchq artifacts list [--json] [--origin ID] [--since TS] # published artifacts, oldest first; TS is RFC3339 or a duration like 24h
dispatchq notify --title T --message M [--url U] # send a notification, no artifact
dispatchq export ID [--out PATH] [--force] # write the task to a .dispatchq file
dispatchq import PATH [--id ID] [--provider ID] [--model NAME] # add the task from a .dispatchq file
dispatchq rm ID
dispatchq enable ID | dispatchq disable ID
dispatchq move ID INDEX # 0 = highest priority
dispatchq run-now ID # run once, now, for testing
dispatchq status [--all] # recent runs; unread marked *
dispatchq status [--all] --json # the same runs as JSON: origin job, error, log path
dispatchq status [--all] --hidden [--json] # also the hidden successes of quiet-history tasks
dispatchq read RUNID | dispatchq read-all
dispatchq provider list [--json] # the harnesses tasks run on
dispatchq provider show ID [--json]
dispatchq provider check ID [--json] # probe it now
dispatchq provider limits [ID] [--json] # 5-hour/weekly windows, used and reset, and the urgency
dispatchq pool list [--json] # provider pools
dispatchq pool next ID [--json] # the member the next run goes to, with its urgency and the ranking
dispatchq pool show ID [--json] # members ranked as a run started now would take them
dispatchq pool add ID --member PROVIDER[=WEIGHT]... [--name N]
dispatchq pool edit ID [--name N] [--member PROVIDER[=WEIGHT]]... # --member replaces the list
dispatchq pool rm ID
dispatchq provider add ID --kind claude-code|codex [--name N] [--path PATH]
[--config-dir PATH] [--default-model MODEL]
[--fallback ID] [--fallback-model MODEL]
[--env NAME=VALUE]... # e.g. ANTHROPIC_API_KEY, see Environment variables
dispatchq provider edit ID [--name N] [--path PATH]
[--config-dir PATH] [--default-model MODEL]
[--fallback ID|none] # who takes over at the limit
[--fallback-model M|none] # what they run it with
[--env NAME=VALUE]... [--unset-env NAME]...
dispatchq provider enable ID | dispatchq provider disable ID
dispatchq provider default ID # run tasks that name none on it
dispatchq provider rm ID
dispatchq settings [--json] [--default-provider ID]
[--heartbeat-minutes N]
[--idle-timeout-minutes N] [--max-run-history N]
[--system-prompt S | --system-prompt-file PATH]
[--paused=BOOL] # pause/resume every run
[--backfill-urgency X] # backfill tasks run above this urgency (0.5-5)
[--artifact-notify=BOOL] # notify when a task publishes an artifact
[--pushover=BOOL] [--pushover-token T] [--pushover-user U]
[--ntfy=BOOL] [--ntfy-server S] [--ntfy-topic T] [--ntfy-token T]
[--webhook=BOOL] [--webhook-url U] [--webhook-template J]
dispatchq update check [--json] # check for a newer release now; the app shows its update prompt
dispatchq uninstall [--purge] [--yes] # run the uninstaller in the terminal (see Uninstall)
dispatchq --version
dispatchq list prints one line per task in priority order, index 0 first:
# ID NAME KIND TRIGGER WHEN PARALLEL ENABLED
0 nightly-sweep Nightly sweep agent cron 0 3 * * * false true
1 needs-review Needs review script cron */5 * * * * true true
Prompts are often pages long, so that table leaves them out, and a name longer
than 40 characters is cut with an ellipsis so the columns stay readable.
dispatchq show ID prints every setting of one task, its full name and then its
complete prompt — or, for a script job, its script; the settings only an agent
job has are left out. A job that reads its text from a file shows the file's
path as source_file instead. Add --json to either command for the same data as JSON,
prompts and full names included.
dispatchq edit changes a task that already exists. The id is fixed and cannot be
changed. There are two ways to use it.
With flags. Only the settings you pass are touched, everything else keeps its value. This is the form to use from a script or an agent.
dispatchq edit nightly-sweep --prompt-file ./new-brief.md # replace just the prompt
dispatchq edit nightly-sweep --cron "30 2 * * 1-5" # reschedule
dispatchq edit nightly-sweep --model opus --notify=true # per-task overrides
dispatchq edit nightly-sweep --provider claude # run it on another provider
dispatchq edit review-branch --provider codex --reasoning-effort xhigh
dispatchq edit prod-watch --quiet-history=true # hide its successful runs
dispatchq edit nightly-sweep --group "Nightly" # file it under a queue group
dispatchq edit nightly-sweep --group "" # and take it back out
dispatchq edit nightly-sweep --enabled=false # pause it
dispatchq edit prod-watch --kind script --prompt-file ./watch.sh # make it a script job
dispatchq edit prod-watch --source-file ./watch.sh # run whatever watch.sh says now--prompt-filereads the prompt from a file, or from stdin when you pass-, once, and stores a copy in the task. That is the practical way to set a long prompt without fighting shell quoting.--source-filestores the file's path instead of a copy: the job reads it at the start of every run (see Inline or from a file). A relative path is made absolute against the folder you run the command in.--promptor--prompt-fileturns the job back into an inline one.--atimplies--trigger fixedand--cronimplies--trigger cron, so a reschedule is one flag. Changing the trigger clears the timing fields that no longer apply.--model ""drops a per-task model override back to the provider's default model, and--provider ""back to the default provider.--reasoning-effortasks the model to think harder or less hard, for the harnesses that take such a setting. Claude Code receives--effort, Codex its reasoning-effort configuration, and opencode its variant. An empty value leaves the harness default unchanged. It is ignored by the ones that do not, so it survives a move between providers.- Changing
--providerwithout naming a--modelclears the model too, so the new provider's own default applies — one harness's model is never carried into another. Pass both to keep an explicit model across the change. - A task is refused if the provider it names is unknown or cannot run right now; the error says which and why (see Providers). A script job is never refused for that reason: it runs no harness, so no provider can be in its way.
--kind scriptturns a task into a script job and clears the provider, model, reasoning effort and permission bypass it no longer uses.--kind agentturns it back.- Every edit is validated before it is written. An invalid cron, an unparseable time, or an empty prompt fails with a message and leaves the task as it was.
Interactively. dispatchq edit ID with no flags opens the whole task as a
commented TOML document in $VISUAL or $EDITOR, falling back to vi. Every
setting is in there, and the prompt is an editable multi-line block. Save and
close to apply; leave the file unchanged to cancel. If what you wrote does not
parse or does not validate, nothing is written and the CLI tells you where it
kept your draft. This form needs a terminal. Without one it stops and points you
at the flags.
dispatchq settings with no flags prints every global setting. With flags it
changes the ones you name and prints the result. They are the same values as the
app's Settings view.
dispatchq settings # show everything
dispatchq settings --default-provider claude # provider for tasks that name none
dispatchq settings --system-prompt-file ./house-style.md # custom system prompt
dispatchq settings --default-working-dir ~/Code # a new task's folder starts here
dispatchq settings --idle-timeout-minutes 45 --max-run-history 1000
dispatchq settings --paused=true # stop every run
dispatchq settings --paused=false # let the queue run again
dispatchq settings --backfill-urgency 2 # backfill only when half of what is left would expire
dispatchq settings --prompt-review=false # turn the prompt review off
dispatchq settings --prompt-review-provider claude-cheap # who answers the review ("" = default provider)
dispatchq settings --prompt-review-model haiku # ("" = the provider's default model)
dispatchq settings --assistant-provider claude-cheap # who answers the assistant chat and drafts feedback
dispatchq settings --assistant-model haiku # ("" = dispatchq's own choice per job)
dispatchq settings --artifact-notify=false # publish artifacts without a notification
dispatchq settings --pushover=true --pushover-token T --pushover-user U
dispatchq settings --ntfy=true --ntfy-topic dispatchq-daniel # server defaults to ntfy.sh
dispatchq settings --webhook=true --webhook-url https://hooks.slack.com/services/... \
--webhook-template '{"text":"{{title}}: {{message}}"}'For the numeric settings 0 means "use the default". --idle-timeout-minutes
also takes a negative value for "never kill a run", and --max-run-history for
"keep every run". You can set channel credentials here, but the CLI never
prints them back; the output only says whether they are configured. A channel
that is switched on but cannot deliver (no topic, no URL, an unknown
{{placeholder}}) is refused at save time, from the CLI same as from the app.
Use dispatchq notify --title X --message Y to check a channel actually
delivers — there is no separate Test button in the app.
The binary path and the default model belong to a provider now, not to the global settings:
dispatchq provider list # ids, kinds, default models, status
dispatchq provider check claude # probe it and print why it is not ready
dispatchq provider limits # how much of each allowance is used
dispatchq provider edit claude --path /Users/me/.local/bin/claude
dispatchq provider edit claude --default-model opus
dispatchq provider disable claude # keep it, run nothing on it
# a second Claude account, a Codex provider, and opencode with a local model
dispatchq provider add claude-work --kind claude-code --config-dir ~/.claude-work
dispatchq provider add codex --kind codex --default-model gpt-5.6-sol
dispatchq provider add local --kind opencode
# a Claude provider on API billing (values are never printed back)
dispatchq provider add claude-api --kind claude-code --config-dir ~/.claude-api --env ANTHROPIC_API_KEY=sk-ant-...provider list --json gives each instance's enabled state, readiness, the
reason it is not ready, and its default model — which is how a running job finds
out where it can send follow-up work. provider rm refuses while a task or the
default-provider setting still names the instance, and lists what has to change
first.
This README is the whole interface. An external app or agent needs nothing but this document to work with DispatchQ.
- Invoke it by full path.
/Applications/DispatchQ.app/Contents/MacOS/dispatchqis not onPATH. - Read with
--json.dispatchq list --json,dispatchq show ID --json,dispatchq settings --json,dispatchq provider list --jsonanddispatchq artifacts list --jsonemit structured output. The other commands print for humans. - A task is refused when its provider cannot run it.
--provider IDselects one; leaving it out uses the default. An unknown or unready provider exits non-zero with the reason, so askdispatchq provider list --jsonfirst rather than assuming one is there. See Providers. - Write with
edit,add,import,rm,enable,disableandmoveinstead of editingconfig.tomlby hand. Those commands validate the change and write it atomically alongside the app's own writes. - Move a task between machines with
exportandimport. The.dispatchqfile carries the whole task; see Sharing tasks as files. - Exit code 0 means success. Any failure exits non-zero and writes the reason
to stderr, prefixed
dispatchq:. - Test a change with
dispatchq run-now IDrather than waiting for the schedule. It exits non-zero while the global pause switch is on (dispatchq settings --paused=falselifts it). - Every run is framed as a headless, unattended run with no next turn — it finishes or hands work on, it does not wait.
- A task that is itself a DispatchQ run has three extra abilities, described below: queueing follow-up work, publishing artifacts and sending a notification.
- Data on disk lists the files these commands read and write.
Every run starts with a built-in system prompt from DispatchQ, ahead of your own custom system prompt. Most of it documents the three abilities below — queueing, publishing, notifying — but it opens with the one thing a run cannot work out for itself: it is headless. Nobody is at the keyboard, there is no next turn, and the process is torn down the moment Claude stops writing. Without that framing a model behaves as if a conversation continues: it schedules a wakeup for later, leaves a watcher running in the background, or ends with a question — all of which die with the run. So the prompt tells it to
- decide rather than ask,
- never end a run by announcing that it is waiting for something,
- either finish work in flight inline and blocking, or
queue a follow-up task (
--in 30m) and stop, and - name anything unfinished concretely — pull request, build, run ids and links — in the last message, because that message is what reaches your phone.
It also tells a run never to search the whole disk or home folder (find /,
find ~) for a tool or file. Such a sweep walks into Music, Photos, OneDrive and
network volumes, and macOS attributes every access by a run to DispatchQ, so you
would see an unexplained privacy prompt for DispatchQ that blocks the run until
answered. Paths a task names, or that its instructions point to, stay
accessible: a job that reads a spreadsheet in OneDrive still does, and macOS asks
once for that folder.
You don't need to repeat any of this in a task's prompt. None of it applies to a script job: there is no model to tell anything to.
A running task can enqueue new DispatchQ tasks instead of doing everything inline. Every run is told, via its system prompt, that the capability exists and how to use it — so a prompt like "scan for TODOs and, for anything non-trivial, queue a separate task to fix it" just works. From inside a run, Claude calls:
dispatchq queue --prompt "…"with an optional time (--at <RFC3339>, --in <duration> like 90m, or
--cron "<expr>"; default is as-soon-as-possible), an optional --dir, and an
optional --name. The new task inherits the calling task's provider, model,
permissions, parallelism, and notification settings automatically. This works
because the daemon injects the CLI's path and the parent task into each run's
environment.
When the follow-up needs different settings from the task that queues it, pass
the same flags dispatchq add takes; anything you leave out keeps inheriting:
dispatchq queue --prompt "Review the change thoroughly …" --model claude-opus-5 --notify--model Mruns the new task on another model (an empty value means the effective provider's default model). This is what lets a cheap watcher — Haiku every five minutes, quiet history — hand expensive work to a visible Opus run.--provider IDruns the new task on another configured harness — which is how a Claude run hands a review to Codex, or the other way round. Without a--modelalongside it, that provider's own default model applies — a model from the calling task is never carried across.--reasoning-effort Esets how hard the new task's model should think, where the harness takes it.dispatchq provider list --jsonsays which providers exist and which of them can run right now; queueing for one that cannot fails with the reason instead of filing work that would not start (see Providers).--parallel=BOOLand--notify=BOOLswitch the respective setting on or off regardless of what the caller has.--skip-permissions=BOOLgrants or withdraws the task's own permission bypass. Note that a run can grant a follow-up more than it has itself; the system prompt tells Claude to do so only when the queued work cannot be done without it.--quiet-history=BOOLopts the new task into (or, explicitly, out of) quiet history. Without it a queued task is never quiet, even when the caller is (see below).
A long --name is accepted as given; dispatchq list and the Queue view cut it
to fit their column (hover the app's row for the whole name), dispatchq show and
--json print it in full.
Some work wants more than one harness — "ask Claude and Codex and give me one
summary", "get every provider's take on the release notes". That is a
fan-out with a join, and it is built from the same queue command:
# one job per provider; --json so the ids can be read back out
dispatchq queue --json --provider claude --prompt "Summarise what changed on main today"
dispatchq queue --json --provider codex --prompt "Summarise what changed on main today"
# one job that waits for both and combines them
dispatchq queue --provider claude \
--depends-on q-20260914T050000-a1b2c3 \
--depends-on q-20260914T050000-d4e5f6 \
--include-results \
--prompt "Consolidate the attached results into one digest and publish it"Every run is told how to do this in its system prompt, so a task that says "ask all providers and consolidate" arranges it itself. DispatchQ does not read the prompt and guess: the harness understands the request, and this just gives it the vocabulary.
It is durable, not a live conversation. The root run queues the children and the join and then stops — it does not wait, and could not: a run ends when the harness stops writing. The daemon owns the rest, so a restart, a closed lid, a long child or a rate limit on one provider costs time and nothing else.
When the join runs. Once every job it waits for has a terminal result: success, failure, auth error or cancellation. A job pausing on a rate limit has not finished — its session is scheduled to continue — so the join keeps waiting. A job that failed does release it: an unattended digest that never appears is worse than one that says which input is missing. So does a job that was deleted before it ran, because nothing about it is ever going to change.
What the join gets. --include-results puts each dependency's final answer
in front of the join's prompt, with the job name, the provider and model it ran
on, its status and any error. It is fenced and labelled as data, and the system
prompt tells the harness to read it as data and never as instructions. The
injected text is bounded; a shortened answer says so and names the run log that
has all of it.
Rules worth knowing:
- A job may only wait for jobs that already exist. Queue the children first. That is also why a cycle cannot happen — dependencies only ever point backwards.
- Dependencies are fixed when the job is queued and never change afterwards.
- A recurring (cron) job cannot depend on one-shot jobs: its second occurrence would find them long finished and run immediately, which is no dependency at all. Nor can you wait for a recurring job — it never has a last result.
- Leave
--modeloff a cross-provider job unless you mean it. A model name belongs to the harness it was chosen for, so each provider uses its own default. - Let only the join publish the artifact or send the notification, unless you want each provider's result separately. Otherwise one request produces several competing reports.
In the app, a job that is waiting says so in the Queue (waiting for 2 jobs, with the names on hover), and the Log groups the runs of one workflow into a single Workflow block instead of scattering them among unrelated runs. A job queued by a run joins that run's workflow, which is also what groups an ordinary self-queued chain.
When a task produces a file worth keeping — a report, an export, a generated HTML page or PDF — it can publish it as an artifact. Every run is told, via its system prompt, that the capability exists, so a prompt like "write the summary to report.html and publish it" just works. From inside a run, Claude calls:
dispatchq publish --file report.html --title "Nightly summary" --description "…"The file is copied into DispatchQ (a permanent snapshot — later changes to the
original don't affect it) and appears in the Artifacts view, attributed to
the task and run that produced it, with an unread flag that clears as soon as you
open it. Each publish also raises a notification (macOS, plus every remote channel that
is configured; Settings → Notifications → Notify about new artifacts or
dispatchq settings --artifact-notify=false turns it off); clicking the macOS one brings up DispatchQ with that artifact open — in
the in-app viewer for HTML, PDF, images, Markdown and text, in your browser for anything
else. Artifacts that were already there when this version first ran are not
announced retroactively. HTML and PDF open in an in-app viewer; any type can be opened in your
browser. Artifacts are kept until you delete them, independent of run-history
pruning. --title defaults to the file name; --file may be relative to the
task's working directory.
A common pattern is a cheap watcher (often a script job) that notices something
and files an agent job to deal with it, and that agent job publishes the
result. The artifact then names the agent job, but what you look for is the
watcher. So every artifact also records its parent: the job at the root of
the chain that created the publisher. A job added with dispatchq add or
dispatchq queue from inside a run remembers the job that ran it, or that job's
own parent if it has one, so a chain of any length leads back to the watcher. A
job nothing created is its own parent while it is in the queue; once a job like
that is deleted, its artifacts have no parent any more, so finished one-off jobs
do not pile up in the parent menu. The artifact also records the publisher's
queue group.
The Artifacts view filters by both. While the parent job is still in the queue, its current name and group are used, so renaming the watcher or moving it to another group takes its artifacts along. Artifacts published before this was recorded are traced back through run history where it still has the runs. When the run itself does not name a parent, they get one only if the trace leads to a job still in the queue (or to that job's parent); otherwise they have none and show up under All parents only.
dispatchq artifacts list prints the artifacts oldest first, with an unread
mark, the parent and the publish time. With --json each entry carries the
title, description, file name, the absolute path of the stored copy, size,
content type, publishing task and run, group, parent and the unread flag. The
parent and group are resolved exactly as the Artifacts view shows them, so
--origin ID keeps the same artifacts as picking that job in the parent menu.
--since keeps what was published after an RFC3339 time, or within a duration
such as 24h:
dispatchq artifacts list --json --origin pr-watcher --since 24h
dispatchq artifacts list --json --since 2026-10-07T09:00:00.123456Z # the last published_at seenListing does not mark anything read.
Some jobs have nothing to hand over — a watcher that checks a deployment, a feed, or a metric every quarter hour only needs to say something changed. Rather than publishing a throwaway artifact to trigger a push, a run can send a notification directly. Every run is told, via its system prompt, that the capability exists, so a prompt like "compare with the last snapshot and notify me only if it differs" just works. From inside a run, Claude calls:
dispatchq notify --title "Prod drifted" --message "3 commits behind main" --url "https://…"The notification goes out over the channels you already configured — macOS Notification Center, Pushover, ntfy and the webhook, each independently — with the same look as DispatchQ's own alerts, and attributed to the task that sent it (its name is appended to the message). Like every notification it is listed in the app's Notifications view; nothing else is stored — no artifact, no history entry. The run's own outcome is still announced according to the task's settings, so a watcher that finds nothing sends nothing and stays silent.
--titleand--messageare required. A title longer than 250 characters or a message longer than 1024 (Pushover's limits) is cut, not rejected.--urlis optional and must be an absolutehttporhttpsURL. Clicking the macOS notification opens it; Pushover and ntfy show it as the message's link, and the webhook fills it into{{url}}if the template uses it.- The CLI hands the notification to the daemon, which sends it on its next tick
(a few seconds). Exit code 0 means it was queued, not that a channel accepted
it. A channel that fails is tried again like any notification (see
When DispatchQ notifies) and logged by the daemon
(
dispatchqd.err.log). - A notification that waited more than 24 hours — the daemon was not running — is dropped with a log line rather than delivered as if it were current.
- It also works outside a run, from a shell: then it is sent without attribution.
- The run must be allowed to execute the command. An agent job with default
permissions runs unattended, so nobody can approve a prompt: a command that
needs approval,
dispatchq notify,queueandpublishincluded, is refused unless the provider's own allow rules already permit it. Either add such a rule in the provider's settings, turn on Skip permission prompts for the task (--skip-permissions=trueondispatchq editorqueue), or make it a script job. The assistant's proposal card shows which permissions a new agent job gets.
A job is one of two kinds, and the task form's Job type switch (or --kind
on dispatchq add / edit / queue) decides which:
- an agent job — the default — sends its prompt to a model through a provider's CLI, and
- a script job runs that same text as a program. No model is involved, no provider is chosen, nothing is spent, and nothing about it is guessed at.
Everything else is shared: the same queue, the same triggers and priority, the same groups, the same run log in the Log, the same notifications, the same quiet history, the same dependencies.
What it runs. The script — typed inline, or read from its
file when the run starts — is written to a temporary
file and executed. Begin
it with a shebang to choose the interpreter — #!/bin/zsh,
#!/usr/bin/env python3, #!/usr/bin/env node — or leave it out and it runs
under /bin/sh. It starts in the task's working directory, and it inherits
the daemon's environment plus the variables every run gets: DISPATCHQ_BIN,
DISPATCHQ_HOME, DISPATCHQ_RUN_ID, DISPATCHQ_TASK_ID, DISPATCHQ_WORKFLOW_ID and
DISPATCHQ_PARENT_TASK.
What counts as the outcome. Exit code 0 is a success, anything else a
failure — that is the whole classification; nothing reads the output looking for
trouble. Standard output and standard error go to the run log, and standard
output alone is kept as the job's answer: the text a notification quotes and
a dependent job consolidates. More output than a run record may carry keeps the
end — a script says what matters last — with a line in front saying so. A hung script is killed by the same idle timeout
as an agent run, and Cancel task stops it and its whole process tree.
Checked before it is queued. Like a prompt, a script is read against this Computer while you write it — missing paths, commands the daemon's PATH does not have, anything that waits for a person. See The prompt review.
What it cannot have. A provider, a model, a reasoning effort, or the permission-prompt bypass. Those all steer a model, and a job that runs none is refused rather than quietly ignoring them. Switching an existing task to Script drops them for you.
Why it exists: watchers. The classic frequent job — poll a query, diff it against a seen-list, and act on what is new — spends a model's allowance on work that is pure plumbing. As a script job it spends nothing, and it keeps running when every provider is out of allowance, because the rate-limit gate belongs to an account and a script job has none. What it finds, it hands to a real agent job:
#!/bin/zsh
set -euo pipefail
new=$(./bin/poll-needs-review --since-file .agents/seen.json)
[[ -z "$new" ]] && exit 0 # nothing changed: say nothing, spend nothing
while IFS= read -r id; do
"${DISPATCHQ_BIN:-dispatchq}" queue --model opus \
--name "Review $id" --prompt "Run the review for work item $id."
done <<< "$new"
"${DISPATCHQ_BIN:-dispatchq}" notify --title "Needs review" --message "$(wc -l <<< "$new") new item(s)"A job queued from a script is an agent job — that is the point of the split,
so the kind is never inherited. A script that genuinely wants to queue another
script says --kind script.
Dependencies. A script job can wait for other jobs with --depends-on like
any other. With --include-results their answers arrive on the script's
standard input rather than in front of its text, because a program cannot
have a digest pasted on top of it. Without dependencies, standard input is
closed immediately — an unattended run never waits for input nobody will type.
Both kinds of job can hold their text in one of two places, picked with the Inline | File switch above the prompt (or script) in the task form:
- Inline — the default — keeps the text in the task, in
config.toml. What the task says is what runs, until you edit the task. - File keeps only an absolute path: pick the file with Choose…, which opens the system's file dialog (macOS: the Finder panel; Windows: the Open dialog) in the folder of the file already set, or type the path. The job reads that file at the start of every run, so it always runs what the file says now: edit a watcher script or a long brief in your own editor or repository, and the next run picks it up without touching the queue. A prompt file is sent to the model exactly like an inline prompt; a script file runs exactly like an inline script, shebang included.
From the CLI, --source-file PATH on dispatchq add, edit and queue does
the same; a relative path is made absolute against the folder you run the
command in. A job has one or the other: --prompt or --prompt-file with
--source-file is refused, and on edit either one replaces the other. (Note
the difference: --prompt-file reads a file once and stores a copy;
--source-file stores the path.)
A file that cannot be read fails the run, and says so. The path is not
checked when the job is saved — a job earlier in a chain may well be what writes
the file. When the run starts and the file is missing, empty, unreadable or
larger than 1 MiB, the run is recorded as failed with the reason, for
example script file /Users/me/scripts/watch.sh does not exist, and the usual
failure notification goes out. Nothing is started.
What the Log shows. Each run keeps the text it actually read, with the file it came from, so an old run still shows what ran even after the file changed. The queue marks such a job with a File badge; hover it for the path.
Dependencies work as for inline jobs: with --include-results an agent job
gets the digest in front of the file's text, a script on standard input.
Not covered. The prompt review reads only inline text
(a file is read at run time, so there is nothing fixed to review when the job
is saved). Asking DispatchQ drafts inline jobs; point one
at a file in the form or with --source-file. A job exported
to a .dispatchq file carries the file's current text, not the path, and
imports as an inline job — the path means nothing on another Computer.
A task that runs every few minutes would, by default, produce hundreds of
successful runs a day: each one unread in the Log, each one counting against
the Max run history limit until it pushes a run you actually care about out of
the record. Mark such a task Quiet history (the switch in the task form, or
--quiet-history on dispatchq add / dispatchq edit / dispatchq queue) and:
- A run that succeeds is hidden: it is written to history marked
"quiet": true, with its log, but the Log leaves it out unless Show hidden runs is on (see The app), it is never unread, anddispatchq statuslists it only with--hidden. Hidden runs are kept for 48 hours, at most the newest 200 per task, and are then deleted with their logs. So that the history file is not rewritten on every tick, they go in batches after a run finishes: once more than 20 are due across all tasks, or once one is six hours past its 48 hours. A hidden run can therefore stay up to six hours (or 20 runs) longer. When the daemon starts, every expired one goes at once. They do not count against Keep run history, so a watcher that ticks every five minutes never pushes a real run out of the record. - A run that fails, hits an auth problem, is cancelled, or is paused by the rate limit is recorded with its log, unread, exactly like an ordinary run: it shows in the Log without any filter, and notifies as usual. A pause is kept even though the daemon resumes it by itself: it closes that provider's gate and holds up every other task on it, so it is the answer to "why is nothing running?" — and its Log entry is where Cancel resume lives.
- While it is running, it shows like any other run: the Queue's running
badge, an entry in the Log and on the Dashboard's Running now card, with
its live log and Cancel task. The entry is held in memory only and never
counts as unread; when the run succeeds it turns into a hidden run.
dispatchq statusreads history, so it does not list the run in flight. - The Dashboard's Up next card leaves it out; the Queue still lists it with its next run.
Everything else — notifications the run sends, artifacts it publishes, tasks it
queues — is unaffected. A task queued from inside a quiet-history run is not
quiet itself unless the call says --quiet-history: follow-up work is real
work, and you will want to see its run.
The trade-off: a successful quiet run's log is there for two days only, and
hidden runs are absent from the Usage statistics (and from what
Ask DispatchQ lists, though it finds one by its run id).
Some work is worth doing only when it costs nothing: a dependency audit, a
documentation pass, the refactor that is nice to have. Mark such a task
Backfill (the switch in the task form, or --backfill on dispatchq add /
dispatchq edit / dispatchq queue) and it runs only on allowance that would
otherwise expire unused.
Urgency says how far behind a provider is on spending its week:
urgency = free share of the week × hours in a week ÷ hours until the week resets ÷ (1 + runs already on it)
At 1, spending evenly from now on uses up exactly what is left; a fresh week
starts there. At 2 it would take twice that pace, so half of what is left is
at risk. The Dashboard's allowance strip shows each provider's urgency against the
backfill threshold and turns it green above it. The threshold is the
slider at the bottom of Settings → Providers (0.5 to 5, default 1.5); while
it moves, each provider's urgency gauge shows whether
it would qualify, and it applies on release. dispatchq settings --backfill-urgency X sets the same
value.
A backfill task is due by its trigger as usual (once, at a time, or on a cron schedule) and then:
- On a provider it starts only while that provider's urgency is above the threshold, it has room in its 5-hour window (under 90%), it can run, and it is not waiting out a rate limit. It never goes to the provider's fallback: that allowance is not the one at risk.
- On a pool it goes only to members that meet the same test, the best of them by the pool's ranking. So one list of backfill tasks on a pool feeds whichever account is about to waste its week.
- Until then it stays due and waits, and the Queue says why, for example Backfill: Codex is at urgency 0.8, backfill runs above 1.5. A cron task that waited through several occurrences runs once when it can, not once per occurrence missed.
- It goes after every other due task: a backfill task at the top of the queue never takes the slot of real work due at the same time.
- Runs in flight share a provider's urgency. Parallel backfill tasks therefore start one after another until what is at risk is covered: at urgency 3.5 and threshold 1.5, two start (3.5, then 1.75) and the third waits (1.17).
- Run now ignores the threshold: it is a deliberate start.
Backfill is for agent jobs: a script job spends no allowance and cannot be
marked. A job queued from a backfill task inherits the flag, like its other
settings; pass --backfill=false to queue real work.
dispatchq add --id audit --prompt "Audit the dependencies and file issues" \
--dir ~/code/app --pool claude-pool --backfill
dispatchq add --id docs --prompt "Tidy the docs" --dir ~/code/app \
--provider codex --cron "0 * * * *" --backfill
dispatchq provider limits # the urgency of each providerA task can be handed to a colleague as a single .dispatchq file. It is a plain
zip archive with two entries:
| Entry | Contents |
|---|---|
task.json |
Every setting of the task (id, name, working directory, trigger and schedule, parallel, enabled, model, permissions, notify) inside a small envelope: format (dispatchq-task), format_version (1), exported_at, and task. |
prompt.md |
The prompt, byte for byte, as Markdown. A task that reads its prompt or script from a file exports the file's current text here, and the bundle opens as an inline task. |
Unzip it to read or edit either part by hand; zip the two files back up and the result imports again (a folder around them, as Finder's Compress adds, is fine).
Export. In the app, the export button on a task row opens the native save
panel, pre-filled with <id>.dispatchq. From the CLI:
dispatchq export nightly-sweep # ./nightly-sweep.dispatchq
dispatchq export nightly-sweep --out ~/Desktop # a directory: default name inside it
dispatchq export nightly-sweep --out share/brief # a file: .dispatchq is appended
dispatchq export nightly-sweep --out x.dispatchq --force # overwrite an existing file
Without --force the CLI refuses to overwrite; the app's save panel asks
before replacing a file.
Import in the app. Import… on the Queue toolbar picks a file and opens its task in the normal task sheet, titled Import task. Nothing is queued yet: prompt, working directory, schedule and every switch come from the file and can be changed, and Add task creates the task, exactly as if you had typed it in. Cancel and nothing happened.
The working directory is the one place where a shared file cannot be trusted, so it is checked against this machine: if that folder does not exist here, the field is left empty and the sheet says which path was dropped — you pick a real one before the task can be added. A folder that exists but that DispatchQ may not read yet counts as existing and is kept.
The provider travels as a hint, never as an account. A file records which
kind of harness the task was written for (claude-code, codex), the model,
and what the exporter called their provider — never a provider id, a
configuration directory or anything to do with a login. On import DispatchQ looks
for your own provider of that kind: exactly one match is taken, with the model.
Anything else — no match, or several accounts of that kind — leaves the choice
to you, and the sheet says which harness the file wants. Two accounts are not
interchangeable (separate allowances, separate logins, often separate
employers), so DispatchQ does not pick one for you. Importing never creates a
provider, copies a path, or touches credentials.
Import from the CLI. dispatchq import PATH (optionally --id ID to choose
the id) adds the task straight to the end of the queue with its settings
exactly as exported — working directory, schedule, model, permissions and
enabled state included. When the file's provider hint matches no single provider
here, the import is refused and names what it wants; --provider ID places it
(and --model NAME picks the model, since the exporter's belongs to their
harness). A working directory that does not exist on this machine
is kept, with a warning naming it; fix it with dispatchq edit ID --dir PATH.
Because settings arrive as-is, a task exported as enabled with an as soon as
possible trigger is eligible to run right after a CLI import; pause or edit it
first if that is not what you want.
What the file cannot decide is filled in, either way:
- Ids may contain only letters, digits,
.,-and_(they appear in the app's own URLs); anything else is rejected. A CLI import keeps the file's id, or a numeric suffix (nightly-sweep-2,-3, …) when that id is already in use — the existing task is never touched; a file with no id gets one derived from its name. A task added from the sheet gets a fresh id like any other new task. - Missing
permissionsmeandefault; a missing name falls back to the id. - On a CLI import, any scheduling history left behind by an earlier task with the same id (deleted before this version cleaned up after itself) is dropped, so the import starts fresh.
A file with an invalid task (no prompt, unknown trigger, bad cron) is rejected in both paths and nothing is added.
A prompt written for one Computer rarely fits the next one unchanged, and a task queued for 3 a.m. has nobody around to notice. So whenever a draft prompt is written or changed — in a new task, an edit, a replay or an import — DispatchQ has Claude read it against this machine and, if something is off, shows it in a purple banner under the prompt box:
✦ DispatchQ suggests: docs/spec.md does not exist here, so the run has nothing to read. Also out/weekly.md would be written into an out/ directory that does not exist yet, so the rewrite creates it first.
[ Apply ] [ Dismiss ]
Apply replaces the prompt with the rewrite, in the box, where you can still change it before saving; the review then runs again on the result. Dismiss just hides the banner. Nothing is ever saved, queued or changed on your behalf, and a task can always be added exactly as written — the banner is advice, not a gate.
What it looks for:
- A path that isn't there. DispatchQ resolves every path the prompt mentions
(absolute,
~/…, or relative to the task's working directory) and checks it. An input file or folder that does not exist is reported; the right path is yours to supply, so no rewrite is offered. - A file to be written into a folder that doesn't exist. The missing file is normal; the missing parent directory is the problem. The rewrite tells the run to create it first.
- Guidelines it would depend on. If the prompt says to follow the rules in some file and that file is small, the rewrite pastes its content straight into the prompt, so the task no longer depends on the file still being readable when it runs.
- A prompt that waits for you. A question, a confirmation, a choice — an unattended run has nobody to answer it, so the rewrite decides up front.
A script job gets the same banner, judged as a program rather
than as a prompt: a path it reads, changes into, sources or names as its
interpreter that isn't there; a file it writes into a folder that doesn't exist
(the rewrite adds mkdir -p); sudo without -n, a read from the terminal
or another prompt nobody will answer; and, for a shell script, a command that
isn't on the PATH it runs with. The daemon's PATH is launchd's minimal one and
reads no shell profile, so a Homebrew tool you use every day in Terminal is
often simply not there at 3 a.m. DispatchQ looks each command up on that very
PATH and, when one is missing but installed elsewhere (/opt/homebrew/bin,
/usr/local/bin, ~/.local/bin, …), the rewrite calls it by its full path.
Paths written as $HOME/… are checked like ~/….
The same banner sits under the custom system prompt in Settings, with the rules adjusted to what that text is: it applies to every task, in every directory, so a relative path there means something different every time.
Practicalities:
- It runs on a provider, so it costs a little usage each time. Which provider and which model are yours to pick under Settings → Assistant → Prompt review — reviewing is frequent, cheap work with no claim on the allowance you reserved for real tasks, so a second account or a fast model earns its keep. Or switch the whole thing off with the toggle above. Only providers that can answer a question of DispatchQ's own are offered (see below); if none can, the banner simply stays away.
- The review itself is the narrowest invocation DispatchQ makes: no tools at all (DispatchQ hands it the path checks and the small files it read), no CLAUDE.md, skills, plugins, hooks or MCP servers, no saved session, and a working directory outside any repository. It reads; it never writes. DispatchQ calls this an aside — a question it asks a harness on its own behalf rather than to do your work — and a provider has to be able to hold one to be offered for it. Claude Code and Codex can; opencode cannot yet. Codex has no switch that removes its tools, so it answers in a read-only sandbox inside that empty directory, with your own Codex config, MCP servers and rules left out.
- It runs on a change, not on a look. Editing the prompt, choosing a working directory or importing a task starts a review from scratch and cancels the one still in flight — including its Claude process — so only the newest answer is ever shown. Opening a sheet changes nothing, so it asks nothing: an answer is remembered for a day, and reopening the same task, or the app, shows that finding again for free, headed DispatchQ suggested earlier and with a Check again button for when the machine has moved on — the missing file exists by now — but the prompt has not. Undoing an edit is free too: the finding for the text you are back at is still remembered. Dismiss forgets a finding rather than hiding it, so it does not come back by itself; editing the prompt back and forth after that asks once more.
- Switching the review off hides the banner everywhere, remembered findings included. A finding is also only ever reused for the reviewer that produced it, so changing the review provider or model — or the default provider the review follows — never replays an answer the new one did not give.
- A task sheet without a working directory yet — an imported task, whose folder came from another Computer — waits for you to choose one before reviewing, since every relative path would otherwise be unresolvable.
- File content it was given is treated as data. Instructions found inside a file cannot steer the review, and any rewrite is shown to you in the prompt box before it can run.
Ask DispatchQ at the top of the sidebar (⌘J, or type a question into the command palette and pick Ask DispatchQ) opens a chat docked to the right of the window; in a window narrower than about 1100 px it lies over the view's right edge instead of squeezing the view. It stays open while you switch views, and it does only DispatchQ things: answer about the queue, runs and artifacts, take you to them, and plan or change jobs. It answers in your language, in a sentence or two; anything else it declines in one line.
- Ask about runs. "What ran last night?", "why did the dependency check fail?" It reads the run's outcome, final output and the end of its log before answering, and lists what it looked at above the answer.
- Open things. "Show me the newest report" opens the artifact in the viewer; a run opens in its log, a task in the task form, a view on its own. This happens straight away: it only shows what is already there.
- Plan and change jobs. "Every weekday at 7, summarise the TODOs in the same project as the dependency check" comes back as a card with the name, schedule, folder and the prompt it wrote. Add to queue files it, Edit opens it in the task form first (where the prompt review sees it), Dismiss drops it. Changing a task, running it now, pausing, enabling or deleting it, cancelling a run and pausing or resuming all runs arrive the same way. Nothing in the queue changes until you confirm the card, and a newer proposal for the same thing ("make it 8 instead") replaces the older card. Before a card is shown, DispatchQ checks it: an invented task or run is dropped, and a draft whose folder does not exist on this Computer, whose cron does not parse or whose provider is unknown is marked and can only be edited.
The assistant covers the whole command line. A task draft
carries every setting dispatchq add, edit and queue take: kind (agent or
script), schedule, group, provider or pool, model, reasoning effort,
permissions, parallel, notify, quiet history, backfill, enabled, and the jobs it
waits for with their results. Everything else the CLI changes (notifications,
publishing and exporting, moving tasks, marking runs read, providers, pools,
settings, the update check) arrives as a card showing the exact dispatchq
command; Run carries it out and shows what the command printed. A command
that removes something for good gets a red button. What the CLI only reads
(provider readiness and limits, pools and the next member, settings with the
credentials masked, run status, artifacts) the assistant runs on its own to
answer you. Three things stay with you: credentials (Pushover, ntfy and webhook
secrets, a provider's --env variables) never pass through the chat, so it
sends you to Settings or the terminal for them; logging a provider in; and
uninstalling, which asks for an administrator password.
The assistant knows the machine it runs on: the system, where the dispatchq
CLI lives, the data directory, how a script job is run (/bin/sh unless its
#! line names another interpreter) and which notification channels are on. So
it writes a script job that calls the CLI correctly, quoted, without asking you
for the syntax or a path. For example, "a script that sends test with the
time every minute" becomes a script job on cron * * * * *:
"$DISPATCHQ_BIN" notify --title 'test' --message "test $(date '+%Y-%m-%d %H:%M:%S')"It never switches a job to may do anything on its own and never estimates a cost it has not measured; a script job runs no model and costs nothing.
Every message carries a compact picture of DispatchQ: the time, the pause
switch, the providers, every queued task (without its prompt), the last runs
and artifacts. For details the assistant asks DispatchQ, read-only: one task
with its prompt, one run's output and log tail, a list of runs or artifacts, a
text artifact's beginning, usage per provider, or a read-only dispatchq
command. That takes one to three model
calls per message, a few seconds each. Like the prompt review, the chat runs as
an aside: no tools, no MCP servers, no project files, in an empty throwaway
directory, so it cannot touch your machine; it knows only what DispatchQ tells
it, and it acts only through the cards you confirm. A confirmed command runs
the dispatchq CLI next to the app directly, as a program with its arguments
and never through a shell, after DispatchQ checked it against the CLI's own
reference again.
It runs on the provider and model under Settings → Assistant (the same setting the Feedback page uses; DispatchQ's choice is Sonnet for the chat on Claude Code, and the harness's own default on Codex). A provider that cannot hold an aside (opencode) is not offered; the panel says so and points to Settings and the task form instead. The conversation lives in the window: Clear starts a new one, and if the daemon restarted meanwhile, the next message carries the conversation along.
Feedback at the bottom of the sidebar is a page of its own, and turns a bug report or a wish into a GitHub issue without you having to write one.
- Say what is wrong, or what DispatchQ should be able to do — in whatever language you think in. The page offers a few examples to start from; picking one only fills the message box. ↵ sends; ⇧↵ starts a new line.
- Claude reads it and either asks one short clarifying question (at most twice, and only when the report cannot be acted on as it stands) or goes straight to a draft. This runs on the provider you chose under Settings → Assistant → Assistant provider, on a small model by default, and costs a fraction of a cent per message; it does not go through the queue, so it works while tasks are running.
- You get the finished issue — an English title and body, labelled either
bugorenhancement— in editable fields. Your DispatchQ version and macOS version are named there and ride along as a last line of the issue. - Open on GitHub opens GitHub's prefilled new issue page in your browser. The issue exists only once you press Create there — and everything, including that version line, can still be changed or deleted on that page.
DispatchQ never talks to GitHub for this and stores no token: your browser is already signed in, and the whole issue travels in the page's URL. If the assistant cannot be reached — no provider set up to draft one, or your usage limit is exhausted — the page keeps what you typed and lets you write the issue by hand.
The chat runs as an aside (see The prompt review): tools,
MCP servers, skills and CLAUDE.md files all switched off (on Codex: a
read-only sandbox without your own Codex config), in an empty throwaway
directory, so it can neither touch your machine nor pull project
context into a public issue. Only a provider that can hold such a conversation
is offered for it. It is told not to put
personal data (paths, names, addresses, prompt contents) in the issue — and
because you see the text before anything is filed, you have the last word on
that.
- Eligibility. On each tick the daemon starts every task that is due and permitted by priority, concurrency, and the limit gate.
- A task that runs alone gets its turn. Priority decides first: a parallel task above it starts, and the task that runs alone waits for the running ones to finish. Parallel work that comes due every minute would keep it waiting for good, so once it has waited five minutes it goes first: no new task starts, the running ones finish, and it runs alone. The tasks held back stay due and start right after it; their waiting badge says why. Backfill tasks never get this turn.
- A one-time task runs once. An asap or earliest start task leaves the
queue when it has run. If the write that removes it fails, the next daemon
start removes it. One that is still listed after it ran for another reason
(written back into
config.tomlby hand under its old id) stays: the Queue marks it already ran and the Dashboard's Up next leaves it out, and the scheduler does not start it again. Run now runs it by hand; deleting it clears the mark, so a task added later under the same id runs normally. - The pause switch wins over everything. While Pause all runs is on, a tick
starts nothing and records nothing,
dispatchq run-nowand the app's Run now are refused, and no task wake is registered (only the heartbeat stays, so the daemon notices when you switch it off). Nothing is lost: a task that came due while paused is still due afterwards. - The limit gate belongs to the provider. When a run reports a rate limit,
new starts on that provider pause until the reset; the other providers carry
on. The wait comes from the reset time the CLI reports, then from its
retry_delay_mssignal, and falls back to 15 minutes when neither is exposed. A terminal rate-limit result is recorded even if the CLI stays alive after emitting it. DispatchQ stops that CLI and its remaining tool processes, preserving the session and reset time. A warning or subagent rejection alone does not stop a working run. At reset the gate reopens and the blocked task resumes its session rather than starting over. - An account that may not run at all counts as a limit. When Claude Code
answers that the organisation has disabled Claude subscription access, or
that the API credit balance is too low (an instance running on an
ANTHROPIC_API_KEYwhose prepaid credit is used up), or refuses it with any other billing error (a spend limit set on the account), the account has nothing to spend, so the provider pauses exactly as it does on a limit — and its fallback takes the work. The pause is an hour, because such a block lifts through an admin or a billing change, never by waiting a few minutes; no reset time is invented for it. For an API key whose credit is used up or refused for billing, the pause lasts until a ping gets an answer: the daily one, or the one Read now on the Dashboard sends after a top-up. It is kept instate.json, so a restart does not hand the account the next task. The run says so instead of reading as a plain failure. - A fallback provider skips the wait. If the blocked provider names one (see When the limit is reached), its tasks run there meanwhile — from the start, since a session belongs to the account that issued it. The interrupted session is then dropped instead of being resumed after the reset, so the task does not run twice. A pool task moves the same way: on the next tick the pool ranks it onto a member that can work. This holds however the paused run was started, by the scheduler or with Run now, also for a task queued again under the id of one that had already run once.
- A blocked queue says so. While the gate is closed a banner names the time
it reopens, the paused run is marked rescheduled in the Log with that time,
and the task carries a rescheduled badge in the Queue — a waiting queue is
never mistaken for a stuck one. Cancel resume on the run drops the plan:
the pending session is forgotten, the run is recorded as
canceled, and a one-shot task leaves the queue. The gate itself stays closed until the reset, since the limit is not yours to lift. - Auth problems don't retry. A login/authentication error is recorded as
auth_errorand notified so you can re-login; it is not retried automatically. - Wake-ups (macOS). After each pass the daemon registers a
pmsetwake at the nearest relevant time (next fixed start, next cron occurrence, or limit reset) plus a recurring heartbeat wake, so the Mac can sleep between runs and wake when there's work. Windows has no scheduled wake: nothing runs while the PC sleeps. - No network, no start. Before an agent task on Claude Code or Codex
starts, the daemon checks that the provider's API host name resolves
(
api.anthropic.com,api.openai.com). When it does not, as in the short wakes of a Mac sleeping with its lid closed, the task stays due with the reason No network connection and starts on the first check that has it, instead of failing at once. Script jobs and opencode (often pointed at a local model) are not held back. The daemon log says once when the network went and when it is back. - Run outcomes are one of:
running,success,failed,rate_limited_waiting(shown as rescheduled while its session is still queued to continue),auth_error,canceled(stopped by the user — either the running process or a scheduled resume).
Notifications go to every channel you switched on (macOS, Pushover, ntfy, the webhook) and to the Notifications view. What is announced, and how often:
| Event | Announced | Phone priority |
|---|---|---|
| A run fails, or hits a login problem | The first failure of a streak. Further failures of the same task are not pushed: its row in the Notifications view counts them ("failing since 08:00 (5 times)"). After 12 hours of failing it is pushed once more as a reminder. The message names the run id, which finds the run in the Log. | High (Pushover priority 1, ntfy high) |
| The task works again | Once, with the run that ended the streak ("Failed 5 times since 08:00; this run succeeded."). Replaces the task's own success notification for that run. | Normal |
| A run succeeds | Only when the task opts in (Notify on result). | Normal |
| A provider is paused by its rate limit | Once per pause, when the pause lasts 2 hours or more, counted from the first limit (back-to-back short pauses add up). Shorter pauses resume by themselves and are not announced. | Normal |
| A due task has not started | Once per wait, after 30 minutes; a backfill task, whose job is to wait for spare allowance, after 24 hours. Not for waits announced otherwise or that are your own doing: runs paused, a provider out of allowance or not ready, no network. | Normal |
| A provider stops or resumes running tasks | Once per change (unchanged). | Normal |
| A run is cancelled, a resume dropped, a run taken over | Not announced: you did it yourself, and the Log records it as canceled. |
— |
The failure streaks are kept in failing.json, so a restart neither announces
a streak again nor forgets to say it ended. Pause and waiting announcements
are kept in memory: after a restart a long pause or wait can be announced once
more.
Delivery. Each channel is delivered to on its own. Before a notification
goes out, a delivery per channel is written to deliveries.json; a channel
that takes it removes its delivery and the daemon log says so, with the
receipt the service answered with (Pushover's request id, ntfy's message id),
for example notification "DispatchQ ✗ nightly failed" accepted by pushover (receipt 5042853c-…). A channel that does not take it (no network, a timeout,
a server error, a rate limit) is tried again after 30 seconds, then with
doubling pauses up to 5 minutes, and at once after the Mac wakes or the
network is back, also across a daemon restart. For two minutes after a wake
a try that fails on the network does not lengthen the pause and comes back
after 30 seconds, since Wi-Fi often comes up a little after the Mac; a rate
limit or server error keeps its pause. A failure streak's reminder replaces
whatever still waits of the streak's earlier push on the channels it goes to,
so the phone gets the latest count rather than a stale alert first. While
config.toml cannot be read, a notification goes to the desktop and is
queued for the remote channels that were on before (known from the last good
read, or from what already waits in deliveries.json). An error names the
service's host, never the full URL (for a Slack or Discord webhook the URL is
the secret). Pushover, ntfy and the webhook
are tried for 24 hours, the macOS banner for 1 hour; past that the daemon
writes NOTIFICATION LOST: … to dispatchqd.err.log. A refusal that would
repeat (a revoked token, a deleted topic: an HTTP 4xx other than 408 or 429)
is logged and not retried, and a delivery to a channel you switched off is
dropped. The Notifications view has every notification either way. When
config.toml cannot be read at the moment a notification goes out (a full
disk, a system out of file handles), it goes to the channels the settings read
last name, not to the desktop alone, and nothing queued is dropped as switched
off; the daemon log says so.
Everything lives under ~/Library/Application Support/dispatchq on macOS and
%APPDATA%\dispatchq on Windows (override with the DISPATCHQ_HOME
environment variable):
| Path | Contents |
|---|---|
config.toml |
Global settings, the configured providers and pools, and the ordered task list — the order is the priority, and tasks of one group sit together in it (human-readable, versionable). No credentials: a provider entry holds its CLI's path and configuration directory, never what is inside them. |
history.jsonl |
Append-only index of every run, pruned to Keep run history; unread runs that did not succeed are never pruned. A quiet-history task's successful runs carry "quiet": true and are dropped after 48 hours (at most 200 per task are kept). Scripts read it through dispatchq status --all --json, which leaves those out unless --hidden is given. |
runs/<run-id>.log |
Full log for each run; a hidden run's log is deleted with it. |
runs/<run-id>.pending.json |
Only after a failed write: a run's record that history did not take yet. The daemon writes it to history at the next chance and removes the file. |
artifacts.json |
Index of published artifacts (title, source task/run, its group and parent job, file name, size, type). |
artifacts/<id>/<file> |
The published files themselves (snapshots copied at publish time). |
notifications.json |
Outbox of notifications sent with dispatchq notify, waiting for the daemon to deliver them (normally empty). |
inbox.json |
The last 1000 notifications the daemon sent (title, message, kind, what each points at, read flag), as listed in the Notifications view. An unread failure notification is kept until it is read, however old. |
deliveries.json |
Notifications a channel has not taken yet (which channel, how often tried, when next, the last error), retried for up to a day (normally absent). See When DispatchQ notifies. |
failing.json |
Tasks failing right now: since when, how often, and the Notifications row that counts it (normally absent). |
state.json |
Machine bookkeeping: read/unread flags (runs and artifacts), which artifacts have been notified about, cron anchors, pending-resume sessions (including one a DispatchQ restart cut off), one-shot tasks whose removal from the queue is still owed, the process of each run in flight (so a restarted daemon can take it back), the provider health you were last told about, the latest credit proof of each API-key account (ping or run, answered or refused), which queue groups are folded shut, dismissed update version, the What's new edition you closed, whether the check for tasks that still use the 1.x names is done. |
dispatchqd.out.log / dispatchqd.err.log |
Daemon stdout/stderr on macOS; on Windows the daemon log is dispatchqd.log. Every line the daemon prints starts with its local time (RFC 3339, e.g. 2026-11-11T17:05:09+01:00); a Go crash trace is written as it is. |
../claudeq |
After an update from a 1.x version: a link from the old data folder to this one. |
.lock / .daemon.lock |
Lock files, both empty of interest. .lock serializes config/state writes between the daemon and a dispatchq command; .daemon.lock holds the running daemon's pid and is what makes a second daemon on the same store refuse to start. Both are released when the holding process exits, so neither needs clearing by hand. |
On macOS the LaunchAgent itself is at
~/Library/LaunchAgents/de.maierdaniel.dispatchq.plist; on Windows the daemon
starts from the per-user Run key instead.
Dragging DispatchQ.app to the Bin is not enough: the background agent stays
registered and macOS keeps trying to start it. DispatchQ ships its own
uninstaller, Uninstall DispatchQ.pkg, inside the app
(Contents/Resources). Settings → System → Uninstall… opens it in the
macOS Installer: read the welcome page, optionally click Customize, then
Install (the Installer's button for running a package) and enter your
password.
It removes:
| What | Where |
|---|---|
| The app | /Applications/DispatchQ.app, and the copy the background agent ran if that is another one |
| The background agent | ~/Library/LaunchAgents/de.maierdaniel.dispatchq.plist; every running DispatchQ daemon and window is stopped, and a run in progress is cancelled |
| The wake permission | /etc/sudoers.d/dispatchq |
| The installer receipt | pkgutil --forget de.maierdaniel.dispatchq |
| What a 1.x version left | ClaudeQ.app (or the link the installer put in its place), its LaunchAgent de.maierdaniel.claudeq, its daemon and window, /etc/sudoers.d/claudeq and its receipt, if any of them are still there |
| Customize → Tasks, history and settings (off by default) | ~/Library/Application Support/dispatchq and the claudeq link next to it, and the app's own folders (also those of 1.x versions) under ~/Library (WebKit, Caches, HTTPStorages, Saved Application State, Preferences) |
The uninstaller leaves no receipt of its own. It also cleans up after an app
that was already dragged to the Bin: open the .pkg from the Bin's copy of
DispatchQ.app (right-click → Show Package Contents → Contents/Resources).
From the terminal, the same package runs through installer; run it as the
user logged in at the screen (not with sudo, it asks sudo for the password
itself):
/Applications/DispatchQ.app/Contents/MacOS/dispatchq uninstall # keep tasks and history
/Applications/DispatchQ.app/Contents/MacOS/dispatchq uninstall --purge # delete them tooThe data choice deletes the default data directory only; the Installer cannot
see a DISPATCHQ_HOME set in your shell, so delete such a folder yourself
(--purge reminds you).
Requires Go 1.26+, Node.js 22+ with npm (for the web UI) and librsvg
(brew install librsvg) for icon rendering.
scripts/build-app.sh # build/DispatchQ.app (double-click or `open` it)
scripts/build-pkg.sh # dist/dispatchq-<version>-macos-arm64.pkg (installer)
make web # only the web UI, into internal/api/web
scripts/build-windows.sh # dist/windows/…-windows-x64-beta.exe (local Windows beta build, see below)The daemon embeds the web UI when it is compiled, so build the UI first
(make web; build-app.sh does it for you). A daemon compiled without it serves
a page that says so. To work on the UI with hot reload against a running daemon,
run npm run dev in web/ (it proxies /api to 127.0.0.1:10765, or to
DISPATCHQ_URL).
Run the same quality gates CI enforces with make check (format, vet, lint,
race tests, and the web UI's typecheck, lint and tests). Releases are built automatically: pushing a v* tag runs
.github/workflows/release.yml, which builds the
Apple silicon .pkg and both Windows beta installers, checks each one's file
name, architecture, version and source commit, and publishes the GitHub Release
only when all three succeeded.
You do not need this to use DispatchQ on Windows; it is for testing a change
before it is released. Every CI run builds both installers and keeps them as
workflow artifacts of that run (dispatchq-<version>-windows-<x64|arm64>-beta,
kept 14 days, for signed-in GitHub users). To build one yourself on a Mac (or
Linux) and copy it over:
brew install mingw-w64 makensis librsvg imagemagick # once
scripts/build-windows.sh # x64, writes dist/windows/
scripts/build-windows.sh --arch arm64 # ARM64, writes dist/windows-arm64/That produces dispatchq-<version>-windows-<x64|arm64>-beta.exe, a
SHA256SUMS file and a BUILD-INFO.json naming the source commit and
toolchain. The ARM64 build needs llvm-mingw
for the window's C++ part; on a Mac the script downloads the pinned official
release into build/windows/cache (checksum verified, nothing installed
system-wide), or uses DISPATCHQ_LLVM_MINGW=<dir> or one on PATH. The build
checks that the three programs carry the target's machine type.
A headless Go daemon owns all state and logic; a thin WKWebView app on macOS or
WebView2 app on Windows is the only
UI, talking to the daemon over loopback. Runs go through a provider adapter:
the daemon knows a task runs on a configured provider instance with a model and
an access mode, and the adapter for that provider owns the rest. The Claude Code
adapter spawns the claude CLI once per task in the task's directory using
--output-format stream-json, which lets it watch for rate-limit and auth events
as they happen and capture the session id, token usage, and cost from the final
result. The daemon also spawns a second, far smaller kind of claude call for
the prompt review: one turn, no tools, no session. DispatchQ
performs no Git operations — any branch/commit behavior is driven entirely by
your prompts and the repo's own configuration. The window's UI is a React and
TypeScript app in web/ (Vite, Tailwind CSS, Radix UI primitives, TanStack
Query), built into internal/api/web and embedded in the daemon: design-system
components under web/src/components/ui, the shell under web/src/app, and one
folder per view under web/src/features. The full design, decisions, and
verification notes are in PLAN.md.
- macOS 12 or newer on an Apple silicon Mac, or for the Windows beta Windows 11 on an x64 or ARM64 PC (Windows 10 may work but is untested)
- The Claude Code CLI, installed and authenticated
- Optionally the Codex CLI, installed and logged in, for tasks you want to run on it
MIT © 2026 Daniel Maier