> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twill.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Desktop runner protocol

> How the connected desktop runner authenticates and executes work.

The desktop runner uses `/api/v1/runners`. This protocol is separate from
workspace API keys: it authenticates with a personally owned runner credential scoped to your persistent personal
workspace. Workspace API keys are cloud-only: they cannot connect a runner,
list local projects, or start, read, or stop local work.

## Connection

The desktop creates a private verifier and sends its SHA-256 challenge through
the signed-in setup flow. The server issues a short-lived pairing code bound
to that challenge, workspace, and user. `POST /enroll` redeems the code and
verifier with a runner-generated credential and capability registration. A
pairing code alone cannot connect a computer.

Reconnection binds the grant to the existing runner ID and the same workspace
owner. Enrollment includes `reconnect: { runnerId, credential, stopped: true }`,
where `credential` is the previous native credential. The native process holds
the connection's exclusive broker lease and confirms every owned process group
has stopped before making this assertion. The server requires that the old
connection is revoked, finalizes its claimed runs, and rotates its credential
without replacing project identities. Unclaimed runs remain queued. Retrying
the new credential recovers the same enrollment receipt after a lost response;
it does not re-enable a subsequently revoked connection.

Authenticated runner calls carry `Authorization: Bearer twill_runner_<token>`. The runner
makes outbound requests; Twill does not expose a local shell endpoint to the
browser. Project registrations omit absolute native paths and harness credentials. Agent output is synced separately and can contain paths.

`GET /identity` returns `{ runnerId, workspaceId, userId }` for the presented
runner credential. It also accepts a revoked credential so Desktop can locate a
connection migrated to its personal workspace without re-enabling it. It returns
no tasks or credentials. Heartbeats include the current `workspaceId`; Desktop
updates ownership without moving source folders, worktrees, or local journals.
API keys and cloud-workspace pairing grants cannot enroll local runners.

## Capabilities

Each capability may include `models: [{ id, name }]` and `modelSource`
(`discovered`, `configured`, or `fallback`). Model IDs are native harness IDs,
without Twill's harness prefix. Registration and heartbeats accept up to 2,000
models per harness, with a 4 MiB request limit. The same metadata is returned with
local execution environments. An explicit empty list means no models are
available; an absent list preserves compatibility with older runners. Local task
dispatch validates the selected model against this catalog.

Project registrations with an empty `repositories` array identify the runner’s projectless execution environment. They are excluded from the project picker. Each task in that environment receives its own native folder; no local paths are sent to Twill.

Updated runners advertise `nativeControls` alongside each harness capability, including
its supported `commands`, `fullAccess`, and `questions`, an optional
`harnessCommands` inventory, plus the optional
`sessionFork`, `finishBackgroundWait`, and `asyncQuestions` flags for runners
that accept fork manifests, background-wait controls, and live question answers. Older registrations remain
valid and do not advertise these new controls. Local execution targets accept
`permissionMode: "full"` in addition to `"auto"` and `"ask"`. Questions and plan
approvals remain interactive. Run manifests and completion reports can carry
`nativeMode: "plan" | "default"`; native plan results use `planOutcome: "NATIVE"`.
Messages can carry `metadata.nativeMode: "plan" | "default"` to set the harness
mode for that turn, overriding the manifest's inherited mode. This keeps mode
changes separate from the user's message text. Local workers do not apply a
custom system prompt.

A runner can upload a `commands` event with its task-specific harness command
inventory. This refines the global discovery advertised during registration:
`name`, optional `description` and `argumentHint`, and `source` (`builtin`,
`skill`, `custom`, `plugin`, or `mcp`). The inventory is limited to 500 entries.

## Runs and events

| Method | Path below `/api/v1/runners` | Purpose                                                                                |
| ------ | ---------------------------- | -------------------------------------------------------------------------------------- |
| POST   | `/heartbeat`                 | Report presence and harness readiness                                                  |
| POST   | `/projects`                  | Register opaque project and repository identities                                      |
| GET    | `/runs`                      | List this runner's assigned work                                                       |
| POST   | `/signals`                   | Wait for controls, deleted runs, file reads, and new work (long poll)                  |
| POST   | `/runs/{runId}/claim`        | Claim the assigned generation                                                          |
| POST   | `/events`                    | Upload the next event batch of several runs in one request                             |
| POST   | `/runs/{runId}/events`       | Upload sequenced, bounded event batches for one run                                    |
| POST   | `/runs/{runId}/event-parts`  | Stage verified parts of a large logical event                                          |
| GET    | `/runs/{runId}/controls`     | Read ordered cancellation, permission decisions, answers, and background-wait requests |
| POST   | `/runs/{runId}/complete`     | Submit the stopped run's completion and final event sequence                           |
| POST   | `/runs/{runId}/wake`         | Ask for a run to stream the turn a CLI kept for that completed run started             |
| POST   | `/runs/{runId}/syncing`      | Report stopped execution while final output is uploading                               |
| POST   | `/runs/{runId}/stopped`      | Confirm process termination with a revoked credential                                  |

Claims, controls, event sequences, and completion receipts are scoped to the
assigned run and execution generation. A retried batch with the same first
sequence must contain the same bytes. The runner retains events until
acknowledged. Completion waits for event delivery and process termination;
losing the network does not authorize another runner to execute the task.

A runner holds one `signals` request open instead of polling each endpoint. Its
body lists the runs it is executing with their control cursors
(`runs: [{ runId, after }]`), the work `version` it last listed its runs at
(`null` at first), and `waitMs` (at most 10,000). Twill answers as
soon as there are `controls` (`{ runId, sequence, message }`) after a cursor,
`missing` runs that no longer exist for this runner (stop them and treat them as
deleted), `reviewRequests` (task files a browser that cannot reach this computer
asked to see; answer each with `POST /review-requests/{id}`), or a `version` different
from the one sent; otherwise after `waitMs`. A new version means new or
cancelled work: list `/runs` again. Each
`signals` request also counts as presence, like `/runs`. Runners that predate it
keep polling `/runs/{runId}/controls` and `/review-requests`.

`POST /events` takes `batches: [{ runId, generation, firstSequence, events }]`,
one per run and at most 4 MiB in total, and answers `results` in the same
shape as the single-run endpoint, per run: `{ runId, acknowledgedSequence }` or
`{ runId, error: { status, message, details? } }`. One run's error does not fail
the others. A sequence conflict carries `details.expectedSequence`: Twill already
has every event before it (a runner that restarted before a receipt arrived may
batch the same events differently), so the runner acknowledges them locally and
continues from there. Rate-limited requests are answered with status 429 and a
`Retry-After` header.

A `permission` event may include `kind: "question"` and `questions`, each with
`id`, `question`, optional `header`, `options` (`label` and optional
`description`), `multiple`, and `allowCustom`. Answer through the ordered
permission control with `decision: "allow"` and
`answers: [{ questionId, values: ["selected label or custom response"] }]`.
All questions must have an answer matching their allowed choices. Invalid
responses leave the request pending. `decision: "deny"` skips the question and
must omit answers. Ordinary permissions do not accept answers or edited tool
arguments. Only the computer owner can respond.

Codex questions that do not pause execution use an `async_question` event with
`requestId` (the native message item ID) and the same `questions` shape. Twill
stores these asks and their answers independently of the trace. A runner with
`nativeControls.asyncQuestions` receives an `async_question_answer` control with
`id` (the stored interaction ID), `requestId`, and `text` (the quoted question and
answer). Deliver the text to the active run through `sendMessage`, deduplicate
by `id`, and upload an `async_question_answer_result` event with `id` and
`delivered: boolean` before reporting completion. Twill sends an undelivered
answer as a follow-up if the turn has already ended. Older runners receive a
normal queued follow-up instead of an unsupported control.

A `finish_background_wait` control (no other fields) means a follow-up is
queued behind the run. The runner stops holding the run open for background
work: it stops what is still running and reports the run as `completed` with
the answer it already has, never as `cancelled`. Received while the harness is
still working, it applies when that turn ends. It is sent at most once per run,
and only to runners that advertise `nativeControls.finishBackgroundWait`.

A Claude Code run whose turn ends with background work still live may complete
with its answer while the runner keeps that CLI, and its process group, alive
for the work. Twill treats the task as idle. The next run of the task whose
manifest resumes the same session, on the same generation, must be started in
that CLI; any other run of the task or a revoked credential stops it first. When the kept CLI starts a turn on its own, the
runner posts to `wake` for the run that kept it. Twill answers
`{ started: true, jobId }` after queueing a run whose manifest has
`resumeParked: true` (its `input` is a marker; nothing is sent) or
`{ started: false, reason, retry }`. Ask again while `retry` is true; otherwise
stop the CLI. A wake never queues behind a running job: that job owns the CLI's
next turn.

After its process group has stopped, a runner with pending output posts
`{ generation }` to `syncing`. This idempotent phase clears pending permission
prompts and displays **Syncing final output**. It preserves the execution lock:
event uploads and the final `complete` receipt are still required before another
run. The runner must not start a process again for that claim.

Outputs above 240 KiB use `event-parts` with up to 192 KiB of data per part
(base64 encoded in JSON). The request includes the run generation, logical
event sequence, zero-based `part`, and a `reference` with `type: "event_ref"`,
SHA-256 `digest`, and total `sizeBytes`. The hashed bytes are the UTF-8 JSON
journal entry `{ sequence, event }`. One logical event can be up to 32 MiB.
Only the next event of a running or syncing job can stage parts.

After every part is acknowledged, submit that reference as the sole item in an
ordinary event batch. The server verifies the complete entry, stores the original
event, and acknowledges its single sequence. Part and batch retries must match
their original contents. Completion waits for the complete output; native stop
receipts can discard unsynced parts after connection revocation. The runner
retains the original output locally until its event batch is acknowledged.

## Pull-request status

`POST /runs/{runId}/pull-requests` accepts
`{ pullRequests: [{ prUrl, title, state }] }`, with up to 50 GitHub PR URLs and
states `open`, `merged`, or `closed`. The server derives the task from the run,
checks the connected runner's ownership and workspace membership, and upserts
its existing task PR records. Refreshes are accepted after completion. An empty list does not remove previously discovered PRs.

The desktop queries GitHub through its local `gh` login. It persists discovered
PR URLs so merge/close refreshes survive restarts and deleted worktrees. These
best-effort lookups run independently of execution and output delivery.

## Model-driven cloud delegation

Regular local project runs expose `delegate_to_cloud`. The model commits and
pushes its intended changes first, then generates a concise `title` (up to 100
characters) and supplies it separately from `instructions`,
`repositories: [{ repository: "owner/repo", branch: "feature/work" }]`, and
optionally `agent` (a complete provider/model ID) and `workspaceId`. Every
project repository must be included. Omitting `agent` uses the destination
workspace's configured cloud model.

If the local task already has a linked cloud task, later calls continue that
same task with a new handoff message and the latest verified branch commits.
The existing task keeps its title, workspace, and current agent/model;
`title`, `workspaceId`, and `agent` select these only for the first handoff.
The existing checkout is preserved and the agent is instructed to fetch and
incorporate the local work. An active cloud run queues the new handoff.

The model-facing tool requires `title`; a new cloud task uses it verbatim after
trimming whitespace. The endpoint still accepts older runner requests without
a title and uses the local task's title for those requests.

The supervisor forwards this call to `POST /runs/{runId}/delegate` with
`{ generation, invocationId, input }`, using its runner credential. The model
cannot choose the source task, user, or runner. The endpoint accepts at most
256 KiB and requires the calling local run to be current and active.

A unique matching workspace is selected automatically. Multiple matches return
`{ ok: false, message, workspaces }` so the model can ask the user and retry with
their selected `workspaceId`. Missing GitHub remotes or workspace repository
connections return setup guidance. Invalid or unpushed branches do not create
a task.

Missing workspace connections also return `markdown` containing a
`[[CLOUD_SETUP]]` block with the source `taskId`. Include it verbatim outside a
code fence, even though `ok` is false. The chat renders a live GitHub setup card
with repository connection and cloud workspace creation actions. Its choices
and permissions are fetched for the signed-in user, rather than taken from model
output. Missing local GitHub remotes still require the agent to fix the project.

Success returns `{ ok: true, taskId, url, markdown }`. Include the returned
`[[TASK]]` Markdown block in the answer to display a live card. Cloud checkout
uses verified commits and rejects changed refs. Instructions carry the context
and remaining work; no separate handoff-generation run is needed.

Reuse `invocationId` and identical input on transport retries. The linked task
and dispatch message are deduplicated. If creation succeeds but dispatch fails,
the error result also includes `taskId`, `url`, and `markdown`; retry the same
request to attempt dispatch for that task. The local chat stays local.

The MCP connection is scoped to the supervised run and removed on shutdown.
Cloud tasks do not receive it. User settings remain native; only the run's
process configuration gains the tool.

The **Handoff** button in the task header uses the same path: it sends a
one-line chat message and carries the detailed handoff brief in the message's
`metadata.inputContext`, which reaches the agent's prompt but not the chat. The
desktop runner never generates a separate handoff document. Both the brief and
the tool description ask for a fast handoff: commit and push the current state
as-is and brief the cloud task from known context, without verifying first. In
the app, the request interrupts a running turn and is promoted ahead of earlier
queued messages.

## Cloud environment setup

Local project agents can call `get_cloud_workspace_ssh` to configure the main
cloud environment without delegating their task. The runner forwards this to
`POST /runs/{runId}/workspace-ssh` with `{ generation, input: { workspaceId? } }`.
The endpoint checks the active run, ownership, destination access, and repository
coverage before issuing temporary SSH access.

A single matching workspace is selected automatically. Multiple matches return
choices; missing access returns setup guidance. The runner adds `ssh.localCommand`,
a helper that keeps credentials in its private run directory and supports remote
commands and bounded stdin transfers. Use it instead of repeating credentials
in model tool arguments. Setup instructions require consent before copying local
secrets to the shared workspace. See [Cloud environment](/environment).

A successful result also returns `markdown` containing a `[[CLOUD_WORKSPACE]]`
block with the destination `workspaceId`. The instructions ask the agent to
always end its final message with it verbatim, outside a code fence, whatever
the setup outcome. The chat renders a workspace card with **Open** (terminal,
VS Code, Cursor, running ports) and **Configure secrets** actions. Access and
workspace details are resolved for the signed-in viewer, not taken from model
output.
