Dispatch from Your Code
Run device-side work on a user's paired runner straight from your product code - what pairing authorizes, the run statuses, and how results come back.
Chat and automated runs dispatch to runners out of the box. This page is the third dispatcher: your own product code sending work to a user's device - an SEO product dispatching a site audit, a deploy tool running a local build check.
Quick start
import { dispatchRunnerRun, getRunnerRunStatus, listRunnerDispatchTargets } from "@repo/api/runner";
const targets = await listRunnerDispatchTargets(userId);
const target = targets.find((t) => t.online && t.connections.length > 0);
if (!target) return; // no paired device is online with a connected CLI
const { runId } = await dispatchRunnerRun({
userId,
deviceId: target.deviceId,
connectionId: target.connections[0].toolId,
input: "Audit https://example.com and save the result.",
origin: "site-audit"
});
const status = await getRunnerRunStatus(runId); // queued | collected | completed | failed | expiredThese are plain server-side functions with server authority, not HTTP routes - entity scoping is your responsibility.
What pairing authorizes
Pairing is the authorization. A user who pairs a runner has authorized the app to run work on that machine, automated and automated runs included, so there is no second per-device grant to find or forget.
- Reach is a union. Pass
organizationIdand the caller also reaches the devices that organization's owner paired, so a workspace runs on the owner's hardware with nobody re-pairing anything. Their own machines stay reachable either way. - The listing is the allowlist.
listRunnerDispatchTargets(userId, organizationId)returns exactly whatdispatchRunnerRunaccepts; anything else is aRunnerDispatchErrorwithcode: "device_not_paired". - You vouch for the organization. Nothing here checks that the user is a member of the
organizationIdyou pass - it is an authorization input, not just a scope. Pass only one you have verified them into. - Two controls remain. The machine's owner can refuse run KINDS locally (
agentrunner origin), and unpairing revokes everything at once.
How results come back
The run acts through the capabilities you compose, so give it one that persists its result and treat the status as a completion signal rather than a result channel.
| Status | Meaning |
|---|---|
queued | Waiting for the device - an offline device holds runs for 24 hours |
collected | The device picked it up and is running it |
completed / failed | The terminal outcome |
expired | The device never came online within the queue window, or the run passed the hard 24-hour ceiling on a started run (approximate: a run that still collects self-corrects) |
null | Unknown run, or records aged out (about 25 hours) |
Pass origin (e.g. "site-audit") and the user's device records it in the local runner audit log (agentrunner log), so app-initiated work is attributable on the machine it ran on.
Refusing dispatch on the device
A dispatched run cannot touch the user's machine - see the trust surface. What a user can decide is whether their machine accepts that kind of work at all:
agentrunner origin show --url https://your-app.com/api # what this device accepts
agentrunner origin set --url https://your-app.com/api --dispatch deny # refuse app-dispatched runs
agentrunner origin set --url https://your-app.com/api --automation deny # refuse automated runsChat is never deniable, an omitted flag is left as it was, and the daemon re-reads this before every run, so a change applies to the next one with no restart. A refused run comes back to your app as a failure like any other terminal error.
The deny matches on the origin tag, so a dispatch that omits origin is indistinguishable from a chat turn and is never refused.
Limits
- One explicit device per dispatch - pick from
listRunnerDispatchTargets(no auto-routing). - No streaming to product code - use capabilities for outputs; the dashboard chat remains the streaming surface.
- Dispatches never coalesce - every call queues one run.
- Dispatch is deliberately unthrottled, like chat and automations, so your product owns its own rate discipline.
Your Agent on a Runner
A runner is a third model source alongside built-in AI and user keys - what it can run, the one call it cannot, and the single constraint your own agent tools must satisfy to reach a device.
Client Architecture
How client code is layered - apps hold thin views, @repo/ui draws shared components, and @repo/app-core owns headless behavior behind host seams.