Skip to main content

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 owns the next phase of outbound catalog, credentials, discovery, and setup. Its delivery plan 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, connector) or the tool taxonomy (tools.md); 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.

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. 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, and the same distinction as a connector event vs an effect-class tool in tools.md. 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 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). 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. 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 §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, extended to a third axis.

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; 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 §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 §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 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, 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) — 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) prohibits shared acting accounts, including explicitly granted ones. See credential ownership. 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 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:
  • 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).
  • 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. 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), 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; 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.