/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 includemodels: [{ 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 exposedelegate_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 callget_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.