Skip to main content
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

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. 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.