worktree linear-listen: start a worktree when Linear delegates an issue
Listen for Linear agent delegations and start a worktree for each one. Run it in the repository it should
serve. When someone delegates a Linear issue to your agent app, Linear sends a webhook here, and the listener
runs worktree branch --linear for that issue, hands the new worktree to your coding
agent, and tells Linear how it went.
It is a foreground command: it stays running until you stop it with Ctrl-C, and it serves one repository (the one
it is started in). It listens on 127.0.0.1 only. Making that address reachable from Linear is your step: put a
tunnel or a reverse proxy with HTTPS in front of it and give Linear that public address as the app’s webhook URL.
The command neither opens a tunnel nor installs itself as a service. The
Linear Agent Delegation guide walks through the one-time setup in Linear.
worktree linear-listenworktree linear-listen --port 9100worktree linear-listen --team ENGworktree linear-listen --path /hooks/linear| Flag | Alias | Description |
|---|---|---|
--port |
Port to listen on, on 127.0.0.1 only. Default 8733; 0 lets the system pick one, and the address is printed |
|
--path |
URL path that receives the webhook. Default /webhook; it must start with / |
|
--team |
Serve only sessions whose issue is in this Linear team key (case does not matter). A session of another team is acknowledged and ignored, so another listener can own it | |
--non-interactive |
Accepted like on every command; this command never prompts | |
--yes |
-y |
Accepted like on every command; this command never prompts |
The default port is 8733. It is not 8787, which this repository’s own docs worker pins for local
development, and it is not one of the ports development servers usually take (3000, 4000, 5173, 8000,
8080, 9000). It is also below the ranges operating systems hand out to outgoing connections (32768 and up
on Linux, 49152 and up on macOS), so nothing on your machine is likely to be holding it. On a collision, pass
another with --port.
What it needs
Section titled “What it needs”Three git config keys. If one is unset, the command exits 2 with missing_value before it listens, and says
which key to set. No secret is ever printed.
linear.agentTokenthe agent app’s OAuth token. It writes the activities you see in the Linear session.linear.webhookSecretthe webhook’s signing secret.linear.apiKeyyour personal Linear API key.worktree branch --linearuses it to read the issue, and it identifies who the listener serves by default.
Then it asks Linear who the token belongs to. If Linear rejects the token, the command exits 1 before it listens.
Who may start an agent
Section titled “Who may start an agent”A delegation runs a coding agent, with your credentials, on your machine, from text anyone in the Linear workspace can write. So only an allowed person can start one.
- With
linear.agentAllowset to a comma-separated list of Linear user ids, those people are allowed. - With it unset, only the owner of
linear.apiKeyis allowed, which Linear tells the command at start-up.
A session started by anyone else gets an error in the Linear session and no worktree. The allowed ids are
printed at start-up.
Issue text
Section titled “Issue text”An issue’s description and comments are writable by everyone with access to the issue, and a delegation hands them to an agent that can run commands. So by default none of it reaches the agent.
Set linear.agentContext to true to change that, on a machine where you accept the
risk. Unset or false means off, and any other value is refused. With it on and a session that carries issue
text, the brief is the same fixed line followed by that text inside a fenced block labelled as untrusted issue
content. Text inside the block cannot close it, and it is cut at 32 KiB without splitting a character. The text
is never written to the --json output, to a Linear activity or to a log line. Start-up prints whether it is on.
Anyone who can edit or comment on a delegated issue can then steer the agent. Leave it off for a shared workspace.
What happens to a delivery
Section titled “What happens to a delivery”For each request, in this order:
| Check | Answer |
|---|---|
The method is not POST |
405, with Allow: POST |
The path is not --path |
404 |
| The body is over 1 MiB | 413, once the rest of the body has been read and thrown away; a sender still writing 5 seconds later is disconnected with no reply. The 5 seconds count from the headers when Content-Length declares more than 1 MiB, and from the byte that passes 1 MiB otherwise |
The Linear-Signature header is not the HMAC-SHA256 of the raw body under linear.webhookSecret |
401 |
| The body is not JSON | 400 |
The body is JSON but not an agent session event the listener acts on: another webhook category, an action other than created or prompted, a session with no issue, or null |
200, logged as ignored, and nothing else happens |
The body’s signed webhookTimestamp is more than a minute from now, either way |
401 |
| Otherwise | 200, sent before any work starts |
The signature is checked on the raw bytes, before the body is parsed. Freshness is judged on the
webhookTimestamp inside the signed body, not on the unsigned Linear-Timestamp header, because a replayed
body could carry a fresh header.
After the 200, the listener writes to the Linear session: a thought at once (Linear expects a first
activity within ten seconds), then one child process, then a response or an error. Children run one at a
time: a session’s child starts only once the one before it has finished, because two worktree branch runs in one
repository contend for git’s locks, and two sessions for one issue would race to create one branch. A waiting
session’s thought says how many sessions are ahead of it. The child is
worktree branch --linear <KEY> --agent-stdin --json --yes, run with an argument list and no shell, with the
brief on stdin. By default the brief names the issue identifier and nothing else of the issue: no title, description or
comment reaches the agent (see Issue text for the opt-in). The identifier is checked against ^[A-Za-z][A-Za-z0-9]*-\d+$ before it reaches the
command.
- A session id seen before is ignored, so a retried delivery does nothing twice.
- A branch that already exists is a
responsesaying so, not an error. - A follow-up message to a session whose agent this listener prompted gets a
responsesaying follow-ups are not delivered to the agent. A follow-up to any other session (a refused or failed one, or one from before a restart) gets nothing, since there is no agent it could be talking to. - The child is stopped after 15 minutes and the session gets an
error.
Output and stopping
Section titled “Output and stopping”Start-up prints the local address, the path, the team and the allowed creators on stdout. Each delivery logs one
status= line on stderr with the Linear-Delivery id and the status it was answered with, or status=- for an
oversized delivery that was disconnected before it finished sending. An accepted delivery
also logs a line per step (such as session.done, or activity.failed when Linear could not be written to), each
starting with the same delivery id. Secrets are redacted.
SIGTERM (kill -TERM <pid>) stops accepting connections and waits for the session in flight, so a worktree is
not left half made. Sessions still waiting their turn are not started; each gets an error saying the listener
stopped. A Ctrl-C in the terminal does the same for the listener, but the terminal also sends the
interrupt to the running worktree branch, which stops at once and may leave a half-made worktree; the session then
gets an error. A second signal exits the listener at once, with 130.
Exit codes
Section titled “Exit codes”| Code | Means |
|---|---|
0 |
stopped by a signal, after the session in flight finished |
130 |
a second signal while stopping |
1 |
Linear rejected the token or the key, or the port could not be bound |
2 |
linear.agentToken, linear.webhookSecret or linear.apiKey is unset (missing_value), or --path does not start with / |