Docs GitHub Get started

Runner

The runner is a small Node daemon that runs next to exactly one coding agent — inside a cloud sandbox, or on your own machine for local threads.

It:

Command line

lemonlime-runner --thread <id> --agent <kind> [options]
lemonlime-runner cleanup --thread <id> --source-repo <path>

  --thread <id>            Thread id (required)
  --agent <kind>           claude-code | codex | opencode | acp (required)
  --agent-command <cmd>    Command for ACP agents, e.g. "gemini --experimental-acp"
  --model <name>           Model to ask the agent for (optional)
  --workdir <path>         Repository working directory (default: ./workspace)
  --repo <url>             Clone this repository into --workdir if it is empty
  --source-repo <path>     Work in a separate git worktree of this local repository
                           (default location: ~/.lemonlime/worktrees/<repo>/<thread>)
  --base <branch>          Branch the agent branch starts from (default: main)
  --start-from <ref>       With --source-repo: start the new branch from this commit or
                           branch instead (forks keep the code they came from)
  --branch <name>          With --source-repo: work on this existing branch, not a new one
  --pull-request <n>       With --branch: it's pull request <n>'s branch (forks too)
  --port <n>               Port to listen on (default: 7420, env LEMONLIME_RUNNER_PORT)
  --host <addr>            Interface to bind (default: 127.0.0.1; use 0.0.0.0 in sandboxes)
  --data-dir <path>        Where history is stored (default: <workdir>/.lemonlime, or
                           ~/.lemonlime/threads/<thread> with --source-repo)
  --control-plane <url>    Send heartbeats to this control plane
  --instructions-file <path>   Your own instructions, added to every turn (at most 32 KB)
  --strip-coauthors        Remove Co-authored-by trailers from the agent's commits
  --branch-prefix <prefix> Name the thread's branch <prefix><thread> (default: agent/)
  --retry-max-wait <min>   Retry turns that hit a busy provider or a usage limit (0: off)
  --disable-skills <names> Leave these bundled skills out (comma-separated)
  --no-command-guard       Turn the command guard off
  --exit-with-parent       Stop when the app that started it goes (with --secret-stdin, also
                           when stdin closes)
  --secret-stdin           Read the thread secret from the first line of stdin
  -v, --version            Print the runner's version

lemonlime-runner --help lists every option and environment variable. Cloud sandboxes get the same settings through LEMONLIME_INSTRUCTIONS, LEMONLIME_STRIP_COAUTHORS=1, LEMONLIME_RETRY_MAX_WAIT and LEMONLIME_USER_SKILLS_<n> (see "Workspace cloud settings" in self-hosting.md).

Your instructions

--instructions-file (or LEMONLIME_INSTRUCTIONS) adds your own instructions to every turn: Claude Code gets them with --append-system-prompt, Codex as developer_instructions, and OpenCode and other ACP agents, which have no system prompt, at the start of each new conversation. Subagents and side chats get them too. More than 32 KB is cut short.

Co-author trailers

With --strip-coauthors, commits the agent makes in the thread lose their Co-authored-by: lines. The runner points the agent's git at a hooks folder of its own (through GIT_CONFIG_PARAMETERS, so nothing in the repository changes); each hook there runs the repository's own hook of the same name first, so project checks still run.

Branch names

--branch-prefix ai/ names a new thread's branch ai/<thread>. A thread keeps the branch it started on (saved in its data folder), so changing the prefix never moves existing threads. Cloud threads always use agent/.

Local threads and worktrees

With --source-repo, the runner never works in your own checkout. It adds a git worktree for the thread's branch (git worktree add), so:

The agent starts from the latest commit of --base (or --start-from); uncommitted work on that branch is not copied in. If the repository has submodules, the new worktree fills them (git submodule update --init --recursive); if that fails, the thread starts anyway and says so. lemonlime-runner cleanup removes the worktree and the thread's local history and keeps the branch, so nothing the agent committed is lost; lemonlime-runner git remove-worktree removes only the worktree, and refuses when it has uncommitted changes.

With --branch <name> the thread works on a branch that's already there (local, or origin's), instead of making agent/<thread>. A branch can be checked out in only one folder, so one that's checked out elsewhere (your own checkout included) is refused with a message saying where. With --pull-request <n> as well, the runner looks the pull request up with gh and makes its branch: from origin, or for a fork through a remote named after the fork's owner, tracking the fork's branch so pushes go back to the pull request. When the fork doesn't allow edits from maintainers, the thread says the agent can't push to it. lemonlime-runner git branches --repo <path> and `git pull-request --repo --ref

` print what the app's branch picker and pull request lookup show, as JSON.

Environment:

Variable Purpose
LEMONLIME_THREAD_SECRET The thread's own secret. Client tokens must be signed with it. Required, unless --secret-stdin is given.
LEMONLIME_HOME Where local worktrees and history live. Default ~/.lemonlime.

Threads on your other machines (SSH)

The desktop app can run a thread on another computer of yours: a home server, a dev box, or a machine on your Tailscale network (which is reachable over plain SSH). Add it under Settings, Machines, by a host from your ~/.ssh/config or as user@host[:port], then pick it in the place menu on the home screen and type the path of a git repository already on that machine.

The app runs your own ssh with your own config, keys and agent, always in batch mode, so it never asks for or stores a password or key. Starting a thread runs

ssh -T -o BatchMode=yes -o ExitOnForwardFailure=yes -o ControlPath=none ... \
    -L 127.0.0.1:<free local port>:127.0.0.1:<remote port> -- <host> \
    "exec lemonlime-runner --thread <id> --agent <kind> ... --secret-stdin --exit-with-parent \
     --host 127.0.0.1 --port <remote port> --source-repo <path>"

The machine needs:

Things that only make sense on this computer stay off for threads elsewhere: opening the folder in your editor, the local folder picker, switching agent accounts kept on this computer, and computer access. Previews come through the runner's tunnel, like a cloud sandbox's. Your instructions and thread settings go along as flags (instructions through LEMONLIME_INSTRUCTIONS); your agent plugins stay on this computer.

Endpoints

Messages, queueing and retries

Terminal

Each thread can have up to 8 shells, at /terminal on the runner's port (same client token as /ws; messages in packages/protocol/src/terminal.ts). The app names each one in its hello (terminal, "main" when left out); a ninth is refused with a limit error, and a close message ends a shell for good. Each runs in the thread's working folder on the machine or sandbox where the agent runs, starts on first use and keeps running when nobody is connected. Every app that opens a terminal sees the same shell. The runner keeps the last 1 MB of output, so a client that reconnects gets only what it missed (or the whole kept screen if that's gone). Input is capped at 64 KB per message, output is sent in 64 KB pieces, and a client more than 4 MB behind for 10 seconds is dropped and reconnects. The shell doesn't get the runner's thread secret or approval token.

GET /file-exists?path=<path> answers with the file's path inside the working folder (or 404), so terminal output like src/app.ts:12 can open the file. Absolute paths count only when they point inside the folder.

GET /file?path=<relative path> (client token) returns one file from the working folder for the app's file tabs, up to 20 MB. The path must be relative and stay inside the working folder once symlinks are followed; .., absolute paths, .git and the runner's own .lemonlime folder are refused.

The PTY comes from @lydell/node-pty (prebuilt, no compiler needed). The desktop app's single-file runner can't hold a native module, so build:sea copies it next to the executable and the app passes LEMONLIME_NODE_PTY_DIR.

Project scripts

A project can keep a few named commands in lemonlime.json at its repository root. The apps show them as buttons in the thread's panel and in the command palette (⌘K), and each runs in its own panel terminal in the thread's folder:

{
  "scripts": [
    { "name": "Setup", "command": "cp \"$LEMONLIME_PROJECT_ROOT/.env\" . && pnpm install", "runOnWorktreeCreate": true, "timeoutSeconds": 600 },
    { "name": "Dev", "command": "pnpm dev", "icon": "play", "previewUrl": "http://localhost:5173", "keybinding": "run-script-1" },
    { "name": "Test", "command": "pnpm test", "icon": "test" }
  ]
}

Every script and terminal gets LEMONLIME_PROJECT_ROOT (your own checkout for a local thread, the thread's folder otherwise), LEMONLIME_WORKTREE_PATH and LEMONLIME_THREAD_ID. The runner reads the file itself (GET /project-scripts lists what it found): the app only ever names a script, so a script runs only where its file is. For a local thread that's the file in your checkout; a cloud sandbox uses the copy in its own clone of the repository, and setup scripts never run there.

Going back (rewind) and compacting

Every user message records the commit the thread's branch was on when its turn began. {"type":"rewind","toSeq":N,"restoreFiles":bool} goes back to the message at seq N: the agent stops, waiting messages are dropped, and the agent starts a fresh conversation that gets a recap of everything said before N. With restoreFiles the branch is reset to that commit; what was there is kept at refs/lemonlime/rewind/<time>, and the next push replaces the remote branch. History is never deleted: a rewind event marks what apps leave out (withoutRewound in the protocol). {"type":"compact"} asks Claude Code to shrink its conversation (/compact); other agents say they can't.

Attachments, models and thinking

Files go to the runner over HTTP with the client token: POST /attachments {id, name, size} starts an upload, PUT /attachments/<id>?offset=N sends the next piece (up to 4 MB), and GET /attachments/<id> says how much arrived, so a dropped connection resumes where it stopped. Images can be up to 10 MB and other files up to 100 MB. Finished files sit in .lemonlime-attachments/ in the thread's folder (kept out of git), and a send_message with their ids tells the agent where they are.

GET /capabilities lists the agent's models (Codex's own list when it has one), thinking levels and limits. --model, --reasoning and --fast set what the thread starts with (set_options changes them later): Claude Code gets --effort and, for fast mode, a fastMode setting; Codex model_reasoning_effort and the fast service tier. ACP agents that offer a model setting (OpenCode does) are asked for the thread's model before each turn.

Reviewing the branch

The apps read the thread's branch from git itself, over HTTP with the client token: GET /git/status (changed files, commits, push state, the GitHub compare page, whether the GitHub CLI is signed in), GET /git/diff?scope=branch|uncommitted|commit (per-file status, line counts and hunks; commit=<sha> for one commit, path= and context= for more lines around one file), POST /git/push (never forced) and GET/POST /git/pull-request (opens one with gh when it's installed and signed in; otherwise the answer has the compare page to open instead). GET /git/pull-request/draft writes a title and description from the thread's first message and commits, without a model. GET /git/status also says how many commits the branch is behind its base (origin's copy when that's newer, fetched every few minutes), and POST /git/update-from-base merges the base in, in the thread's own folder only: when it conflicts, the merge is undone and the conflicting files are listed. POST /git/pull-request/watch {on} watches the pull request every three minutes and sends the agent a message when its checks fail, new comments arrive or it gets merge conflicts; comments alone wake it at most 10 times, and it says once if no checks have shown up after 15 minutes. The watch is saved, so it carries on after a restart, and stops when the pull request is merged or closed. Diffs are capped at 400 files and 3 MB of text; bigger files say they were shortened. Working-tree diffs use a scratch index, so the agent's own index is never touched.

Every git call the runner makes turns off core.fsmonitor, hooks and bare-repository discovery, never runs an external diff or textconv filter, has a time limit, and refuses branch names, refs and paths that start with -.

Previews

When the agent starts a dev server it runs lemonlime-runner preview <port> --name <what> (the bundled lemonlime-previews skill tells it to), which signs a runner token with the thread's secret and offers the port; a preview_update event lists what's on offer. On this computer the apps open http://localhost:<port> directly. For a cloud thread the desktop app runs the runner in tunnel mode: it listens on 127.0.0.1 only, refuses requests whose Host or Origin isn't that address (so other sites can't reach it through DNS rebinding), and carries each connection to the sandbox over the runner's /tunnel socket, which takes a client token and only offered ports. HTTP, WebSockets and server-sent events all pass through as bytes; there is no public URL.

The runner also finds dev servers on its own. While the agent works, and for a while after a terminal is used, it checks every few seconds which TCP ports the processes it started listen on (ps and lsof, on macOS and Linux; never on Windows), looking only at its own process tree. Every process it starts carries LEMONLIME_THREAD_ID. A listening_ports event lists what it found, the apps offer "Open localhost:", and {"type":"offer_preview","port":N} makes one of those ports a preview.

Subagents

Agents get three MCP tools from lemonlime-runner subagents-mcp (Claude Code through --mcp-config, Codex through -c mcp_servers…): spawn_subagent, list_subagents and message_subagent. A subagent is another conversation with the same agent, in the same working folder, given one task. Spawning is idempotent per request_id; an agent can have 3 working at once, and subagents can start their own once (2 levels). When a subagent finishes, its reply is queued to whoever started it as a message, so the work goes on with no app open; the thread shows subagent_started / subagent_replied notices. Only the agent that started a subagent can message it. The tools call the runner's /subagents routes with a runner token signed with the agent key (see Tokens).

Computer access (cloud threads)

In a sandbox, agents also get computer_read_file, computer_list_dir and computer_run_command for the user's own computer. The runner forwards each request to the newest connected desktop app that said it can answer (hello.computer), and the app decides by the thread's setting: Off (the default), Read-only, Ask first (it shows the exact request, with control, invisible and non-ASCII characters escaped so look-alikes can't hide anything) or Full. Paths must be absolute and are never followed through symbolic links; reads stop at 1 MB, commands at 60 seconds and 256 KB of output. With no desktop app open, the agent is told the computer can't be reached.

The desktop app's native side, not its window, keeps each thread's setting (in ~/.lemonlime/computer-access.json) and checks it on every request; raising a thread's setting and each Ask first request are confirmed in a native dialog. A command runs in its own process group, which is stopped when it ends or times out, so nothing it started is left running.

Moving a thread (teleport)

A thread can move between this computer and the cloud as one conversation. The desktop app asks the source runner to pack it (POST /teleport/export): its own commits (from where it left the base branch) as a git bundle, and its history, with a sha256 checksum. It downloads both (GET /teleport/<id>/bundle|history.json?offset=), uploads them to the target runner as attachments, and calls POST /teleport/import. The target checks the checksum, refuses a branch that already has work of its own (nothing is overwritten), needs the base commit to exist there (push the base branch first otherwise), applies the commits, adds the history and gives the agent a recap. Importing the same transfer twice does nothing. Afterwards the old copy is archived (and a cloud one paused).

Side chats

side_ask (with no sideId) starts a side chat: a separate agent conversation, at the read-only autonomy level, that gets a recap of the thread and the question. It can read files but not change them (Claude Code: editing tools off and nothing else allowed; Codex: its read-only sandbox; ACP: only read, search and fetch). Its messages come back as side_events and never enter the thread's history. Unpinned chats are discarded on side_close or when the runner stops; side_pin keeps one in the thread's data folder.

Usage limits

When an agent stops because the user's plan limit is reached (Claude says when it resets; others usually print a time or "try again in …"), the thread gets a usage_limited notice with untilAt, and the runner sends "Continue where you left off" for the user once the limit lifts, or straight away when they sign in again. Without a reset time it tries again after 30 minutes. It does this at most 3 times a day per thread, and not at all once the user writes something themselves.

Automatic retry

--retry-max-wait <minutes> goes further: when a turn fails because the AI provider is overloaded (529, 503, "overloaded", ...) or a usage limit is hit, the runner records a retry_scheduled notice with untilAt and runs the same message again then, without a new user message. It waits until the time the agent gave, or 30 s, 1, 2, 4, 8 and then 15 minutes when it didn't say, and never longer than the max wait in all for one message (at most 12 tries). A limit that lifts after the max wait isn't retried (retry_given_up). Sending a message or stopping the agent cancels the wait (retry_cancelled). A waiting retry is saved, so it survives a runner restart; it needs no client connected. With retry on, it replaces the "Continue where you left off" above.

Safety guards

Memory on your computer

Local threads share your computer, so the runner watches the memory used by everything the agent starts (the agent, builds, dev servers). Past half the computer's memory it stops the agent's turn and says why in the thread; if the programs left behind are still over at the next check, it closes the biggest one. --memory-limit-mb sets a different limit. Cloud sandboxes have their own fixed memory instead.

Skills

Every thread gets LemonLime's bundled skills (packages/runner/skills), copied to where its agent looks: .claude/skills for Claude Code, .agents/skills for Codex, OpenCode and other ACP agents. They cover git hygiene, running tests first, opening a pull request, and when to stop and ask. The copies are listed in the repository's local .git/info/exclude, so they are never committed. Add your own with --skills <dir> (each skill is a folder with a SKILL.md), or leave ours out with --no-bundled-skills (all of them) or --disable-skills lemonlime-open-pr,lemonlime-previews (some). Skills in ~/.lemonlime/skills (or $LEMONLIME_HOME/skills) are always copied too; the desktop app's Settings → Skills adds and removes them there.

With --plugins-dir <dir> (the desktop app passes ~/.lemonlime/plugins), the enabled agent plugins listed in <dir>/plugins.json are added too: skills for every agent, commands and subagents for Claude Code, MCP servers for Claude Code and Codex. Plugin hooks are never run. See Agent plugins.

Approvals

At autonomy edits (the default for local threads) agents edit files freely but ask before anything else, most importantly before running a command. The runner turns each question into an approval_request event; the app shows the exact command with Allow / Don't allow, and answers with an approve message. The decision is recorded as approval_result (by: user, timeout after 30 minutes, interrupted when the turn is stopped, or expired when the runner restarted before anyone answered). Anything not answered is declined.

At autonomy full (cloud sandboxes) nothing is asked.

Signing agents in (cloud sandboxes)

An app sends start_login; the runner starts the agent's own sign-in inside the sandbox (claude setup-token for Claude Code, device sign-in for Codex) with no browser, and reports progress as login messages: a link to open, a code to type there, or a request to paste back the code the page shows (login_input). These messages go to connected apps only and never enter the thread's history. On success the runner uses the credential straight away, removes its value from every event, and uploads it to the control plane, which keeps it encrypted for the user's next sandboxes.

Safety limits

Tokens

The control plane derives a per-thread secret with deriveThreadSecret(master, threadId) and gives only that to the runner. The desktop app does the same with a secret it generates locally. Tokens are short-lived HMAC-signed claims (packages/protocol/src/token.ts); the runner refuses any that claim to live longer than 15 minutes. A thread or terminal connection is closed (code 4001) when the token it opened with runs out; apps reconnect at once with a fresh token, so removing someone from a workspace cuts them off within one token's lifetime.

The agent never gets the thread secret. The runner removes LEMONLIME_THREAD_SECRET from the environment the agent and everything it starts inherit, and sets LEMONLIME_AGENT_KEY instead: a key derived from the secret with deriveAgentKey that can't be turned back into it. The agent's own tools (lemonlime-runner preview, the subagents and computer tools, the git credential helper) sign with that key, and only those routes accept it. Opening the thread as the user (client tokens) and talking to the control plane as the runner still need the secret, so an agent can't approve its own requests or post as the runner.

Idle pausing

The runner reports idleSince (when the agent last finished work) in every heartbeat. The control plane pauses any cloud sandbox that has been idle for 5 minutes.

Edit this page on GitHub