| name | localterm |
| description | 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. |
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"
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 over , or a command),
model/thinking knobs, the per-run transcript log, compaction, and Triage.
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
curl -s "$BASE/automations"
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" }
}'
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 }
}'
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
}'
curl -s -X POST \
-H \
-d
curl -s -X POST \
-H \
-d Deploy cycle \'enabledkindforevercloseOnFinishautomation…triggerkindeventeventsnotification/automationsnamedeploy on CI pingtriggerkindwebhookcwd/Users/me/projectrunnerkindshellgit pull && pnpm deployenabledkindforeverautomation…triggerkindwebhook<token>accepted/automationsnamenightly commit reviewtriggerkindscheduleschedulekinddailyhourminutecwd/Users/me/projectrunnerkindagentpromptReview the commits since yesterday and post a one-paragraph summary.sessionModefreshenabledkindforeverautomation…/automationsnamestand-up triage agenttriggerkindeventeventsgit-commitcwd/Users/me/projectrunnerkindagentpromptSummarize what changed since you last reported and flag anything risky.sessionModethreadmodelanthropic/claude-opus-4-5thinkingmediumrequestedSecretsslack_webhookenabledautomation…/automations/<>kindcountmax/automations/<>/automations/<>/runrunId…clearHistory/automations/<>/reset/agent-models/agent-skills?cwd=/Users/me/project/automations/<>/session?runId=<runId>/automations/<>/agent-session-url/automations/<>/compact/automations/<>/clear-thread/automations/<>/runs/<runId>/read/triage/mark-all-read/triage/clear-history/automations/<>/clear-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:
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
.
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.
BASE="http://127.0.0.1:$(cat ~/.localterm/server.port 2>/dev/null || echo 3417)/api"
curl -s "$BASE/sessions"
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 }'
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" }'
curl -s -X POST "$BASE/sessions/$SID/exec" -H 'content-type: application/json' \
-d '{ "command": "ls *.ts" }'
curl -s -X DELETE "$BASE/sessions/$SID"
curl -s -X POST "$BASE/sessions/$SID/input" -H 'content-type: application/json' \
-d '{ "data": "npm run dev\n" }'
curl -s
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
localterm session new --cwd /Users/me/project --shell /usr/bin/fish --json
localterm session current [--json]
localterm session exec <id> "cd src && pwd" --json
localterm session send-keys <id> 'ls\n'
localterm session press <id> Escape : w q Enter
localterm session capture <id> --lines 200
localterm session capture <id> --png -o shot.png
localterm session wait <id> --text "done" --timeout 10
localterm session mouse click <id> --on-text OK
localterm session attach <id>
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"
curl -s "$BASE/sessions"
curl -s "$BASE/secrets"
curl -s "$BASE/git/diff-summary?cwd=/path/to/repo"
curl -s "$BASE/git/diff?cwd=/path/to/repo"
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
localterm theme get
localterm theme import <file>
localterm theme set <id>
localterm theme delete <id>
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
localterm font get
localterm font set <id>
localterm font family "<name>"
localterm font nerd-font <on|off>
localterm font ligatures <on|off>
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.