localterm API
localterm (https://github.com/monotykamary/localterm) is a local daemon that serves
terminals as browser tabs. It exposes an unauthenticated, loopback-only HTTP API you
can call with curl. The flagship resource is automations: server-managed jobs that fire on a
trigger (schedule, filesystem watch, session event, or webhook) and run a
runner in a chosen directory. A shell runner ({kind:"shell", command})
opens a new browser tab and types the command into a fresh shell — the tab stays
open afterwards so the user sees that it ran and whether it succeeded (never
append exit to a command). An agent runner ({kind:"agent", prompt, …}) runs
an agent session headlessly in the daemon — no tab — and reports back findings plus
a transcript; see references/agent-runner.md.
Connect
The daemon writes its state to ~/.localterm/:
PORT=$(cat ~/.localterm/server.port 2>/dev/null || echo 3417)
BASE="http://127.0.0.1:$PORT/api"
curl -s "$BASE/health" # → {"ok":true,"sessions":N}
If the health check fails, the daemon isn't running. Ask the user to start it (or
run it yourself if authorized):
npx @monotykamary/localterm@latest start
Requests must come from the same machine; Host must be loopback (using
127.0.0.1 with curl satisfies this).
The user-facing browser URL (what localterm status prints as url:) is
resolved across three surfaces, best-first: tailnet
(https://<node>.ts.net, when localterm install ran the Tailscale step),
local (https://localterm.localhost, when the portless proxy service is
up on :443), or loopback (http://localterm.localhost:<port>, always
works via RFC 6761). The API calls above use the loopback raw form directly —
don't depend on which surface the browser happens to use.
Automations
An automation is {name, trigger, cwd, runner, enabled, limit, closeOnFinish, requestedSecrets}:
trigger — what makes the automation run, a tagged union on kind:
{kind:"schedule", schedule} — time-based (the common case; daily is shown in the create examples below).
{kind:"watch", recursive, filter?} — fires when the automation's cwd changes (native filesystem events, no polling).
{kind:"event", events: [...]} — fires when a localterm session emits a named event matching cwd (session-scoped).
See references/triggers.md for the full schedule-shape table (hourly/weekly/monthly/cron/…), git-event taxonomy, watch filter/debounce/grace semantics, and the cron escape-hatch details.
{kind:"webhook"} — fires when an external POST hits /api/webhooks/<id>. The id is a server-generated capability token (Discord-style: anyone with the URL can fire it); it is returned in the created automation's trigger.id and preserved across PATCHes that keep the webhook kind. The POST body is ignored — runner/cwd are fixed at create time, so a webhook is a pure signal like schedule/watch/event.
cwd — absolute path; must exist and be a directory on the daemon's machine
(validated at create/update time).
runner — what the automation runs when it fires, a tagged union on kind.
Orthogonal to trigger — any runner fires on any trigger:
{kind:"shell", command} — the original model: types command into a fresh
shell in a new browser tab (shell syntax like && and pipes work; max 4096
chars). The tab stays open after the command finishes; its exit code drives
the run status.
{kind:"agent", prompt, sessionMode, model?, thinking?, harness?} — runs an
agent session headlessly in the daemon (no tab, no PTY). prompt is
natural language (max 4096 chars); sessionMode is fresh (ephemeral) or
thread (resumes one persistent session file per fire). See
references/agent-runner.md for the harness
abstraction (built-in pi over pi --mode rpc, or a custom command),
model/thinking knobs, the per-run transcript log, compaction, and Triage.
enabled — defaults to true. Disabled automations never fire.
limit — {kind:"forever"} (default) or {kind:"count", max:N} = "stop after
N runs". When the limit is reached the automation finishes (a terminal
lifecycle:"finished" state) and stops firing but stays listed with its
history. Scheduled, watch, and event runs count toward the limit; manual /run never
does.
closeOnFinish — defaults to false (the tab stays open). Shell runs only:
when true, the run's browser tab is closed once the command finishes (only
honored for CDP-opened tabs; silent no-op on the OS-opener fallback).
Meaningless for agent runs (no tab) but stored as-is.
requestedSecrets — defaults to [] (the run gets no secrets). A list of secret names (by stable identifier, not env var) whose values are resolved from the Keychain and injected as env vars into the run's PTY (shell) or subprocess (agent) at spawn. Per-automation, opt-in least-privilege: an automation gets exactly the secrets it named, nothing else. Unknown names are rejected at create/update time (catches typos); a name deleted after you selected it is skipped at run time (fail-closed). Values still never cross HTTP — resolution is Keychain → daemon → PTY/subprocess env. See references/secrets-sessions.md.
For run-tab mechanics (background CDP vs. opener fallback, LOCALTERM_DISABLE_CDP_TABS), the run-status table (launched/running/completed/failed/missed/skipped), runs/runCount/lifecycle/lastRun shape, the trigger field values, and the shell-run log (ANSI-stripped PTY output), see references/run-states.md. For agent runs (headless, the findings/changedFiles/unread run fields, the 10-min timeout), see references/agent-runner.md.
Endpoints
# List (each item adds computed nextRunAt epoch-ms (null when disabled/finished
# or a watch/event/webhook trigger), a derived `cron` string (null for
# watch/event/webhook), the capped `runs` history, and a back-compat `lastRun`)
curl -s "$BASE/automations"
# Create
curl -s -X POST "$BASE/automations" \
-H 'content-type: application/json' \
-d '{
"name": "nightly build",
"trigger": { "kind": "schedule", "schedule": { "kind": "daily", "hour": 2, "minute": 0 } },
"cwd": "/Users/me/project",
"runner": { "kind": "shell", "command": "pnpm build && pnpm test" },
"enabled": true,
"limit": { "kind": "forever" }
}'
# → 201 {"automation":{"id":"…","cron":"0 2 * * *","nextRunAt":1765591200000,…}}
# Create a folder-watch automation (runs when cwd changes; no cron/nextRunAt)
curl -s -X POST "$BASE/automations" \
-H 'content-type: application/json' \
-d '{
"name": "rebuild on change",
"trigger": { "kind": "watch", "recursive": true },
"cwd": "/Users/me/project",
"runner": { "kind": "shell", "command": "pnpm build" },
"limit": { "kind": "count", "max": 50 }
}'
# → 201 {"automation":{"id":"…","cron":null,"nextRunAt":null,…}}
# Create a filtered folder-watch (only triggers on .mov files)
curl -s -X POST "$BASE/automations" \
-H 'content-type: application/json' \
-d '{
"name": "autoconvert mov→mp4",
"trigger": { "kind": "watch", "recursive": false, "filter": "*.mov" },
"cwd": "/Users/me/Downloads",
"runner": { "kind": "shell", "command": "find /Users/me/Downloads -maxdepth 1 -iname *.mov -type f | while IFS= read -r f; do mp4=\"${f%.*}.mp4\"; if [ ! -f \"$mp4\" ]; then ffmpeg -y -i \"$f\" -c:v libx264 -crf 28 -preset medium -c:a aac -b:a 128k \"$mp4\" && rm \"$f\"; else rm \"$f\"; fi; done" },
"enabled": true,
"limit": { "kind": "forever" },
"closeOnFinish": true
}'
# → 201 {"automation":{"id":"…","trigger":{"kind":"watch","recursive":false,"filter":"*.mov"},…}}
# Create an event-triggered automation (fires on git ref changes in the directory)
curl -s -X POST "$BASE/automations" \
-H 'content-type: application/json' \
-d '{
"name": "run tests after commit",
"trigger": { "kind": "event", "events": ["git-commit"] },
"cwd": "/Users/me/project",
"runner": { "kind": "shell", "command": "git log --oneline -1 HEAD" },
"enabled": true,
"limit": { "kind": "forever" }
}'
# → 201 {"automation":{"id":"…","cron":null,"nextRunAt":null,…}}
# Runs the command whenever a localterm session in /Users/me/project detects
# that git HEAD moved (commit, push, checkout, reset). No prompt-cycle
# noise — only real ref changes. For a webhook, pipe into curl inside the
# command — $DISCORD_WEBHOOK and other env vars are available.
# Create an event automation that reacts to a custom shell notification
curl -s -X POST "$BASE/automations" \
-H 'content-type: application/json' \
-d '{
"name": "on deploy-complete signal",
"trigger": { "kind": "event", "events": ["notification"] },
"cwd": "/Users/me/project",
"runner": { "kind": "shell", "command": "echo 'Deploy cycle done'" },
"enabled": true,
"limit": { "kind": "forever" },
"closeOnFinish": true
}'
# → 201 {"automation":{"id":"…","trigger":{"kind":"event","events":["notification"]},…}}
# Fires when any command in this directory does: printf '\e]9;deploy-complete\a'
# Create a webhook automation (id is server-generated; the body is ignored on fire)
curl -s -X POST "$BASE/automations" \
-H 'content-type: application/json' \
-d '{
"name": "deploy on CI ping",
"trigger": { "kind": "webhook" },
"cwd": "/Users/me/project",
"runner": { "kind": "shell", "command": "git pull && pnpm deploy" },
"enabled": true,
"limit": { "kind": "forever" }
}'
# → 201 {"automation":{"id":"…","trigger":{"kind":"webhook","id":"<token>"},…}}
# Anyone with the URL can fire it: POST $BASE/webhooks/<token> → 202 {"accepted":true}
# Duplicate/in-flight POSTs coalesce into one run; counts toward the limit.
# Create a fresh agent automation (runs an agent headlessly on a schedule; no tab)
curl -s -X POST "$BASE/automations" \
-H 'content-type: application/json' \
-d '{
"name": "nightly commit review",
"trigger": { "kind": "schedule", "schedule": { "kind": "daily", "hour": 2, "minute": 0 } },
"cwd": "/Users/me/project",
"runner": { "kind": "agent", "prompt": "Review the commits since yesterday and post a one-paragraph summary.", "sessionMode": "fresh" },
"enabled": true,
"limit": { "kind": "forever" }
}'
# → 201 {"automation":{"id":"…",…}} The daemon spawns `pi --mode rpc` headlessly,
# captures the transcript as the run log, and lands a completed/failed run with
# findings. Poll GET /automations → runs[0] for the outcome (no tab opens).
# Create a thread agent automation (resumes one persistent session per fire,
# fires on every commit, granted a secret it needs)
curl -s -X POST "$BASE/automations" \
-H 'content-type: application/json' \
-d '{
"name": "stand-up triage agent",
"trigger": { "kind": "event", "events": ["git-commit"] },
"cwd": "/Users/me/project",
"runner": {
"kind": "agent",
"prompt": "Summarize what changed since you last reported and flag anything risky.",
"sessionMode": "thread",
"model": "anthropic/claude-opus-4-5",
"thinking": "medium"
},
"requestedSecrets": ["slack_webhook"],
"enabled": true
}'
# → 201 {"automation":{"id":"…",…}} Thread mode resumes
# ~/.localterm/agent-sessions/<id>.jsonl every fire, so the agent remembers across
# runs. requestedSecrets resolve into the agent subprocess env at spawn. See
# references/agent-runner.md for the harness options, compaction, Triage, and the
# session transcript.
# Update any subset of fields (pass a `trigger` to change the schedule/watch/event,
# or a `runner` to switch shell↔agent)
curl -s -X PATCH "$BASE/automations/<id>" \
-H 'content-type: application/json' \
-d '{"limit": {"kind": "count", "max": 20}}'
# Delete
curl -s -X DELETE "$BASE/automations/<id>"
# Run immediately (opens the tab now; does not affect the schedule or the limit)
curl -s -X POST "$BASE/automations/<id>/run"
# → {"runId":"…"}
# Reset a finished automation (zeroes runCount, re-activates, re-enables).
# Optional body {"clearHistory": true} also empties the run history.
curl -s -X POST "$BASE/automations/<id>/reset"
# Agent runner + Triage (see references/agent-runner.md)
curl -s "$BASE/agent-models" # pi's available models (cached)
curl -s "$BASE/agent-skills?cwd=/Users/me/project" # discoverable pi skills (cached)
curl -s "$BASE/automations/<id>/session?runId=<runId>" # thread session transcript up to a run
curl -s "$BASE/automations/<id>/agent-session-url" # tab URL to resume a thread session in pi
curl -s -X POST "$BASE/automations/<id>/compact" # manually compact a thread session
curl -s -X POST "$BASE/automations/<id>/clear-thread" # restart a thread from fresh (drops its context)
curl -s -X POST "$BASE/automations/<id>/runs/<runId>/read" # mark one run's findings read
curl -s -X POST "$BASE/triage/mark-all-read" # mark every run read
curl -s -X POST "$BASE/triage/clear-history" # clear every automation's run history
curl -s -X POST "$BASE/automations/<id>/clear-history" # clear one automation's run history
Error responses
400 with {"error": "invalid_body" | "invalid_schedule" | "invalid_cwd" | "invalid_secret" | "too_many_automations" | "automation_finished" | "compact_failed"},
or 404 {"error":"not_found"} for unknown ids. automation_finished is returned
when a PATCH tries to re-enable a finished automation — reset it instead.
invalid_secret is returned at create/update for an unknown requestedSecrets
name; compact_failed (400) carries a message from the harness. The
agent-session-url and compact endpoints return 409 {"error":"not_thread"} /
not_compactable for fresh-mode or shell automations. On invalid_cwd, confirm
the directory exists on the daemon's machine and retry with an absolute path. The
webhook endpoint (POST /webhooks/:id) returns 202 {"accepted":true} on a
valid+active id, 404 {"error":"not_found"} for an unknown id, and 409 {"error":"automation_not_active"} when the automation is disabled or finished.
Playbook
- Health-check first; surface a clear "daemon not running" message if it fails.
- Prefer one automation per task; reuse/update an existing automation with the
same name instead of creating duplicates (list, then PATCH).
- Prefer a structured
schedule (e.g. {"kind":"daily","hour":9,"minute":0})
over raw cron so the user sees a friendly label; fall back to
{"kind":"cron","expression":"…"} only for schedules the presets can't express.
- After creating, echo back the human-readable schedule and the
nextRunAt
time so the user can confirm the intent.
- To verify an automation end-to-end, trigger
POST …/run (this does not count
toward a limit) and poll the list until the newest runs[0].status /
lastRun.status becomes completed (or failed — then read the tab for a
shell run, or runs[0].findings for an agent run).
- Don't schedule destructive commands without explicit user confirmation.
- For git-related workflows ("run tests after commit", "notify after merge"),
use the granular git events such as
{kind:"event", events:["git-commit"]},
{kind:"event", events:["git-merge"]}, or {kind:"event", events:["git-fetch"]}.
- When the command is too complex for a readable one-liner (loops, multi-step
pipelines with temp files, heredocs, structured output payloads, etc.), write
a shell script in the automation's
cwd and set command to bash <name>.sh.
This keeps the automation JSON legible and the logic version-controlled:# Instead of inlining a 200-char pipeline, write e.g. push-watch.sh in cwd:
curl -s -X POST "$BASE/automations" \
-H 'content-type: application/json' \
-d '{
"name": "push watcher",
"trigger": { "kind": "event", "events": ["git-fetch"] },
"cwd": "/Users/me/open-source",
"runner": { "kind": "shell", "command": "bash push-watch.sh" },
"enabled": true
}'
- For recurring LLM tasks ("review last night's commits", "triage the inbox"),
use an agent runner (
{kind:"agent",…}) instead of a shell command — it
runs headlessly and reports findings. Default to sessionMode:"fresh"; use
"thread" only when the agent should remember across fires. See
references/agent-runner.md.
- An agent run opens no tab. After
POST …/run, poll GET /automations until
runs[0].status is completed/failed, then read runs[0].findings for the
summary and runs[0].log for the transcript; mark it read with
POST …/runs/:runId/read.
Sessions & exec (PTY control)
Drive PTYs like tmux over the REST API and the localterm session CLI, plus
exec — the synchronous command+output+exit-code primitive that's the
LLM-ergonomic upgrade over tmux's fire-and-forget send-keys.
# List every live PTY
BASE="http://127.0.0.1:$(cat ~/.localterm/server.port 2>/dev/null || echo 3417)/api"
curl -s "$BASE/sessions"
# One-shot exec: run a command in a fresh shell, get output + exit code (the 90% case)
curl -s -X POST "$BASE/exec" \
-H 'content-type: application/json' \
-d '{ "command": "pnpm test 2>&1 | tail -20", "cwd": "/Users/me/project", "timeoutMs": 60000 }'
# → { "exitCode": 0, "output": "…", "timedOut": false, "truncated": false, "durationMs": 4321 }
# Stateful: a pinned session survives across calls (cwd/env/history persist)
SID=$(curl -s -X POST "$BASE/sessions" -H 'content-type: application/json' \
-d '{ "cwd": "/Users/me/project" }' | node -pe 'JSON.parse(require("fs").readFileSync(0)).session.id')
curl -s -X POST "$BASE/sessions/$SID/exec" -H 'content-type: application/json' \
-d '{ "command": "cd src && pwd" }' # → exitCode 0, output "/Users/me/project/src"
curl -s -X POST "$BASE/sessions/$SID/exec" -H 'content-type: application/json' \
-d '{ "command": "ls *.ts" }' # cwd is still src — state survived
curl -s -X DELETE "$BASE/sessions/$SID" # pinned sessions don't self-reap — clean up
# send-keys (raw input; \n executes a line) + capture-pane (rendered screen text)
curl -s -X POST "$BASE/sessions/$SID/input" -H 'content-type: application/json' \
-d '{ "data": "npm run dev\n" }'
curl -s "$BASE/sessions/$SID/pane?lines=200"
CLI equivalents:
localterm exec "pnpm test 2>&1 | tail -20" --cwd /Users/me/project --timeout 60 --json
localterm exec "fish -c 'status'" --shell /usr/bin/fish --json # --shell overrides the daemon default (create/new/exec only)
localterm session new --cwd /Users/me/project --shell /usr/bin/fish --json # prints the session id
localterm session current [--json] # the id of the session this process runs in
localterm session exec <id> "cd src && pwd" --json
localterm session send-keys <id> 'ls\n' # \n=Enter, \x03=Ctrl-C
localterm session press <id> Escape : w q Enter # named keys (F2, Ctrl-C, literal text)
localterm session capture <id> --lines 200
localterm session capture <id> --png -o shot.png # screenshot via the browser (CDP)
localterm session wait <id> --text "done" --timeout 10 # block until the pane matches
localterm session mouse click <id> --on-text OK # drive mouse-first TUIs (CDP or SGR fallback)
localterm session attach <id> # open a browser tab onto it
localterm session ls [--json] | kill <id> | rename <id> <name> | pin <id> | unpin <id>
Key points for agents:
- Default to one-shot
exec for stateless commands — no session to manage.
With --json the CLI exits 0 and the exit code is in the payload; without it,
the CLI prints output and exits with the command's code (124 on timeout).
--shell / shell picks the shell for exec and session new (one-shot
exec + create-session; in-session exec uses the session's already-spawned shell).
Omit it to use the daemon's detected default (LOCALTERM_SHELL → login shell →
$SHELL → /bin/sh); a non-executable path is rejected with 400 invalid_shell.
- Use a pinned session only when state must survive across calls (a
cd,
an rc-sourced alias, a REPL). Create, drive, then DELETE it — pinned
sessions don't self-reap.
exec takes a single command line. Pipes, &&/||, redirects work;
for multi-line logic write a script and exec "bash script.sh".
capture-pane/exec read the rendered grid, not infinite history (matches
tmux); tail a long build with repeated capture-pane or a long timeoutMs.
For the full surface — all REST endpoints, request/result fields, the pinned/
grace-window model, error responses, and the agent playbook — see
references/sessions-exec.md.
Other endpoints
curl -s "$BASE/health" # {"ok":true,"sessions":N}
curl -s "$BASE/sessions" # live PTYs (attach by id or kill)
curl -s "$BASE/secrets" # per-program secrets (names + policy; never values)
curl -s "$BASE/git/diff-summary?cwd=/path/to/repo" # {isRepo, files, additions, deletions, binaries}
curl -s "$BASE/git/diff?cwd=/path/to/repo" # full per-file unified patches
For the sessions (GET/DELETE /sessions/:id) and secrets (GET/PUT/DELETE /secrets/:name) surfaces — including the security model (values never return over the API; use localterm secret get for that) and the PATH-shim injection mechanism — see references/secrets-sessions.md. Secrets are also managed from the terminal via the localterm secret list|get|set|delete CLI.
Themes
Terminal themes (built-ins + imported customs + the active selection) are server-managed in ~/.localterm/themes.json, shared by the localterm theme CLI and every browser tab. Manage them from the terminal:
localterm theme list # built-ins + imports, active one marked
localterm theme get # → active theme id + name
localterm theme import <file> # JSON {name,colors}/bare colors, or iTerm .itermcolors → stored custom
localterm theme set <id> # a built-in id, 'auto', or a custom id from `import`
localterm theme delete <id> # delete an imported custom (resets active to the default)
Import accepts a JSON theme ({name, colors} or a bare xterm ITheme colors object) or an iTerm .itermcolors plist; the daemon parses — one parser shared with the browser UI's upload — and returns the stored theme with a server-minted id. The active theme is also settable over REST (GET/POST /themes/import, PUT /themes/active, DELETE /themes/:id, plus a one-time POST /themes/migrate the browser uses on upgrade). For the full surface — endpoints, error responses, import formats, and the auto/light-dark resolution — see references/themes.md.
Fonts
Terminal fonts (the active font id + the user-entered custom family + the Nerd Font / ligatures toggles) are server-managed in ~/.localterm/fonts.json, shared by the localterm font CLI and every browser tab — the same promotion themes got, replacing the per-browser localStorage the UI used to keep. Manage them from the terminal:
localterm font list # built-ins + the custom entry, active one marked
localterm font get # active font id + name (+ custom family, toggles)
localterm font set <id> # a built-in id, or 'custom'
localterm font family "<name>" # set the custom family (a system font) + activate it
localterm font nerd-font <on|off> # toggle the Nerd Font symbol layer
localterm font ligatures <on|off> # toggle ligature joining (Fira Code etc.)
font family <name> sets the custom family and activates the custom font in one step; a blank name clears the family back to the bundled default. The font state is also settable over REST (GET/PUT /fonts, plus a one-time POST /fonts/migrate the browser uses on upgrade). For the full surface — endpoints, error responses, the built-in catalog, and the "custom" resolution — see references/fonts.md.
1---2name: localterm3description: Drive the localterm daemon's HTTP API and CLI — schedule automations, set up event-driven triggers (git changes, shell notifications, directory changes), trigger runs, manage per-program secrets (Keychain-backed PATH shims), control PTYs like tmux (list, create, send-keys, capture-pane, resize, rename, kill, self-reference the current session), run synchronous exec commands with captured output + exit code, run headless agent sessions (the built-in pi harness or a custom command, fresh or thread), send named keys (press), wait for a pane state, screenshot a pane to PNG (capture --png via the browser), drive TUIs with the mouse (click/drag/move/scroll, by coords or label), manage terminal themes (list/import/set/delete, shared with the browser UI), inspect git diffs, and check server health. Use when the user asks to schedule, list, or manage automations, secrets, sessions, themes, run shell commands, or drive headless agent runs in localterm, or to script against the localterm server.4---56# localterm API78localterm (https://github.com/monotykamary/localterm) is a local daemon that serves9terminals as browser tabs. It exposes an unauthenticated, loopback-only HTTP API you10can call with `curl`. The flagship resource is **automations**: server-managed jobs that fire on a11trigger (schedule, filesystem watch, session event, or webhook) and run a12**runner** in a chosen directory. A **shell** runner (`{kind:"shell", command}`)13opens a new browser tab and types the command into a fresh shell — the tab stays14open afterwards so the user sees that it ran and whether it succeeded (never15append `exit` to a command). An **agent** runner (`{kind:"agent", prompt, …}`) runs16an agent session headlessly in the daemon — no tab — and reports back findings plus17a transcript; see [references/agent-runner.md](references/agent-runner.md).1819## Connect2021The daemon writes its state to `~/.localterm/`:2223```bash24PORT=$(cat ~/.localterm/server.port 2>/dev/null || echo 3417)25BASE="http://127.0.0.1:$PORT/api"26curl -s "$BASE/health" # → {"ok":true,"sessions":N}27```2829If the health check fails, the daemon isn't running. Ask the user to start it (or30run it yourself if authorized):3132```bash33npx @monotykamary/localterm@latest start34```3536Requests must come from the same machine; `Host` must be loopback (using37`127.0.0.1` with curl satisfies this).3839The user-facing browser URL (what `localterm status` prints as `url:`) is40resolved across three surfaces, best-first: **tailnet**41(`https://<node>.ts.net`, when `localterm install` ran the Tailscale step),42**local** (`https://localterm.localhost`, when the portless proxy service is43up on `:443`), or **loopback** (`http://localterm.localhost:<port>`, always44works via RFC 6761). The API calls above use the loopback raw form directly —45don't depend on which surface the browser happens to use.4647## Automations4849An automation is `{name, trigger, cwd, runner, enabled, limit, closeOnFinish, requestedSecrets}`:5051- `trigger` — what makes the automation run, a tagged union on `kind`:52 - `{kind:"schedule", schedule}` — time-based (the common case; `daily` is shown in the create examples below).53 - `{kind:"watch", recursive, filter?}` — fires when the automation's `cwd` changes (native filesystem events, no polling).54 - `{kind:"event", events: [...]}` — fires when a localterm session emits a named event matching `cwd` (session-scoped).55 See [references/triggers.md](references/triggers.md) for the full schedule-shape table (`hourly`/`weekly`/`monthly`/`cron`/…), git-event taxonomy, watch filter/debounce/grace semantics, and the cron escape-hatch details.56 - `{kind:"webhook"}` — fires when an external POST hits `/api/webhooks/<id>`. The `id` is a server-generated capability token (Discord-style: anyone with the URL can fire it); it is returned in the created automation's `trigger.id` and preserved across PATCHes that keep the webhook kind. The POST body is ignored — `runner`/`cwd` are fixed at create time, so a webhook is a pure signal like schedule/watch/event.5758- `cwd` — absolute path; must exist and be a directory on the daemon's machine59 (validated at create/update time).60- `runner` — what the automation runs when it fires, a tagged union on `kind`.61 Orthogonal to `trigger` — any runner fires on any trigger:62 - `{kind:"shell", command}` — the original model: types `command` into a fresh63 shell in a new browser tab (shell syntax like `&&` and pipes work; max 409664 chars). The tab stays open after the command finishes; its exit code drives65 the run status.66 - `{kind:"agent", prompt, sessionMode, model?, thinking?, harness?}` — runs an67 agent session **headlessly** in the daemon (no tab, no PTY). `prompt` is68 natural language (max 4096 chars); `sessionMode` is `fresh` (ephemeral) or69 `thread` (resumes one persistent session file per fire). See70 [references/agent-runner.md](references/agent-runner.md) for the harness71 abstraction (built-in `pi` over `pi --mode rpc`, or a `custom` command),72 model/thinking knobs, the per-run transcript log, compaction, and Triage.73- `enabled` — defaults to `true`. Disabled automations never fire.74- `limit` — `{kind:"forever"}` (default) or `{kind:"count", max:N}` = "stop after75 N runs". When the limit is reached the automation **finishes** (a terminal76 `lifecycle:"finished"` state) and stops firing but stays listed with its77 history. Scheduled, watch, and event runs count toward the limit; manual `/run` never78 does.79- `closeOnFinish` — defaults to `false` (the tab stays open). Shell runs only:80 when `true`, the run's browser tab is closed once the command finishes (only81 honored for CDP-opened tabs; silent no-op on the OS-opener fallback).82 Meaningless for agent runs (no tab) but stored as-is.83- `requestedSecrets` — defaults to `[]` (the run gets no secrets). A list of secret **names** (by stable identifier, not env var) whose values are resolved from the Keychain and injected as env vars into the run's PTY (shell) or subprocess (agent) at spawn. Per-automation, opt-in least-privilege: an automation gets exactly the secrets it named, nothing else. Unknown names are rejected at create/update time (catches typos); a name deleted after you selected it is skipped at run time (fail-closed). Values still never cross HTTP — resolution is Keychain → daemon → PTY/subprocess env. See [references/secrets-sessions.md](references/secrets-sessions.md#automation-secret-exposure).8485For run-tab mechanics (background CDP vs. opener fallback, `LOCALTERM_DISABLE_CDP_TABS`), the run-status table (`launched`/`running`/`completed`/`failed`/`missed`/`skipped`), `runs`/`runCount`/`lifecycle`/`lastRun` shape, the `trigger` field values, and the shell-run `log` (ANSI-stripped PTY output), see [references/run-states.md](references/run-states.md). For agent runs (headless, the findings/changedFiles/unread run fields, the 10-min timeout), see [references/agent-runner.md](references/agent-runner.md).8687### Endpoints8889```bash90# List (each item adds computed nextRunAt epoch-ms (null when disabled/finished91# or a watch/event/webhook trigger), a derived `cron` string (null for92# watch/event/webhook), the capped `runs` history, and a back-compat `lastRun`)93curl -s "$BASE/automations"9495# Create96curl -s -X POST "$BASE/automations" \97 -H 'content-type: application/json' \98 -d '{99 "name": "nightly build",100 "trigger": { "kind": "schedule", "schedule": { "kind": "daily", "hour": 2, "minute": 0 } },101 "cwd": "/Users/me/project",102 "runner": { "kind": "shell", "command": "pnpm build && pnpm test" },103 "enabled": true,104 "limit": { "kind": "forever" }105 }'106# → 201 {"automation":{"id":"…","cron":"0 2 * * *","nextRunAt":1765591200000,…}}107108# Create a folder-watch automation (runs when cwd changes; no cron/nextRunAt)109curl -s -X POST "$BASE/automations" \110 -H 'content-type: application/json' \111 -d '{112 "name": "rebuild on change",113 "trigger": { "kind": "watch", "recursive": true },114 "cwd": "/Users/me/project",115 "runner": { "kind": "shell", "command": "pnpm build" },116 "limit": { "kind": "count", "max": 50 }117 }'118# → 201 {"automation":{"id":"…","cron":null,"nextRunAt":null,…}}119120# Create a filtered folder-watch (only triggers on .mov files)121curl -s -X POST "$BASE/automations" \122 -H 'content-type: application/json' \123 -d '{124 "name": "autoconvert mov→mp4",125 "trigger": { "kind": "watch", "recursive": false, "filter": "*.mov" },126 "cwd": "/Users/me/Downloads",127 "runner": { "kind": "shell", "command": "find /Users/me/Downloads -maxdepth 1 -iname *.mov -type f | while IFS= read -r f; do mp4=\"${f%.*}.mp4\"; if [ ! -f \"$mp4\" ]; then ffmpeg -y -i \"$f\" -c:v libx264 -crf 28 -preset medium -c:a aac -b:a 128k \"$mp4\" && rm \"$f\"; else rm \"$f\"; fi; done" },128 "enabled": true,129 "limit": { "kind": "forever" },130 "closeOnFinish": true131 }'132# → 201 {"automation":{"id":"…","trigger":{"kind":"watch","recursive":false,"filter":"*.mov"},…}}133134# Create an event-triggered automation (fires on git ref changes in the directory)135curl -s -X POST "$BASE/automations" \136 -H 'content-type: application/json' \137 -d '{138 "name": "run tests after commit",139 "trigger": { "kind": "event", "events": ["git-commit"] },140 "cwd": "/Users/me/project",141 "runner": { "kind": "shell", "command": "git log --oneline -1 HEAD" },142 "enabled": true,143 "limit": { "kind": "forever" }144 }'145# → 201 {"automation":{"id":"…","cron":null,"nextRunAt":null,…}}146# Runs the command whenever a localterm session in /Users/me/project detects147# that git HEAD moved (commit, push, checkout, reset). No prompt-cycle148# noise — only real ref changes. For a webhook, pipe into curl inside the149# command — $DISCORD_WEBHOOK and other env vars are available.150151# Create an event automation that reacts to a custom shell notification152curl -s -X POST "$BASE/automations" \153 -H 'content-type: application/json' \154 -d '{155 "name": "on deploy-complete signal",156 "trigger": { "kind": "event", "events": ["notification"] },157 "cwd": "/Users/me/project",158 "runner": { "kind": "shell", "command": "echo 'Deploy cycle done'" },159 "enabled": true,160 "limit": { "kind": "forever" },161 "closeOnFinish": true162 }'163# → 201 {"automation":{"id":"…","trigger":{"kind":"event","events":["notification"]},…}}164# Fires when any command in this directory does: printf '\e]9;deploy-complete\a'165166# Create a webhook automation (id is server-generated; the body is ignored on fire)167curl -s -X POST "$BASE/automations" \168 -H 'content-type: application/json' \169 -d '{170 "name": "deploy on CI ping",171 "trigger": { "kind": "webhook" },172 "cwd": "/Users/me/project",173 "runner": { "kind": "shell", "command": "git pull && pnpm deploy" },174 "enabled": true,175 "limit": { "kind": "forever" }176 }'177# → 201 {"automation":{"id":"…","trigger":{"kind":"webhook","id":"<token>"},…}}178# Anyone with the URL can fire it: POST $BASE/webhooks/<token> → 202 {"accepted":true}179# Duplicate/in-flight POSTs coalesce into one run; counts toward the limit.180181# Create a fresh agent automation (runs an agent headlessly on a schedule; no tab)182curl -s -X POST "$BASE/automations" \183 -H 'content-type: application/json' \184 -d '{185 "name": "nightly commit review",186 "trigger": { "kind": "schedule", "schedule": { "kind": "daily", "hour": 2, "minute": 0 } },187 "cwd": "/Users/me/project",188 "runner": { "kind": "agent", "prompt": "Review the commits since yesterday and post a one-paragraph summary.", "sessionMode": "fresh" },189 "enabled": true,190 "limit": { "kind": "forever" }191 }'192# → 201 {"automation":{"id":"…",…}} The daemon spawns `pi --mode rpc` headlessly,193# captures the transcript as the run log, and lands a completed/failed run with194# findings. Poll GET /automations → runs[0] for the outcome (no tab opens).195196# Create a thread agent automation (resumes one persistent session per fire,197# fires on every commit, granted a secret it needs)198curl -s -X POST "$BASE/automations" \199 -H 'content-type: application/json' \200 -d '{201 "name": "stand-up triage agent",202 "trigger": { "kind": "event", "events": ["git-commit"] },203 "cwd": "/Users/me/project",204 "runner": {205 "kind": "agent",206 "prompt": "Summarize what changed since you last reported and flag anything risky.",207 "sessionMode": "thread",208 "model": "anthropic/claude-opus-4-5",209 "thinking": "medium"210 },211 "requestedSecrets": ["slack_webhook"],212 "enabled": true213 }'214# → 201 {"automation":{"id":"…",…}} Thread mode resumes215# ~/.localterm/agent-sessions/<id>.jsonl every fire, so the agent remembers across216# runs. requestedSecrets resolve into the agent subprocess env at spawn. See217# references/agent-runner.md for the harness options, compaction, Triage, and the218# session transcript.219220# Update any subset of fields (pass a `trigger` to change the schedule/watch/event,221# or a `runner` to switch shell↔agent)222curl -s -X PATCH "$BASE/automations/<id>" \223 -H 'content-type: application/json' \224 -d '{"limit": {"kind": "count", "max": 20}}'225226# Delete227curl -s -X DELETE "$BASE/automations/<id>"228229# Run immediately (opens the tab now; does not affect the schedule or the limit)230curl -s -X POST "$BASE/automations/<id>/run"231# → {"runId":"…"}232233# Reset a finished automation (zeroes runCount, re-activates, re-enables).234# Optional body {"clearHistory": true} also empties the run history.235curl -s -X POST "$BASE/automations/<id>/reset"236237# Agent runner + Triage (see references/agent-runner.md)238curl -s "$BASE/agent-models" # pi's available models (cached)239curl -s "$BASE/agent-skills?cwd=/Users/me/project" # discoverable pi skills (cached)240curl -s "$BASE/automations/<id>/session?runId=<runId>" # thread session transcript up to a run241curl -s "$BASE/automations/<id>/agent-session-url" # tab URL to resume a thread session in pi242curl -s -X POST "$BASE/automations/<id>/compact" # manually compact a thread session243curl -s -X POST "$BASE/automations/<id>/clear-thread" # restart a thread from fresh (drops its context)244curl -s -X POST "$BASE/automations/<id>/runs/<runId>/read" # mark one run's findings read245curl -s -X POST "$BASE/triage/mark-all-read" # mark every run read246curl -s -X POST "$BASE/triage/clear-history" # clear every automation's run history247curl -s -X POST "$BASE/automations/<id>/clear-history" # clear one automation's run history248```249250### Error responses251252`400` with `{"error": "invalid_body" | "invalid_schedule" | "invalid_cwd" | "invalid_secret" | "too_many_automations" | "automation_finished" | "compact_failed"}`,253or `404 {"error":"not_found"}` for unknown ids. `automation_finished` is returned254when a PATCH tries to re-enable a finished automation — reset it instead.255`invalid_secret` is returned at create/update for an unknown `requestedSecrets`256name; `compact_failed` (400) carries a `message` from the harness. The257agent-session-url and compact endpoints return `409 {"error":"not_thread"}` /258`not_compactable` for fresh-mode or shell automations. On `invalid_cwd`, confirm259the directory exists on the daemon's machine and retry with an absolute path. The260webhook endpoint (`POST /webhooks/:id`) returns `202 {"accepted":true}` on a261valid+active id, `404 {"error":"not_found"}` for an unknown id, and `409262{"error":"automation_not_active"}` when the automation is disabled or finished.263264### Playbook2652661. Health-check first; surface a clear "daemon not running" message if it fails.2672. Prefer one automation per task; reuse/update an existing automation with the268 same name instead of creating duplicates (list, then PATCH).2693. Prefer a structured `schedule` (e.g. `{"kind":"daily","hour":9,"minute":0}`)270 over raw cron so the user sees a friendly label; fall back to271 `{"kind":"cron","expression":"…"}` only for schedules the presets can't express.2724. After creating, echo back the human-readable schedule and the `nextRunAt`273 time so the user can confirm the intent.2745. To verify an automation end-to-end, trigger `POST …/run` (this does not count275 toward a `limit`) and poll the list until the newest `runs[0].status` /276 `lastRun.status` becomes `completed` (or `failed` — then read the tab for a277 shell run, or `runs[0].findings` for an agent run).2786. Don't schedule destructive commands without explicit user confirmation.2797. For git-related workflows ("run tests after commit", "notify after merge"),280 use the granular git events such as `{kind:"event", events:["git-commit"]}`,281 `{kind:"event", events:["git-merge"]}`, or `{kind:"event", events:["git-fetch"]}`.2828. When the command is too complex for a readable one-liner (loops, multi-step283 pipelines with temp files, heredocs, structured output payloads, etc.), write284 a shell script in the automation's `cwd` and set `command` to `bash <name>.sh`.285 This keeps the automation JSON legible and the logic version-controlled:286 ```bash287 # Instead of inlining a 200-char pipeline, write e.g. push-watch.sh in cwd:288 curl -s -X POST "$BASE/automations" \289 -H 'content-type: application/json' \290 -d '{291 "name": "push watcher",292 "trigger": { "kind": "event", "events": ["git-fetch"] },293 "cwd": "/Users/me/open-source",294 "runner": { "kind": "shell", "command": "bash push-watch.sh" },295 "enabled": true296 }'297 ```2989. For recurring LLM tasks ("review last night's commits", "triage the inbox"),299 use an **agent runner** (`{kind:"agent",…}`) instead of a shell command — it300 runs headlessly and reports findings. Default to `sessionMode:"fresh"`; use301 `"thread"` only when the agent should remember across fires. See302 [references/agent-runner.md](references/agent-runner.md).30310. An agent run opens no tab. After `POST …/run`, poll `GET /automations` until304 `runs[0].status` is `completed`/`failed`, then read `runs[0].findings` for the305 summary and `runs[0].log` for the transcript; mark it read with306 `POST …/runs/:runId/read`.307308## Sessions & exec (PTY control)309310Drive PTYs like tmux over the REST API and the `localterm session` CLI, plus311**exec** — the synchronous command+output+exit-code primitive that's the312LLM-ergonomic upgrade over tmux's fire-and-forget `send-keys`.313314```bash315# List every live PTY316BASE="http://127.0.0.1:$(cat ~/.localterm/server.port 2>/dev/null || echo 3417)/api"317curl -s "$BASE/sessions"318319# One-shot exec: run a command in a fresh shell, get output + exit code (the 90% case)320curl -s -X POST "$BASE/exec" \321 -H 'content-type: application/json' \322 -d '{ "command": "pnpm test 2>&1 | tail -20", "cwd": "/Users/me/project", "timeoutMs": 60000 }'323# → { "exitCode": 0, "output": "…", "timedOut": false, "truncated": false, "durationMs": 4321 }324325# Stateful: a pinned session survives across calls (cwd/env/history persist)326SID=$(curl -s -X POST "$BASE/sessions" -H 'content-type: application/json' \327 -d '{ "cwd": "/Users/me/project" }' | node -pe 'JSON.parse(require("fs").readFileSync(0)).session.id')328curl -s -X POST "$BASE/sessions/$SID/exec" -H 'content-type: application/json' \329 -d '{ "command": "cd src && pwd" }' # → exitCode 0, output "/Users/me/project/src"330curl -s -X POST "$BASE/sessions/$SID/exec" -H 'content-type: application/json' \331 -d '{ "command": "ls *.ts" }' # cwd is still src — state survived332curl -s -X DELETE "$BASE/sessions/$SID" # pinned sessions don't self-reap — clean up333334# send-keys (raw input; \n executes a line) + capture-pane (rendered screen text)335curl -s -X POST "$BASE/sessions/$SID/input" -H 'content-type: application/json' \336 -d '{ "data": "npm run dev\n" }'337curl -s "$BASE/sessions/$SID/pane?lines=200"338```339340CLI equivalents:341342```bash343localterm exec "pnpm test 2>&1 | tail -20" --cwd /Users/me/project --timeout 60 --json344localterm exec "fish -c 'status'" --shell /usr/bin/fish --json # --shell overrides the daemon default (create/new/exec only)345localterm session new --cwd /Users/me/project --shell /usr/bin/fish --json # prints the session id346localterm session current [--json] # the id of the session this process runs in347localterm session exec <id> "cd src && pwd" --json348localterm session send-keys <id> 'ls\n' # \n=Enter, \x03=Ctrl-C349localterm session press <id> Escape : w q Enter # named keys (F2, Ctrl-C, literal text)350localterm session capture <id> --lines 200351localterm session capture <id> --png -o shot.png # screenshot via the browser (CDP)352localterm session wait <id> --text "done" --timeout 10 # block until the pane matches353localterm session mouse click <id> --on-text OK # drive mouse-first TUIs (CDP or SGR fallback)354localterm session attach <id> # open a browser tab onto it355localterm session ls [--json] | kill <id> | rename <id> <name> | pin <id> | unpin <id>356```357358Key points for agents:359360- **Default to one-shot `exec`** for stateless commands — no session to manage.361 With `--json` the CLI exits 0 and the exit code is in the payload; without it,362 the CLI prints output and exits with the command's code (124 on timeout).363- **`--shell` / `shell` picks the shell** for `exec` and `session new` (one-shot364 exec + create-session; in-session exec uses the session's already-spawned shell).365 Omit it to use the daemon's detected default (`LOCALTERM_SHELL` → login shell →366 `$SHELL` → `/bin/sh`); a non-executable path is rejected with `400 invalid_shell`.367- **Use a pinned session** only when state must survive across calls (a `cd`,368 an rc-sourced alias, a REPL). Create, drive, then `DELETE` it — pinned369 sessions don't self-reap.370- **`exec` takes a single command line.** Pipes, `&&`/`||`, redirects work;371 for multi-line logic write a script and `exec "bash script.sh"`.372- **`capture-pane`/exec read the rendered grid**, not infinite history (matches373 tmux); tail a long build with repeated `capture-pane` or a long `timeoutMs`.374375For the full surface — all REST endpoints, request/result fields, the pinned/376grace-window model, error responses, and the agent playbook — see377[references/sessions-exec.md](references/sessions-exec.md).378379## Other endpoints380381```bash382curl -s "$BASE/health" # {"ok":true,"sessions":N}383curl -s "$BASE/sessions" # live PTYs (attach by id or kill)384curl -s "$BASE/secrets" # per-program secrets (names + policy; never values)385curl -s "$BASE/git/diff-summary?cwd=/path/to/repo" # {isRepo, files, additions, deletions, binaries}386curl -s "$BASE/git/diff?cwd=/path/to/repo" # full per-file unified patches387```388389For the sessions (`GET`/`DELETE /sessions/:id`) and secrets (`GET`/`PUT`/`DELETE /secrets/:name`) surfaces — including the security model (values never return over the API; use `localterm secret get` for that) and the PATH-shim injection mechanism — see [references/secrets-sessions.md](references/secrets-sessions.md). Secrets are also managed from the terminal via the `localterm secret list|get|set|delete` CLI.390391## Themes392393Terminal themes (built-ins + imported customs + the active selection) are server-managed in `~/.localterm/themes.json`, shared by the `localterm theme` CLI and every browser tab. Manage them from the terminal:394395```bash396localterm theme list # built-ins + imports, active one marked397localterm theme get # → active theme id + name398localterm theme import <file> # JSON {name,colors}/bare colors, or iTerm .itermcolors → stored custom399localterm theme set <id> # a built-in id, 'auto', or a custom id from `import`400localterm theme delete <id> # delete an imported custom (resets active to the default)401```402403Import accepts a JSON theme (`{name, colors}` or a bare xterm `ITheme` colors object) or an iTerm `.itermcolors` plist; the daemon parses — one parser shared with the browser UI's upload — and returns the stored theme with a server-minted id. The active theme is also settable over REST (`GET`/`POST /themes/import`, `PUT /themes/active`, `DELETE /themes/:id`, plus a one-time `POST /themes/migrate` the browser uses on upgrade). For the full surface — endpoints, error responses, import formats, and the `auto`/light-dark resolution — see [references/themes.md](references/themes.md).404405## Fonts406407Terminal fonts (the active font id + the user-entered custom family + the Nerd Font / ligatures toggles) are server-managed in `~/.localterm/fonts.json`, shared by the `localterm font` CLI and every browser tab — the same promotion themes got, replacing the per-browser `localStorage` the UI used to keep. Manage them from the terminal:408409```bash410localterm font list # built-ins + the custom entry, active one marked411localterm font get # active font id + name (+ custom family, toggles)412localterm font set <id> # a built-in id, or 'custom'413localterm font family "<name>" # set the custom family (a system font) + activate it414localterm font nerd-font <on|off> # toggle the Nerd Font symbol layer415localterm font ligatures <on|off> # toggle ligature joining (Fira Code etc.)416```417418`font family <name>` sets the custom family **and** activates the `custom` font in one step; a blank name clears the family back to the bundled default. The font state is also settable over REST (`GET`/`PUT /fonts`, plus a one-time `POST /fonts/migrate` the browser uses on upgrade). For the full surface — endpoints, error responses, the built-in catalog, and the `"custom"` resolution — see [references/fonts.md](references/fonts.md).