# Local Operator Docs > Guides for the Local Operator agent runtime: the terminal UI, the desktop app, workflow automations, remote access, and building on top of it. Full text of every page on https://docs.local-operator.com, in navigation order. Each entry is preceded by its title and a `Source:` line; the body is the page's markdown exactly as `/raw/` serves it. The index of pages (titles and descriptions) is `/llms.txt`. ## Get started ### What is Local Operator? Source: https://docs.local-operator.com/getting-started/what-is-local-operator # What is Local Operator? Local Operator is an agent runtime that runs on **your machine**. You hand it a task the way you would to someone sitting at your desk — *sort this folder*, *find what broke in last night's build*, *pull the numbers out of these invoices* — and it works: running commands, editing files, reading the web, asking for approval before anything risky. It is open source (MIT), and it does not need a cloud account. Your sessions, credentials and configuration live in `~/.local-operator/` on your computer, and model calls go to whichever provider you signed in to. ## One runtime, every surface One install is one pool of sessions, and several front ends drive it: - **Terminal.** `lop` opens a full-screen TUI: streaming transcripts, expandable tool cards, an approval prompt before anything writes. - **Desktop app.** A window for the same work — chat with run details, projects, schedules, a browser, and the Agent hub. - **Phone.** The mobile relay lets you watch, steer, or start sessions from your phone, against the machine doing the work. - **Browser extension.** The agent gets the browser you are already signed into — its own tab, your logins, your permission per site. - **Headless.** `lop exec "..."` runs one task with no interface at all, ready for scripts, cron, and CI. - **Python SDK.** `local_operator.sdk` embeds the runtime in your own Python code. {% diagram name="SurfaceMap" caption="One runtime, every surface" /%} Sessions are shared, not siloed: a conversation started in the terminal shows up in the desktop app's list, the sidebar, and the phone relay — because all of them read the same local session store. ## What makes it different - **It runs on your machine.** Commands execute on your computer, edits land in your files, and nothing runs on servers we control — because there are none. Your provider credentials stay local. - **Teams, not one-off prompts.** A team is a manager plus members (reviewer, coder, architect, designer, and more) with a written brief. You hand over a goal; the manager splits it, delegates, and reports back. - **Projects.** Durable workstreams gather related sessions, milestones and progress in one place, so work that spans days or several conversations keeps its shape. - **The mesh.** Pair your computers and they can see each other's sessions: start one on the always-on box, prompt it from the laptop, and move a conversation between machines — transcript and all — when you move. {% diagram name="SessionHandoff" caption="A conversation moving between machines — transcript and all" /%} - **Work continues when you walk away.** Sessions schedule their own future turns, and a small supervisor wakes them even if the terminal that scheduled the work is long closed. Aida, the built-in chief of staff, checks in once a day with what needs your attention. - **Bring your own models.** Sign in to the subscription you already pay for (Claude, ChatGPT, Kimi, Grok, Z.AI, Qwen), use an API key, or point it at a local model with LM Studio, Ollama, vLLM or llama.cpp. ## What it isn't - **Not a cloud service.** There is no Local Operator cloud, and no remote account holding your sessions. When all your machines are off, nothing runs. - **Not a model.** It does not ship weights and does not charge for model use — you bring a provider or a local model, and you pay that provider directly. - **Not autopilot.** Read-only tools run on their own, but anything that writes files or runs commands asks first until you decide otherwise. - **Not only for code.** The same agent does research, file work, data cleanup and scheduled jobs — coding is just where it starts. ## Where to go next {% cards %} {% card title="Install & first run" href="/getting-started/install" icon="download" %} Get the runtime, sign in to a provider, verify the install. {% /card %} {% card title="Quickstart" href="/getting-started/quickstart" icon="rocket" %} Your first task, end to end, in five minutes. {% /card %} {% card title="Choose your surface" href="/getting-started/choose-your-surface" icon="compass" %} Terminal, desktop, phone, browser, or headless — where each one fits. {% /card %} {% card title="Why Local Operator" href="/about/why" icon="sparkles" %} The longer argument, and when something else is the better tool. {% /card %} {% /cards %} ### Install & first run Source: https://docs.local-operator.com/getting-started/install # Install & first run Local Operator installs three ways that share one runtime: the terminal runtime (a Python package), the desktop app (a bundled build of the same runtime), and container / Nix paths for the rest. Pick the surface you want to drive it from; you can add another later without losing sessions. {% callout type="note" %} The runtime needs **Python 3.12 or newer**. On systems with an externally-managed Python (Debian/Ubuntu, Homebrew), use `pipx` or `uv` instead of `pip`. {% /callout %} ## Terminal runtime {% tabs %} {% tab label="pip" %} ```bash pip install local-operator ``` {% /tab %} {% tab label="pipx" %} ```bash pipx install local-operator ``` {% /tab %} {% tab label="uv" %} ```bash uv tool install local-operator ``` {% /tab %} {% /tabs %} The install provides two equivalent entry points: `lop` (used throughout these docs) and `local-operator`. Optional features live behind extras. `pip install "local-operator[all]"` brings every extra listed below: | Extra | Adds | | --- | --- | | `server` | The HTTP API server (`lop serve`) and the background scheduler | | `mcp` | Model Context Protocol client support | | `images` | HEIC/HEIF image attachment decoding | | `tokenizer` | Exact BPE token counting (estimated otherwise) | | `fetch` | Markdownify renderer for `web_fetch` | | `lsp` | Jedi-backed Python navigation for the `lsp` tool | | `all` | Every extra above, `lsp` included | If a feature's extra is missing, the agent tells you which one to install instead of failing with an import error. ## Desktop app The desktop build for macOS, Windows and Linux ships its own backend and uses an existing Local Operator install when it finds one — a separate `lop` install is optional. Grab the installer from the [downloads page](https://local-operator.com/download), or run it from a terminal (Node.js 22.13.1 or newer): ```bash npx local-operator-ui # download and run in one command npm install -g local-operator-ui # or install it globally ``` The [desktop app guide](/surfaces/desktop) covers the app in full — what it adds on top of the runtime, and how the two work together. ## Docker and Nix ```bash docker compose up -d # with the provided compose file nix develop # reproducible dev shell via the flake ``` ## Verify the install ```bash lop --version lop login # list the providers you can sign in to ``` {% cards %} {% card title="Quickstart" href="/getting-started/quickstart" icon="rocket" %} Sign in to a provider and run your first task, step by step. {% /card %} {% card title="Choose your surface" href="/getting-started/choose-your-surface" icon="compass" %} TUI, desktop app, phone, or headless — where each one fits. {% /card %} {% /cards %} ### Quickstart Source: https://docs.local-operator.com/getting-started/quickstart # Quickstart {% steps %} {% step title="Sign in to a provider" %} Every provider runs on the same harness and the same model picker. OAuth providers sign in through the browser and use your existing subscription; API-key providers prompt once and store the key locally. ```bash lop login # list every login-capable provider lop login anthropic # or openai, kimi, xai-oauth, zai-oauth, ... ``` `lop login ` also sets that provider as your default hosting and picks a default model, so the very next `lop` just works. Skip this step and an interactive `lop` opens in a setup state that walks you through `/login` instead. {% /step %} {% step title="Launch the terminal UI" %} ```bash lop ``` The welcome screen is ready for a first prompt. Give it something real: *sort this folder by project and tell me what's misnamed* or *summarize this repo and list what's untested*. {% /step %} {% step title="Let it work — and steer it" %} Tool calls stream in as one-line receipts; expand a card with {% kbd %}enter{% /kbd %} or {% kbd %}space{% /kbd %} to see the full command and output. Type while the agent works and your message is delivered at the next step as steering — no need to wait. {% kbd %}esc{% /kbd %} stops the agent without ending the session. {% /step %} {% step title="Approve a tool call" %} Read-only tools run automatically; anything that writes files or executes commands prompts first, showing the exact command before it runs. Approve once, or set a mode with `/approvals` when you trust the task. {% /step %} {% step title="Come back later" %} Sessions persist. `/resume` opens a picker over your recent conversations, each with its title and age, and continues the one you pick. Configuration and credentials live under `~/.local-operator/` (`config.yml`, `auth.db`). {% /step %} {% /steps %} {% figure src="/shots/tui/welcome.png" alt="The welcome screen: brand mark, version, working directory, the command-picker hint, and a composer waiting for a first prompt." caption="First launch: the command picker hint (`/`), `/help`, and a composer ready for your first task." /%} ## Prefer a local model? A 7–14B model needs roughly 10–16 GB of RAM or VRAM. Start [LM Studio](https://lmstudio.ai), load a chat model, enable its server, then `/login lmstudio` inside Local Operator — `/login` also offers Ollama, vLLM, llama.cpp and a generic OpenAI-compatible server. The CLI form works too, for a model already installed in [Ollama](https://ollama.com/download): {% command %}lop --hosting ollama --model qwen2.5:14b{% /command %} ## Where to go next {% cards %} {% card title="The terminal UI" href="/tui/quickstart" icon="terminal" %} Launch, layout, keys worth knowing, and the slash commands that matter. {% /card %} {% card title="Choose your surface" href="/getting-started/choose-your-surface" icon="compass" %} The same sessions from the desktop app, your phone, or headless scripts. {% /card %} {% card title="Connect your phone" href="/surfaces/mobile-relay" icon="smartphone" %} Watch and steer sessions from your phone with the mobile relay. {% /card %} {% /cards %} ### Choose your surface Source: https://docs.local-operator.com/getting-started/choose-your-surface # Choose your surface One runtime, same sessions, several ways in. You can add another surface at any time — the terminal, the desktop app, and the mobile relay all drive the same sessions and read the same `~/.local-operator/` configuration. | Surface | You drive it from | Install | Choose it when | | --- | --- | --- | --- | | [Terminal (TUI)](/tui/quickstart) | Any terminal | `pip install local-operator` | You live in a terminal, or you want scriptable, keyboard-first control | | Desktop app | macOS / Windows / Linux | [Downloads](https://local-operator.com/download) or `npx local-operator-ui` | You want the browser pane, file canvas, schedule pages, and a window to watch | | Mobile relay | Your phone | `lop mobile install` | You want to check on, steer, or start sessions from the sofa | | Browser extension | Your browser | `lop browser install` | You want the agent to drive the browser you already use, with the pages visible | | Exec mode | Scripts, CI, cron | bundled with the runtime | You want one task, headless: `lop exec "..."`, `--json`, `--background` | | Python SDK | Your own Python code | ships inside the `local-operator` package | You want to embed Local Operator in an application | ## A note on mixing The surfaces are not separate products with separate state: - Sessions started in the terminal register with the local daemon, so the phone relay can watch and steer them (see [Mobile relay](/surfaces/mobile-relay)). - The desktop app drives the same sessions, teams, schedules and configuration the TUI reads. - `lop exec` runs one task headless and exits — ideal for scripts, and the same runtime underneath. - Agents inside a session work with tools, not the shell: the `sessions` tool lists, inspects, spawns, resumes and stops other sessions, and a spawned run is a listed workstream by default. The CLI forms (`lop sessions`, `lop exec --workstream`) stay the human path — and the fallback where the tool is absent. {% cards %} {% card title="Quickstart" href="/getting-started/quickstart" icon="rocket" %} Already know what you want? Sign in and run a first task. {% /card %} {% card title="Install & first run" href="/getting-started/install" icon="download" %} The full install matrix, verified. {% /card %} {% /cards %} ### Updating Local Operator Source: https://docs.local-operator.com/getting-started/updating # Updating Local Operator ## The built-in updater `lop update` upgrades the install from PyPI and restarts the mobile daemon when its LaunchAgent is installed. Inside the TUI, `/update` installs the latest version and relaunches the terminal. Running work is not at risk: the runtime adopts newly installed code only when all work is safely idle (active turns, tools, compaction, loops, child agents and background jobs defer the refresh), and a failed update leaves the current terminal and runtime running. The new terminal reattaches to the same conversation — streamed output and unanswered prompts included. ## Upgrading by hand | Install method | Upgrade with | | --- | --- | | pip | `pip install --upgrade local-operator` | | pipx | `pipx upgrade local-operator` | | uv | `uv tool upgrade local-operator` | | Desktop app | Updates itself from GitHub Releases; reinstall from the [downloads page](https://local-operator.com/download) if needed | The [desktop app guide](/surfaces/desktop) covers the app's own update and settings flows. ## Checking your version ```bash lop --version # what's installed lop login-status # what's currently signed in ``` Release notes and installers live on [GitHub Releases](https://github.com/damianvtran/local-operator/releases). {% cards %} {% card title="Install & first run" href="/getting-started/install" icon="download" %} The full install matrix, extras, Docker and Nix. {% /card %} {% card title="Quickstart" href="/getting-started/quickstart" icon="rocket" %} First run, first task, first approval. {% /card %} {% /cards %} ## Surfaces ### The terminal UI Source: https://docs.local-operator.com/tui/quickstart # The terminal UI `lop` opens a full-screen [Textual](https://textual.textualize.io/) app, not a REPL. Everything the agent does shows up as a card or a one-line receipt: tool cards expand to show the full command and output, and the status line tracks the current step, token usage and cost. ## Launch ```bash lop ``` That is the whole command. If no provider is configured, the welcome state walks you through `/login`; a headless or piped run prints the exact commands to configure hosting, model and a key instead. {% figure src="/shots/tui/welcome.png" alt="The welcome screen: the local operator mark, the version and working directory, and a composer ready for a first prompt" caption="The launch state — the composer takes a first prompt; `/` opens the command picker." /%} ## The screen - **Transcript** — streamed responses, expandable tool cards, and one-line receipts for every action. - **Composer** — where you type. While the agent works, your message is delivered at the next step as steering. - **Status line** — current step, token usage, and cost. - **Approval prompts** — when a tool needs sign-off, the prompt shows the exact command before anything touches your system. {% figure src="/shots/tui/approval.png" alt="An approval prompt showing the exact command about to run, with Allow, Deny and Allow all options" caption="An approval prompt — the exact command first, then allow once, deny, or allow for the session." /%} ## Keys worth knowing | Key | Does | | --- | --- | | `esc` | Stop the agent without ending the session | | `enter` / `space` | Expand or collapse a tool card | | `ctrl+b` or `/sidebar` | Show or hide the conversation sidebar (`f9` focuses it) | | `shift+tab` | Cycle reasoning effort | | `f8` | Open an aside — a side question that never joins the conversation | | `ctrl+l` | Clear the transcript (history is untouched) | | `ctrl+t` / `ctrl+g` | Expand the todo list · cycle the subagent panel | | `$` alone | Open the skill picker (`$research the payments rewrite` runs one) | ## Slash commands that matter `/help` shows the full table in-app. The highlights: - `/model` — switch model for this session; `/model default` saves the current one for new conversations. - `/resume` — pick a past conversation and continue it. - `/theme` — 20+ built-in themes, with live preview as you move. - `/usage` and `/context` — provider quota and spend; what is occupying the context window. - `/approvals` — set whether tools ask first (`ask`/`auto`). - `/update` — install the latest version from PyPI and relaunch. - `/team`, `/skills`, `/mcp` — launch a saved team; list loaded skills; list MCP servers. {% figure src="/shots/tui/command-picker.png" alt="The slash-command picker open in the composer, filtering a list of commands" caption="Type `/` to filter the command list — `/help` shows the full table in-app." /%} ## Sessions and resume Transcripts persist — come back with `/resume` and pick from a list of recent conversations with titles and ages. In a terminal multiplexer (tmux, wezterm, cmux), `lop` also publishes a per-pane crash-restore binding, so a killed terminal does not take the session with it. Subagent and background-session transcripts are cleaned up automatically after 48 hours of inactivity (configurable from 2 hours to 30 days in Settings > Delegated work); your own conversations are never removed unless you turn that on. A one-time notice tells you when this first happens. {% figure src="/shots/tui/resume-picker.png" alt="The resume picker listing recent conversations with titles and ages" caption="`/resume` — recent conversations with titles and ages; type to filter." /%} ## Themes `/theme` previews each registered theme live as you arrow through; the chosen theme is stored as `tui.theme` in `~/.local-operator/config.yml`. {% figure src="/shots/tui/themes-list.png" alt="The theme picker with the registered themes, descriptions and a live preview as the selection moves" caption="`/theme` — every registered theme, previewed live as you move." /%} {% cards %} {% card title="Exec mode" href="/build/exec-mode" icon="terminal-square" %} The same tasks headless — one command, scriptable output, background jobs. {% /card %} {% card title="Connect your phone" href="/surfaces/mobile-relay" icon="smartphone" %} Watch and steer this session from your phone. {% /card %} {% /cards %} ### Desktop app Source: https://docs.local-operator.com/surfaces/desktop # Desktop app The desktop app is the same runtime in a full window: chat with a run-details pane, a projects board, schedules, a browser the agent can share, and the Agent hub. It ships its own backend, so a separate `lop` install is optional — it uses one when it finds it. ## Install Pre-built apps are available for macOS (`.dmg`), Windows (`.exe`) and Linux (`.deb`, `.rpm`, `.AppImage`): - [Downloads page](https://local-operator.com/download) — pick your platform - [GitHub Releases](https://github.com/damianvtran/local-operator-ui/releases/latest) — the same installers, plus release notes To run it from a terminal instead (Node.js 22.13.1 or newer): ```bash npx local-operator-ui # download and run in one command npm install -g local-operator-ui # or install it globally ``` ## First run The first launch walks you through signing in to a provider. OAuth providers open the browser and use your existing subscription; API-key providers take a pasted key; and you can connect a Radient account for Radient Automatic, which picks a model for each step and saves a Pass on your machine. Then the window opens on an empty chat. The rail on the left is the map: - **New chat** (⌘N) and **Search** (⌘K) - **Aida** — the built-in chief of staff - **Agents** and **Projects** — reusable agents, and durable workstreams - **Schedules** — wakes with their cadence and run history - **Browser** — a browser in the app the agent can drive - **Agent hub** — community agents you can pull into your own setup - **Mesh** — the devices you have paired {% figure src="/shots/ui/app-shell-chat-light.webp" alt="The desktop app: the sidebar's navigation (New chat, Search, Aida, Agents, Projects, Schedules, Browser, Agent hub, Mesh) above its agents, teams and recent chats; the chat shows a request, its tool receipts and the reply; a follow-up is drafted in the composer; run details lists two running subagents and the session's to-dos." caption="A working session: the conversation with its tool receipts, a drafted follow-up in the composer, and run details with the subagents and to-dos alongside." /%} ## Chat and run details The middle column is the conversation. On the right, **run details** keeps the work visible while it happens: which subagents are running (with elapsed time, context and spend), and the session's to-do list, updated as items close. You stay in control of the pace: - Approvals surface as cards in the transcript — the exact command first, then Approve or Deny. - Type while the agent works and your message lands as steering, at the next step. - The composer's pickers switch the model and the reasoning effort without leaving the conversation. ## The tool trace Every tool call leaves a row in the transcript — no invisible action. Rows expand to the full command and its output, risky calls are held at an approval card, and questions from the agent show up in the same stream. {% figure src="/shots/ui/chat-trace-light.webp" alt="A working session's transcript: tool rows for commands run, a risky action waiting on an approval card, and a question the agent asked." caption="A working session: tool rows, a risky action waiting on approval, and a question the agent asked." /%} ## Projects and schedules **Projects** gather related sessions into a durable workstream, so work that spans days or several conversations keeps its shape. The board shows each project's state at a glance. {% figure src="/shots/ui/projects-board-light.webp" alt="The projects board with active, paused and done columns, each holding project cards." caption="The projects board: active, paused and done columns." /%} **Schedules** is the desktop view of wakes — future turns a session has armed for itself. Each row shows the next run, the cadence, and how many times it has fired. (How wakes behave is covered in [Workflow automations](/capabilities/workflow-automations).) {% figure src="/shots/ui/schedules-light.webp" alt="The schedules page listing scheduled tasks, each with its next run time, cadence and run count." caption="Schedules: the next run, the cadence, and how often each task has fired." /%} ## Themes Settings → **Appearance** has 59 themes — grouped dark and light, from the brand's Local Operator Dark (the default) to Dracula, Gruvbox, Catppuccin, Tokyo Night and more. Pick one and the app remembers it across restarts. {% figure src="/shots/ui/appearance-light.webp" alt="The appearance settings page showing themes as swatches in dark and light groups." caption="Appearance: 59 themes as swatches, grouped dark and light." /%} ## The Agent hub inside the app The **Agent hub** page browses Radient's community registry without leaving the app: categories on the left, then agent cards with likes, downloads and a Get button that pulls the agent into your setup. The same registry is reachable from the CLI — see [Agent Hub](/capabilities/agent-hub). {% figure src="/shots/ui/agent-hub-dark.webp" alt="The Agent hub page in the dark theme: categories on the left and a grid of community agent cards with Get buttons." caption="The Agent hub: categories on the left, community agents with Get buttons." /%} ## Updating The app updates itself from GitHub Releases and checks on launch; a notification offers the install when a new version is out. If you ever need to install by hand, re-download from the [downloads page](https://local-operator.com/download) — your sessions and settings stay where they are. The runtime side (`lop update`) is covered in [Updating Local Operator](/getting-started/updating). {% cards %} {% card title="Choose your surface" href="/getting-started/choose-your-surface" icon="compass" %} When the desktop app is the right front end — and when the terminal is. {% /card %} {% card title="Connect your phone" href="/surfaces/mobile-relay" icon="smartphone" %} Watch and steer the same sessions from your phone. {% /card %} {% /cards %} ### Mobile relay Source: https://docs.local-operator.com/surfaces/mobile-relay # Mobile relay The mobile relay is how a phone reaches the Local Operator sessions running on your computer. Nothing runs on the phone: a small daemon on the machine serves the phone pages and relays control to the same sessions the terminal and the desktop app see. A path from the internet to that daemon — a tunnel — is what puts it in your pocket. ## What `lop mobile install` sets up `lop mobile install` installs the daemon as a supervised service: a LaunchAgent on macOS, a `systemd --user` unit on Linux, and a Task Scheduler task on Windows. The unit name is fixed and owns its port, so a second daemon fails to bind loudly instead of splitting the control plane. When the web bundle is absent, the install builds it. Every TUI and `exec` session publishes a 0600 discovery record under a 0700 directory, so the phone sees terminal sessions too, not only the sessions the daemon started. ## Set it up There are two ways to reach the daemon from outside the machine. The Radient personal tunnel is the recommended path: it carries the authentication, so there is no relay password to copy. The local-password path is for a tunnel or URL you run yourself. ### A Radient personal tunnel {% steps %} {% step title="Sign in" %} Check which account this machine is signed in with: ```bash lop login-status ``` Then sign in — or create the account — with `/login radient` in a session. {% /step %} {% step title="Read the plan" %} ```bash lop tunnel setup --json ``` This is a read-only plan: it observes and changes nothing. It checks the selected account and its billing as they stand, that a new enrollment would have positive credit, that `cloudflared` is at least 2025.4.0, and that this computer runs a user supervisor the relay supports. Follow whatever blockers it names — one common one is an explicit `lop mobile install` for the phone origin. {% /step %} {% step title="Apply only when told to" %} When the plan is ready it prints the apply command: ```bash lop tunnel setup apply --credential-id ``` Add `--accept-monthly-price ` only for the exact quote you accepted — never a guess. The CLI shows you the price; this page does not quote one. {% /step %} {% /steps %} If the tunnel already exists in the Radient console, connect it instead: ```bash lop tunnel connect --credential-id ``` Setup is finished when all of these hold: the mobile health check passes, the auth gate is closed, the connector is running, the gateway answers, and the cloud route is active and fresh. The tunnel URL is returned only then; a plan that stops early names the check that blocked it. {% diagram name="RelayFlow" caption="From the phone to a loopback-only daemon, through a path you put in front" /%} ### A tunnel or URL you run yourself Install the daemon and set the password it will ask for: ```bash lop mobile install lop mobile password ``` On a host with no user service supervisor (some containers), run `lop mobile serve` instead. Then put an identity gate in front of the loopback daemon — the tunnel's own access policy, or a reverse proxy that authenticates. Never widen the daemon's bind address, and never expose it through an anonymous quick tunnel. ## Commands | Command | What it does | | --- | --- | | `lop mobile status` | Daemon health, the auth gate and live sessions | | `lop mobile start` / `stop` / `restart` | Move the supervised service between states | | `lop mobile logs --lines N [--follow]` | The daemon's and its runtimes' logs | | `lop mobile password` | Show or rotate the password (interactive only) | | `lop mobile devices` | The push-device registry: `list`, `revoke `, `unrevoke ` | | `lop mobile uninstall [--purge]` | Remove the service; `--purge` also deletes the password | | `lop mobile serve --port 4098` | Run the daemon in the foreground | `lop mobile devices revoke ` and `unrevoke ` are the human path: `revoke` blocks that installation until you `unrevoke` it — registering again does not clear a revoke. A device marked `expired` is cleared by registering again, not by unrevoking it. ## What the phone can do {% figure src="/shots/reuse/mobile-session-view.png" alt="A session on a phone: streamed transcript with collapsed tool rows, the composer reading 'steer this turn', and a stop button." caption="A session from the phone: the transcript streams, tool rows collapse, and the composer steers the running turn." /%} The phone is a controller, not a second agent. From it you can: - watch a transcript stream, with tool rows collapsed as they finish; - steer a running turn with a message, or stop it; - switch the session's model and reasoning effort; - run the session's slash commands; - drill into its subagents; - start a session or resume an existing one; - answer the approval and ask cards a phone-started session raises — sessions started at the terminal still answer at the terminal. Schedules are read-only on the phone. Projects support the writes you need away from the desk: create one, add or complete a milestone. ## Security invariants - The daemon is loopback-only. It is not a server you expose; the tunnel is. - Transport is HTTP with server-sent events, never a WebSocket. - The password never appears on a command line or in a log. It lives in the platform's own secret store — Keychain on macOS, the Secret Service (or a 0600 file) on Linux, DPAPI on Windows — with `LOP_MOBILE_PASSWORD` as the container override and an interactive prompt otherwise. - Cookies are signed with a key derived from the password, so rotating the password invalidates every existing session. - Discovery records are 0600, under a 0700 directory. ## The native app A native client for this relay, [Local Operator Mobile](/surfaces/mobile-app), is still in development: there is no App Store, TestFlight or Google Play build, and no GitHub Release. It speaks the same relay described here, so the computer side of this page is what it connects to. ### Mobile app Source: https://docs.local-operator.com/surfaces/mobile-app # Mobile app **Local Operator Mobile** is the open-source, native iOS and Android client for the [mobile relay](/surfaces/mobile-relay): the sessions running on your computer, in your pocket. Follow a session as it works, steer it or stop it, answer the approvals and questions it raises, start new work, and move a session between your machines — reached through a Radient personal tunnel, or any tunnel or URL you run yourself plus the relay password. {% callout type="note" %} **In development.** The app is built and exercised in the open — against a mock relay, a frame audit and its own test suite — but no build has been published yet: it is not in the App Store, TestFlight or Google Play, and no GitHub Release has been cut. Push notifications are designed, not built. Everything below is what the app on `main` does today; the [repository](https://github.com/damianvtran/local-operator-mobile) is where it lives. {% /callout %} {% figure src="/shots/mobile-app/welcome-light.png" alt="The app's first-launch screen: 'Your agent runs on your computer.' above a Sign in with Radient button, with Set up your own tunnel beneath it." caption="First launch: sign in with Radient, or set up your own tunnel." /%} The captures on this page come from the app's own fixture corpus — every frame is the app driven against its mock relay — the conversations, session names and model labels in them are placeholders, not anyone's real sessions. ## Getting connected The computer side is the mobile relay that ships with Local Operator (`lop mobile`) — a supervised daemon that only listens on the loopback interface. {% steps %} {% step title="On your computer" %} Install the relay: ```bash lop mobile install # a supervised service: launchd, systemd --user, or Task Scheduler ``` Then give the phone a way to reach it: - **A Radient personal tunnel (recommended).** `lop login radient` once, then `lop tunnel setup --json` for a read-only plan, and `lop tunnel setup apply --credential-id ` when it tells you to. The tunnel carries the authentication, so there is no relay password to copy. The [mobile relay guide](/surfaces/mobile-relay) walks through it. - **Any tunnel or URL you run yourself.** Put it in front of the relay's loopback port and set the relay password with `lop mobile password`. {% /step %} {% step title="On your phone" %} No store build exists yet. Today the app runs from its repository — build it (`pnpm install`, then `pnpm ios`, `pnpm android` or `pnpm dev:web`), or sideload the debug APK that CI uploads. {% /step %} {% step title="Sign in" %} **Sign in with Radient** — the app finds your personal tunnels for you; the tunnel handles authentication, so there is no relay password. Or **Use an address and password** — point it at any tunnel or URL you run yourself. {% /step %} {% /steps %} {% figure src="/shots/mobile-app/sign-in-light.png" alt="The app's Sign in screen: 'Your browser finishes the sign-in, so the app never sees your password', a Sign in with Radient button, and Use an address and password beneath it." caption="Sign in: the browser finishes the hand-off, so the app never sees your password." /%} A tunnel that refuses, an expired login and a computer that is asleep each get their own explanation and a remedy. The [mobile relay guide](/surfaces/mobile-relay) covers the computer side. ## Sessions The app opens on a **new chat**: pick the folder on your computer where it will run, pick the model, and type the first message. The sessions panel sits behind it — every conversation on the connected computer, each row carrying its folder, its model and its state: running, waiting on you, or ended. - **Search** filters the list, and searches past sessions by their contents. Open one from **Past sessions** and resume it. - **Remote sessions.** With more than one computer, sessions from your other machines sit in the same list as first-class rows; a row you cannot reach names the device and says why. - **Move a session.** Send a session to another computer, recall it back, or copy it. The receipt arrives phase by phase, and a refusal is the relay's own sentence, never an invented one. {% figure src="/shots/mobile-app/session-list-light.png" alt="The sessions panel: the connected computer's address above a list of conversations, each row with its folder, model and a state marker — an agent count, an approval dot, or nothing — and a New session button with Past and Computers along the bottom." caption="The sessions panel: the conversations on your computer, each with its folder, model and state." /%} ## The session view Open a session and the transcript streams in as it works: messages and markdown, code blocks, diffs and tables, tool calls with their durations, and images the agent produced. Two aids ride along: - **The checkpoint rail** — a column of marks down the right edge of the transcript, one per user turn and per completed agent turn, read from the whole conversation rather than the window the phone has loaded. - **Find** — the desktop's in-conversation search, over the transcript the phone holds: the sheet browses the hits, the bar steps through them. The session's to-do list and its subagents sit in strips above the composer, each opening into detail, and the composer is one control that never moves: send, steer a running turn, or stop it. Beside it are the session's model and reasoning effort; a leading `/` opens the session's own commands; and a photo or screenshot can go into the conversation. Approvals and questions arrive in the transcript where they were asked, and are answered there. {% figure src="/shots/mobile-app/session-view-light.png" alt="A session in progress: a header with context use and a running-tasks count, a transcript with a diff, markdown tables and a running tool row, the to-do strip and a subagent strip, and the composer with its model and effort chips." caption="A session at work: the transcript with a diff, tables and a running tool row, the to-do and subagent strips, and the composer with its model and effort chips." /%} {% figure src="/shots/mobile-app/session-view-dark.png" alt="The same session view in the dark theme." caption="The same transcript in the dark palette." /%} You stay in control of the pace: approvals surface as cards in the transcript — the command first, then Approve, Deny, or always allow for the session. {% figure src="/shots/mobile-app/approval-light.png" alt="An approval card in a session's transcript — 'approval requested · bash', the command it wants to run, and Approve and Deny buttons above an 'Always allow bash in this session' checkbox." caption="An approval in the transcript: the command it wants to run, then Approve, Deny, or always allow for the session." /%} ## Schedules and projects Both live behind the sessions panel's own routes. **Schedules** gathers every conversation that carries wakes and monitors into one screen — what is armed, overdue, dormant or expired. Arming, editing and cancelling stay on the desktop and the terminal. **Projects** shows each project's state and milestones, and takes the writes you need away from the desk: create a project, add or complete a milestone, delete behind a confirm. ## Where it differs from the desktop and the terminal - **A remote control, by design.** Nothing runs on the phone: the sessions, files and tools are all on your computer, and the app drives the same sessions the desktop app and the terminal see. If you already use the relay's phone pages, this is that surface native — platform sign-in, and a layout built for a phone rather than adapted to one. - **Some things stay desk-side.** Schedules are read on the phone; arming and editing live on the desktop and the terminal. And the checkpoint rail marks the conversation without jumping between marks yet. {% cards %} {% card title="Mobile relay" href="/surfaces/mobile-relay" icon="smartphone" %} The transport behind the app — how a phone reaches your computer at all. {% /card %} {% card title="Desktop app" href="/surfaces/desktop" icon="monitor" %} The full window for the same sessions, on the machine itself. {% /card %} {% card title="Choose your surface" href="/getting-started/choose-your-surface" icon="compass" %} Which front end fits which kind of work. {% /card %} {% /cards %} ### Browser extension Source: https://docs.local-operator.com/surfaces/browser-extension # Browser extension The browser extension hands the agent the browser you already use — with your real logins. It can open pages, read them, click, type and take screenshots, and you decide which sites it may visit. Sign in to a site by hand and the agent carries on from there. ## Install {% steps %} {% step title="Add the extension" %} Install **Local Operator** from the [Chrome Web Store](https://chromewebstore.google.com/detail/local-operator/omibaecbjdhgbbcedbnnnmjpmopfheof). It works in Chrome and other Chromium-based browsers (Edge, Arc, Brave). {% /step %} {% step title="Install the bridge" %} The bridge is the piece that lets your local runtime reach the extension — it runs on your machine: ```bash lop browser install # install the bridge daemon lop browser status # check that it is running ``` {% /step %} {% step title="Pair" %} ```bash lop browser pair ``` That prints a 6-digit code. Type it into the extension's popup — no account, no sign-up. To revoke the pairing, reset it from the extension or run `lop browser pair --reset`. {% /step %} {% /steps %} ## What it does - **Drives the browser you are signed into.** The agent can open pages, read them, click, type and take screenshots, so sites that need your login work without passing credentials around. - **Works in its own tab.** It opens a tab for the task and leaves yours alone. - **Asks before new sites.** The first time the agent wants a site, you get a prompt: allow once, always allow, or deny. - **Shows its work.** While the agent is attached, Chrome displays its debugger banner — that banner is the visible tell that the session is driving the browser. ## What stays on your machine The bridge is **loopback-only**: the connection between your runtime and the extension never leaves your computer, and there is no analytics reporting and no cloud browser involved. The permissions the extension asks for are: `debugger · tabs · tabGroups · scripting · storage · alarms · webNavigation · notifications` The debugger permission is what lets the agent click and type in a page — Chrome reflects that with the banner above, so an active session is always visible. ## Building from source The extension lives in `extension/` in the [local-operator repo](https://github.com/damianvtran/local-operator). To run your own build: ```bash pnpm -C extension build ``` Then load `extension/dist` as an unpacked extension in your browser's developer mode. {% cards %} {% card title="Desktop app" href="/surfaces/desktop" icon="monitor" %} A browser the agent can share lives inside the desktop app too. {% /card %} {% card title="Agent Hub" href="/capabilities/agent-hub" icon="store" %} Pull community agents that use the browser, teams, and MCP servers. {% /card %} {% /cards %} ## Capabilities ### Workflow automations Source: https://docs.local-operator.com/capabilities/workflow-automations # Workflow automations Local Operator has three ways to keep work moving when you are not sitting at the keyboard: **wakes** schedule a future turn, **monitors** watch something and report when it changes, and **background jobs** run work alongside the session. Each is a tool the agent can call and a CLI form you can script. A fourth primitive, output modes, turns a run's final response into something a pipeline can parse. {% diagram name="SchedulePipeline" caption="A scheduled wake, fired with or without a terminal open" /%} ## Wakes A wake is a scheduled future turn. The agent arms one with the `wake` tool ("check the build again in 30 minutes"), and the schedule is durable with the session — it survives a closed terminal, and a wake supervisor starts a runtime when the session's wake comes due. ```bash lop wake status lop wake list lop wake create "in 2h" "re-run the deploy smoke tests" --every 1d --until "in 7d" ``` `lop wake status` reports whether a supervisor is installed, what it would fire next, and what is owed. It names the supervisor's state — running, loaded but not running, not loaded, not installed, unverifiable for this store, or with no installer for this platform — and says what comes next. `lop wake install` repairs or rolls out the supervisor now; `lop wake serve --once` runs one pass in the foreground. `lop wake create` takes the session id, when to fire (a duration such as `"in 2h"` or a clock time such as `"at 09:30"`), and the message it delivers. It accepts `--every 1d` for a recurrence, `--until "in 7d"` for an end, `--limit N` for a fire count, and `--notify` to ask for a notification when the wake's turn completes. Give `--until` a future or relative time; an absolute date you copied from an example will expire. The supervisor is installed on demand: arming a wake sets it up, and a machine that never schedules one never runs it. On macOS it is a LaunchAgent (`com.local-operator.wakes`), on Linux a `systemd --user` service, on Windows a Task Scheduler task. The honest details: - **Missed occurrences are skipped, not replayed.** A laptop asleep for six hours owes one hourly check, not six. - **A fire that cannot be delivered is owed, not lost.** It is recorded and retried with backoff, and `lop wake status` shows what is owed. - **Unattended turns respect approvals.** A wake that fires while you are away waits at the first approval prompt until you answer — from the phone or `/resume`. `/approvals auto` or `--yolo` is how you opt into unattended execution. {% figure src="/shots/tui/wake-panel.png" alt="The wake panel above the TUI composer: two scheduled wakes with their next time, recurrence and message." caption="The wake panel in the TUI: what this session has scheduled, before it fires." /%} ## Monitors A monitor watches a read-only call on an interval and reports only when the result changes — a build status, a file, an endpoint. The interval has a 30-second floor and a 60-second default. Set `notify` when the user asked to be told; without it the delta lands in the conversation and waits for the next turn. ```bash lop monitor status lop monitor cancel ``` `lop monitor status` lists every monitor on the machine, soonest first. There is no supervisor: a monitor runs while its session's runtime is open. That is also why `lop monitor cancel` exists — it is for a session with no runtime open. A live session changes its own monitors from inside. ## Background jobs Background work is how one session does several things at once: - **Child sessions.** The `task` tool runs a subagent in the background; `sessions` with `op=spawn` opens one as a listed workstream by default. - **Background tool calls.** `bash` and `eval` accept `background=true` and return a job id immediately. - **Inspection.** `jobs` lists, peeks at new output since your last cursor, and cancels. `wait` blocks until a job settles or a message arrives. - **Delivery.** A settled job's result is delivered automatically when the session is idle — but a result already consumed by `wait` stays quiet rather than arriving twice. From a shell, `lop exec --background` detaches a whole task with a log file and exits immediately; `lop exec --status ` reads it back as JSON. ## Where the schedules show up Schedules are visible where they run — a wake panel in the TUI, a Schedules page in the desktop app: {% figure src="/shots/ui/schedules-light.webp" alt="The desktop app's Schedules page: each task with its next run, cadence and run count." caption="The same schedules in the desktop app, with run history." /%} Aida, the built-in chief of staff, is the same machinery on a daily cadence — see [Always-on & proactive agents](/capabilities/always-on). ## Output modes When a run's result feeds another system, prose is a parsing problem. `lop exec` can enforce the assistant's final response as a machine-readable payload — JSON, YAML, TOML or markdown, with an optional schema — and retry inside the same turn when the response does not conform. It is **off by default**: without a flag, nothing about a run changes. A schema is a plain JSON file the decoded payload must satisfy: ```json { "type": "object", "required": ["status", "items"], "properties": { "status": { "type": "string" }, "items": { "type": "array", "items": { "type": "string" } } } } ``` Point a run at it, and consume the result directly: ```bash lop exec "Check the nightly build; reply as JSON" \ --output-format json \ --output-schema build-check.schema.json \ --output-retries 2 | jq -r '.items[]' ``` - On success, stdout is the validated payload span — stripped of any fence or prose around it, in the model's own bytes. - A rejected attempt logs one stderr line — `! output check failed (json, attempt 1/3): …` — and the reason is fed back for the retry, inside the same turn. - When the retry budget is spent (default 2 retries, so three attempts), the run exits 1 and stdout stays empty: a wrong payload is worse than an empty one for `… | jq` consumers. The other formats are one flag away — `--output-format yaml` and `--output-format toml` — and markdown takes `{"required_sections": ["Summary", "Risks"]}` as its schema: each named section must appear as a heading, in order. The [exec mode](/build/exec-mode#final-response-enforcement) page owns the per-format strictness table and the extraction rules. The same contract is available to scripted automations through the [Python SDK](/build/python-sdk): ```python from pydantic import BaseModel from local_operator.sdk import SessionSpec class Invoice(BaseModel): total: int currency: str spec = SessionSpec( output_format="json", # enforce the final response, every turn output_schema=Invoice, # model class, dataclass, TypedDict or JSON Schema output_retries=1, # 0-5; default 2 (three attempts per turn) ) ``` The checked span arrives as an `output_validation` event (`payload_text` on success) — the same event a `--json` run emits for each checked attempt. ## A worked example A nightly loop that only speaks when something is wrong: ```bash # One wake per morning, this week only; notify when the turn completes. lop wake create "at 07:30" \ "Check the overnight build. If it failed, name the job and the first error." \ --every 1d --until "in 7d" --notify ``` A build watcher is a monitor instead: ask the agent to watch the build log and tell you when it changes, and it arms a read-only monitor with `notify` set. For work that should not hold the session open, detach it and read the result back: ```bash lop exec "Run the release checklist and write the report" --background lop exec --status ``` All three are the same three primitives arranged differently: when to run, what to watch, and where the result goes. ### Always-on & proactive agents Source: https://docs.local-operator.com/capabilities/always-on # Always-on & proactive agents Most sessions are reactive: they wait for you. This page is about the other kind — sessions that start their own turns, long after you closed the window. The mechanism underneath is the *wake*; the built-in example is Aida, your chief of staff. ## Reactive and proactive A session is idle until something addresses it — your turn, a peer message, a steering note. Making it proactive needs one primitive: **schedule a future turn**. A wake is durable (it lives in the session, not in a running process), it delivers itself as a normal user-attributed message, and the turn it starts has the full tool surface — files, commands, web, everything. ## Scheduled wakes The agent can arm wakes itself (tell it *"check the build again in 30 minutes"* — the `wake` tool schedules it), and you can schedule from outside a session: ```bash lop wake list # every scheduled wake on this machine, soonest first lop wake status # is the supervisor installed, what fires next, what is owed lop wake create "in 2h" "re-run the deploy smoke tests" --every 1d --until "in 7d" ``` Caps keep wakes sane: a 60-second minimum recurrence, up to 16 schedules per session, and a 2,000-character message. The wake panel in the TUI lists a session's schedules — a wake fires with no keystroke, so the panel exists to make a session's autonomy visible before it fires. ## Running with every interface closed Close the terminal. Close the desktop app. Wakes still fire, because of the **wake supervisor**: a small always-on process that reads the schedule index and starts a runtime when a session's wake comes due. It is installed on demand — arming a wake sets it up; if you never schedule one, it never runs. On macOS it is a launchd agent (`com.local-operator.wakes`), on Linux a systemd user service, on Windows a Task Scheduler task. The honest details: - **Missed occurrences are skipped, not replayed.** A laptop asleep for six hours owes one hourly check, not six; the late delivery says how many occurrences were skipped. - **A fire that cannot be delivered is owed, not lost.** It is recorded and retried with backoff, and `lop wake status` (and the TUI's wake panel) shows what is owed. - **Unattended turns respect approvals.** If a wake fires while you are away and a tool needs approval, the turn waits at the prompt until you answer — from the phone or `/resume`. Turning on `/approvals auto` (or `--yolo`) is how you opt into unattended execution. ## Notifications when you are elsewhere When a background completion lands and no surface is focused, it is announced — an OS notification from the desktop app, or the terminal's own notifier (desktop notification, cmux, libnotify). A privacy setting gates whether the notification carries the session name or any content. Either way, the result waits in the session, unread, until you come back. ## Aida, the built-in chief of staff Aida ships with Local Operator — there is no setup step. She is **one long-lived conversation** you reach with `/aida`, and her job is to orchestrate rather than do the work herself: hand her a request and she delegates to teams and specialist agents, tracks the work, and reports back. She is an ordinary session in every other way — `/resume` lists her, the sidebar shows her, and every runtime feature (tools, teams, projects, subagents, wakes) works normally. {% figure src="/shots/tui/resume-picker.png" alt="The resume picker with Aida pinned first, above two ordinary sessions." caption="Aida is pinned first in the resume picker — she is one conversation, kept at the top." /%} {% diagram name="AidaCadence" caption="One check-in a day, plus a bounded allowance of extras" /%} Once a day she wakes herself and reviews what is in flight — sessions, projects, scheduled wakes, usage signals — then reports **only what needs your action**. A quiet day gets exactly `(no action needed)`. The check-in time is yours to set: ```yaml aida: cadence: at: "09:00" # daily check-in, local time max_extra_per_day: 2 # escalation budget min_gap_minutes: 90 # spacing between her wakes ``` She can also ask for an extra check-in on a day that needs one, and that escalation is bounded by the budget above — she cannot wake you twelve times because a morning was busy. If the machine was off at her check-in time, she checks in late (on the next wake or runtime) and then re-arms for the next day. ### Asking, pausing, switching off | Command | What it does | | --- | --- | | `/aida` | Open her conversation (created on first use) | | `/aida ` | Open her and send your message as the next turn | | `/aida status` | One line: state, next check-in, today's budget | | `/aida pause` | Stop proactive check-ins; ordinary conversation still works | | `/aida resume` | Re-arm the schedule | {% figure src="/shots/tui/aida-status.png" alt="The /aida status receipt: Aida: active — next check-in 09:00 — extra check-ins used today: 0 of 2." caption="/aida status: her state, the next check-in, and the day's escalation budget." /%} Pausing is the everyday control — the receipt below is what you get back, and `/aida resume` re-arms the cadence. {% figure src="/shots/tui/aida-paused.png" alt="The /aida pause receipt: Aida: paused — no proactive check-ins. /aida resume re-arms them." caption="/aida pause: no proactive check-ins until you resume." /%} To turn Aida off entirely, either switch works — and it is a zero-footprint switch: no session is created, no state file is written, no wake is armed: - `aida.enabled: false` in configuration, or - the environment variable `LOCAL_OPERATOR_NO_AIDA=1` ### The switches, in one place All of Aida's settings live in `/settings` → **Aida** (or `lop config`): | Setting | Default | What it does | | --- | --- | --- | | `aida.enabled` | `true` | Master switch; `false` disables her completely | | `aida.cadence.at` | `"09:00"` | Daily check-in time (local) | | `aida.cadence.paused` | `false` | Written by `/aida pause` and `/aida resume` | | `aida.cadence.max_extra_per_day` | `2` | Escalation budget; `0` means only the daily check-in | | `aida.cadence.min_gap_minutes` | `90` | Minimum spacing between her wakes | {% cards %} {% card title="Workflow automations" href="/capabilities/workflow-automations" icon="calendar-clock" %} Wakes, background jobs, and `lop exec --background` — the scheduling primitives; agents spawn parallel runs with the `sessions` tool. {% /card %} {% card title="Agent Mesh" href="/capabilities/agent-mesh" icon="network" %} Wake an always-on machine at home — or move the session onto it. {% /card %} {% /cards %} ### Agent Mesh Source: https://docs.local-operator.com/capabilities/agent-mesh # Agent Mesh A mesh pairs your devices into one network, so a session on your laptop can see — and hand work to — sessions on another machine: the always-on box at home, the workstation at the office, a teammate's machine with the right access. {% figure src="/shots/tui/mesh-empty.png" alt="The mesh panel on a fresh device: no identity yet, no networks, no peers, relay not running, and the next command named wherever there is one." caption="A fresh device: the mesh panel names the next command wherever there is one." /%} ## Pairing One device creates the network and mints a single-use invite; the other joins with it. Both steps end at the same place: a human on each screen comparing what they see before anything is admitted. On the first device: ```bash lop network init ``` It creates this device's identity and member row and starts the relay under a user service supervisor — launchd on macOS, `systemd --user` on Linux. `--no-start` leaves the relay stopped; a host with no user supervisor runs `lop network serve` in the foreground. By default it binds `0.0.0.0:4097`; a device that should only dial out passes `--listen-address 127.0.0.1` and is reachable only where it connects. ```bash lop network invite --role read --expires 30m ``` `--role` is `read`, `drive` or `admin`; `--expires` is how long the token stays open; `--device ` binds it to one device id so only that device may redeem it. The token is written to a 0600 file and the command prints only the path — never paste the token itself anywhere. On the second device: ```bash lop network join @ ``` Joining shows six digits. Compare them with the code the inviting side shows, and confirm only if they match. `--verify` compares the 160-bit fingerprint instead. Three bad tries burn the invite. When nobody is at the keyboard, `--park` starts the pairing and waits: the inviting side answers the queue with `lop network confirm `, the joining side finishes with `join --confirm `. No agent path can complete the comparison — that is the interlock, not a gap. {% diagram name="MeshPairingFlow" caption="A token, a code, and a person comparing the two" /%} Before admission, a credential share list names what the new device will be given: each credential reads `will be served` or `not offered`. The owner may only remove rows from that list, and the joined receipt names the final set. The list appears only when both builds are new enough; an older build on either side shows no list at all, and an absent list does not mean there is nothing to share. After pairing, `lop network credential share --with ` lends one credential to one device, and `lop network credential revoke --from ` takes it back. {% figure src="/shots/tui/mesh-panel.png" alt="The mesh panel after pairing: this device, two networks with roles and member counts, peers with reachability, and a running relay." caption="Paired: networks with their roles, peer reachability, and relay health in one panel." /%} ## Roles and grants A role decides what a member may do on your device: - `read` sees: it can list and view sessions. - `drive` adds the controls: list, view, prompt, steer, stop and slash. - `admin` holds the full vocabulary, including membership changes. Beyond the role, `lop network member grant` adds capabilities to a member one at a time — for example `move`, `delete`, `unattended` or `broker_credential`: ```bash lop network member grant move lop network member grant delete lop network member grant unattended ``` `lop network member revoke ` takes a grant back. `unattended` is what lets a peer start sessions on this device without approval prompts. ## Onboarding another machine Approval records are how one machine gets set up to reach another: ```bash lop network approvals list lop network approvals show lop network approvals request --host --network lop network approvals approve lop network approvals deny lop network approvals withdraw lop network approvals run ``` Since 0.84.6 the signed **network-only** enrollment is the narrow path: it joins the mesh and provisions no logins, tools or account access. ```bash lop network approvals request --network-only \ --host --network --credential-ref lop network approvals approve-network-enrollment \ --request-digest lop network approvals run ``` A desktop client that advertises `mesh_onboard: 1` exposes the same restricted path. Readiness is a separate question from membership: ```bash lop network ready --peer --json ``` Do not offload work on a `FAIL`. A `warn` marks equipment that is named but does not hold the verdict. ## Across devices - **Sessions across devices** — `lop network sessions --peer ` lists what that device is running; `--all-peers` merges every peer's list. `--create` starts one there, `--send` delivers a turn, `--steer` injects into a running turn, `--peek` watches, `--stop` ends it. - **Move a conversation** — `lop sessions move --to ` hands it over, and `--to local` brings it home. The session keeps its id and its transcript; the copy on the device it left is deleted unless `--keep` is given, which leaves the original running and mints a new id for the copy. `--wait` blocks for the receipt. A wake travels with the move; monitor state does not. A turn in flight refuses the move rather than being interrupted. - **From the TUI** — `/new remote ` starts a session on another device, and `/move --to ` moves the current one. {% figure src="/shots/tui/mesh-new-remote.png" alt="The /new remote picker listing paired devices with their network and role." caption="Starting a session on another device: the picker lists the peers you can reach." /%} {% figure src="/shots/tui/sidebar-peers.png" alt="The session sidebar with a remote device's sessions grouped under a heading naming that device." caption="Remote sessions in the sidebar, grouped under the device that owns them." /%} ## Trust and incidents - **Removing a member** — `lop network member rm ` revokes it and rotates the shared secret. A removed member that wants back in needs `lop network identity rotate` first. - **Leaving** — `lop network disconnect` stops trusting the network, closes links and deletes the local secret; the audit trail stays. - **An incident** — `lop network panic` broadcasts a revoke, rotates the secret, bumps the epoch and marks the network untrusted here. Every other device must be re-admitted before it can connect again. - **Re-admitting** — `lop network trust --active` marks the network active again on this device. - **Diagnosis** — `lop network doctor`, `lop network log` and `lop network status` answer reachability, epoch and install-state questions, and `lop network peers` shows what is reachable right now. ### Agent Hub Source: https://docs.local-operator.com/capabilities/agent-hub # Agent Hub The Agent Hub is Radient's public registry of community agents — a shelf of taught agents you can pull down and add to your own setup. Browsing and pulling are public; publishing takes a Radient account. ## Browse the hub In the desktop app, the **Agent hub** page is the browse surface: "Discover and download community agents on Radient". Categories on the left, search and sorting on top, and cards that carry each agent's description, tags, likes, favourites and download count. The **Get** button pulls the agent without leaving the page. {% figure src="/shots/ui/agent-hub-light.webp" alt="The Agent hub page in the light theme: category rail on the left, agent cards with descriptions, tags, likes and Get buttons." caption="The Agent hub: categories on the left, community agents with Get buttons." /%} {% figure src="/shots/ui/agent-hub-dark.webp" alt="The same Agent hub page rendered in the dark theme." caption="The same page in the dark palette." /%} The registry is also a public API — `GET https://api.radienthq.com/v1/agents` — the same list the page renders. ## Pull an agent ```bash lop agents pull --id "" ``` No key needed: pulling is public. The agent lands in your Local Operator setup, ready to use in a session or to be added to a team. Pulled agents keep a link home: `lop agents sync` brings hub-pulled agents (and installed starters) up to their latest published text — `lop agents sync --name ` for one, `--force` to also replace a copy you edited locally. ## Publish an agent Publishing needs a [Radient account](https://console.radienthq.com/signup) (sign-up is free). One credential does it: ```bash lop credential update RADIENT_API_KEY # masked prompt lop agents push --name "My Agent" ``` Your agent is published as an instruction-set document, and moderation runs server-side before it appears. The same command updates or repoints an agent you already published, and refusals are specific — a taken name, a reserved name, or a moderation failure each says which. {% diagram name="HubPublishFlow" caption="Push through moderation, then pull anywhere with no key" /%} ## Share privately with a team Org sharing puts agents and teams in front of your teammates — and nobody else. Org content is **visibility: org**: visible to members of that organization, never listed on the public hub, and not fetchable by id from outside. - **Sign in first:** `lop login radient`. Org calls act as *you* (your signed-in account), not as a machine key. - **`--org` takes the organization's tenant id.** Not sure of it? Run the command once without `--org`: it prints your memberships — name, tenant id, role and plan — and returns, so no default org is ever guessed for you. The [Radient console](https://console.radienthq.com)'s Team page shows the same membership. - **Publish and pull with the org target:** ```bash lop agents push --name "My Agent" --org lop agents pull --id "" --org lop teams push --org lop teams pull --org ``` Pushes print the id to share — `Team ID: …` for a team, `Agent ID: …` for an agent; that id is what a teammate pulls with. - **Teammates pull the same way.** They sign in (`lop login radient`), need membership in the organization — an owner or admin invites them from the console's Team page — and then pull by id with `--org `. A pulled team arrives with its roster and both briefs, ready to run with `/team ` (see [Agents & teams](/build/agents-and-teams)). - Roles are `owner` / `admin` / `member`, and refusals are explicit — `not_a_member`, `insufficient_role`, and `team_plan_required` each name the next step (invite, ask an admin, activate the plan) instead of failing silently. The desktop app's Agent hub page carries the same org browsing, team pulls and org-targeted publishing when you would rather click than type. ## The Radient side: plan and billing Org sharing sits behind the Radient **Team plan** — sign up or activate it at [console.radienthq.com](https://console.radienthq.com), whose Team page manages the plan, members, invites and billing. Billing is handled in the console — nothing about the plan is billed through Local Operator itself. {% callout type="tip" %} You do not need a Radient account to use Local Operator, and you do not need one to pull agents from the hub. The account only comes in when you want to publish, or to share within a team. {% /callout %} {% cards %} {% card title="Desktop app" href="/surfaces/desktop" icon="monitor" %} The Agent hub page, projects, schedules and the rest of the window. {% /card %} {% card title="Agent Mesh" href="/capabilities/agent-mesh" icon="network" %} Sharing across your own machines — a different plane from the hub. {% /card %} {% /cards %} ### Code review Source: https://docs.local-operator.com/capabilities/code-review # Code review A coding session leaves a trail of pull requests and merge requests — ones it opens, ones it comments on or merges, ones it only reads about. **Code review** keeps that trail for each session and answers the question the transcript makes you scroll for: *which code requests did this conversation touch, and where is each one up to?* It has three parts: - **Tracking** — Local Operator notices the code requests a session touches and records how the session relates to each one. - **Status** — for GitHub and GitLab, it reads each request's state, checks and review comments, including the review rounds your team posts. - **Where you see it** — a **Code review** tab in the desktop app, and a read-only `code_requests` tool the agent itself can call. {% callout type="note" %} **Availability.** The Code review tab appears only when the runtime you are connected to reports the capability, so an older runtime shows the app exactly as it was. Status for a request needs a login on its forge (see [Supported forges](#supported-forges)); without one, the request still shows up as a link. {% /callout %} ## Opened, mentioned, or unknown Each code request is classified by how *this session* met it. The distinction matters because a session that opened a pull request owns it, and one that only saw it in a log does not. | Relation | What it means | | --- | --- | | **Opened** | This session's own tool call created it, proven from the tool result — a `gh pr create` or `glab mr create` that answered with the new request. A subagent's open counts, and the row says `via subagent `. | | **Mentioned** | The request appears in the conversation — you pasted it, the agent read it, a command listed it — but nothing in this session created it. | | **Unknown** | The evidence is ambiguous: a script ran, a pull request URL appeared in its output, and nothing proves the script created it. The row reads `unknown — possibly opened`. Local Operator does not guess either way. | Two more tags refine the picture without replacing it: - A request the session **acted on** without opening — it commented, reviewed, pushed, marked ready or merged — carries a `you commented`, `you merged` style tag, so a pull request opened elsewhere and merged here is not mistaken for a mention. - A **forked** session keeps the requests it inherited from its parent and marks them `inherited`; the fork did not open them. In the tab, **Opened** collects everything the session owns or may own — opened, unknown, acted-on and inherited rows — and **Mentioned** holds the rest. Requests that only ever appeared inside tool output, such as a long audit listing, are collapsed and not counted, so a session that scanned a hundred pull requests does not bury the three it worked on. ## How a review cycle reads If your team reviews with agents, the review rounds live in the pull request's comments. Code review reads that convention, so you see where a request is up to without opening it. It looks for headings of the form: ```markdown ### Agent review — round 2 Reviewer: Scope: .. ``` answered by `### Agent review remediation — round 2`. The same shape covers the other lanes: **Design review**, **QA report** and **UX review**, each with its own `… remediation` reply. The lanes are labelled **Code** (the agent review), **Design**, **QA** and **UX** in the tab. The verdict comes from a `Verdict:` line or a `Verdict` heading. A long review that closes with its verdict is read to the end, so it reports that verdict rather than "not stated"; quoted text (a blockquote or code fence) is never mistaken for the comment's own verdict. For each lane you get the **current round**, a **state**, and a **freshness**: | State | Reads as | | --- | --- | | Awaiting review | No review comment in this lane yet — `No agent review yet` | | Findings open | The latest round's verdict reports findings | | Remediation posted | A remediation reply followed the review: `Round 2 · remediation posted · awaiting re-review` | | Clean | The latest round's verdict is clean or approved | | Terminal | The latest verdict is clean *and* names itself terminal — the reviewer's final word | | Reviewed, verdict not stated | A round was posted but its verdict could not be classified — reported as unstated, never invented | Freshness compares the commit a review names against the request's current head. The review is **fresh** when the head starts with the reviewed SHA (at least seven hex characters), **stale** when it does not — the row then shows both, as `stale — reviewed a3ffd2b, head 9d29452` — and **unknown** when the comment names no commit. Unknown is shown as unknown, not guessed at. Two things the reader deliberately does not do. It never uses the comment's author to judge whether a review was independent — a shared forge account makes that meaningless — so the `Reviewer:` text is reported verbatim and nothing is inferred from it. And it does not judge whether a later push only carried unrelated fixes; whether a stale review still stands is your call. ## The Code review tab In the desktop app, **Code review** is a tab on the right-hand rail, listed beside the run, asks, browser, console and canvas tabs — last, so adding it moves none of the others. It is there whenever you are in a session, including when there is nothing to show yet — an empty tab says so — and a dot marks it when an opened request has findings open or failing checks. Each row shows: - the repository and number (`#123` on GitHub, `!45` on GitLab), with the relation tag where it needs one; - a state pill — **Open**, **Draft**, **Merged** or **Closed**; - the title; - one segmented strip per review lane, a segment per round, with the lane's state beneath; - the CI result (`23/23 passed`, `2 failing`, `CI pending`, `No checks yet`), the comment count and when it was last refreshed. Click a row to open the request in your system browser. The tab's refresh button re-reads the session's requests on demand. A **count chip** in the composer's status row, after the other count chips (subagents, jobs, wakes and monitors), shows how many code requests the session has (`3 code requests`). It appears once there is at least one visible request, its tooltip splits the count into opened and mentioned, and clicking it reveals the tab; it never closes it. When a read goes wrong the tab says so in place and keeps the last known data: a row that could not refresh reads `Couldn't refresh:` followed by the reason, and a host that is rate-limiting you shows `GitHub rate-limited until 14:05.` Nothing is lost; the session's own record is unchanged. ## Supported forges | Forge | Support | | --- | --- | | GitHub, including GitHub Enterprise | Full — state, comments, review rounds, CI | | GitLab, including self-hosted | Full — state, comments, review rounds, pipeline status | | Gitea, Forgejo, Codeberg, Bitbucket, Azure DevOps, Gerrit | **Detect and link** | **Detect and link** means Local Operator recognises the request, lists it, and opens it when you click — but fetches no state, so there is no pill, CI result or review progress. It is the honest floor: you still see that the session touched it. Hosts are identified from the URL's shape, the repository's git remotes and the hosts you have logged in to, never from the hostname alone, so an unrecognised self-hosted server stays a link rather than being guessed at. Status is fetched with **your own logins** — the GitHub CLI (`gh auth login`) and GitLab CLI (`glab auth login`) sessions already on the machine, or a `GITLAB_TOKEN` secret for GitLab. Tokens are held in memory and never displayed. If there is no usable login, or the forge rejects it, the row falls back to a link and names the remedy; it never turns into an error wall. Reads are cached and revalidated conditionally, so repeated looks are cheap, and a rate-limited host is left alone until its window reopens. The runtime runs no background poller: rows refresh when the session acts on a request, when the app window regains focus, and — only while the tab is visible and a turn is running or checks are pending — about once a minute. A row that could not refresh keeps its last known data, marked stale. ## For the agent: the `code_requests` tool The same data is available to the model as a read-only tool, `code_requests`, so it can answer questions about a session's pull requests from the tool's parsed view rather than re-deriving it from `gh` and `glab` output: - **`list`** — this session's rows with their relation, state and link. It reads only what is already known and never waits on the network. - **`show `** — one request in full: state, CI, each lane's round, state and freshness, and quoted excerpts of the review comments. `ref` is any request URL or a qualified reference such as `owner/repo#123` or `group/project!45`, including one the session never saw — such a lookup is answered but not added to the session's record. If the cache is cold, `show` waits a few seconds for one fetch. Comment text is remote and untrusted, so the tool quotes it inside a fence as data and never as instructions. The tool is read-only: comments, merges and pushes still go through `gh` and `glab`. Because it is observation-only, it can be watched. Ask the agent to *tell you when the review on this pull request goes clean*, and it can arm a `monitor(code_requests show )` rather than polling by hand. When a request URL first appears in a session, the agent is also given a one-line pointer to the tool. Agents learn when to use it from a packaged guide, `guide://code-requests`: use the tool when the question is about *this session's* work or about review rounds, and fall back to `gh` or `glab` for what it does not carry — a diff, a file, or an action. {% cards %} {% card title="Desktop app" href="/surfaces/desktop" icon="monitor" %} The right-hand rail, run details and the rest of the window the tab lives in. {% /card %} {% card title="Always-on & proactive agents" href="/capabilities/always-on" icon="clock" %} Wakes and monitors — the way to be told when a review moves. {% /card %} {% /cards %} ## Build & automate ### Supply teams, agents & system prompts Source: https://docs.local-operator.com/build/agents-and-teams # Supply teams, agents & system prompts A session can be composed from parts you write once and reuse: a **registered agent** — a durable profile, either a *role* (a reusable way of working) or a *specialist* (a named agent with its own instructions) — and a **team** — a roster of those agents under one manager, plus briefs that do not belong to any one member. Supply them here; every surface then takes them by name: `/agent` and `/team` in the session, `--team` / `--profile` / `--agent` on `lop exec`, and `team=` / `profile=` on the Python SDK's `SessionSpec`. {% diagram name="SupplyFlow" caption="Declare once, share through the hub, run with a team, profile or agent" /%} ## Register an agent ```bash lop agents create research ``` ```console ╭─ Created New Agent ─────────────────────────── │ Name: research │ ID: c1c5d72b-c3a9-49d1-a876-d06dd8b74f8e │ Created: 2026-09-28 23:53:20.643935+00:00 │ Version: 0.64.1 ╰────────────────────────────────────────────────── ``` The registry lives under your configuration root as one directory per agent — `agents//agent.yml` for the row, with the conversation and state files beside it, and `system_prompt.md` once the profile has instructions. The row carries the hosted model and sampling settings (when set), description, tags and categories. `lop agents list` reads it back: ```console ╭─ Agents ──────────────────────────────────── │ └── Agent 1 │ • Name: research │ • ID: c1c5d72b-c3a9-49d1-a876-d06dd8b74f8e │ • Created: 2026-09-28 23:53:20.643935+00:00 │ • Version: 0.64.1 │ • Hosting: default │ • Model: default │ │ Page 1 of 1 (Total agents: 1) ╰────────────────────────────────────────────── ``` **Starters.** Packaged seed profiles — `aida`, `architect`, `coder`, `copy-reviewer`, `designer`, `manager`, `reviewer`, `scout`, `tui-designer`, `ux-reviewer` — are **resolved by name without being installed**: launching a role that has no row in your registry runs the packaged text directly, and the registry stays empty. *Installing* is the separate, deliberate act — the `agent` tool's `install` op copies the seed into your registry as a row you own and edit (`reset` restores the packaged text over an edited install). `lop agents sync` then keeps installed starters and hub-pulled agents up to their latest text: ```bash lop agents sync # every installed seed and hub pull lop agents sync --name reviewer # one profile lop agents sync --force # also replace copies edited after install ``` With nothing installed yet, `sync` says so — `nothing to sync: no installed seed or hub-pulled profiles.` — rather than inventing work. ## Write the instructions — the system-prompt path An agent's instructions are its **base**: the reusable half that is true wherever it works; a team's briefs layer on top of it. Three layers, read outermost last: - **Base — `agents//system_prompt.md`.** The role itself: how this kind of work is done well. Reusable across every team. The `agent` tool's `create` / `update` write it, and `/agent ` adopts it for the current session. - **Collaboration — `teams//instructions.md`.** How this group works together: review order, sign-offs, who blocks a release. - **Project — `teams//project.md`.** The product or domain this instance of the team owns. Swap it to reuse the same roster elsewhere. {% callout type="note" %} Older agent rows also carry a `security_prompt` field — the legacy per-agent security context for the runtime's code-security checks. It is not the profile instructions: new profiles are described by `system_prompt.md`, and `security_prompt` stays a free-text row field for installs that want to state their security context explicitly. {% /callout %} ## Declare a team ```bash lop teams create feature-release --manager manager --member coder --member reviewer:2 ``` ```console ╭─ Created New Team ─────────────────────────── │ Name: feature-release │ Manager: manager │ Members: 3 ╰────────────────────────────────────────────────── ``` `--member` is repeatable and takes `role` or `role:count`. A team is stored as `teams//team.yml` beside its two briefs — `instructions.md` and `project.md`, empty until you write them. `lop teams show ` prints the roster: ```console ╭─ Team feature-release ─────────────────────────── │ Manager: manager │ Roster: │ - manager: manager (you, when this team is invoked) │ - coder │ - reviewer x2 ╰────────────────────────────────────────────────── ``` A member slot can also be another team — prefix it with `team:` — which turns a roster into an org: `--member team:pod` nests the team `pod`, `--member team:pod:2` adds two more copies, and `lop teams show` badges nested slots `(team)`: ```bash lop teams create pod --member coder lop teams create eng-org --manager manager --member team:pod --member team:pod:2 ``` In a session, `/team` lists teams, `/team ` attaches one — the current agent becomes its manager — and sends the request as a real turn, and `/team chart [name]` draws the org chart. ## Run with a specific team, profile or agent | Command | Attaches | | --- | --- | | `lop exec "…" --team ` | a saved team — manager, roster and briefs | | `lop exec "…" --profile ` | a reusable role or specialist (the `/agent` counterpart) | | `lop exec "…" --agent ` | a legacy named agent; creates it if missing | | `lop exec "…" --agent-id ` | one exact registered agent, by id | | `SessionSpec(team=…, profile=…, agent_name=…, agent_id=…)` | the same four, from Python | ```bash lop exec 'Review the change' --team release --background printf 'Inspect this report' | lop exec --profile reviewer ``` ```python spec = SessionSpec( hosting="openrouter", model="…", team="release", # or profile="reviewer" name="episode task_001", ) ``` The SDK resolves `team` and `profile` against the session's declared roots — through the same helper `lop exec` uses — and attaches them to the session post-open, so a script composes exactly what the CLI names. On `spawn_session`, attachments belong to the owner's side: open in-process, attach what you need, dispose, then spawn the same id — resume restores them. The [Python SDK](/build/python-sdk) page has the full entry-point table. ## Typed contracts For embedders, the supply objects are typed — all under the `local_operator` package: | Object | Carries | | --- | --- | | `AgentData` — `agents.py`, a pydantic model | `id`, `name`, `version`, `security_prompt`, `hosting`, `model`, `description`, `tags`, `categories`, sampling settings | | `AgentProfile` — `agent_profiles.py`, a frozen dataclass | `name`, `description`, `when_to_use`, `instructions`, `tools`, `effort`, `may_delegate` | | `SessionSpec` / `SessionRoots` / `ApprovalPolicy` — `sdk` | the run's composition — model pair, `team`, `profile`, `agent_name`, `agent_id`, `tools`, approvals — over explicit roots | | `TeamRegistry` — `teams.py` | create, read and update of team rows and their briefs | The SDK's full surface — entry points, approval presets, isolation rules — is documented in the repository: [`docs/SDK.md`](https://github.com/damianvtran/local-operator/blob/main/docs/SDK.md). {% cards %} {% card title="Exec mode" href="/build/exec-mode" icon="terminal" %} Run a supplied team or profile headless — flags, JSON streams and background jobs. {% /card %} {% card title="Python SDK" href="/build/python-sdk" icon="code" %} The same composition, typed: SessionSpec, explicit roots, approval policies. {% /card %} {% card title="Agent Hub" href="/capabilities/agent-hub" icon="globe" %} Share agents and teams publicly, or with your organization. {% /card %} {% /cards %} ### Exec mode Source: https://docs.local-operator.com/build/exec-mode # Exec mode `lop exec` runs an ordinary conversation without opening a terminal UI. Use it for scripts, CI jobs, and tasks you start now and check later. The session is persisted like any other conversation — `lop --resume` reopens it — and the command is built to compose: final text on stdout, receipts on stderr, and an exit code that means what it says. ```bash lop exec "summarize the failures in ./test.log" ``` {% callout type="note" %} **From inside a session, reach for the `sessions` tool first.** A run an agent opens for parallel work is spawned with `op=spawn` — a listed workstream by default, so it shows in your sidebar, `/resume` and phone list. `lop exec` remains the human CLI, the script interface, and the fallback where the tool is absent. {% /callout %} ## What each shape is for - **One-shot tasks** — a prompt in, a result out: *summarize this*, *review this change*, *screen these names*. - **Scripts and CI** — `--json` emits one JSON line per event for anything to process; the exit code tells the surrounding job whether to pass. - **Long runs** — `--background` detaches a worker with its own log, and `--status` reports the durable outcome at any time afterwards. - **Goal loops** — `--goal` sets a standing objective; `--loop N` runs N continuations and `--loop-goal TEXT` keeps going until the goal is judged achieved. - **Hand-offs** — a run started this way is a full session: attach to it from any surface, or deliver further work to it (the [Python SDK](/build/python-sdk) is the typed way in). {% diagram name="ExecPipeline" caption="One command in, one headless run out — stdout, stderr, exit code" /%} ## Flags The full set from `lop exec --help` (common ones first): | Flag | What it does | | --- | --- | | `command` | Literal prompt. `-`, or omitted with piped stdin, reads stdin. Optional when you pass `--loop` or `--loop-goal`. Slash-looking text stays literal. | | `--resume [SESSION_ID]` | Resume a previous session by id; with no id, the most recent. | | `--hosting HOSTING` | Provider for this run, e.g. `anthropic`, `openai`, `openrouter`. | | `--model MODEL` | Model for this run; when omitted, the provider's suggested model is used. | | `--run-in DIRECTORY` | Working directory to run in. | | `--team NAME` | Attach a saved team with its manager, roster and briefs. | | `--profile NAME` | Attach a reusable role or specialist (the `/agent` counterpart). | | `--tools NAMES` | Comma-separated tools this run may reach, and the only ones. Excluded tools are unreachable by name, not merely unapproved. | | `--effort LEVEL` | Reasoning effort, validated against the selected model. | | `--output-format FORMAT` | Enforce the format of the assistant's FINAL response for this run: `markdown`, `json`, `yaml` or `toml`. Bounded by contract: formats, optional schemas and a retry budget only — enforcement is validation plus bounded retry, never a re-encode, and the payload on stdout is the model's own validated bytes. Without this flag no enforcement happens and the run is byte-identical to before the flag existed. See [Final-response enforcement](#final-response-enforcement). | | `--output-schema PATH` | A JSON file the decoded payload must satisfy: a JSON Schema for `json`/`yaml`/`toml`, or `{"required_sections": ["
", ...]}` for `markdown`. Read and validated before the run starts (a bad file fails as `exec failed: …`, exit 1, with no session directory behind it). Requires `--output-format`. | | `--output-retries N` | Max retries after a rejected final response (0–5; default 2, so three attempts). Not persisted — each `--resume` invocation carries its own flags. Requires `--output-format`. | | `--name TEXT` | Set the persisted conversation name. | | `--goal TEXT` | Set a literal standing goal. Unlike the TUI's `/goal`, this does not also send the text as a message — pair it with a prompt or a loop. | | `--clear-goal` | Clear the resumed standing goal. | | `--loop N` | Run N continuation iterations after the optional prompt; needs a standing goal. | | `--loop-goal TEXT` | Continue and judge this goal until achieved; no fixed iteration count. | | `--background` | Detach the task: spawn a background worker with a log file and exit immediately. | | `--status JOB_ID` | Read a durable background-job status as JSON. Starts nothing. | | `--json` | Emit one JSON line per agent event instead of the final text. | | `--control` | Route approvals and questions to an attached supervisor, which may wait. Without it, non-TTY approvals deny. | | `--supervisor-fd FD` | Hand this run's operator capability to an inherited descriptor so a supervisor can approve the cards this run parks. Requires `--control`; refused with `--background`. | | `--yolo` | Auto-approve all tool executions without prompting. An explicit override — never implied by teams, loops or background runs. | | `--agent`, `--agent-name NAME` | Select a legacy named agent (creates it if missing). | | `--agent-id ID` | Select an exact existing legacy agent; alternative to `--agent`. | | `--train` | Use the legacy agent history directory instead of a separate session. `--resume` wins over it. | | `--workstream` | Publish this run as a long-lived workstream: listed in the sidebar, `/resume` and the phone list, followable and steerable. Without it, a run opened by an agent stays ephemeral — hidden everywhere and silent. Visibility only; it changes no approval handling. | | `--debug` | Verbose launcher output. | | `-h`, `--help` | Show every exec flag and the examples without running anything. | This is a bounded startup interface, not an alias for the TUI's slash commands: UI commands and configuration-mutating commands are not startup flags. ## Teams, profiles and registered agents `--team`, `--profile`, `--agent` and `--agent-id` compose a run from parts supplied on the [Agents & teams](/build/agents-and-teams) page: ```bash lop exec 'Review the change' --team release --background printf 'Inspect this report' | lop exec --profile reviewer lop exec "Draft the migration notes" --agent-id 3d0b0f0e-6a5c-4c96-9a5b-1a2b3c4d5e6f ``` - `--team ` attaches a saved team — its manager, roster and briefs — and the run starts as that team's manager. - `--profile ` attaches a reusable role or specialist (the `/agent` counterpart). `--agent` / `--agent-id` select a registered agent instead — by name (created if missing) or by exact id. - Attachments are resolved against your configuration root before the run starts, through the same helper the [Python SDK](/build/python-sdk) uses. ## The prompt, and where output goes The prompt is the first positional argument. Piped stdin works too — with `-`, or with no argument at all: ```bash lop exec "review the change" # literal prompt lop exec - < review-notes.md # read the prompt from stdin printf 'Inspect this report' | lop exec --profile reviewer ``` Foreground runs write the final text to stdout and receipts (session id, log path, warnings) to stderr, so `lop exec ... > result.txt` gives you the answer alone. A run that reaches its runtime also prints its session id on stderr: ```console lop exec session: session_id=d5a8e194bb0c pid=12705 port=61672 record=... ``` That id is the conversation. `lop --resume d5a8e194bb0c` opens it in the TUI later; a live run attaches instead of replaying. ## Goals and loops `--goal` sets the objective but sends nothing: ```bash lop exec 'Finish the checklist' --goal 'Finish the checklist' # goal + message in one lop exec --goal 'Finish the checklist' --loop 3 --name 'Night audit' lop exec --resume SESSION_ID --loop-goal 'Verify every acceptance criterion' ``` `--loop N` counts continuations after the optional initial prompt. `--loop-goal` has no fixed count: each iteration is judged against the goal and the loop stops when it is achieved. Saved loop progress stays visible on resume, but iterations never replay automatically — pass the flag again to start another loop. A goal with no prompt and no loop is refused rather than silently starting a turn. ## Approvals in a headless run A non-TTY run cannot ask for approval, so any gated call is denied unless you say otherwise. The runtime tells you this at launch: ```console Warning: this run cannot ask for approval (no terminal attached), so any tool call that needs approval will be denied. Remedies: --control parks cards for a supervisor; --yolo auto-approves every tier; --tools NAME[,NAME] pre-approves the listed tools and bounds this run's reach to them. ``` - `--tools read,grep,glob` bounds the run's reach and — where nobody can be asked — the declaration stands as approval for the listed tools. A tool outside the list is unreachable, and delegated children inherit the bound. - `--control` parks approval cards for a supervisor instead of denying them. - `--yolo` approves every tier. Reach for it only when you mean it; teams, loops and `--background` never imply it. On a terminal, and under `--control`, the per-call prompt remains: naming a tool says which tools the run may reach, never that each command it runs was agreed to in advance. ## Background runs and status ```bash lop exec "long migration" --background ``` The launcher exits after a bounded readiness check and prints a receipt: ```console Background job a9bb51b5eea9: running (execution receipt) Session: bf8ab4473ed3 (the conversation) — attach or resume: lop --resume bf8ab4473ed3 Status: lop exec --status a9bb51b5eea9 Log: ~/.local-operator/logs/exec-20260927-224717-say-hello-a9bb51b5eea9.log ``` The launcher's exit code is **not** the execution result — it only means the worker detached. Ask for the outcome with `--status`: ```console $ lop exec --status a9bb51b5eea9 {"id": "a9bb51b5eea9", "started_at": "...", "prompt": "say hello", "log": ".../logs/exec-...-a9bb51b5eea9.log", "pid": 13118, "process_generation": "...", "status": "succeeded", "requested_team": null, "finished_at": "...", "exit_code": 0, "session_id": "bf8ab4473ed3", "team": "", "session_directory": "~/.local-operator/sessions/bf8ab4473ed3", "runtime_path": ".../run/mobile/13118.json"} ``` `status` moves through `starting`, `running`, `succeeded`, `failed`, `cancelled` and `interrupted`. While a run is parked on an approval it stays `running` and the record adds `"pending": "approval"` — `lop sessions` shows the same thing in its `NEEDS` column. The job id names the execution receipt; the session id names the conversation. They are different things, and both are in the receipt. ## JSON output `--json` turns stdout into one JSON object per agent event, each line stamped with the session id: ```json {"type": "agent_start", "generation": 1, "session_id": "d5a8e194bb0c"} {"type": "message_update", "message_id": "a7c67724d3e14...", "delta": "Hello", "session_id": "d5a8e194bb0c"} ``` The turn closes with `message_end`, `turn_end` and a final `agent_end`; a tool-heavy run streams `tool.start` / `tool.delta` / `tool.end` around the message deltas. This is the same projection the [Python SDK](/build/python-sdk) hands to line-oriented consumers — one serialization, two transports. For a single value out of the stream: ```bash lop exec "audit deps" --json > events.jsonl jq -r 'select(.type == "agent_end") | .messages[-1].content[0].text' events.jsonl ``` ## Final-response enforcement `lop exec` can require the assistant's FINAL response to be a machine-readable payload and fail the run (exit 1) when it is not. The check runs at the one point a turn ends cleanly: a rejected response becomes one more model step **inside the same turn** (the retry notice is an ordinary, persisted user message, so a resumed transcript explains the continuation), and a run that exhausts its retries fails loudly rather than printing something wrong. ```bash lop exec 'Extract the invoice as JSON' --output-format json lop exec 'Reply with YAML' --output-format yaml --output-retries 3 lop exec 'Draft release notes' --output-format markdown --output-schema sections.json lop exec 'Parse the report' --output-format json \ --output-schema report.schema.json --background ``` **What is enforced, per format.** Tolerance is about *locating* the payload; content is strict. | format | decoder | strict about | tolerated / noted | | --- | --- | --- | --- | | `json` | `json.loads` | full strict JSON: no trailing commas, no single quotes, `NaN`/`Infinity` rejected | any JSON value (object/array/scalar) when the whole text or a fence is exact; a prose scan finds a bare `{`/`[`-anchored span | | `yaml` | PyYAML safe loader | one document only; the decoded value must be a mapping or sequence (a bare scalar is prose, not a payload) | PyYAML semantics: JSON is a subset, `yes`/`on` are booleans, duplicate keys last-wins, dates decode to dates | | `toml` | `tomllib` | TOML 1.0 as stdlib enforces it: duplicate keys rejected | trailing commas in arrays; the root is always a table, so the payload is always a mapping | | `markdown` | none — the text IS the payload | non-empty; balanced fenced code blocks | everything else; markdown has no formal well-formedness | **Where the payload is found (in this order, first decode that also satisfies the schema wins):** a fence labelled with the target format; an unlabelled/unknown-label fence; the whole text; and for `json` only, a bare `{`- or `[`-anchored span in prose. A fence labelled with a *different* recognised format is skipped — the label is an explicit claim, and the retry is the remedy. **Schemas.** For `json`/`yaml`/`toml`, `--output-schema` names a JSON Schema file (Draft 2020-12; meta-schema checked before the run). For `markdown` it is the one vocabulary markdown has: `{"required_sections": ["Summary", "Risks"]}` — each named section must appear as an ATX heading, case-insensitively, in the given order. **Failure semantics.** A rejected attempt logs one dim stderr line (`! output check failed (, attempt /): `) and, when the budget is spent, the run ends with exit 1 and the two stderr lines `Error: final response did not satisfy the output contract () after N attempts: ` and `exec failed: the turn did not complete` (the existing headless failure line, whose text comes from `exec_session`'s fallback unless a prior prompt failure recorded a more specific reason). **stdout carries nothing** for an exhausted turn — a wrong payload on stdout is worse than an empty one for `… | jq` consumers. On success, stdout is the VALIDATED payload span (the winning candidate, stripped): `lop exec --output-format json | jq .` works even when the payload arrived inside a fence or surrounded by prose. **Interactions.** - `--json` gains one `output_validation` event per checked attempt (`ok`, `attempt`, `max_attempts`, `payload_text` on success); nothing else on the event stream changes, and an unenforced run emits none. - `--loop` / `--loop-goal` enforce **every** turn; a turn that exhausts its retries fails the run and stops the loop. - `--background` is the same request run elsewhere: the schema path is made absolute before the spawn, the worker re-validates the file, and a failed run reaches the ledger as `failed` (`lop exec --status JOB_ID`), with both stderr lines in the job log. - `--resume` never persists enforcement: the flags of THIS invocation apply, and a later resume without them is unrestricted (like `--tools`). - `--output-retries` counts retries **per turn** (default 2, so three attempts per turn); the range is 0–5. The same contract is available programmatically in the [Python SDK](/build/python-sdk) — `SessionSpec.output_format`, with optional `output_schema` and `output_retries`. The local-operator repo's [`docs/EXEC.md`](https://github.com/damianvtran/local-operator/blob/main/docs/EXEC.md) is the exec reference. ## Exit codes - A **foreground** run exits `0` on success and nonzero on failure or cancellation. A request the model refuses fails the run: ```console Error: model refused: I can't help with that request. (stop_reason=mock_refusal) exec failed: the turn did not complete ``` - A **background launcher** exits after readiness; follow the job's `status` and `exit_code` fields for the real outcome. A worker that dies abruptly is reconciled as `interrupted`, never reported as success. ## Use it in CI A CI step is a non-TTY run, so the same rules apply: bound the reach, expect failures as exit codes, and read the machine-readable stream. ```yaml - name: Nightly audit run: | lop exec --goal "Finish the nightly audit" --loop 3 \ --tools read,grep,glob \ --name "nightly audit" \ --json > audit.jsonl ``` - Keep the prompt's reach inside `--tools`; anything outside it is unreachable, so a run cannot surprise you with a write tier you did not declare. - The step fails when the run fails. If you would rather inspect the artifact than fail the job, keep the command and check `agent_end` in `audit.jsonl` yourself. - For long jobs, run in the background and poll `lop exec --status` — a CI timeout then leaves a resumable conversation behind. For the wider automations picture — schedules, background jobs and where final-response enforcement fits in a pipeline — see [Workflow automations](/capabilities/workflow-automations). {% cards %} {% card title="Python SDK" href="/build/python-sdk" icon="code" %} The typed surface for scripts: open or spawn sessions, deliver prompts, stream events. {% /card %} {% card title="SSE streaming" href="/build/sse-streaming" icon="radio" %} How a runtime is embedded behind an event stream, and Local Operator's own endpoints. {% /card %} {% /cards %} ### Python SDK Source: https://docs.local-operator.com/build/python-sdk # Python SDK `local_operator.sdk` is the programmatic way to run sessions. It is not a wrapper around the CLI: your script calls the same objects every front end calls — the same construction path `lop exec` uses, the same engagement router the phone and mesh use, the same session object the TUI drives. Caching, compaction, failover, delegation, skills, guides and MCP arrive exactly as they do for any other surface. There is nothing extra to install: the SDK ships inside the `local-operator` package you already have. ```bash pip install local-operator # or pipx / uv tool, as in the install guide ``` ```python from local_operator.sdk import ( ApprovalPolicy, SessionRoots, SessionSpec, events, open_session, ) ``` {% diagram name="SdkLifecycle" caption="Spec, roots and approvals; open, subscribe, prompt, close" /%} ## Quickstart: a session in your own process `open_session` builds the session inside your process — the same shape as a foreground `lop exec` run: if your program dies, the turn dies with it. ```python import asyncio from local_operator.sdk import ApprovalPolicy, SessionRoots, SessionSpec, events, open_session async def main() -> None: roots = SessionRoots( # REQUIRED: explicit roots, no ambient default config_dir="/srv/audit/config", # the private store: transcripts, credentials, secrets agent_home="/srv/audit/home", # the workspace home the agent reads and writes cwd="/srv/audit/checkout", # where the session runs ) spec = SessionSpec( hosting="deepseek", model="deepseek-flash", approvals=ApprovalPolicy.declared(["read", "grep", "glob"]), name="nightly audit", ) async with open_session(spec, roots=roots) as session: stream = events(session) # subscribe FIRST — events are not replayed await session.prompt("Summarize the repository") async for event in stream: print(event.type) if event.type == "agent_end": # this prompt's terminal event break # the stream itself never ends — end it explicitly await stream.aclose() asyncio.run(main()) ``` Exiting the `async with` disposes the session. Subscribe before prompting: like every runtime stream here, the event feed is live, not a replay log — the transcript is the replay log. ## Entry points | Entry point | What it does | | --- | --- | | `open_session(spec, roots=…, mode="own"\|"attach")` | `own`: builds and runs the session in this process. `attach`: opens a viewer for an id whose runtime is **already live**; a cold id is refused with a remedy. | | `spawn_session(spec, roots=…, errand=…)` | Starts a runtime that survives your process, then delivers the errand. Mints the new session id; a resumed id skips the warm step. | | `deliver(session_id, roots=…, errand=…)` | One engagement for an existing session — live or cold — without a spec. Accepts a concrete id or `@latest`. | | `events(session)` | Subscribe and get a `SessionEventStream` — an async iterator over the session's events; `aclose()` (or `async with`) unsubscribes. | Errands are the runtime's typed vocabulary: `PromptErrand` (a user turn), `SteerErrand`, `PeerMessageErrand`, `WakeErrand`, `WarmErrand`. `spawn_session` and `deliver` return an `EngageOutcome` — `session_id`, `detail`, `spawned`, `duplicate`. ## Worked example: spawn, deliver, stream, collect A long-lived runtime fed by several engagements, watched by a viewer, with the final message collected from the event stream. ```python import asyncio from local_operator.sdk import ( PromptErrand, SessionRoots, SessionSpec, deliver, events, open_session, spawn_session, ) async def main() -> None: roots = SessionRoots( config_dir="/srv/audit/config", agent_home="/srv/audit/home", cwd="/srv/audit/checkout", ) # 1. Spawn: a runtime that outlives this process, with the first prompt. # A spawned child is composed from its environment, so the spec carries # its model pair and nothing else — see "What the surface does not carry". outcome = await spawn_session( SessionSpec(hosting="deepseek", model="deepseek-flash"), roots=roots, errand=PromptErrand(text="Summarize the repository"), ) print(f"session {outcome.session_id}: {outcome.detail}") # 2. Deliver: further work to the same conversation, from anywhere. await deliver( outcome.session_id, roots=roots, errand=PromptErrand(text="Now list what is untested"), ) # 3. Attach as a viewer and follow the next turn live. # An attach spec carries resume only — everything else is the owner's side. async with open_session( SessionSpec(resume=outcome.session_id), roots=roots, mode="attach" ) as viewer: stream = events(viewer) await viewer.prompt_and_wait("Draft a short summary for the team") final = None async for event in stream: if event.type == "agent_end": # this turn's terminal event final = event.messages[-1] if event.messages else None break await stream.aclose() if final is not None: print(final.content[0].text) asyncio.run(main()) ``` Points that matter in that script: - `spawn_session` hosts the runtime in a detached child process, so the work survives the script; long-lived work belongs under a supervisor (launchd). A person ends it with `lop stop ` — not by killing a pid. - `prompt_and_wait` submits a turn and waits for its terminal outcome. On a viewer, plain `prompt` returns as soon as the runtime admits the turn, so a scripted turn uses this verb. - `agent_end` carries the turn's messages; the last one is the assistant's reply. Events are typed objects — `event.type` is the discriminator — so your loop matches cases rather than parsing text. ## Teams, profiles and agents `SessionSpec` carries the same supply attachments `lop exec` takes — resolved against the `roots` you pass, through the same helper exec uses, and attached through the same session methods: ```python spec = SessionSpec( hosting="deepseek", model="deepseek-flash", team="release", # a saved team: manager, roster and briefs profile="reviewer", # a reusable role or specialist instead # agent_name="research", agent_id="3d0b0f0e-…", # a registered agent name="episode task_001", ) ``` - `team=` and `profile=` are post-open attachments; `agent_name` / `agent_id` select a registered agent — the pair `exec --agent` / `--agent-id` names. - Spawned runtimes are composed from their environment, so these fields have no channel to a *new* child — see the caveat below for the composition that works (open in-process, attach, dispose, then spawn). - The supply side — registering agents, writing instructions, declaring teams — is [Agents & teams](/build/agents-and-teams). ## What the surface does not carry Stated plainly, and refused loudly rather than ignored when a call asks for it: - **Loops.** `--loop` and `--loop-goal` are exec's continuation mechanism; an SDK caller drives turns itself. - **`--effort` as a runner knob.** Use `birth_effort` for the construction-time reasoning level. - **Attach auto-spawn.** A cold id gets a remedy, not a spawn — use `spawn_session` or `deliver` first, then attach. - **Post-open attachments on a spawn.** `team`, `profile`, `tools`, `name`, `goal`, non-default `approvals` and `yolo` have no channel to a new runtime child. The composition that works: `open_session` in-process, attach what you need, dispose, then `spawn_session` the same id — resume restores the attachments. ## Approval policies | Preset | Behaviour | | --- | --- | | `ApprovalPolicy.refuse()` | The default. Every gated call gets a typed refusal — never a silent "user denied". | | `ApprovalPolicy.auto()` | Every gated call approved. The same posture as `--yolo`. | | `ApprovalPolicy.declared([...])` | The named tools stand as their own approval, inside a reach bound to exactly them. Excluded tools are unreachable, and delegated children inherit the bound. | | `ApprovalPolicy.callback(fn)` | `fn` decides, exactly as a full front end's approval handler does. | The SDK is stricter than a piped `lop exec` on purpose: exec infers the declared-tools posture from a terminal, and an SDK process has no terminal to consult. So the stand is opt-in — you say `declared([...])` — and under `refuse()` a declared inventory's write/exec calls are still gated. `ask` questions are not installed by any preset; the `ask` tool is simply absent until a host supplies a surface on the session object. ## The spec `SessionSpec` mirrors `lop exec`'s options; the first eight fields are pinned field-for-field to exec's session namespace. | Field | Meaning | | --- | --- | | `hosting`, `model` | Provider and model for the session. | | `agent_name`, `agent_id` | Legacy named-agent selection (the exec flags of the same names). | | `yolo`, `train` | Exec's approval override and legacy history mode. | | `resume` | Session id to resume, or `@latest`. There is a helper: `spec.with_resume(id)`. | | `workstream` | Ask for the run to be listed as a long-lived workstream (meaningful under an agent's shell). | | `birth_effort` | Construction-time reasoning level. | | `team`, `profile` | Attach a saved team or a reusable role after construction. | | `tools` | The run's whole reach; one-way for the session's life — a second declaration may only tighten. | | `approvals` | An `ApprovalPolicy`, below. | | `name`, `goal` | Conversation title and standing goal. | | `notifications` | A spawned runtime is silenced by default; `True` inherits the launcher's posture. | ## Enforcing the final response contract `SessionSpec.output_format` (with optional `output_schema` and `output_retries`) makes the session's harness validate each turn's terminal assistant text, and retry inside the same turn when it does not conform — the same `Session.set_output_contract` method `lop exec --output-format` installs, through the same validator (`OutputContract`). `None` (the default) leaves every turn byte-identical to a session without it. The [exec mode](/build/exec-mode) docs own the per-format strictness table and the extraction rules; the schema spelling here is a TYPE or a MAPPING rather than a file path: ```python from pydantic import BaseModel from local_operator.sdk import SessionSpec, SessionRoots, events, open_session class Invoice(BaseModel): total: int currency: str spec = SessionSpec( hosting="openrouter", model="deepseek/deepseek-v4.1-flash", output_format="json", output_schema=Invoice, # model class, dataclass, TypedDict, raw JSON Schema, … output_retries=1, # 0-5; default 2 (three attempts per turn) ) async with open_session(spec, roots=roots) as session: stream = events(session) await session.prompt("Extract the invoice") async for event in stream: if event.type == "output_validation" and event.ok: invoice = Invoice.model_validate_json(event.payload_text) # checked span if event.type == "agent_end": if event.error: # exhaustion is a turn error, like any provider error raise RuntimeError(event.error) break ``` Failure semantics are the SDK's ordinary turn semantics: an exhausted budget ends the turn with `agent_end.error` of `final response did not satisfy the output contract (…) after N attempts: ` and `session.last_turn_outcome == "error"` — `await session.prompt(...)` still resolves (a turn error is an event, not an exception, exactly as every provider error). A successful check is visible as an `output_validation` event with `ok=True` and `payload_text` (the exact span that validated); `local_operator.sdk.decode_output` re-decodes that span with the same rules if you only have the text. Refused rather than silently inert: `output_*` on an `attach` spec (the viewer cannot install owner state) and on `spawn_session` (post-open state has no channel to a new runtime child). The composition that works is the documented one: `open_session(..., mode="own")` → `dispose` → `spawn_session` the same id, or drive the session in-process. The local-operator repo's [`docs/SDK.md`](https://github.com/damianvtran/local-operator/blob/main/docs/SDK.md) is the SDK reference. ## Isolation is the default A programmatic session cannot "just happen" to target your real store: - **Roots are required and explicit.** `SessionRoots(config_dir, agent_home, cwd)` has no defaults, and the SDK scopes the environment to them for the session's lifetime. - **The resolved roots are asserted, not assumed.** Construction checks the environment actually resolves to the roots you declared. - **Your uid-default roots are refused** unless you opt in with `allow_ambient=True` — a deliberate, greppable acknowledgement for single-machine scripts. "Default" is answered from the passwd home, not `$HOME`. - **The model cache is checked by its real resolver too** — if it still points at your real home, the run is told to redirect `HOME`, loudly. - **Roots must be durable.** A store under `/tmp` or `$TMPDIR` can be purged mid-run; `allow_volatile=True` is the explicit opt-out. - **One root per process.** `open_session`, `spawn_session` and `deliver` refuse a second, different root while another is live (`allow_multi_root=True` is the migration-tool escape). - **Spawned children get the roots explicitly**, in their environment — HOME set to the agent home, launcher variables stripped, notifications silenced unless opted in. ## Lifecycle verbs `abort()` ends a turn; `dispose()` ends an owner (the `async with` does it for you); a viewer's `interrupt()` is turn-scoped. `request_stop` — the user's kill switch — is deliberately not exposed: only a person ends a session. {% cards %} {% card title="Exec mode" href="/build/exec-mode" icon="terminal-square" %} The command-line half of the same machinery — flags, JSON events, exit codes. {% /card %} {% card title="SSE streaming" href="/build/sse-streaming" icon="radio" %} Put a runtime behind an event stream, and what that stream carries. {% /card %} {% /cards %} ### SSE streaming Source: https://docs.local-operator.com/build/sse-streaming # SSE streaming If you run agents behind your own product, something has to carry the run's trajectory to a browser: text arriving token by token, tools opening and closing, approvals waiting. This page is the shape for that transport, drawn from a service that runs it in production — endpoint, frames, event taxonomy, resume, keepalives and terminal handling — with the names stripped so you can build your own. At the end, what Local Operator itself ships on this front. {% callout type="note" %} Why Server-Sent Events rather than a WebSocket: the trajectory is a one-way fan-out, and control actions already have ordinary authenticated HTTP endpoints. SSE rides the web stack you already run — no upgrade handshake, no control plane — and reconnects are a protocol feature, not something you build. {% /callout %} {% diagram name="SseSequence" caption="Subscribe, keep the last event id, reconnect with Last-Event-ID" /%} ## The endpoint ``` GET {base}/v1/runs/{runId}/events/stream ``` - **Response**: `200 text/event-stream`, `Cache-Control: no-cache`, `X-Accel-Buffering: no` (the last one tells nginx and friends not to buffer). - **Query parameters**: `after_sequence` (an integer cursor — "send me events after this one") and, in richer modes, an opaque `cursor`. Both resume strategies are described below. - **Status codes**: `400` for a physically malformed resume value, `401` when the caller is not authenticated (no body), `404` for a run the caller cannot see — deliberately sanitized, so a scope mismatch and a missing run are indistinguishable and the route cannot be used as a run-existence oracle. - **Auth**: one credential per caller class, compared in constant time, plus capability scope headers validated against the run's persisted owner. Repeat the resource id inside the scope so a valid capability cannot be replayed against a different run, and authorize once at connect — not per poll tick. ## Frames Three field lines per data frame, one blank line to end it: ``` : connected retry: 1000 id: 42 event: run.item.delta data: {"event_id":"evt_9f3","run_id":"run_01HXYZ","sequence":42,"event_type":"run.item.delta","payload":{"type":"item.delta","delta":"…"},"created_at":"2026-09-27T22:47:17Z"} : heartbeat ``` - **`id:`** is the resume token — a monotone sequence number for this run. - **`event:`** is the event name; the JSON body repeats it as `type` / `event_type` so a raw `fetch` consumer never has to correlate the two halves of a frame. - **`data:`** is one JSON envelope. Keep a `sequence` inside the body too, so a buffering client can persist its position without tracking the header. - **Comment frames** (`: connected`, `: heartbeat`) carry no event name. They keep proxies and stall detectors happy; `EventSource` discards them, so a client that needs to *see* a liveness tick should get a real event (Local Operator's own transport emits `keepalive` as an event for exactly this). - **`retry:`** is an optional reconnection hint, in milliseconds. Emit it once. ## The event taxonomy Names are dotted, stable, and shaped `.`. Three families plus stream-level frames: | Family | Examples | When | | --- | --- | --- | | `run.turn.*` | `run.turn.started`, `run.turn.completed` | The model loop's iteration boundaries. | | `run.item.*` | `run.item.started`, `run.item.delta`, `run.item.output_delta`, `run.item.completed`, `run.item.failed` | One thing the turn is doing: a message streaming, a tool call running. | | `run.approval.*` | `run.approval.required`, `run.approval.resolved` | A gate is waiting on a person, then answered. | | stream-level | `run.terminal`, `error`, the comment frames | The run finished; the stream failed. | Normalize provider-native event names into this vocabulary at the edge, before anything downstream sees them — consumers should match your names, not a vendor's. Adding richer families later (reasoning deltas, compaction markers) is fine as long as old consumers can ignore what they do not know. ## Resume, and why reconciliation beats validation The client reconnects with `Last-Event-ID` (the browser's `EventSource` sends it automatically) or a query cursor. When both are present and disagree, take the **larger** — the header is current while the URL may hold the cursor the stream originally opened with, and replaying from the stale one duplicates everything in between. Only physically impossible values are a `400`; a spec-shaped client reconnecting is normal, and rejecting it fails every native auto-reconnect. - **Ordering**: one monotone sequence per run, enforced by a unique constraint, so a page cursor never skips or repeats. - **Delivery**: at-least-once. Clients dedupe with `max(last_seen_sequence)`. - **The richer mode** uses an opaque, self-describing cursor (base64url JSON bound to the run and owner digest, strictly parsed). A cursor from another run fails closed; that is the point of binding it. ## Keepalives and backpressure - **Heartbeat** every 15 s, under the common 60 s proxy idle cutoff. Local Operator's own transport reports the same 15 s cadence in its capabilities document. - **No per-connection buffer.** Catch-up is pulled in bounded pages (this design: 500 records and a 4 MiB payload budget per page) directly from the log, and the socket write itself is the throttle: a blocked write fails once and ends that stream — never retried, never buffered. - **Poll cadence** for a log-backed implementation: 250 ms is plenty, and continue immediately while pages remain rather than waiting a tick. - **Terminal detection is the hard part.** Events signal liveness, but a run that ends without writing one would leave the panel "thinking" forever, so probe the run's status on an idle stream — at most once per second, immediately after any page that carried events. ## Terminal and error frames - When a run reaches a terminal status (`completed`, `failed`, `cancelled`), send one terminal frame (`run.terminal`) and close. The frame's payload can carry the final cursor and metadata; don't make the client infer anything from the close itself. - A mid-stream failure sends one generic `error` frame — a fixed message, no internals — then closes cleanly. - **Write failures are invisible to the client** (the 200 already went out; the client just sees end-of-body). Log them with the run id and frame kind, and count them, or "the client went away" and "we gave up on a slow client" are indistinguishable in production. ## A server sketch The pattern end to end: an append-only per-run log, a fan-out for listeners, resume by sequence, and a keepalive ticker. Python + FastAPI here; the shape ports to any stack. ```python import asyncio import json from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse app = FastAPI() RUNS: dict[str, "RunLog"] = {} HEARTBEAT_S = 15.0 # under the common 60 s proxy idle cutoff class RunLog: """Append-only per-run event log, with wake-ups for its open streams.""" def __init__(self) -> None: self.events: list[dict] = [] # sequence = index + 1 self.listeners: set[asyncio.Event] = set() def publish(self, event_type: str, payload: dict) -> None: self.events.append({ "sequence": len(self.events) + 1, "event_type": event_type, "payload": payload, }) for event in self.listeners: event.set() def since(self, cursor: int, limit: int = 500) -> tuple[list[dict], bool]: page = [r for r in self.events if r["sequence"] > cursor][:limit] return page, bool(page) and page[-1]["sequence"] < len(self.events) @app.get("/v1/runs/{run_id}/events/stream") async def stream(run_id: str, request: Request, after_sequence: int = 0): log = RUNS.setdefault(run_id, RunLog()) # Resume: accept both forms; the larger cursor wins (the header is current # on an EventSource reconnect, while the URL may hold the opening cursor). header = request.headers.get("Last-Event-ID") or "" cursor = max(after_sequence or 0, int(header) if header.isdigit() else 0) wake = asyncio.Event() log.listeners.add(wake) async def frames(): nonlocal cursor yield ": connected\n\n" yield "retry: 1000\n\n" while True: if await request.is_disconnected(): return wake.clear() # clear first: anything new re-arms it page, more = log.since(cursor) for record in page: cursor = record["sequence"] yield f"id: {cursor}\nevent: {record['event_type']}\n" yield "data: " + json.dumps(record, separators=(",", ":")) + "\n\n" if record["event_type"] == "run.terminal": return if more: continue # keep draining; do not wait try: await asyncio.wait_for(wake.wait(), timeout=HEARTBEAT_S) except asyncio.TimeoutError: yield ": heartbeat\n\n" return StreamingResponse( frames(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) ``` Two things a long-lived version adds, and it should: trim the replay window (and answer an over-old cursor with a `stream.gap` frame that tells the client to reconcile over REST rather than silently resume from the wrong place), and probe run status on idle streams so a finished run closes without a terminal write. ## Clients **Browser — `fetch` with a manual cursor.** Full control: you set the resume header yourself and own the backoff. ```js async function followRun(runId, onEvent) { let cursor = 0, attempt = 0; for (;;) { const url = `/v1/runs/${runId}/events/stream?after_sequence=${cursor}`; let res; try { res = await fetch(url, { headers: cursor ? { "Last-Event-ID": String(cursor) } : {} }); if (!res.ok || !res.body) throw new Error(`stream failed: ${res.status}`); attempt = 0; } catch { attempt += 1; await new Promise((r) => setTimeout(r, Math.min(15000, 250 * 2 ** attempt))); continue; // reconnect with the same cursor } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let sep; while ((sep = buffer.indexOf("\n\n")) !== -1) { const block = buffer.slice(0, sep).split("\n"); buffer = buffer.slice(sep + 2); let name = "message", data = ""; for (const line of block) { if (line.startsWith(":")) continue; // comment / keepalive const idx = line.indexOf(":"); const field = line.slice(0, idx); const value = line.slice(idx + 1).trimStart(); if (field === "id") cursor = Number(value); // resume token else if (field === "event") name = value; else if (field === "data") data += value; } if (name === "run.terminal") return; if (name === "error") throw new Error("stream error"); if (data) onEvent(name, JSON.parse(data)); } } } } ``` **Browser — `EventSource`, if you want the browser to reconnect for you.** The trade: no custom request headers, and its built-in retry is immediate on some server-close shapes — which hammers a dying server. Close it and reopen with your own backoff when that matters. Frame parsing is the same; the `Last-Event-ID` header is sent for you. **Python client** — same loop, `httpx.stream`: ```python import json, time, httpx def follow(base: str, run_id: str, token: str): cursor, attempt = 0, 0 while True: headers = {"Authorization": f"Bearer {token}"} if cursor: headers["Last-Event-ID"] = str(cursor) try: with httpx.stream("GET", f"{base}/v1/runs/{run_id}/events/stream", headers=headers, timeout=None) as res: res.raise_for_status() attempt = 0 event, data = None, [] for line in res.iter_lines(): if line == "": # frame boundary if data: payload = json.loads("".join(data)) if event == "run.terminal": return yield event, payload event, data = None, [] elif line.startswith(":"): continue # comment / heartbeat else: field, _, value = line.partition(":") if field == "id": cursor = int(value) elif field == "event": event = value elif field == "data": data.append(value) except httpx.HTTPError: attempt += 1 time.sleep(min(5.0, 0.25 * 2 ** attempt)) # 250 ms x 2^n, capped 5 s ``` ## Local Operator's own streaming surfaces The runtime ships SSE on its server (`lop serve`, loopback by default — the server has no authentication of its own, so keep it there or front it with access controls), and the same design is what the desktop app and the phone relay speak: - `GET /v1/sse/jobs/{job_id}` — the one to prefer: it can be opened the instant a turn is submitted, before any record exists. - `GET /v1/sse/messages/{message_id}` — for a message whose id you already hold. - `GET /v1/sse/capabilities` — transport negotiation: `preferred: "sse"`, the resume contract (`Last-Event-ID` and `?after_seq`, larger wins, 256-frame replay buffer), the 15 s heartbeat, and the full event list. Its event vocabulary is the same idea with its own names — `stream.open`, `stream.gap` (frames were lost; reconcile over REST), `stream.terminal`, plus `message.delta`, `reasoning.delta`, `tool.start`/`tool.delta`/`tool.end`, `turn.start`/`turn.end`, `agent.start`/`agent.end`, `notice`, `steering.delivered`, `compaction.*`, `retry.*` and `job.status`. And the phone relay streams whole-state repaints over SSE (never a WebSocket, by design — its client reconnects with its own backoff rather than trusting the built-in retry). {% cards %} {% card title="Python SDK" href="/build/python-sdk" icon="code" %} Drive sessions from Python and consume the same event stream, typed. {% /card %} {% card title="Exec mode" href="/build/exec-mode" icon="terminal-square" %} The JSON event stream `lop exec --json` emits, line for line. {% /card %} {% /cards %} ## Ecosystem ### What's in the ecosystem Source: https://docs.local-operator.com/ecosystem # What's in the ecosystem Local Operator is one program on your machine. What surrounds it is a small ecosystem you can plug into — providers to run the models, tools and knowledge to give the agent, a place to publish what it learns, and surfaces to reach it from. This page is the map. {% diagram name="EcosystemMap" caption="Providers, tools, sharing, and the surfaces that reach it" /%} ## Model providers Local Operator talks to whichever provider you point it at, and keeps the differences out of your way: - **Subscriptions you already pay for** — sign in once and the agent uses the plan's quota: ChatGPT, Claude, Kimi, Grok, Z.AI and Qwen all have login flows (`lop login`, or `/login` in the TUI). Multiple accounts on one provider pool and rotate; failover follows a chain you can edit in `config.yml`. - **API keys** — Gemini, DeepSeek, Mistral, OpenRouter and Radient are set with a masked prompt and stay on your machine (`~/.local-operator/auth.db`). - **Local servers** — LM Studio, Ollama, vLLM and llama.cpp run models on your own hardware; anything speaking the OpenAI API can be added by URL, so a proxy or a homegrown gateway fits too. {% figure src="/shots/tui/login-prompt.png" alt="A provider login in progress in the TUI — the browser opens for OAuth, or a key is pasted into a masked prompt" caption="A provider login: OAuth through the browser, or a key into the masked prompt." /%} ## Radient Radient is the hosted service the agent can run on, and where its community work lives. **Radient Pass** is a prepaid balance on one key. One credit is one dollar at provider cost — no subscription and no per-request markup — you top up in the console (a small top-up fee applies, minimum $5), and credits do not expire. The key is a standard bearer token, so anything that speaks the OpenAI API can use it. **Radient Automatic** is the routing mode: instead of naming a model, ask for `model: auto` and each step goes to a model picked for cost and quality. In the terminal it is `lop --hosting radient` with no `--model`; in the desktop app, signing in with Google or Microsoft creates a Pass for you and turns Automatic on. Sign up at [console.radienthq.com](https://console.radienthq.com); the documentation lives at [docs.radienthq.com](https://docs.radienthq.com). ## The Agent Hub The Agent Hub is the community registry of agents — browse it in the desktop app's Agent hub page, or pull straight from the CLI: ```bash lop agents pull --id "agent_id" # reading is public; no key needed lop credential update RADIENT_API_KEY lop agents push --name "My Agent" # publishing needs an account ``` Pulled agents join your local registry. The registry's API is public too: [api.radienthq.com/v1](https://api.radienthq.com/v1). ## MCP servers Local Operator speaks the [Model Context Protocol](https://modelcontextprotocol.io) over the official SDK, with lazy tool loading: a server advertises a summary, and individual tool schemas enter the context only when the agent enables them. ```bash lop mcp add linear --url https://mcp.linear.app/mcp --oauth lop mcp login linear lop mcp list ``` Servers configured for Claude Code, Cursor, VS Code or Codex CLI are imported where possible, and project-level configs are discovered too. ## Skills A skill is a folder with a `SKILL.md`. Drop it in `~/.local-operator/skills//` and the agent indexes it — only skills relevant to the current turn surface, and their bodies load on demand, so unused knowledge does not tax the context. `/skills` lists what is loaded, and `$` in the composer picks one by name (`$research compare these two API designs`). A skill marked `hide: true` is reachable only that way. ## Teams A saved team is a roster — a manager plus members with counts — layered with two briefs the individual agents never hard-code: how the group works together, and what product this instance owns. A slot can name another team, so a team nests into an org of teams. `/team ` puts the roster on the work; `/team chart ` draws it. Reviewer, coder, architect, manager, designer and scout ship as starter roles; `agent install` copies one into your registry to edit. From the CLI: `lop teams list`, `lop teams show`. {% figure src="/shots/tui/org-chart.png" alt="A team drawn as an org chart, with a director role and two nested pods" caption="A team as an org chart — `/team chart` shows the roster and any nested teams." /%} ## The browser extension The extension gives the agent the browser you already use, with your logins intact, working in its own tab: it can open pages, read, click, type and take screenshots, and it asks before visiting a new site. The bridge stays on your machine — loopback only, no cloud browser. Install it from the [Chrome Web Store](https://chromewebstore.google.com/detail/local-operator/omibaecbjdhgbbcedbnnnmjpmopfheof), then pair: `lop browser install`, `lop browser status`, `lop browser pair`. See [the extension guide](/surfaces/browser-extension) for the flow in full. ## Where things live | What | Where | | --- | --- | | Product site, downloads | [local-operator.com](https://local-operator.com) | | Runtime source, releases | [github.com/damianvtran/local-operator](https://github.com/damianvtran/local-operator) | | Desktop app source | [github.com/damianvtran/local-operator-ui](https://github.com/damianvtran/local-operator-ui) | | Python package | [pypi.org/project/local-operator](https://pypi.org/project/local-operator/) | | Radient console | [console.radienthq.com](https://console.radienthq.com) | | Radient docs | [docs.radienthq.com](https://docs.radienthq.com) | {% cards %} {% card title="Agent Hub" href="/capabilities/agent-hub" icon="store" %} Browsing, pulling and publishing agents — the guide. {% /card %} {% card title="Python SDK" href="/build/python-sdk" icon="code" %} Drive any of this from your own code. {% /card %} {% /cards %} ## About ### Why Local Operator Source: https://docs.local-operator.com/about/why # Why Local Operator Plenty of tools can put a model in a chat box. Local Operator is built around a different bet: the agent should work **where your work actually lives** — your machine, your files, your accounts — and you should be able to reach it from anywhere, or leave it working while you walk away. ## It runs on your machine Sessions, files, credentials and configuration live on your computer, under `~/.local-operator/`. When the agent runs a command, it runs here. When it edits a file, the edit lands in your working copy. You can unplug it and nothing about your setup stops working. So what leaves the box? Only what you would expect: - **Model requests** go to the provider you signed in to — your existing subscription, an API key, or a local model, in which case the prompts do not leave your machine at all. - **Web search** is on by default (that is where search results come from), and both search and `web_fetch` can be switched off (`lop search off`, `lop fetch set enabled off`) for a setup where no prompt leaves the machine. - **Nothing goes to us.** There is no Local Operator cloud holding your sessions, and no account to create before your first prompt. The same principle shapes the safety model: read-only tools run automatically, writes and commands ask first with the exact command shown, and every action leaves a receipt in the transcript. ## One runtime, every surface The terminal, the desktop app, the phone relay, the browser extension and headless `exec` are not separate products with separate state. They are front ends onto one local runtime: - Start a session in the terminal, watch and steer it from the desktop app, see it in the phone's session list. - Hand one task to `lop exec` from a script, then open the same session and read the full transcript. - Inside a session, an agent manages other sessions with the `sessions` tool: list what is running, spawn a listed workstream, or peek at a transcript — the CLI (`lop sessions`, `lop exec`) stays the human path. - Move between machines later without moving your configuration — it is all the same `~/.local-operator/` layout wherever it runs. ## Teams and projects, not just prompts - **Teams** make delegation concrete: a manager agent plus members with a written brief, a shared project context, and a report at the end. You can ask for a review team, a research team, or one you designed yourself. - **Projects** keep work that spans days or several conversations from scattering: related sessions, a progress line, milestones and dates in one record. ## A mesh for your own computers Your laptop cannot always do the work — it sleeps, it travels, it runs out of battery. The Agent Mesh pairs your machines so a session on one can reach another: see what the always-on box at home is running, start a session *there*, or move a conversation onto it. Moving is lossless and deliberate: a session can be **handed over** to another device with its transcript, tools and todos intact, and it can come home again — or be recovered from the last synced copy if the machine holding it is gone. {% diagram name="SessionHandoff" caption="Handing a session to another device, and bringing it home again" /%} Pairing is human on both ends: one device mints a single-use invite, the joining side shows a code and a fingerprint, and a person compares them before anything is admitted. Agents can drive the ceremony to the point of confirmation — never through it. ## Work that continues when you don't Schedules (wakes) let a session start its own future turns, and a small supervisor wakes sessions even when every terminal is closed. That is the same raw material Aida, the built-in chief of staff, is built on: a daily check-in that reports only what needs your attention, and can be paused or switched off entirely. ## Open and extensible - **MIT and readable.** The runtime is open source; you can read exactly what it does. - **Your models.** Subscriptions via login (Claude, ChatGPT, Kimi, Grok, Z.AI, Qwen), API keys, or local servers (LM Studio, Ollama, vLLM, llama.cpp) — and an OpenAI-compatible escape hatch for anything else. - **Skills.** Drop a `SKILL.md` folder into `~/.local-operator/skills/` and the agent loads it when a task matches; you can also invoke one by name from the composer. - **MCP.** Connect Model Context Protocol servers (`lop mcp add ...`), with tool loading kept lazy so a large server does not flood the context. - **Community.** Agents and skills can be packaged and shared — the public shelf is the [Agent Hub](/capabilities/agent-hub) — and teams can be shared privately within an organization. ## When to use something else Honest boundaries, so you can pick well: - **You want zero setup and no machine of your own.** A hosted assistant that runs entirely in someone else's cloud will ask less of you. Local Operator's answer to "where does it run" is your hardware, on purpose. - **You need work to continue while every one of your machines is off.** Nothing local can run then — wake scheduling needs a computer that is on (or one that comes back soon; missed occurrences are skipped, not replayed). - **You only ever want a single quick question.** Any chat window is faster than installing a runtime. - **Your environment forbids local agents.** The extension, the mesh and the wake supervisor are all local services; managed corporate machines sometimes disallow that class of software. If none of those apply, start with [Install & first run](/getting-started/install) — the rest of these docs assume you did. {% cards %} {% card title="What is Local Operator?" href="/getting-started/what-is-local-operator" icon="home" %} The overview, the surfaces, and what it is not. {% /card %} {% card title="FAQ" href="/about/faq" icon="help-circle" %} Privacy, accounts, models, cost, platforms — short answers. {% /card %} {% /cards %} ### FAQ Source: https://docs.local-operator.com/about/faq # FAQ Short answers to the questions that come up first. Each one links into the guide that goes deeper. ## Does my data leave my machine? Most of it never does. Sessions, files, credentials and configuration stay in `~/.local-operator/` on your computer, and commands run locally. Two things can go out, both by design: model requests (to the provider you chose) and web search / `web_fetch`, which are on by default and can be switched off (`lop search off`, `lop fetch set enabled off`). With a local model and search off, no prompt leaves the machine. ## Do I need a cloud account to use it? No. The runtime never asks you to create an account. You *do* sign in to a model provider — a subscription login or an API key — or point it at a local model. A Radient account only comes in if you want to publish to the [Agent Hub](/capabilities/agent-hub) or share within a team. ## Which models can I use? - **Subscriptions you may already pay for** — Claude, ChatGPT, Kimi, Grok, Z.AI, Qwen — signed in through the browser; multiple accounts per provider can pool and rotate. - **API keys** — Gemini, DeepSeek, Mistral, OpenRouter, Radient, plus standard environment variables. - **Local servers** — LM Studio, Ollama, vLLM, llama.cpp, and any OpenAI-compatible URL. Switch provider or model mid-conversation from the model picker. ## What does it cost? Local Operator itself is free and open source (MIT). Model use is billed by whichever provider you signed in to — your existing subscription, or your API usage — and local models cost nothing beyond your hardware. ## Does it work offline? With a local model, yes: sessions, files, commands and the interface all work without a network. Cloud models, web search and `web_fetch` need connectivity. ## Is my code sent anywhere? Only when a cloud model needs to see it. The agent reads and edits files on your machine, and when a piece of content is part of the conversation it travels to your provider the way any prompt would. Approvals gate the actions, and every tool call leaves a receipt — so it is visible what was read and when. With a local model, nothing is sent anywhere. ## Which platforms does it run on? The terminal runtime runs anywhere Python 3.12 or newer runs — macOS, Linux and Windows. The desktop app ships for macOS, Windows and Linux. Scheduled wakes are supervised by launchd on macOS, a systemd user service on Linux, or a Task Scheduler task on Windows. ## How do updates work? `lop update` — or `/update` inside the TUI — upgrades from PyPI. The desktop app updates itself from GitHub Releases. Running work is not disturbed: new code is adopted only once everything is idle. The full matrix is in [Updating Local Operator](/getting-started/updating). ## Do sessions survive closing the terminal? Yes. Transcripts persist on disk, and `/resume` brings any conversation back with its history. Work that has been scheduled keeps going too — wakes fire with every interface closed, handled by the wake supervisor. ## Can I use it on more than one computer? Yes. Install on each machine and pair them with the [Agent Mesh](/capabilities/agent-mesh): sessions on one become reachable from the other, and a conversation can be handed over — or brought home — with its transcript. ## Is this only for software work? No. Code is one use, and a good one — but the same agent does research, file organization, data cleanup, spreadsheet work and scheduled checks. If you can describe it to a capable assistant at your computer, it is fair game. ## Can it run things on a schedule while I'm away? Yes. A session can schedule its own future turns (wakes), and the wake supervisor starts a runtime when one comes due — even with the terminal long closed. Missed occurrences are skipped, not replayed, and approvals still apply unless you opt into unattended execution. Aida, the built-in chief of staff, is a daily check-in built on the same mechanism — see [Always-on & proactive agents](/capabilities/always-on). ## How do I stop something it is doing? In the TUI, `esc` stops the agent without ending the session. At an approval, **Deny** refuses that action and the turn continues. For proactive output, `/aida pause` holds her check-ins; `aida.enabled=false` (or `LOCAL_OPERATOR_NO_AIDA=1`) switches her off entirely. {% cards %} {% card title="Why Local Operator" href="/about/why" icon="sparkles" %} The longer argument — and when a different tool is the better fit. {% /card %} {% card title="What is Local Operator?" href="/getting-started/what-is-local-operator" icon="home" %} The overview, the surfaces, and what it is not. {% /card %} {% /cards %}