GenerateSaaS

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

Server-side only - these are plain functions with server authority, not HTTP routes. Entity scoping is your responsibility.

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.

On this page