> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mesh.texturehq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# External integration strategy

# External integration strategy: inbound connectors and outbound tools are two seams, one trust substrate

**Scope update (2026-09-18):** This document owns the inbound/outbound boundary.
The [tool integration strategy](https://github.com/TextureHQ/mesh/blob/main/docs/runtime/tool-integration-strategy.md) owns the next phase
of outbound catalog, credentials, discovery, and setup. Its
[delivery plan](https://github.com/TextureHQ/mesh/blob/main/docs/roadmap/tool-integration-delivery.md) supersedes older outbound
sequencing here without changing the inbound connector contract.

> This document answers a specific strategic question: *when a third party (Texture,
> or anyone running open-source Mesh) wants to (a) drive an agent from their own
> system and (b) give that agent access to their own tools, is that one concern or
> two?* The short answer is **two data planes, one control plane.** They share
> identity, credential storage, gating, and audit. They do not share a wire
> protocol, and conflating them produces a worse version of both.

This is a synthesis doc. It does not redefine the connector contract
([`../connectors/model.md`](/connectors/model),
[`connector`](/connectors/slack)) or the tool taxonomy
([`tools.md`](/runtime/tools)); it sits above both and settles the boundary between
them, plus the one genuinely new surface — a generic programmatic inbound
connector — specified in full in
[`../connectors/generic.md`](/connectors/generic).

## The two questions, stated precisely

The request decomposes into two questions that *sound* like one:

1. **Ingress.** The connector, instead of Slack or Telegram, is an external
   third-party system. An operator turns it on inside Mesh, gets a key, signs the
   message the way Slack does, and can call in. It must also be possible to run an
   agent that has *only* this connector — no Slack, no Telegram.

2. **Capabilities.** The agent needs access to tools. Some tools (Linear, etc.)
   are hand-rolled today as direct calls. Should a third party be able to add
   tools the agent can reach — over a network, exposing an endpoint Mesh calls out
   to for a JSON block of available tools, and calling out when the agent picks
   one? Are these first-party citizens, or should third parties simply expose MCP
   servers?

They feel entangled because both are "a third party plugging into Mesh over the
network." They are not entangled, and the reason is directional.

## Why they are two concerns: the direction of the call

The cleanest way to see the separation is to ask *who initiates the network call*
and *what the payload is a statement about.*

| | **Inbound connector** | **Outbound tool** |
| - | - | - |
| Who calls whom | third party → Mesh | Mesh → third party |
| What the payload is | *an utterance* — "someone said something to the agent" | *a capability invocation* — "the agent decided to do something" |
| What it produces | a `NormalizedMessage`, gated, maybe a turn | a typed `Observation`, folded into the model's context |
| Trust question | "is this really from the installer, and is it addressed to me?" | "is the agent allowed to call this, and what did it touch?" |
| Failure mode of conflation | tool results start turns; an inbound message tries to execute | an utterance is treated as an authorized action |

An inbound message is a **fact about the world** the agent observes. A tool call
is an **action the agent takes**. Mesh's entire architecture is built on keeping
those apart — it is the same distinction as `observe` vs `engage` in the
[addressing gate](/runtime/addressing), and the same distinction as a connector event
vs an `effect`-class tool in [tools.md](/runtime/tools). A single "third-party plugin
protocol" that carried both would have to re-derive that boundary internally on
every message, which is exactly the mistake
[`addressing.md`](/runtime/addressing) exists to make impossible: *membership is not
addressability, and reachability is not authorization.*

So: **do not build one protocol.** Build two seams that share a substrate.

## What they *do* share: the control plane

The felt entanglement is real, but it lives one layer down. Both seams
share:

* **Identity & attribution.** Both resolve to a RuntimeAgent's perspective
  ([`../perspective.md`](/perspective)). An inbound message becomes an Actor
  observation; a tool call is attributed to the agent and persisted as a RunStep.
* **Credential storage.** Both use the same encrypted `secrets` table, the same
  master key, and the same rotation, with the owner chosen by what the secret
  *is*. An inbound connector's signing secret is connector-scoped
  (`owner_type='agent_connector'`, migration `0005`), because it belongs to one
  install of one connector. Current MCP and service-account credentials are
  agent-owned and referenced by the `(agent, provider)` binding in
  [tools.md §Registration and gating](/runtime/tools#registration-and-gating).
  Infrastructure payer credentials, such as web-provider keys, can instead have
  instance defaults with agent overrides. The planned named-connection model
  preserves single-agent ownership of acting accounts; a common provider endpoint
  never makes an account shareable between agents.
* **Enablement lifecycle.** Both are "turned on" per agent through the same
  operator surface, with the same `disabled → enabling → enabled | error` state
  machine ([tools.md](/runtime/tools) §Lifecycle), the same durable, idempotent
  provisioning hooks, and the same "readable before it runs" invariant.
* **Gating & audit.** Inbound uses the engagement gate; outbound uses the tool
  policy gate. Both write an explainable verdict to a durable row. Neither is a
  private modal — a turn that did or did not happen is answerable after the fact.

This is the correct shape of "one system, two surfaces": a shared control plane
(identity, secrets, enablement, audit) and two distinct data planes (utterances
in, actions out). It is exactly the split Mesh already made between *message
connectors* and *model connectors* in [`../connectors/model.md`](/connectors/model),
extended to a third axis.

```text theme={null}
                        ┌─────────────────────────────────────┐
                        │            CONTROL PLANE             │
                        │  identity · perspective · secrets    │
                        │  enablement lifecycle · gating · audit│
                        └───────────────┬─────────────────────┘
              inbound data plane        │        outbound data plane
        (utterances: third party→Mesh)  │   (actions: Mesh→third party)
                        ▼               │               ▼
        ┌───────────────────────────┐   │   ┌───────────────────────────┐
        │  generic connector        │   │   │  tools                    │
        │  (webhook in, HMAC-signed)│   │   │  first-party packages     │
        │  → NormalizedMessage      │   │   │  + remote MCP providers   │
        │  → engagement gate → turn │   │   │  → Observation → context  │
        └───────────────────────────┘   │   └───────────────────────────┘
```

***

## Question 1: the generic inbound connector

**Decision: build a first-class `generic` connector that speaks a small, stable,
Mesh-defined webhook contract.** It plugs into the exact same seam Slack and
Telegram already plug into — `connector.Normalizer` / `connector.Outbound` /
optional `connector.Reactor` — so nothing downstream of normalization changes.
Full wire spec in [`../connectors/generic.md`](/connectors/generic); the
strategic decisions are here.

### Webhooks, not WebSockets

Webhooks are the right call — the same request/response shape Slack and Telegram
already use — for reasons that hold independent of the existing ingress:

* **Symmetry with the existing ingress.** Slack and Telegram are both
  request/response webhooks landing on the shared mux (`/slack/events`,
  `/telegram/events/{botID}`). A webhook `generic` connector is a third
  `HandleFunc` in front of the identical `dispatcher`. A WebSocket connector would
  need its own long-lived-connection lifecycle, backpressure model, and
  reconnection semantics — a second ingress architecture for no gain on the
  call-and-response shape that is 95% of the need.
* **Stateless horizontal scale.** The 3-second-ack async-turn model
  ([slack.md](/connectors/slack) §ack budget) is already built and already
  connector-agnostic. A webhook inherits it for free. A WebSocket holds server
  state per client and fights the "fifty co-tenanted agents on one small box"
  property Mesh is built around.
* **The auth story is already proven twice.** HMAC-signed request + timestamp
  replay window (Slack) and per-install echoed secret (Telegram) are two working
  precedents. The generic connector uses the stronger one — HMAC.

**Push (Mesh → third party) is the outbound half, and it is also a webhook**: the
third party registers a delivery URL, and Mesh POSTs the agent's reply to it,
signed with the same secret so the third party can verify it came from Mesh. This
is the generic analogue of `chat.postMessage`. A streaming/SSE push channel is a
*later, additive* capability for progress events — noted, not required for v1. See
[`../connectors/generic.md`](/connectors/generic) §Outbound.

### Authentication: HMAC-signed, per-install secret, verify-after-lookup

This is a direct lift of the Slack contract, which is the security model to copy:

1. Operator enables the `generic` connector on an agent → Mesh mints a **signing
   secret** and an **install id**, stored connector-scoped and encrypted. The
   secret is shown once (write-only `secret` disposition).
2. The third party includes the install id in a header (the routing key — the
   generic analogue of Slack's `(api_app_id, team_id)` pair) and signs the raw
   body + timestamp with the secret: `X-Mesh-Signature: v1=<hmac-sha256>`,
   `X-Mesh-Timestamp: <unix>`.
3. Mesh reads the routing key, **looks up the install, then verifies** with that
   install's secret in constant time, timestamp within a 5-minute window. Lookup
   miss and signature mismatch both return an opaque `401` — the same anti-oracle
   posture the Slack connector documents.

One agent can hold many generic installs (several third-party systems), and two
agents never collide because the install id resolves to exactly one connector row.
This is the `(api_app_id, team_id)` pattern with a Mesh-native routing key.

### The payload: an utterance, deliberately minimal

The whole point of a *generic* connector is that the third party does not have a
rich domain model Mesh must learn. So the inbound contract is the
`NormalizedMessage` fields, expressed as flat JSON the caller fills in:

* an external actor id (who said it, in the caller's namespace)
* a conversation/thread key (the caller's own room identity)
* the body + body format (`markdown` default; `text` allowed)
* an external message id (for idempotency + reply addressing)
* optional parent id, display name, channel topology hint

Mesh maps these straight into `event.InboundMessage`, runs the *same* engagement
gate, persists into the owning agent's perspective, and the turn pipeline is
identical from there. **The generic connector adds no new runtime concepts** — it
is a new *skin* over the existing normalization seam, which is exactly why it is
cheap and exactly why it must not smuggle tool-invocation semantics.

### "An agent with only this connector"

This is already how Mesh works and requires **zero special-casing**. An agent's
connectors are rows (`connectors` table, `runtime_agent_id`-scoped). An agent with
a single `generic` row and no Slack/Telegram row is a fully valid agent: it
receives via the generic webhook, replies via the generic outbound, and never
touches a chat surface. The provisioning wizard just needs to allow "generic" as a
standalone connector choice. Running an agent with this connector and no other is
a UX affordance on top of an already-correct data model.

### Is there an existing protocol to leverage?

Considered and rejected as the *inbound* contract:

* **A2A / agent-to-agent protocols** — heavier than needed for "a system sends my
  agent a message," and still evolving. Worth tracking as a future *additional*
  connector, not the v1 generic surface.
* **MCP** — this is an *outbound tool* protocol (Mesh calls out to get
  capabilities). Using it for inbound would invert its direction and is precisely
  the conflation this document rejects. (See Question 2.)
* **Raw CloudEvents envelope** — reasonable as an *encoding* choice inside our
  webhook body, and we can adopt its envelope conventions, but it is not a
  turnkey ingress-auth story. We define the auth; CloudEvents-shaped body is a
  compatible option, not a requirement.

Verdict: **define a small Mesh-native webhook contract**, borrow CloudEvents
envelope conventions where they cost nothing, and keep the surface small enough
that a third party integrates it in an afternoon. The value is not protocol
novelty; it is that it lands on the same gate, perspective, and audit as every
other connector.

***

## Question 2: external tools — MCP is the answer, and it is already the answer

**Decision: for third-party tools, the surface is remote MCP. This is not a new
decision — [tools.md](/runtime/tools) already settled it.** The strategic clarification
that remains is *when to reach for MCP vs. a first-party package*, and whether the
Linear-style hand-rolled approach was a mistake.

### The boundary that already exists

From [tools.md](/runtime/tools), verbatim intent:

> **One unambiguous third-party boundary today — remote MCP.** The package format
> is first-party structure whose schema does not foreclose a future installable
> path.

* **Remote MCP** (streamable HTTP, OAuth/token auth) is the **third-party
  execution surface.** A third party who wants Mesh to run *their* capability
  stands up an MCP server, registers it as a `tool_provider`, and the operator
  assigns it an effect class and policy at registration. This is *exactly* the
  flow the second question describes — expose an endpoint where Mesh calls out and
  gets a JSON block of available tools, adds them to the agent's tool list, and
  calls out when the agent picks one. MCP *is* that protocol, already standardized,
  already "describe-then-trust," already out-of-process. We do not invent it.
* **First-party packages** (`tools/<name>/manifest.toml` + compiled-in handlers)
  are the **structure for tools that ship with Mesh**, including workspace,
  GitHub, and the current Linear integration. Native web capabilities use
  provider-neutral adapters. Popularity or dogfooding alone does not justify
  another native vendor API wrapper.

Local stdio MCP is **out of scope on purpose**: a subprocess of the control plane
is the ambient-authority pattern the whole execution layer exists to kill.

### Was hand-rolling Linear a mistake? No — but it stops being the default

The underlying question: Linear tools were hand-rolled instead of using
Linear-in-MCP — right call, or should the default be more flexible and simply
expose MCP surfaces?

The honest answer is **it was the right call for that tool at that time, and it is
the wrong default going forward.** Here is the decision rule, which belongs in the
doc so nobody re-litigates it per tool:

**Reach for a first-party package when:**

* The tool needs **tight coupling to a Mesh runtime primitive**: workspace
  materialization and credential grants, the commit boundary, browser-session
  handoff, or perspective-scoped graph reads. The current GitHub path delivers an
  existing PAT; short-lived credential minting is not implied.
* Mesh deliberately owns a **small provider-neutral capability contract**, such
  as search, page reading, or browser interaction, implemented by multiple
  provider adapters. Convenient setup alone is not this justification.

**Reach for the vendor's CLI inside the workspace when:**

* The service ships a CLI the model already knows well (`gh`, `git`, the cloud
  CLIs), and the call needs nothing from Mesh beyond a shell and a credential the
  workspace was granted. This is how the pull-request flow already works: the
  checkout installs a pinned `gh`, the workspace holds `GH_TOKEN` for the agent's
  GitHub account, and the model runs `gh pr create`.
* Context cost matters — a CLI's schema is not in the request, where an MCP
  tool list is paid for on every model call. The CLI is not free: what teaches
  it is rendered into the prompt — today the `gh` workflow in
  `internal/prompt.codeWorkSection`, later a `SKILL.md` once the loader exists
  ([tools.md](/runtime/tools#three-tiers-which-transport-a-capability-uses)) — but as
  one short procedure when the work calls for it, not a per-request catalog of
  every callable.
* The maintainer should be the vendor, and the vendor maintains a CLI.

**Reach for remote MCP when:**

* A **third party** (or a Texture team that is not the Mesh core) owns the
  capability and its credentials.
* The tool is a **thin API wrapper** with no Mesh-primitive coupling and no good
  CLI — read/write an external system, return JSON.
* We want to **catalog a capability without shipping or trusting its code** —
  MCP's "list tools" is metadata Mesh reads before granting anything, which is the
  same describe-then-trust invariant the package format has.

In every tier, acting identity and permission must be explicit. Agent-owned
service accounts are required; infrastructure payer keys are a separate
concern. The [identity guardrail (repository)](https://github.com/TextureHQ/mesh/blob/main/docs/roadmap.md#agent-owned-acting-identities)
prohibits shared acting accounts, including explicitly granted ones. See [credential ownership](https://github.com/TextureHQ/mesh/blob/main/docs/runtime/tool-integration-strategy.md#credentials-inheritance-and-authorization).
The execution transport does not determine whose identity acts.

The native Linear integration helped establish the package format. **The next
fifty integrations should not be first-party packages**: a wrapper per vendor API
does not scale. Move Linear to remote MCP only after the documented
[parity and migration gate](https://github.com/TextureHQ/mesh/blob/main/docs/roadmap/tool-integration-delivery.md#h--linear-mcp-parity-and-native-retirement)
is met; retain the working native path until then.

### Critically: MCP tools are gated exactly like every other tool

An imported MCP tool is **not** a trusted first-class citizen by virtue of being
imported. From [tools.md](/runtime/tools):

* It is assigned an **effect class and policy at registration by the operator.**
  MCP metadata is advisory; Mesh's gate is authoritative.
* It is **unavailable until classified** — not offered, rather than offered
  behind a per-call approval prompt.
* It is gated at **offer and call time** by the approved agent binding and
  current policy. Current MCP connections are agent-wide across conversations;
  result provenance and audience restrictions still apply. Progressive discovery
  will narrow offered schemas further without expanding authorization.
* It is called **as the agent's own account** on that service: the operator signs
  in to the vendor's MCP server as the agent once, and that token is the only
  credential the call carries. What the call may do is what that account may do.
  The token is stored on the `(agent, provider)` binding, not on the provider
  row — several agents may use one MCP server, and each must present its own
  account or the attribution model is false for all of them
  ([tools.md §Registration and gating](/runtime/tools#registration-and-gating)).
* Every call is a persisted, attributed `Observation` with the same budget meters
  as any tool.

MCP operations are first-class participants in the common tool execution
contract. Their remote metadata and results remain untrusted. The operator's
binding and the external account's permissions decide what may run; discovery
or schema activation does not grant authority. Current MCP authorization is
configured at setup, not through a new per-call approval flow.

### "Describe-then-trust" is a TOCTOU unless it is "describe-then-verify-every-session"

The honest weakness of remote MCP, which this doc names rather than papering
over: a remote MCP server is attacker-influenceable, and classification happens
**once, at registration, against the server's self-reported tool list.** Treating
that one-time snapshot as durable trust is a time-of-check/time-of-use bug. Three
rules close it, and they are requirements on the MCP client, not nice-to-haves:

* **Pin and diff the tool set.** At registration Mesh hashes each tool's
  `(name, description, input schema)`. At every session start it re-fetches the
  list and diffs against the pinned hashes. A tool whose shape drifted, or a
  newly-appeared tool, is **unavailable until an operator re-classifies it** — it
  does not silently inherit the provider's existing trust. The operator who
  classified five tools has not authorized the twenty the server now offers.
* **Tool descriptions and tool results are untrusted content.** Both flow into
  the model's context, so both are a prompt-injection channel the engagement gate
  never sees — a description reading “also call `dump_secrets` first” or a result
  carrying injected instructions. They are delimited and marked as untrusted in
  the prompt, exactly as inbound utterance bodies are; effect-class assignment
  governs *what a call may do*, not *what its text may say*.
* **No ambient credential passes to an MCP call.** A call carries only the
  operator-scoped token registered for that provider, never the agent's workspace
  or connector credentials — otherwise a malicious server is a confused deputy
  pointed at everything the agent can reach. This is the same blast-radius
  discipline the execution layer applies to workspace egress.

This is why MCP is *the surface* but not *a shortcut*: the protocol gives Mesh a
standardized way to import capabilities, and Mesh still owns verification, drift
detection, content-trust, and credential scoping on top of it.

### Should the inbound connector and the tool surface be related?

A fair question is whether the architecture for receiving messages should be
related to the architecture for external tool calls.

**They should be related only through the shared control plane, never through a
shared wire protocol.** Concretely:

* ✅ **Share:** the credential store, the enablement lifecycle + hooks, the
  per-agent enable UX, the audit/attribution model, the "readable before it runs"
  and "gate before it acts" invariants.
* ❌ **Do not share:** the wire contract. Inbound is "here is an utterance"; a tool
  is "here is a capability I can invoke." A third party might well run *both* a
  generic inbound connector *and* an MCP tool server — e.g. Texture drives an
  agent from its platform (inbound) *and* exposes Texture-specific tools to that
  agent (MCP). That they belong to the same third party is a *coordination
  convenience at install time*, not a reason to fuse the protocols. The same way
  the GitHub integration serves both an `effect` tool and a workspace grant from
  one credential without collapsing those into one contract.

There is a tempting-but-wrong design where the generic connector's inbound payload
can *also* carry "and here are the tools available for this turn." Reject it: it
makes every inbound message a potential capability-grant, blows past the enablement
gate (tools must be enabled and classified *before* a turn, not smuggled in with
one), and reintroduces ambient authority. Tools are enabled out of band and gated
at offer time. An utterance never grants a capability.

***

## The synthesized recommendation

1. **Two seams, one substrate.** Ingress and tools are separate data planes over a
   shared control plane (identity, secrets, enablement, gating, audit). Do not
   build a unified "third-party plugin protocol."

2. **Inbound: build a `generic` webhook connector** ([`../connectors/generic.md`](/connectors/generic)),
   HMAC-signed with a per-install secret, verify-after-lookup, opaque 401s —
   a direct lift of the Slack auth model onto a small Mesh-native payload that maps
   into the existing `NormalizedMessage` seam. Outbound push is a signed webhook
   POST to a registered delivery URL. An agent with only this connector is already
   a valid agent; the work is a provisioning affordance + the connector
   implementation, not new runtime concepts.

3. **Outbound tools: remote MCP is the third-party surface; first-party packages
   are Mesh's own structure.** This is already decided in [tools.md](/runtime/tools);
   this doc adds the decision rule for which to reach for and states plainly that
   MCP tools are *gated guests*, not first-class citizens — operator-classified at
   registration, unavailable until classified, gated at offer time, budgeted and
   audited on every call, and acting as the agent's own account. Between the two
   sits the vendor's CLI in the workspace, which is where GitHub already lives.
   Hand-rolling Linear was right for a dogfooded seed tool; the next fifty
   integrations are a CLI in the sandbox or remote MCP, never a package per API.

4. **Sequencing.** The generic connector is a small, self-contained slice that can
   land immediately against the existing ingress. Remote MCP is already sequenced
   as tools.md Phase 5 (effect tools + remote MCP client). The generic connector
   does **not** block on MCP and vice versa — which is the practical proof they are
   two concerns.

## Design guardrails (new, specific to this boundary)

* do not build a single "third-party plugin" protocol that carries both utterances
  and tool invocations — they are two data planes with opposite call direction
* do not let an inbound payload grant, declare, or carry tools — capabilities are
  enabled out of band and gated at offer time; an utterance never authorizes an
  action
* do not treat an imported MCP tool as trusted because it was imported — class and
  policy are operator-assigned at registration; unclassified means not offered
* do not put a per-call human approval in front of an MCP or CLI effect — the
  call acts as the agent's own account, and that account's permissions are the
  authorization
* do not hand-roll a first-party package for a third-party API with no Mesh-
  primitive coupling — that is what remote MCP is for
* do not add a WebSocket ingress for the call-and-response shape — webhook + the
  existing async-ack model is the connector-agnostic path; SSE push is additive
* do not let the generic connector invent new runtime concepts — it is a skin over
  `NormalizedMessage`, gated and perspective-scoped like every other connector
* do not sign inbound and outbound with the same construction — the direction tag
  (`in`/`out`) and install binding are mandatory, or an outbound push is a valid
  inbound signature (reflection)
* do not POST to an operator-supplied delivery URL without SSRF validation on the
  *resolved* IP, https-only, no redirect-following — the same for the health check
* do not deliver a reply inline-and-forget — commit the outbound row, then push
  from a durable, retried, ordered outbox so a crash after ack still delivers
* do not trust an MCP tool set past registration — pin `(name, description,
  schema)` hashes, diff every session, and treat descriptions and results as
  untrusted content; drift or new tools are unavailable until re-classified

## Provenance: red-teamed before landing

The security- and reliability-critical parts of this design were adversarially
reviewed across three independent realms before this doc was proposed —
application-security/protocol, distributed-systems/API-contract, and
agent-platform/MCP-ecosystem. The findings that changed the design, not just the
prose:

* **Signature reflection (security).** Inbound and outbound originally shared one
  signing construction and key; an outbound push Mesh emits was a valid inbound
  signature. Fixed with the `in`/`out` domain tag and install binding.
* **SSRF via delivery URL (security).** The outbound POST (and the health-check
  ping) to an operator-supplied URL was unfenced. Fixed with normative
  resolved-IP validation, https-only, no-redirect, per-install egress allowlist.
* **At-most-once reply (distributed-systems).** Inline delivery lost the reply on
  a crash after ack. Fixed with a durable, retried, ordered outbox and a stated
  at-least-once + dedupe-on-outbound-id contract.
* **MCP TOCTOU + tool-set drift (agent-platform/security).** One-time
  registration-time classification did not survive a server that changes its
  tool list. Fixed with pin-and-diff-per-session, untrusted-content handling, and
  no-ambient-credential scoping.
* **Caller-controlled unbounded state + idempotency scope (distributed-systems).**
  `conversation_key`/`actor_id` growth and install-global idempotency were
  unbounded/collision-prone. Fixed with per-install caps, conversation-scoped
  atomic claims, and a retention-outlives-replay-window invariant.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.