---
title: "Dispatch developer guide"
description: "How Dispatch's package and generated-app source are split, the orchestrator's remote agent registry, scaffolding a workspace with Dispatch, and customizing a curated workspace template."
---

# Dispatch developer guide

This page is for engineers extending Dispatch or customizing the template: how the orchestrator finds other agents, how the published package and your generated app source are split, and how to add your own management screens or create a private app from another template. See [Dispatch action and data reference](/docs/template-dispatch-reference) for the full action inventory, database schema, and route list.

## An action walked through {#action-walkthrough}

`approve-vault-request` looks trivial from the outside: mark a request approved, create the secret, grant it. The store function behind it, `approveRequest` in `packages/dispatch/src/server/lib/vault-store.ts`, handles a real problem that trivial version ignores: two admins approving the same request at the same moment.

<AnnotatedCode
  id="doc-block-dispatch13"
  title="approveRequest (vault-store.ts)"
  filename="packages/dispatch/src/server/lib/vault-store.ts"
  language="ts"
  code={
    'export async function approveRequest(\n  requestId: string,\n  secretValue: string,\n  secretName?: string,\n  ctx: VaultCtx = requireVaultCtx(),\n) {\n  await assertCanManageVault();\n  const db = getDb();\n  const request = await getRequestForTenant(requestId, ctx);\n  if (!request) throw new Error("Request not found");\n\n  const timestamp = now();\n  const reviewer = ctx.ownerEmail;\n  const staleApplyingBefore = timestamp - 5 * 60 * 1000;\n\n  // Fence the transition on the current status — scoped to caller\'s tenant —\n  // so a concurrent approve can\'t both win: only the caller that flips\n  // pending -> applying proceeds to create the secret/grant. A crashed worker\n  // leaves an applying row behind, so a later reviewer may reclaim a lease\n  // that has been idle for five minutes. See\n  // claimAgentTeamRun in packages/core/src/server/agent-teams-run-queue.ts\n  // for the same pattern.\n  const claimed = await db\n    .update(schema.vaultRequests)\n    .set({\n      status: "applying",\n      updatedAt: timestamp,\n    })\n    .where(\n      and(\n        eq(schema.vaultRequests.id, requestId),\n        tenantScope(schema.vaultRequests, ctx),\n        or(\n          eq(schema.vaultRequests.status, "pending"),\n          and(\n            eq(schema.vaultRequests.status, "applying"),\n            sql`${schema.vaultRequests.updatedAt} < ${staleApplyingBefore}`,\n          ),\n        ),\n      ),\n    )\n    .returning();\n\n  if (claimed.length === 0) {\n    const current = await getRequestForTenant(requestId, ctx);\n    if (current?.status === "applying") {\n      throw new Error("Vault request is already being applied");\n    }\n    return current;\n  }\n\n  const claimedRequest = claimed[0];\n\n  // Secret + grant must land in the REQUEST\'s tenant, not the approver\'s\n  // (the approver may be acting on behalf of another user in the same org).\n  const requestCtx = ctxForRow(claimedRequest);\n\n  try {\n    // Check if secret already exists in the request\'s tenant for this key.\n    const existingSecrets = await db\n      .select()\n      .from(schema.vaultSecrets)\n      .where(\n        and(\n          eq(schema.vaultSecrets.credentialKey, claimedRequest.credentialKey),\n          ctxScope(schema.vaultSecrets, requestCtx),\n        ),\n      );\n    let secret = existingSecrets[0] ?? null;\n\n    if (!secret) {\n      secret = await createSecret(\n        {\n          credentialKey: claimedRequest.credentialKey,\n          value: secretValue,\n          name: secretName || claimedRequest.credentialKey,\n        },\n        requestCtx,\n      );\n    }\n\n    if (secret) {\n      // Create the grant in the request\'s tenant as well.\n      await createGrant(secret.id, claimedRequest.appId, requestCtx);\n    }\n  } catch (error) {\n    await db\n      .update(schema.vaultRequests)\n      .set({ status: "pending", updatedAt: now() })\n      .where(\n        and(\n          eq(schema.vaultRequests.id, requestId),\n          tenantScope(schema.vaultRequests, ctx),\n          eq(schema.vaultRequests.status, "applying"),\n        ),\n      );\n    throw error;\n  }\n\n  await db\n    .update(schema.vaultRequests)\n    .set({\n      status: "approved",\n      reviewedBy: reviewer,\n      reviewedAt: timestamp,\n      updatedAt: now(),\n    })\n    .where(\n      and(\n        eq(schema.vaultRequests.id, requestId),\n        tenantScope(schema.vaultRequests, ctx),\n        eq(schema.vaultRequests.status, "applying"),\n      ),\n    );\n\n  await recordVaultAudit({\n    action: "request.approved",\n    appId: claimedRequest.appId,\n    summary: `Approved ${claimedRequest.credentialKey} for ${claimedRequest.appId} (requested by ${claimedRequest.requestedBy})`,\n    metadata: { requestId, reviewer },\n  });\n\n  return getRequestForTenant(requestId, ctx);\n}'
  }
  annotations={[
    {
      lines: "16-42",
      label: "Concurrency lock via conditional update",
      note: "The claim only succeeds if the row is still pending, or stuck applying for over five minutes. Two simultaneous approvals can't both win: only one caller's update actually matches a row and returns it.",
    },
    {
      lines: "44-50",
      label: "Losing the race isn't an error",
      note: "If the claim matched nothing, the request is either already mid-flight elsewhere (throw) or was already resolved by the time this call ran (just return its current state).",
    },
    {
      lines: "59-69",
      label: "Reuse an existing secret instead of duplicating one",
      note: "A second request for the same credential key finds the secret already created by an earlier approval and grants access to it, rather than creating a duplicate.",
    },
    {
      lines: "86-98",
      label: "Roll back the lock on failure",
      note: "If creating the secret or grant throws, the request goes back to pending instead of staying stuck in applying, which is what makes the five-minute stale-lease reclaim above necessary in the first place.",
    },
    {
      lines: "100-114",
      label: "Finalize only from the lock this call holds",
      note: "The final flip to approved is still scoped to status = applying, so it can only apply the transition this specific call claimed.",
    },
  ]}
/>

## Orchestrator agent and remote agent registry {#orchestrator}

### Routing to specialist agents

The chat is set up as a router: it reads `AGENTS.md` and `LEARNINGS.md` and routes to specialist sub-agents or remote A2A agents. A2A agent manifests are workspace-runtime entries, not a checked-in template source folder. In a multi-app workspace, sibling apps under `apps/` are auto-discovered as A2A peers, with no manual registration needed. Dispatch calls them using the `call-agent` action.

### Webhooks and vault tables

Server plugins register each connected channel's webhook (Slack, Microsoft Teams, Discord, Telegram, WhatsApp) and forward incoming messages to the orchestrator agent. Vault tables (secrets, grants, requests, approvals, audit logs) are Drizzle tables defined in the `@agent-native/dispatch` package (`packages/dispatch/src/db/schema.ts`). The template's own `templates/dispatch/server/db/index.ts` re-exports that same schema via `createGetDb` rather than declaring a template-local `server/db/schema.ts`.

## Delivery destinations and queue internals {#delivery}

That inbound flow has an outbound counterpart. **Destinations** (`/admin/destinations`) is where reusable delivery targets are saved: a Slack channel, a Telegram chat, or an email address, each with a name, platform, destination id, and an optional thread/topic reference and notes.

Dispatch delivers messages through a queue-and-processor pattern rather than a fire-and-forget call, so a slow or failed delivery can't block whatever triggered it:

<Steps>

### Save a destination

From **Admin → Destinations**, save a target with a name, platform, and destination id.

### Send through it

A job or the agent calls `send-platform-message` with that destination. The message is queued, not sent inline.

### A processor delivers it

A separate processor drains the queue and does the actual delivery.

### Check the result

The same **Destinations** page shows a queue-health panel, and each saved destination can send its own quick test message.

</Steps>

Agents manage destinations directly with `list-destinations`, `upsert-destination`, and `delete-destination`.

## Workspace connections and provider access {#workspace-connections}

A **workspace connection** is a saved, shared account for one provider — Slack, GitHub, Notion, Salesforce, and more — that any granted app can use without doing its own OAuth or holding its own key. Setting one up, granting it, and querying it are separate steps on purpose, so a plan or preview never has to touch a secret value.

<Steps>

### Plan the setup

`plan-workspace-connection-setup` inspects the provider or an existing connection and returns what's needed: required and suggested credential ref names, which apps commonly use it, and a summary of current grants. It never returns secret values.

### Apply it

`apply-workspace-connection-setup` saves the connection using those credential ref names only. Anything that looks like a raw secret — a Slack token, a GitHub PAT, a private key — is rejected outright.

### Grant or preview impact

`set-workspace-connection-grant` grants or revokes one app's access, or switches the whole connection between all-apps and selected-apps only. `preview-workspace-connection-impact` shows which apps would lose access before a revoke, disable, or delete.

### Query it

Once a connection exists, the agent reaches it through `provider-api-request`, optionally staging the response with `stageAs` instead of returning it inline.

</Steps>

`list-workspace-connections` lists the provider catalog, saved connections, and every app's current access in one call; `delete-workspace-connection` removes a connection's metadata and grants, and secret values stay in Vault or OAuth stores either way. See [Dispatch action and data reference](/docs/template-dispatch-reference#actions) for the full action list.

### Calling a provider directly

A grant is not a capability limit. When a canned action doesn't cover the exact endpoint, filter, or pagination mode needed, `provider-api-request` makes the actual authenticated call:

<AnnotatedCode
  id="doc-block-dispatch-provider-api"
  title="provider-api-request call"
  filename="provider-api-request call"
  language="json"
  code={
    '{\n  "provider": "slack",\n  "path": "/conversations.history",\n  "query": { "channel": "C0123456789", "limit": 50 },\n  "stageAs": "support-channel-last-50"\n}'
  }
  annotations={[
    {
      lines: "2",
      note: "`provider` is one of the configured provider APIs. Use `provider-api-catalog` to see the full list and each provider's auth style.",
    },
    {
      lines: "3",
      note: "`path` is the provider's real REST path, not a Dispatch-invented shape — copy it from `provider-api-catalog`/`provider-api-docs` when unsure.",
    },
    {
      lines: "4",
      note: "Query params are passed straight through to the provider's HTTP API.",
    },
    {
      lines: "5",
      label: "Stage instead of return",
      note: "`stageAs` writes the parsed response rows into a staged dataset instead of returning the full payload — query it afterward with `query-staged-dataset`. A staged dataset is scoped to the requesting app and user, capped at 200,000 rows or 50MB.",
    },
  ]}
/>

### Sharing MCP servers across the workspace

Dispatch can also act as the workspace's MCP hub: an HTTP MCP server uploaded once as an `mcp-server`-kind workspace resource, under `mcp-servers/<name>.json`, is inherited by every app the same way a skill or instruction is, instead of each app configuring its own server list. Scope it to All apps or selected app grants, the same as any other workspace resource.

## Dreams internals {#dreams-internals}

Dreams collects evidence from real agent runs, thread debug data, feedback, evals, and repeated tool failures, then writes a report with proposed changes. Because that evidence comes from external, untrusted sources — chat transcripts, webhooks, third-party integrations — the pipeline bounds what a single message can do to a report:

- **Bounded evidence length.** Quoted evidence is truncated before it's written into a proposal, roughly 120–260 characters for most snippets and up to 1,200 characters for full message/row text, so one oversized message can't dominate a report.
- **Secret redaction.** Secret-looking substrings, like `sk-...` keys, `Bearer ...` tokens, and long base64 blobs, are redacted out of evidence text before it's compiled.
- **Injected-context stripping.** A user message containing a fake `<context>` marker has everything after that marker dropped before the pipeline checks it for correction or frustration signals, so a message can't smuggle instructions into what looks like a genuine user correction.
- **Clamped scan parameters.** Candidate limit, per-source/per-thread timeouts, and concurrency are Zod-validated and clamped at the action boundary, for example `limit` 1–50 and `sourceTimeoutMs` up to 60 seconds, so a malformed or oversized request can't force an unbounded scan.

`list-dream-candidates` finds recent threads with grounded signals: explicit user corrections, failed runs, tool errors, feedback, eval failures, and successful checkpointed workflows. Pass `sourceId: "all"` or `sourceIds` to scan multiple thread-debug sources; `sourceTimeoutMs`, `sourceConcurrency`, `sourceStartStaggerMs`, `threadConcurrency`, and `threadTimeoutMs` keep production scans partial and bounded, and the response includes per-source health. `create-dream-report` writes the report and pending proposals; multi-source reports include a Source Health section so partial scans stay visible during review. Repeated corrections and recurring failures can become `workspace-instruction` proposals; repeated successful checkpointed workflows can become `workspace-skill` proposals.

In development, the same workflow runs from `apps/dispatch` through the local action runner:

```bash
pnpm action get-dream-settings
pnpm action set-dream-settings --enabled true --schedule "0 9 * * 1" --allSources true --limit 8
pnpm action create-dream-report --allSources true --sourceTimeoutMs 30000 --limit 8
```

## Agent profile imports {#agent-profile-imports}

Two import paths land in the same place: a runnable profile at `agents/<slug>.md`.

### Importing a single profile

`import-agent` normalizes a Claude Markdown or JSON agent definition into `agents/<slug>.md`, skipping credentials, hooks, shell access, and local env it might reference.

### Importing an agent pack

`import-agent-pack` handles a Claude Project- or Cowork-style folder: a full bundle instead of a single file.

<Steps>

### Hand it the folder

Point `import-agent-pack` at a Claude Project or Cowork-style export.

### The profile lands at the usual path

The runnable profile still ends up at `agents/<slug>.md`, the same place a single-file import would put it.

### Everything else lands beside it

Sibling context, references, and skills land under `agents/<slug>/context/`, `agents/<slug>/references/`, and `agents/<slug>/skills/<name>/SKILL.md`. Use `list-agent-pack` to inspect the files, and the shared agent pack UI to edit them.

</Steps>

## Package vs app-owned surface split {#package-split}

### What the package owns

`@agent-native/dispatch` owns the shell, sidebar, vault schema, and every built-in page: vault, integrations, messaging, operations, destinations, metrics, thread debug, audit, identities, dreams, approvals, automations, and the extensions catalog. Most files under `templates/dispatch/app/routes/` are thin re-exports of those packaged pages, for example:

```ts
export { default, meta } from "@agent-native/dispatch/routes/pages/operations";
```

`integrations.tsx` is the one route that's a full custom page in the template, reusing shared package components directly.

### What the app owns

The app-owned action surface in `templates/dispatch/actions/` covers Workspace Connections, staged datasets, and the `provider-api-*` escape hatch. Everything else, like vault, dreams, resources, destinations, approvals, thread debug, audit, identities, and metrics, is a packaged action from `@agent-native/dispatch/actions`.

<FileTree
  id="doc-block-dispatch7"
  title="packages/dispatch vs templates/dispatch"
  entries={[
    {
      path: "packages/dispatch/src/actions/",
      note: "Vault, dreams, resources, destinations, approvals, thread-debug, audit, and identities actions",
    },
    {
      path: "packages/dispatch/src/db/schema.ts",
      note: "Vault, resources, destinations, dreams, approval, and audit tables",
    },
    {
      path: "packages/dispatch/src/routes/pages/",
      note: "Built-in screens: overview, vault, integrations, messaging, operations, destinations, metrics, thread-debug, audit, identities, tools, new-app, ...",
    },
    {
      path: "templates/dispatch/actions/",
      note: "App-owned: workspace connections, staged datasets, provider-api-catalog/docs/request, run, view-screen",
    },
    {
      path: "templates/dispatch/app/routes/*.tsx",
      note: "Mostly thin re-exports of the packaged pages; integrations.tsx is a full custom page",
    },
    {
      path: "templates/dispatch/app/dispatch-extensions.tsx",
      note: "Where a generated workspace registers its own nav items and query keys",
    },
    {
      path: "templates/dispatch/AGENTS.md",
      note: "Dispatch-specific agent instructions",
    },
  ]}
/>

## Operator console internals {#operator-console}

`/admin/operations` is the focused operator console. It has two tabs, and both reuse existing framework surfaces rather than building new ones.

### Monitoring

Reuses the shared observability dashboard: traces, conversations, evaluations, experiments, and feedback.

### Database

Reuses Code-mode database admin, for browsing and editing the workspace's SQL tables directly.

Use `navigate --view operations|monitoring|observability|database` or `view-screen` to align the agent with whichever tab is open.

### Thread Debug

`/admin/thread-debug` searches across the current or a configured thread-debug source by title, preview, message text, tool name, or owner email, or you can paste a thread id directly. A selected thread opens with **Transcript**, **Runs**, **Internals** (debug runs, trace summaries and spans, feedback and evals), and **Raw** tabs. Agents use `list-agent-thread-sources`, `search-agent-threads`, and `get-agent-thread-debug`.

## Scaffolding a workspace with Dispatch {#scaffolding}

<Steps>

### Scaffold a workspace

```bash
npx --yes @agent-native/core@latest create my-platform
# Dispatch is included automatically. Deselect Chat for Dispatch only, or select domain apps to scaffold alongside it
```

Or name the template directly instead of using the picker:

```bash
npx --yes @agent-native/core@latest create my-platform --template dispatch
# add more apps in the same workspace as you go
```

### Add at least one domain app

There is no meaningful `--standalone` Dispatch: a control plane with nothing to coordinate is just an empty inbox. Scaffold it into a workspace with at least one domain app so it has agents to route to over A2A. The flag still works and produces a runnable app, but the orchestrator has no specialists to delegate to until you add sibling apps.

### Install and run

From the workspace root, `pnpm install` and `pnpm dev` bring up the dev server. Dispatch's shared auth, database, and brand are inherited from the workspace core. See [Multi-App Workspace](/docs/multi-app-workspace) and [Getting started](/docs/getting-started) for the rest of local setup, including account creation and connecting an LLM provider.

</Steps>

## Customizing it {#customize}

### Ask the agent

Dispatch is a full template like any other. See [Templates](/docs/cloneable-saas). Ask the agent to "add a new integration for Datadog" or "route Slack DMs from channel X to the analytics agent" and it'll edit the routing config, add the webhook handler, and wire it up.

### Add a management screen by hand

For workspace-specific management screens, add local React Router pages and register them in `app/dispatch-extensions.tsx`. The generated workspace owns only the extra tab and route; `@agent-native/dispatch` keeps owning the shell, sidebar, built-in pages, and future package updates.

<AnnotatedCode
  id="doc-block-dispatch8"
  title="app/dispatch-extensions.tsx"
  filename="app/dispatch-extensions.tsx"
  language="tsx"
  code={
    'import type { DispatchExtensionConfig } from "@agent-native/dispatch/components";\nimport { IconChartBar } from "@tabler/icons-react";\n\nexport const dispatchExtensions = {\n  navItems: [\n    {\n      id: "reports",\n      to: "/reports",\n      label: "Reports",\n      icon: IconChartBar,\n      section: "operations",\n    },\n  ],\n  queryKeys: ["list-reports"],\n} satisfies DispatchExtensionConfig;'
  }
  annotations={[
    {
      lines: "1",
      note: "`DispatchExtensionConfig` is exported by the package so the generated workspace's extension file stays type-checked against it.",
    },
    {
      lines: "5-13",
      label: "Extra tab",
      note: 'Each nav item needs a matching `app/routes/*.tsx` file at `to` — the sidebar renders it, but Dispatch\'s shell and built-in pages don\'t change. `section` is `"primary"` or `"operations"`.',
    },
    {
      lines: "14",
      label: "Live sync",
      note: "`queryKeys` lists the action names your new route's queries use, so Dispatch's shared DB-sync refresh loop invalidates them too.",
    },
  ]}
/>

## App creation hand-off {#app-creation}

"Build me an app for tracking customer onboarding" and the Apps page's **New app** button both start at `start-workspace-app-creation`, which branches on whether the workspace is running locally or in a hosted deployment. Dispatch itself never writes the new app's code either way: it hands the request to whichever coding agent is already running locally, or kicks off a real Builder Cloud Agent branch.

### Local development

`start-workspace-app-creation` records the new app's metadata, grants any vault secrets or resources you specified, and returns without touching the filesystem. Your prompt goes back with it, meant for the coding agent already running in your terminal, such as Claude Code, to act on. Dispatch's own chat model doesn't scaffold the app itself.

`scaffold-workspace-app` is the one action that writes files directly, by shelling out to `agent-native add-app --template <name>`. It's local-only and limited to the fixed first-party template catalog (mail, calendar, content, slides, clips, brain, assets, analytics, forms, design), which is what a curated-template remix calls, not a free-text request. See [Create an app from a curated workspace template](#remix) below.

### Hosted deployments

Without a local coding agent to hand off to, `start-workspace-app-creation` resolves Builder API credentials, reuses or provisions a Builder project for the workspace (an org owner or admin sets this up once), reserves a pending app entry, and calls Builder's agent-run API to start a real Builder Cloud Agent branch. If credentials or project setup aren't in place, it returns an explicit unavailable result with the reason instead of failing silently.

`get-app-creation-settings` / `set-app-creation-settings` manage exactly one thing: which Builder project new app-creation branches use. Default app visibility, private or org-wide, is a separate, unrelated setting.

### How a pending app becomes a ready one

Dispatch never polls the Builder job itself. `list-workspace-apps` merges two sources: the real deploy manifest of apps the workspace can already reach, and a pending-apps list written when creation started. An app drops off the pending list the moment its id shows up in the manifest, which happens once the Builder branch has merged and the workspace has redeployed with it. The Apps page re-polls that same action to move an app from pending to ready.

## Create an app from a curated workspace template {#remix}

Curated workspace templates are reviewed sources for creating an independent private app: mail, calendar, analytics, slides, content, clips, brain, assets, forms, or design. This is the same [app creation hand-off](#app-creation) above, applied to a fixed template instead of a free-text request.

<Steps>

### Browse the catalog

`list-curated-workspace-templates` returns the reviewed catalog: stable metadata, the live product URL, setup guidance, and whether each source template is already installed in the current workspace.

### Remix one into a new app

`remix-workspace-template` creates a private, independent app. In a local runtime it scaffolds the template directly. In a hosted workspace it starts a Builder app-creation branch instead. The existing action and `<template>-remix` app id remain stable for compatibility. The request must use empty or synthetic seed data only, and never copies the source app's records, credentials, secrets, or private configuration.

</Steps>

## What's next

- [**Dispatch**](/docs/template-dispatch) — the overview and getting-started steps
- [**Dispatch action and data reference**](/docs/template-dispatch-reference) — the full action inventory, database schema, and route list
- [**Features**](/docs/template-dispatch-features) — every feature area, with setup steps for each
- [**Talk to the Agent**](/docs/template-dispatch-agent) — prompts by task and how the agent sees your screen
- [**Dispatch in a Multi-App Workspace**](/docs/template-dispatch-integrations) — how other apps connect to Dispatch, and how Dispatch discovers them
- [**Creating Templates**](/docs/creating-templates) — the current build pattern for a full template
- [**A2A Protocol**](/docs/a2a-protocol) — how Dispatch delegates to specialist agents
