ProjectslassoDocs

HTTP routes

Every listener serves the same routes: the local -listen, and the network listeners -tailnet, -listen-access and -listen-serve when they are on. The browser UI uses the /api/* routes; agents use /mcp, /herdr-mcp and /cdp. The /api/* routes are lasso's own UI API, not a stable public interface: they can change between releases. Script lasso through lasso mcp or the MCP server instead.

Auth gates#

Who is calling depends on the listener the request arrived on:

listener proof lasso checks the caller admitted by default with LASSO_ALLOW set
local (-listen) none whoever connected everyone; the gates below decide not read
access (-listen-access) Cf-Access-Jwt-Assertion, verified against LASSO_ACCESS_TEAM's keys and LASSO_ACCESS_AUD a person (the email) or a machine (service:<client id>) whoever Access admitted only the entries it lists
tailnet (-tailnet) tailscaled's whois of the connection's peer address a person (the device owner's login) or a machine (the device's tags) the node's owner only only the entries it lists
serve (-listen-serve) the Tailscale-User-Login header tailscale serve sets a person the node's owner only only the entries it lists

A request on a network listener without its proof, or from a caller not admitted, gets 403, except on /hooks/bots and /bot-channel, which pass unproven to handlers that check their own keys. A machine caller reaches only /mcp, /herdr-mcp, /cdp, /hooks/bots, /bot-channel, /oauth/register, /oauth/token and the two /.well-known/oauth-* documents, unless its LASSO_ALLOW entry carries ui: or it sends a lasso token with the ui scope.

A request passes through these layers, outermost first:

  1. Internal CDP token. lasso's own chrome-devtools-mcp children reach /cdp with a random per-process token, honored on /cdp paths only, ahead of every other gate.
  2. The caller check, by listener role, as in the table above. It also moves an X-Lasso-Token header into Authorization: Bearer, and on a network listener drops a bearer token lasso did not issue.
  3. The local guard, on every listener: a Host lasso does not answer to gets 421 (skipped on the access listener), and a state-changing request or websocket upgrade from another site's page gets 403, except on /oauth/register, /oauth/token, /hooks/bots and /bot-channel. See Security.
  4. The Access header gate (deprecated), when -require-access-header is on: every route answers 403 without a Cloudflare Access identity (or with one not in -access-allowed-emails). With LASSO_ACCESS_AUD set it verifies the assertion; otherwise it reads Cf-Access-Authenticated-User-Email. A caller a network listener verified passes. Off by default.
  5. UI_AUTH basic auth, when UI_AUTH is set: every route except /mcp, /herdr-mcp, /cdp, /hooks/bots, /bot-channel, /.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server, /oauth/register and /oauth/token (and their subpaths). /oauth/authorize stays behind it on purpose: that is where a human approves a client. A caller a network listener verified, or a lasso token with the ui scope, passes without the password. With MCP_OAUTH set, its 401 also carries a Bearer challenge naming the resource metadata and the scope mcp ui.
  6. Per-route gates on the exempt routes:
route gate open by default? with UI_AUTH only with MCP_OAUTH
/mcp withMCPAuth yes open bearer token (Authorization or X-Lasso-Token) or UI_AUTH basic credentials
/mcp's browser_* tools withBrowserToolGate, checked per call yes UI_AUTH basic as /mcp; the token's scope must include lasso's own machine
/herdr-mcp withMCPAuth yes open same as /mcp; each call's host is checked against the token's scope
/cdp withCDPAuth yes UI_AUTH basic bearer token or UI_AUTH basic; the token's scope must include lasso's own machine

The browser_* tools front /cdp, so they meet /cdp's standard rather than /mcp's: a call that falls short is answered with a tool error (isError), while the rest of /mcp works as usual on the same connection.

/cdp and the browser_* tools also refuse any request whose Origin names a different website, before any credential check and whatever the auth settings. That is what stops a page you visit from driving a lasso on your own machine. Under MCP_OAUTH without UI_AUTH, lasso's own Browser tab (same origin, no Authorization header) still reaches /cdp.

A caller a network listener verified passes every per-route gate without a credential of its own; a lasso token it also sends still sets its MCP scope.

So on the loopback listener with no UI_AUTH and no MCP_OAUTH, everything is open to whatever can connect to it on lasso's machine. Reach lasso from anywhere else through a network listener, which identifies the caller first. See Security.

Routes#

Terminals and live state#

route purpose
/terminal/<host>/ Reverse proxy (HTTP and WebSocket) to the ttyd running herdr for one host.
/api/events Server-sent events: focused pane, layout, theme and state revisions, pushed to the browser. A notice event is a one-shot toast, {level, title, detail?, id, at}: id is unique within the running lasso (its prefix changes on every start) and at is when it was raised, in unix milliseconds. A notification event carries every notification to every open client, {id, at, kind, level?, bot?, title, body?, tag?, host?, url?, icon?}; each client filters it against its own device's choices from /api/notify. lasso's notices are sent as both events, notice for older clients. The Mac app opens its stream with app=mac, so an open-file event an agent meant for one app (open_file's app) reaches only that app's streams.
/api/host POST: attach the calling tab to a host (spawns its terminals if needed). It switches nothing for other tabs.
/api/hosts The host list from ~/.ssh/config, with each host's reachability and herdr version.
/api/active, /api/panes, /api/all-panes The focused pane; one host's panes; panes across the fleet. A pane's transcript_at is its log's mtime on whichever host has the log.
/api/focus, /api/close, /api/rename, /api/workspace-rename Focus, close or rename herdr panes, tabs and workspaces.
/api/clients, /api/term-claim The browsers attached to this lasso; which tab owns the terminal's size.
/api/ui-state Read and patch the shared UI state stored in lasso.db. Collections a stale client must not overwrite change only through ops: agent_pins (for pinned_agents) and card_dismiss: {<key>: <ms>|null} (for dismissed_cards, at most 256, smallest value dropped first), each key an Inbox entry's: a host, a NUL and a pane id (up to its transcript_at), bot:<name> (up to its last_at), bot:<name>#<question id>, or notice:<id>. inbox_since (unix ms) is where the Inbox's finished turns start counting, the same on every device: lasso sets it once (at boot, or on a read that finds none) and ignores it in a body.
/api/notices GET: the notices lasso keeps for the Inbox, {notices: [{id, at, kind, level?, title, body?, …}]}, oldest first: the last 50 lasso_notice and agent_message notifications since lasso started, whether or not a client was open. Each also went out as the notification event with the same id; acknowledging one is card_dismiss notice:<id>.
/api/desktop/sound GET: the inbox arrival sound uploaded for Lasso, the Mac app, with its Content-Type, an ETag (its SHA-256), Cache-Control: no-cache and a 304 for a matching If-None-Match; 404 when none is set. PUT: replace it with the request body, the raw audio file (at most 1 MiB, 413 beyond; empty is a 400), and ?name= for its display name (at most 64 characters, control characters dropped; Custom sound when absent). The format is told by the bytes, never by Content-Type: MP3, WAV, AIFF/AIFC, M4A (AAC or ALAC in MP4) or CAF, anything else a 415. Answers the stored metadata. DELETE: remove it (204, also when none is set). The metadata is desktop_sound in /api/ui-state, {name, sha256, size, content_type, updated_at} (unix ms) and absent when none is set; only this route changes it, and each change moves ui_state_rev, so a client re-downloads only when sha256 differs from its copy.

Most /api/* routes act on the calling tab's host, sent as the X-Lasso-Host header or a ?host= parameter. See Hosts.

Files and diffs#

route purpose
/api/files, /api/file List a directory; read a file. /api/file sends X-Lasso-Mtime (unix ms). within=<dir> answers 403 unless the file is under that folder with no hidden or symlinked segment below it, and only directly in it when the folder is / or at or above the home directory: the Mac app's rendered HTML loads its sibling files through it.
/api/file-write, /api/file-rename, /api/file-delete, /api/file-upload Write, rename, delete, upload. /api/file-write takes an optional base_mtime (the X-Lasso-Mtime the editor loaded) and answers 409 {error, mtime} without writing when the file has changed since; it answers {ok, mtime}.
/api/file-stat POST {host, paths} (1-64): each path's exists, dir, size and mtime (unix ms) in request order, a missing file exists: false. What the Mac app's file windows poll.
/api/file-find GET ?root=&host=: every file under root, root-relative, for the file viewer's quick open (⌘P). git ls-files --cached --others --exclude-standard inside a repository, else a breadth-first walk skipping hidden directories and node_modules, stopped at 50,000 files or 4 s. Answers {root, files, truncated, source} (source is git or walk).
/api/paste-file Save a pasted or dropped file into uploads/dropped-files on the target host: the raw body, ?name= its filename, answering {path}. A file over one request body (Cloudflare refuses bodies over 100 MB) goes in parts: the first also sends ?size=<total bytes> and is answered {path, upload, received}; each next part is POSTed with ?upload=<token>&offset=<received> and appended, the last answered {path}. A wrong offset, a part past size or a failed write ends the upload and removes the file; one idle for 10 minutes is removed too. The web and Mac clients send 50 MiB parts.
/api/diff, /api/diff-file The focused repo's changed files; one file's diff.

These read and write arbitrary absolute paths as the user lasso runs as, on the calling tab's host or any host named in the request. Anyone who can reach them can read your SSH keys and overwrite your shell profile. They are only as safe as the gate in front of lasso.

Agents#

route purpose
/api/create-agent, /api/agent-upload Create an agent; stage its attachments. agent-upload takes multipart files, or one file as the raw body with ?name= and ?dir= (the upload_dir an earlier upload answered, to stage beside it), answering {upload_dir, files}; a raw file over one request body goes in parts exactly as /api/paste-file takes them (?size=, then ?upload=&offset=). The web dialog sends each file raw, in 50 MiB parts.
/api/agent/close, /api/agent/reopen, /api/agent-history Close an agent (what lasso closeme calls); reopen a closed agent's directory; past agents for the pane switcher.
/api/agent/rename Set or clear an agent's custom label (herdr's agent.rename), {pane_id, name} with ?host=; an empty name clears it. The name is made a herdr agent name first ("Check HN page" is check-hn-page). A bot's pane is refused (409): lasso finds a bot by that name.
/api/create-terminal, /api/workspaces Open a plain terminal; list workspaces to put it in.
/api/agent-config, /api/repo-config, /api/repos, /api/repo-branches, /api/auto-title New dialog settings, repos and branches.
/api/chat, /api/chat/send, /api/chat/answer The chat view: read an agent's transcript, send it a message, answer its question dialog. /api/chat's host is the pane's machine; the log may be served from another host lasso drives, named by served_by. An empty answer carries unavailable: {reason, checked, unanswered}.
/api/chat/queue A pane's "Up next" queue, held in lasso.db and delivered by lasso once the agent is idle (chatqueue.go). Addressed like /api/chat/send (?host=, else the tab's host). GET ?pane= lists it; POST {pane_id, text, session?, client?} appends (the pane must exist on the host; session is the transcript path on screen, which a new queue is bound to; at most 50 messages of 64 KiB); PATCH {pane_id, paused?, order?} pauses or resumes (resuming puts a failed message back in line and unbinds the conversation) and puts the named waiting ids first. /api/chat/queue/<id>: PUT {pane_id, text} edits a waiting message, DELETE ?pane= removes one (409 while it is being typed). Each answers {paused, note?, tone?, items: [{id, text, state: queued|sending|sent|failed, created_at, created_by?, sent_at?, after?, detail?}]}, which /api/chat also carries as queue. /api/chat/stop pauses a non-empty queue. Every change bumps chat_queue_rev on the SSE stream.
/api/chat/answer POST {pane_id, expect, labels, answers: [{selected, multi, options, custom?, previews?}]}, one answer per question in order, selected being option indexes and options the question's option count. lasso types the keys a human would into the claude or omp dialog once the pane's screen still shows expect or one of labels (the first question's), and answers {outcome: "sent" | "refused", detail?}; nothing is retried. custom is a free-text answer typed into the dialog's own "Other" row: one line (no newline or other control character), at most 1200 characters, at most 8 per request, with selected empty on a single-select question (a multi-select one may carry both); with any custom in the request, the first answer's options must equal the length of labels when those are sent, since the walk to the "Other" row is counted in options. previews marks a question whose options carry previews: claude draws a single-select one with no "Other" row, so a custom answer there is refused. A malformed body is a 400.
/api/bots, /api/bots/<name>[/…] Bots: create, configure, start and stop them, their jobs, skills, env and sign-ins. A blocked omp bot's row carries pending_ui, the questions it waits on, as its chat does. An omp bot's chat is /api/bots/<name>/chat with chat/send ({text, meta?}; meta is up to 16 string values, at most 1 KB, shown back on the user's row), chat/stop and chat/answer. See Bots.
/api/transcribe POST a PCM16 WAV (raw body, at most 4 MiB and 120 s; ?language=, ?prompt=) and get {text, engine, provider, model, language, audio_ms, elapsed_ms}; GET says whether it is available and its limits. 503 while the provider is off, 429 past 2 in flight. See Transcription.
/api/telemetry POST {app: "web" | "phone", events: [{event, props}]} from the web and phone apps. At most 16 KiB and 20 events, 60 events a minute per client; anything off the analytics allowlist is dropped, and nothing is forwarded while analytics is off. Always 204 for a well-formed body.
/api/telemetry/config The telemetry setting: GET answers {enabled, active, env_off, has_key, privacy_url}, POST {enabled} sets it.
/api/transcribe/config The transcribe setting: provider (off, groq, openai, openai-compatible), model, base_url and the NAME of the key's variable.

Settings, themes and maintenance#

route purpose
/api/theme, /api/theme-set, /api/theme-sync The resolved theme; change it; push it to every host now.
/api/omarchy-themes, /omarchy/bg/…, /omarchy/thumb/… The theme catalog; background images and thumbnails.
/api/version, /api/self-update Versions and herdr compatibility; update a supervised source checkout (403 with -disable-self-update). A lasso running with -dev adds dev: true and listen, the address it bound.
/api/host-update, /api/host-provision Update herdr on a host; install and start herdr on a host that lacks it.
/api/usage Provider rate-limit windows for the usage footer.
/api/frameable Whether a URL can be framed, for the Browser tab's iframe mode.
/api/browser, /api/browser/profiles[/…] Shared-browser status, and the browsers (stored as profiles, hence the path).
/api/push, /api/push/subscribe, /api/push/unsubscribe, /api/push/test Web Push devices. subscribe accepts only an https endpoint with a valid P-256 key, and names the notification device it belongs to (device_id).
/api/notify GET ?device=<id>: the notification kinds (kinds: id, label, description, default) and that device's resolved choices (device: {id, label, wants}).
/api/notify/device POST {id, label?, kinds?}: register or relabel a notification device and change its choices, merged per kind; null puts a kind back to its default, and an unknown kind refuses the whole write (400). A device id is 8 to 64 characters of A-Z a-z 0-9 _ -.
/api/log With -dev only: browser log sink.

Plugins#

route purpose
/api/plugins, /api/plugins/… The plugin listing and its actions (enable, trust, install, logs, …). Listed in Writing a plugin.
/plugins/<name>/… An enabled plugin's tab pages and font files. Every response, 404s included, carries Content-Security-Policy: sandbox …, which makes the page an opaque origin that cannot use your session.

MCP, OAuth and the browser#

route purpose
/mcp lasso's MCP server (streamable HTTP): agents, hosts, notify, open_file, browsers and their tabs, the browser_* tools (chrome-devtools-mcp's), plugin tools. See MCP tools and The browser tools.
/cdp, /cdp/p/<id> Raw Chrome DevTools Protocol WebSocket for the default browser, or for browser <id>.
/cdp/browsers GET: every browser and the CDP address of each, for a client with no MCP. Never starts a browser. Same gates as /cdp. /cdp/profiles answers the same list under a profiles key. See Discovering browsers over CDP.
/.well-known/oauth-protected-resource[/…], /.well-known/oauth-authorization-server[/…] OAuth discovery documents. 404 unless MCP_OAUTH is set.
/oauth/register, /oauth/token Dynamic client registration; token endpoint. 404 unless MCP_OAUTH is set.
/oauth/authorize The consent screen; grants mcp, or mcp ui when the client asks for ui. Behind UI_AUTH and the network listeners' caller check. 404 unless MCP_OAUTH is set.

Static#

route purpose
/assets/… Hashed frontend build assets, cached long-term.
/sw.js The service worker, served no-store so it is never stale.
/m, /m/… The simple app (Inbox, Agents, Bots, Settings), the single-page app's phone entry. A phone opening / goes here unless that device chose the full UI.
/ The single-page app; any unmatched path serves it.