---
name: external-agents
description: >-
  Connect external agents and MCP hosts (Claude, Claude Desktop, Claude Code,
  ChatGPT custom MCP apps, Codex, Cursor, Claude Cowork, VS Code GitHub
  Copilot, Goose, Postman, MCPJam) to an agent-native app over MCP, and
  round-trip artifacts back into the UI with MCP Apps and deep links. Use when
  adding an action's `link` builder or `mcpApp`, wiring the
  `/_agent-native/open` route, exposing an "ingest" action to MCP/A2A, or
  scaffolding apps from an external agent.
metadata:
  internal: true
---

# External Agents (MCP bridge + deep links)

## Rule

An agent-native app is reachable by any MCP-compatible host (Claude, Claude
Desktop, Claude Code, ChatGPT custom MCP apps, Codex, Cursor, Cowork, VS Code
GitHub Copilot, Goose, Postman, MCPJam, and future standard clients). Keep
setup simple: for workspace or cross-app access, add one remote MCP connector:
`https://dispatch.agent-native.com/_agent-native/mcp`. Dispatch's Agents page
controls whether that single connector reaches all apps or only selected apps,
and Dispatch filters `list_apps`, `ask_app`, and `open_app` to the granted set.
For a deliberately isolated app, add that app directly at
`https://<app>.agent-native.com/_agent-native/mcp` or
`https://<your-host>/_agent-native/mcp`.

OAuth-capable hosts should use the standard remote MCP OAuth flow. Claude
connectors and Claude Code `/mcp` authentication discover the protected
resource, open the Agent-Native authorization page, and store their own tokens.
ChatGPT custom MCP connectors use the same URL: choose OAuth, scan/discover
tools, sign in, and approve the scopes. Local stdio proxying and older clients
can still use `npx @agent-native/core connect <url>`, which mints a per-user,
scoped, revocable token from a logged-in browser session; no shared secret is
copied.

Claude and ChatGPT can cache custom connector tool/resource metadata. After
changing MCP App metadata or the shared `embedApp()` shell, validate with a
fresh tool call; if the host still behaves like the old descriptor, reconnect
the Claude connector or rescan/review the ChatGPT connector.

Once connected, every action that produces or lists a navigable resource SHOULD
return a deep link from a `link` builder, so the external agent can surface an
**"Open in <app> →"** link that drops the user back into the running UI at the
right view and record. Actions can also declare `mcpApp` so hosts that support
MCP Apps render an inline interactive preview. The link is a pure pointer —
the record-focusing write is always scoped to the **browser session**, never
the agent's token.

## Why

External agents are great at producing artifacts (a draft, an event, a
dashboard) but they live in a terminal, chat host, or another app. Without a
bridge, the user gets a wall of JSON and has to go find the thing. MCP Apps
give compatible hosts an inline review/edit surface; the deep-link bridge
closes the loop everywhere else by handing the user a single link that opens
the real app focused on exactly what was produced. It reuses the existing
`navigate` / `application_state` contract the UI already drains every 2s (see
**context-awareness**) — we never invent a second navigation mechanism.

## How

### 1. Connect to hosted apps

Use one connector for normal workspace access:

```text
https://dispatch.agent-native.com/_agent-native/mcp
```

Then open Dispatch → Agents to choose whether the gateway exposes every app or
only selected app IDs. External agents call `list_apps` to see the granted set,
`ask_app` to route a natural-language task over A2A to a granted app, and
`open_app` to return a deep link or inline app preview.

Use a direct app URL only when you intentionally want one isolated app:

```text
https://mail.agent-native.com/_agent-native/mcp
https://<your-app>.agent-native.com/_agent-native/mcp
```

Claude / Claude Desktop: add a custom connector with the URL, click Connect,
then sign in and approve `mcp:read`, `mcp:write`, and `mcp:apps`. Claude Code:
add the same remote HTTP URL, restart if needed, run `/mcp`, and choose
Authenticate. ChatGPT: create a custom MCP connector/app, paste the same URL,
choose OAuth, scan/discover tools, then sign in and approve scopes. Each host
stores its own OAuth tokens; MCP App iframes never receive raw tokens because
the host mediates tool and resource calls over the authenticated MCP
connection.

For local stdio proxying, Codex/Cowork compatibility, or clients without
remote MCP OAuth, use the hosted connect fallback:

```bash
npx @agent-native/core@latest connect https://dispatch.agent-native.com
# or, for an isolated app:
npx @agent-native/core@latest connect https://mail.agent-native.com
```

The command opens the app in the browser, the user clicks **Authorize**, and a
per-user, scoped, revocable token is written to the selected client config. The
no-CLI equivalent is `https://<app>/_agent-native/mcp/connect`, which shows
the copyable MCP URL, Claude / ChatGPT / Cursor / Claude Code / Codex / Other
steps, and static-token fallback for clients that need it.

Re-running `npx @agent-native/core@latest connect <url> --client claude-code` over an older
Claude bearer-token entry is the migration path: the CLI replaces
`Authorization` headers with URL-only OAuth config and tells the user to
authenticate from `/mcp`.

To re-authenticate an already-installed local/fallback client without
reinstalling skills or connectors, use:

```bash
npx -y @agent-native/core@latest reconnect https://dispatch.agent-native.com --client codex
# or:
npx -y @agent-native/core@latest connect reconnect https://dispatch.agent-native.com --client codex
```

With no URL, `reconnect` searches local client configs for the existing Agent
Native MCP entry. With a URL, it reconnects only the clients that already have
that MCP URL; pass `--client` to limit which configs it searches. Pass
`--name <serverName>` only when you need to force a custom server name.

Under the hood: a logged-in browser session mints an `A2A_SECRET`-signed JWT
carrying the caller's `sub` + `org_domain` and a unique `jti`, so tool runs
stay tenant-scoped via `runWithRequestContext`. The existing
`/_agent-native/mcp` endpoint accepts it like any bearer — no new endpoint.
The same Connect page lists and revokes minted tokens by `jti`; treat them
like personal access tokens. Nothing exposes the deployment's shared secret.

### 1a. Generic cross-app verbs + scaffolding

Once connected, on top of the per-action tools the MCP server also exposes a
stable verb set (see `packages/core/src/mcp/builtin-tools.ts`) so an external
agent has a predictable surface without guessing per-app action names:

- `list_apps` — workspace apps + their URLs / running state.
- `open_app({ app, view?, path?, params?, embed? })` — returns a deep link or
  direct same-origin app route (no user-data side effects); surfaces as an
  "Open …" link and, with `embed: true`, an inline full-app MCP App in capable
  hosts.
- `ask_app({ app, message })` — routes a natural-language task to that app's
  in-app agent (delegates to the existing `ask-agent` meta-tool).
- `create_workspace_app({ name, template })` — scaffolds + boots a new app via
  the workspace path (rejects non-allow-listed templates), returns its running
  URL + deep link.
- `list_templates` — the allow-listed templates only.

A same-named template action overrides a builtin (template-over-core
precedence). Disable the set with `MCPConfig.builtinCrossAppTools: false`.

The advertised `tools/list` and `resources/list` catalogs are intentionally
tiny by default for ChatGPT/Claude-style app hosts, including OAuth MCP Apps
callers and generic authenticated remote HTTP/static-token callers. The model
sees the generic app-facing verbs (`list_apps`, `open_app`, `ask_app`, and
app-only `create_embed_session`) and routes UI through
`open_app({ embed: true })`. Stdio/code clients that explicitly identify as
developer clients keep the full connected action surface, and
`publicAgent.expose` remains the opt-in for safe read/ingest tools outside the
compact MCP Apps catalog. Do not rely on action-specific `mcpApp` resources
appearing in ChatGPT/Claude discovery by default; use `open_app` for the
first-class app embed path. If a specific
action truly must remain visible in that compact app-host catalog, set
`mcpApp.compactCatalog: true` as a rare escape hatch.

### 1b. Fast-path expectations for MCP Apps hosts

Keep ChatGPT/Claude paths short. For a known app-facing intent, the external
agent should call the specific action that creates or opens the thing, then let
the MCP App launch the route. Do **not** route simple UI handoffs
through `ask_app`, broad `list_resources`, or generic app-agent delegation just
to find a screen.

Expected shape:

- Email draft: `manage_draft` → inline Mail compose route. The widget calls
  `create_embed_session` itself.
- Dashboard/filter/search: `open_app({ path, embed: true })` or the dashboard
  action with `mcpApp` → inline full app/dashboard route.
- Calendar invite: `manage-event-draft` → inline Calendar event draft route.
- Forms/content/slides/design/clips: create/search action with `mcpApp` →
  inline editor/player route.

`list_apps` is fine when the model genuinely needs to choose among granted
apps. `resources/list`/`resources/read` are host plumbing for MCP Apps UI
resources; they are not a planning strategy. If a host/model repeatedly calls
large discovery tools before obvious app-facing actions, tighten action names,
descriptions, `mcpApp` metadata, or compact-catalog filtering until the direct
path is obvious.

### 2. Add a `link` builder to an action

`defineAction` accepts an optional `link` builder. When set, every MCP/A2A
result for that tool auto-appends a markdown `[label →](absoluteUrl)` block and
a structured `_meta["agent-native/openLink"] = { label, view, webUrl,
desktopUrl }`; `tools/list` adds
`annotations["agent-native/producesOpenLink"]` plus a description suffix so the
external agent knows the tool yields an openable link.

Real example — mail's `manage-draft` (`templates/mail/actions/manage-draft.ts`):

```ts
import { buildDeepLink } from "@agent-native/core/server";

function composeDeepLink(draft: Record<string, string>): string {
  return buildDeepLink({
    app: "mail",
    view: "inbox",
    compose: encodeComposeDraft(draft), // base64url JSON → compose-<id> draft
  });
}

export default defineAction({
  // ...schema, run...
  link: ({ result }) => {
    if (!result || typeof result !== "object") return null;
    const draft = (result as { draft?: Record<string, string> }).draft;
    const id = (result as { id?: string }).id;
    if (!draft || !id) return null;
    return { url: composeDeepLink(draft), label: "Open draft in Mail", view: "inbox" };
  },
});
```

List/search actions point at a record-focused view the same way — mail's
`list-emails` returns
`{ url: buildDeepLink({ app: "mail", view: "inbox", params: { label, search } }), label: "Open list in Mail" }`.

**The `link` contract:** pure, synchronous, **no I/O, no awaits**. It runs
best-effort — a throw, `null`, or `undefined` is swallowed and **never** fails
the tool call. It only reads the call's `args` and `result`; it must not query
the DB, read app-state, or call other actions.

### 2a. Optional MCP Apps UI

For hosts that support the MCP Apps extension, an action can also advertise an
inline UI resource with `mcpApp`. This is a progressive enhancement for flows
where the external agent should hand the user an interactive surface instead of
only text — for example reviewing an email draft, editing a calendar invite, or
choosing between generated dashboard variants.

Use the real React app with `embedApp()` whenever the user needs UI. The mental
model is simple: the action's `link` target is also the MCP App embed target.
Expose the operation as a normal action/tool, return a focused deep link with
`link`, and add `mcpApp.resource = embedApp(...)` so capable hosts load that
same route inline instead of opening a new tab. Keep the `link` fallback even
when adding `mcpApp` — non-UI clients still need the "Open in … →" link.

```ts
import { embedApp } from "@agent-native/core";

export default defineAction({
  // ...schema, run, link...
  mcpApp: {
    resource: embedApp({
      title: "Review draft",
      description: "Open the generated draft in the real Mail compose UI.",
      iframeTitle: "Agent-Native Mail",
      openLabel: "Open in Mail",
    }),
  },
});
```

Do not hand-write one-off plain HTML MCP Apps for product UI; if the action
needs a custom surface, add or reuse a real app route/component first and embed
that route. For known first-party handoffs, prefer a direct action with
`mcpApp` (e.g. Mail `manage-draft`, Calendar `manage-event-draft`) over letting
the model hunt through screens; `open_app({ path, embed: true })` is the
generic escape hatch for full dashboards, filtered inboxes, analyses, or
extension pages.

The host bridges (Claude transplant vs. ChatGPT `window.openai`), embed start
tickets, extension-page `srcDoc` rendering inside chat embeds, host sizing
(`embedApp({ height })`), `sendToAgentChat`, `_meta.ui.domain` rules, and
ngrok/prod testing caveats are documented in
**`references/mcp-apps-embedding.md`**. Read it before changing the
`embedApp()` shell, the `ui://` resource, or any host-bridge behavior.

### 3. The `/_agent-native/open` route

`buildDeepLink(...)` returns the app-relative path
`/_agent-native/open?app=…&view=…&<recordId>=…`. The MCP layer turns that into
an absolute web URL (`toAbsoluteOpenUrl`, using the request origin) and a
desktop `agentnative://open?…` URL (`toDesktopOpenUrl`). When the user clicks
it in any browser or inline webview, `GET /_agent-native/open`
(`createOpenRouteHandler`, mounted by the core routes plugin, gated by
`disableOpenRoute`, customizable via `resolveOpenPath`):

1. Resolves the **browser** session via `getSession` (the auth guard bypasses
   the exact path `/_agent-native/open`).
2. If unauthenticated, serves the configured login HTML **at the same URL**
   (`getConfiguredLoginHtml`); the form's success handler reloads
   `window.location`, re-entering the route authenticated — no `?next=`
   plumbing.
3. Writes the existing one-shot `navigate` application-state command (payload =
   every non-reserved query param + `view`) scoped to the browser session's
   email with `requestSource: "deep-link"`, and decodes a `compose` base64url
   draft into a `compose-<id>` key.
4. 302-redirects to a safe same-origin relative path (`to=`, else `/<view>`,
   else `resolveOpenPath`), forwarding `f_*` filter params so lists/dashboards
   open pre-filtered before the `navigate` command is even drained.

Cross-origin, scheme-relative `//host`, and control-char redirects are rejected
(open-redirect guard). **Identity rule:** the link carries no privileged
state — it is just `view` + record ids + filters. The record-focusing
`navigate` write is scoped to whoever is logged into the browser, never the
external agent's MCP token. See **context-awareness** for the
`navigate`/`application_state` contract this bridges to.

### 4. "Ingest" actions for external agents

An action an external agent reads to pull live app state into its own context
must be: `http: { method: "GET" }` + `readOnly: true` +
`publicAgent: { expose: true, readOnly: true, requiresAuth: true }`. GET +
`readOnly` keeps it side-effect-free and out of the screen-refresh change event;
`publicAgent` is the explicit opt-in (public web routes never imply public
MCP/A2A exposure). Design/content ingest actions MUST read **live** state
(e.g. the Yjs document) — not the stale DB snapshot column — so the external
agent sees what the user actually has on screen.

### 5. Advanced: local development & manual setup

The hosted `connect` flow above is the recommended path. For local dev, run
the app (`pnpm dev` / `pnpm exec agent-native dev`) then point a local agent at it:

```bash
pnpm exec agent-native mcp install --client claude-code|claude-code-cli|codex|cowork \
  [--app <id>] [--scope user|project]
```

It provisions a token (random `ACCESS_TOKEN` into the workspace `.env` for
local dev, or a `signA2AToken` JWT for a detected hosted origin) and writes an
idempotent stdio server entry — `.mcp.json` / `~/.claude.json` for Claude Code,
the `[mcp_servers.*]` block in `~/.codex/config.toml` for Codex, the
Claude-Code JSON shape for Cowork. The entry runs `pnpm exec agent-native mcp serve
--app <id>`, by default a **thin stdio proxy** to the running local app's
`/_agent-native/mcp` (live registry + HMR + correct deep links stay the single
source of truth; `--standalone` builds the registry in-process). Companion
subcommands: `mcp uninstall`, `mcp status`, `mcp token [--rotate]`. You can
also hand-write an `http` `.mcp.json` entry with a token you supply yourself —
the unmanaged equivalent of what `connect` writes.

**Dev vs production tool surface:** in plain local dev
(`NODE_ENV=development` and `AGENT_MODE !== "production"`) the MCP `tools/list`
deliberately exposes only the generic builtins plus actions with
`publicAgent.requiresAuth === false` — per-app ingest (`requiresAuth: true`)
and mutating actions are filtered out (`filterPublicAgentActions`). The full
surface appears when authenticated as a real caller: a deployed /
`AGENT_MODE=production` app, or a local app reached through `connect` /
`pnpm exec agent-native mcp install` (which provisions an identity-bearing token). A
sparse or empty `tools/list` is diagnostic, not proof of auth failure: check
OAuth scopes, compact-catalog filtering, and the client/server auth status
before telling the user they are unauthenticated.

## Do

- Do connect local/fallback clients to Dispatch with
  `npx @agent-native/core@latest connect https://dispatch.agent-native.com`;
  use `npx -y @agent-native/core@latest reconnect ...` for reauth without
  reinstalling; use a direct app URL only when the host should be isolated to
  one app.
- Do add a `link` builder to any action that produces or lists a navigable
  resource (draft, event, dashboard, document).
- Do add `mcpApp` when a UI-capable MCP host should render an inline review or
  edit surface, while keeping the `link` fallback.
- Do use `embedApp()` / `open_app({ embed: true })` when the right UI is the
  existing React app at a specific route, including full app routes and focused
  component routes like an Analytics chart embed.
- Do test real ChatGPT/Claude web behavior with a fresh inline render after any
  resource-shell or host-bridge change; old frames are not proof that a new
  shell is still broken.
- Do build the URL with `buildDeepLink(...)` — it is the single source of truth
  for the open-route format.
- Do keep `link` pure and synchronous; return `null` when there's nothing to
  open.
- Do keep `link` and `mcpApp` metadata pure and synchronous; use `embedApp()`
  so the user sees the shared React UI.
- Do make external-agent read/ingest actions GET + `readOnly` + `publicAgent`,
  and read live (Yjs) state, not the stale DB column.
- Do let the open route resolve the browser session; pass record ids as deep-
  link params and let the UI focus them via the polled `navigate` command.

## Don't

- Don't copy a deployment's shared `ACCESS_TOKEN` / `A2A_SECRET` into a client
  config when `connect` can mint a per-user, revocable token instead.
- Don't hand-format the `/_agent-native/open` URL — always go through
  `buildDeepLink`.
- Don't do I/O, awaits, DB reads, or app-state reads inside a `link` builder.
- Don't replace deep links with MCP Apps; non-UI clients still need the link.
- Don't hand-write product UI in `mcpApp.resource.html`; use a real React
  route/component and embed it with `embedApp()`.
- Don't test Claude full-app embeds against raw Vite dev modules and conclude
  production is broken; use `pnpm exec agent-native start`, a preview deploy, or prod.
- Don't scope the `navigate` write to the agent token, or pass privileged
  state through the deep link — it's a pure pointer.
- Don't invent a new navigation mechanism; bridge to the existing
  `navigate`/`application_state` contract.
- Don't widen the public template allow-list when scaffolding an app from an
  external agent — the allow-list in `packages/shared-app-config/templates.ts`
  is authoritative and guarded.

## Related Skills

- **actions** — defining actions, `publicAgent`, GET/`readOnly`
- **context-awareness** — the `navigate` / `application_state` contract the
  open route bridges to
- **a2a-protocol** — the `ask-agent` meta-tool and JSON-RPC peer calls
- **adding-a-feature** — the four-area checklist (add a `link` builder when a
  feature produces a navigable resource)
