Dispatch from Your Code
Run device-side work on a user's companion straight from your product code.
Chat and scheduled runs dispatch to companions 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 { dispatchCompanionRun, getCompanionRunStatus, listCompanionDispatchTargets } from "@repo/api/companion";
const targets = await listCompanionDispatchTargets(userId);
const target = targets.find((t) => t.dispatchGranted && t.connections.length > 0);
if (!target) return; // nothing granted - prompt the user (see Consent below)
const { runId } = await dispatchCompanionRun({
userId,
deviceId: target.deviceId,
connectionId: target.connections[0].toolId,
input: "Audit https://example.com and save the result.",
origin: "site-audit"
});
const status = await getCompanionRunStatus(runId); // queued | collected | completed | failed | expiredServer-side only - these are plain functions with server authority, not HTTP routes. Entity scoping is your responsibility.
Consent: the per-device grant
Product-code dispatch is denied by default. Each user allows it per device from the Companions page (the device card menu: "Allow app-dispatched runs"); unpairing a device revokes it. dispatchCompanionRun throws a typed CompanionDispatchError (code: "dispatch_not_granted") for an ungranted device - catch it and point the user at the Companions page. Chat and schedules are unaffected: the user initiated those directly.
How results come back
The run acts through the capabilities you compose - the same tools chat and schedules inject. Give the run a capability that persists its result (e.g. save_audit_result) and treat the status as a completion signal, not a result channel:
- 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 (approximate: a run that still collects self-corrects)
- null - unknown run, or records aged out (about 25 hours)
Attribution
Pass origin (e.g. "site-audit") and the user's device records it in the local companion audit log (opencompanion log), so app-initiated work is attributable on the machine it ran on.
Refusing dispatch on the device
The user can also refuse app-dispatched work on the machine itself, independent of your app. The daemon's policy set --url <app> --dispatch deny makes this device locally refuse product-code dispatches (--schedule deny does the same for scheduled runs), and policy show prints the current stance. Chat is always allowed and can never be refused.
This device-side deny is belt-and-suspenders, not the primary gate. A dispatch is recognised only by its origin tag, so a backend that omits origin is wire-indistinguishable from a chat turn - the device-side deny therefore defends against honest backends only. The per-device grant above (denied by default) remains the real gate for app-dispatched work; a trusted wire-level surface field is future work.
Limits
- One explicit device per dispatch - pick from
listCompanionDispatchTargets(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 schedules - your product owns its own rate discipline. A dispatch loop pins the target device's companion at the fast poll tier and grows its 24-hour queue.
Your Agent on a Companion
A companion 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.
Point at a Shared Companion
Run the companion feature with zero daemon maintenance - point your project at an existing companion repo (OpenCompanion by default) and let your app render its install command.