> ## 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.

# Generic

# Generic connector

The generic connector is the answer to "the connector, instead of Slack or
Telegram, is some external third-party system." It is a small, Mesh-defined
webhook contract that a third party integrates in an afternoon, and it lands on
the *exact same* ingress seam as Slack and Telegram: authenticate → decode →
normalize → gate → persist → turn. It invents no new runtime concepts.

For *why* this is a connector and not a fused "plugin protocol," and for the
relationship to external tools, read
[`../runtime/external-integration-strategy.md`](/runtime/external-integration-strategy)
first. This document is the wire contract.

## What it is for

* Drive a Mesh agent from a system that is not a chat surface: a web app, a
  backend service, a CRM, the Texture platform.
* Run an agent whose **only** connector is this one — no Slack, no Telegram.
* Get a reply back out to a URL the third party controls, verifiable as coming
  from Mesh.

## What it is *not* for

* Granting tools to the agent. Tools are enabled out of band and gated at
  offer time; an inbound message never carries or authorizes a capability. See the
  strategy doc.
* Model inference, persona, memory policy, provisioning UX — same exclusions as
  every connector ([`slack.md`](/connectors/slack) §What it is not responsible for).

## Enablement and credentials

The operator enables the `generic` connector on an agent. On enable
(`on_enable` lifecycle hook, per [tools.md](/runtime/tools) discipline):

* Mesh mints an **install id** (`inst_...`, the routing key) and a **signing
  secret** (high-entropy, shown once, stored connector-scoped and encrypted with
  the `secret` disposition — write-only, never returned).
* The operator optionally registers a single **delivery URL** for outbound
  replies (see §Outbound). Registering a new delivery URL *rotates* (replaces)
  the active one — v1 has no fan-out and no multi-endpoint failover; outbound
  delivery targets exactly one URL so retry, ordering, and dedup stay
  deterministic. A delivery URL can be added or rotated later.

One agent may hold **many** generic installs (several third-party systems, each
its own secret). Two agents never collide: an install id resolves to exactly one
connector row, scoped to one RuntimeAgent perspective. This is the
`(api_app_id, team_id)` routing model from [slack.md](/connectors/slack), with a
Mesh-native single-value key.

## Inbound: the wire contract

`POST /generic/events`

### Headers

| Header | Required | Meaning |
| - | - | - |
| `X-Mesh-Install` | yes | the install id — the routing key, read *before* verification |
| `X-Mesh-Timestamp` | yes | unix seconds; must be within 5 minutes of Mesh's clock, either direction |
| `X-Mesh-Signature` | yes | `v1=<hex hmac-sha256>` over the exact bytes `v1:in:{install}:{timestamp}:{raw_body}` |
| `Content-Type` | yes | `application/json` |

The signed string is **direction- and install-bound**. The literal `in` is a
domain-separation tag and the `{install}` is bound into the MAC, not just the
header. This is deliberate and load-bearing: outbound push (§Outbound) signs
`v1:out:{install}:…` with the *same* per-install secret, and an outbound body is a
superset of the inbound fields. Without the `in`/`out` tag, a `(timestamp, body,
signature)` triple Mesh legitimately generates for a delivery POST would be a
valid signature for an inbound `/generic/events` call with the same bytes — the
delivery endpoint (a low-trust, operator-controlled URL) could reflect Mesh's own
signatures back in as injected utterances. The tag makes an inbound MAC and an
outbound MAC disjoint even under identical bytes and one shared key.

### Verify-after-lookup (the order is load-bearing)

Signature verification needs the secret, and the secret is stored per install. So,
exactly as Slack does:

1. Parse only enough to read `X-Mesh-Install`.
2. Look up the install → its connector row, its secret, its owning RuntimeAgent.
3. Verify `X-Mesh-Signature` with **that install's** secret, constant-time
   (`hmac.Equal`), and check the timestamp window.

A lookup miss and a signature mismatch return the **same opaque `401`** — same
status, same body. Two things the status-code claim alone does not buy, and which
are therefore required:

* **No timing oracle.** A miss must not short-circuit visibly faster than a
  bad-signature-on-a-real-install. The handler performs the constant-time compare
  against a decoy secret on a lookup miss so the crypto path runs either way;
  otherwise the latency delta enumerates the `inst_...` namespace, which is
  exactly the “learns nothing about which installs exist” claim the strategy doc
  makes.
* **Pre-auth flood protection.** `X-Mesh-Install` is read and the store is hit
  *before* authentication, so a flood of random install ids would drive unbounded
  secret-store lookups and bypass the post-normalization per-agent/per-install
  ceilings. Ingress therefore applies a cheap per-source rate limit and a
  short-TTL negative cache on unknown install ids ahead of the store lookup. The
  negative cache short-circuits the *store lookup* only — a cached miss still
  runs the same constant-time decoy compare (or equivalent latency padding) as a
  first-seen miss, so neither the initial miss nor a repeated cached miss is
  timing-distinguishable from a bad signature on a real install; the cache saves
  a DB hit, never a step on the crypto path.

### Body

Flat JSON that fills in the `event.InboundMessage` fields. Nothing here is a Mesh
internal id; every id is in the *caller's* namespace.

```json theme={null}
{
  "message_id": "evt_01H...",
  "conversation_key": "ticket-4821",
  "actor_id": "user_92",
  "actor_display_name": "Dana Ruiz",
  "author_is_bot": false,
  "parent_message_id": "evt_01G...",
  "topology": "direct",
  "body": "Can you summarize the incident and open a ticket?",
  "body_format": "markdown"
}
```

| JSON field | Mesh field | Notes |
| - | - | - |
| `message_id` | `ExternalMessageID` | opaque string, kept verbatim; the idempotency key and the value echoed on the reply |
| `conversation_key` | `ExternalThreadID` (+ `ExternalChannelID`) | the caller's room identity; Mesh namespaces it under the install |
| `actor_id` | `ExternalActorID` | connector-scoped participant identity; no human/agent/service classification is inferred |
| `actor_display_name` | `ActorDisplayName` | optional; populates the prompt's actor label without a lookup |
| `author_is_bot` | `AuthorIsBot` | optional (default `false`); caller labels the author as automated. A bot-authored message is persisted as context but does not engage at Tier 0 (the anti-loop rule from [addressing.md](/runtime/addressing)) |
| `parent_message_id` | `ParentMessageID` | optional threading |
| `topology` | `ChannelTopology` | optional hint into conversation-shape derivation; defaults to `direct` for a two-party integration |
| `body` | `Body` | the utterance |
| `body_format` | `BodyFormat` | `markdown` canonical; `text` accepted |

**Schema evolution.** The `v1` in the signature construction is the *envelope*
version, and it is the schema version too: the body is additive-only within a
major (a newer Mesh tolerates unknown fields it does not consume, an older caller
never has to send a field a newer Mesh added), and a breaking change is a `v2`
signature domain — which a caller opts into by signing `v2:in:…`, so a payload
shape and the contract that validates it can never disagree silently. First-party
strictness (reject-unknown at build time) stays on Mesh's own emitters; the
inbound third-party path is tolerant-read within a major on purpose.

**Bounded growth.** `conversation_key` and `actor_id` are caller-namespace
strings, so an adversarial or buggy caller can mint unbounded distinct
conversations and actors under one install. Mesh caps distinct
conversations/actors per install (config, with a sane default) and rejects new
keys past the cap with a `429`-class refusal rather than growing rows without
bound; idle generic conversations are eligible for the same retention/GC as any
other connector's. The cap is per install so one tenant cannot exhaust the shared
box on behalf of another.

The connector maps these into `event.InboundMessage`, and from there the pipeline
is **identical to every other connector**: the engagement gate runs, the message
is persisted into the owning agent's perspective with its verdict, and the shared
`dispatcher` governs the turn under the single-flight invariant and the per-agent /
per-install ceilings. No generic-specific concurrency or gating logic exists,
because those invariants are process-wide and connector-agnostic
([addressing.md](/runtime/addressing), [ingress dispatch](/runtime/turn-pipeline)).

### The 3-second ack and idempotency

Same model as Slack ([slack.md](/connectors/slack) §ack budget): Mesh acks `2xx` fast and
runs the turn asynchronously on a `context.Background()`-derived context; shutdown
drains in-flight turns (and the outbox above covers ungraceful death). A non-2xx
invites the caller to retry, so "we chose not to act" (observe, unsupported) is
still a `200`.

Idempotency is keyed on **`(install, conversation_key, message_id)`** through the
existing `connector_event_claims` machinery. Three properties are load-bearing and
stated so an implementer cannot skip them:

* **Conversation-scoped, not install-global.** A caller that runs independent
  per-room id sequences must not have room B's `message_id=7` dropped because
  room A already used `7`. The key includes `conversation_key`.
* **Atomic claim.** The claim is an atomic insert (`ON CONFLICT DO NOTHING`);
  two concurrent identical deliveries cannot both win the claim and start two
  turns. The claim is written *before* the ack, and the ack path is claim → ack
  → dispatch.
* **Retention outlives the replay window.** Claim retention MUST exceed the
  5-minute signature timestamp window (§Verify-after-lookup); otherwise an
  in-window replay of a valid signed request could re-process after its claim was
  evicted. This coupling is a documented invariant, not an accident of the
  default GC interval.

One consequence worth naming: because `message_id` is caller-controlled, a caller
that *pre-sends* a future `message_id` will cause the later real message with that
id to dedupe away. That is the caller's own namespace to manage — the same way a
Slack `event_id` collision would — and Mesh does not attempt to distinguish
“intended” from “accidental” reuse within one `(install, conversation_key)`.

### Topology and addressing

A two-party integration (`topology: "direct"`, the default) means every message is
addressed to the agent — the Tier-0 `direct` shape engages by default, exactly like
a Slack DM. A caller that models a multi-party room sends `group`/`channel` and the
full engagement gate applies (mentions, threading, and — once shipped — Tier-2
classification). The generic connector supplies the same connector-agnostic signals
the addressing ladder consumes; it does not get a bespoke gate.

## Outbound: signed webhook push

Outbound is the generic analogue of `chat.postMessage`, satisfied as
`generic.Outbound` implementing `connector.Outbound` (`Renderer` + `Send`), a
distinct type from the inbound handler for the same reason `slack.Outbound` is.

When a turn produces a reply, Mesh renders the canonical Markdown for the generic
surface (default: pass-through canonical Markdown, since a generic consumer has no
proprietary dialect — `internal/render/generic` is a near-identity renderer that
still owns chunking and escaping) and **POSTs it to the install's registered
delivery URL**, signed so the third party can verify provenance:

`POST {delivery_url}`

| Header | Meaning |
| - | - |
| `X-Mesh-Install` | the install id |
| `X-Mesh-Timestamp` | unix seconds |
| `X-Mesh-Signature` | `v1=<hmac>` over `v1:out:{install}:{timestamp}:{raw_body}`, **same secret** as inbound, **`out` domain tag** |

```json theme={null}
{
  "reply_to_message_id": "evt_01H...",
  "conversation_key": "ticket-4821",
  "message_id": "mesh_out_01K...",
  "body": "Here is the incident summary… I opened TICKET-91.",
  "body_format": "markdown"
}
```

The third party verifies the signature (proving it came from Mesh), matches
`reply_to_message_id` / `conversation_key` to its own record, and renders. The
`out` domain tag (§Inbound headers) guarantees a signature Mesh emits here can
never be replayed back into `/generic/events`.

### Delivery URL validation is mandatory (SSRF is the default risk)

A delivery URL is an operator-supplied address that Mesh makes an authenticated,
Mesh-signed POST to — which is a server-side request forgery primitive unless it
is fenced, and on the “fifty co-tenanted agents on one box” deployment the blast
radius is the *other tenants'* loopback services and the cloud metadata endpoint.
The validation below is normative, applies identically to the `health_check`
ping (so enablement alone cannot fire an unvalidated request). These control-plane
checks remain mandatory and are separate from configurable agent-workspace
egress ([sandboxed-execution.md](/runtime/sandboxed-execution)):

* **`https` only.** No `http`, no non-HTTP schemes.
* **Reject private/link-local/loopback/metadata destinations** — evaluated on the
  *resolved* IP, v4 and v6, including `127.0.0.0/8`, `0.0.0.0/8`, RFC1918,
  `169.254.0.0/16`, and their IPv6 equivalents: `::1` (loopback), the full
  unique-local range `fc00::/7` (which covers `fd00::/8`), the link-local range
  `fe80::/10`, and IPv4-mapped forms (`::ffff:0:0/96`) so a v4 private address
  cannot be smuggled through a v6 literal. `169.254.169.254` is not special-cased
  away — the whole link-local range is refused, v4 and v6.
* **Resolve-then-pin, and do not follow redirects.** Resolve the host, validate
  the IP, then connect to that pinned IP; a 3xx from the endpoint is a delivery
  failure, never a followed hop. This closes DNS-rebinding and open-redirect
  pivots that a pure allowlist misses.
* **Per-install egress allowlist is the hardened posture** on multi-tenant
  deployments: the operator registers the delivery host, and egress rides a
  dedicated proxy rather than the control plane's own network namespace.

A delivery URL that fails validation is refused at save time with a message the
operator can act on — the same “readable-before-it-runs” invariant enablement
already owns — not silently accepted and failed at send.

### Delivery is durable, retried, and ordered (a persistent outbox)

A `2xx` from the delivery URL is the `Receipt`. But the reply must survive a
process death between ack and delivery, so outbound push is **not** a
fire-once-inline POST: the rendered reply is committed to the graph as the
outbound `message_event` **and enqueued to a persistent outbox in the same
transaction**, then delivered by a worker, exactly the durability the turn
pipeline already gives a Slack reply. The single-transaction boundary is
load-bearing: committing the `message_event` and the outbox row atomically is
what makes the at-least-once claim below true — a crash between the two would
otherwise leave a persisted reply with no delivery record (a silently lost
reply) or a queued delivery with no committed event. (An implementation that
cannot share one transaction MUST instead run a reconciliation worker that
re-enqueues committed outbound events lacking an outbox/delivery record;
at-least-once is not claimable without one of these two guarantees.) That yields
the contract a third party can rely on:

* **At-least-once, dedupable.** Delivery retries with capped exponential backoff
  and jitter until a `2xx` or the retry ceiling; on ceiling it lands in a
  dead-letter state surfaced through the same `ErrDeliveryFailed` incident class
  as a revoked Slack token (distinct from an execution failure — see
  [ingress dispatch](/runtime/turn-pipeline)). The receiver dedupes on the
  Mesh-namespace outbound `message_id`, which is stable across retries.
* **Ordered within a conversation.** Ordering is scoped to the effective
  `(agent, conversation_key)` — the generic `conversation_key` is namespaced
  under its install, never a bare caller-provided key, so two installs (or two
  agents) reusing the same literal key do not share an ordering domain. Within
  that scope replies deliver in commit order; the outbox does not reorder a later
  reply ahead of an earlier undelivered one for the same conversation.
* **Crash-safe.** Because the outbound row is committed before the POST is
  attempted, a process that dies after ack still delivers the reply after
  restart. “Shutdown drains in-flight turns” covers graceful stop; the outbox
  covers the ungraceful case.

### No delivery URL configured

An install may run **inbound-only** (fire-and-forget, or a caller that polls a
separate read surface). With no delivery URL, the reply is still persisted to the
graph; the outbound POST is skipped and logged. This is a legitimate configuration,
not an error — some integrations ingest and read back through a different channel.

### Reactions

The generic connector does **not** implement `connector.Reactor` in v1: a webhook
sink has no notion of reacting to a specific message, and `Reactor` is optional by
design precisely so a surface like this is not forced to implement a no-op
(the `connector.Reactor` interface in `internal/connector/connector.go` is
optional by design; see [ingress dispatch](/runtime/turn-pipeline) for why
React is a separate interface). Lifecycle acknowledgement, if a caller wants
it, is expressible as an ordinary outbound status message later; it is not a v1
requirement.

## Streaming/SSE push (deferred, additive)

Progress emissions and token streaming to a third party are a real future want, and
the shape is already anticipated by the roadmap's "authenticated, perspective-
scoped push stream (SSE first)" item. It is **additive** to this connector, not a
prerequisite: v1 is request/response webhooks in and signed webhooks out. When SSE
lands it reuses the same install + signing secret for auth, so the generic
connector's credential model does not change.

## Self-echo and bot authorship

The generic connector carries the same `AuthorIsBot` semantics as the others: a
caller may label an inbound author as automated, in which case the message is
persisted as context but does not engage at Tier 0 (the same anti-loop rule from
[addressing.md](/runtime/addressing)). Self-echo is structurally impossible on
the inbound path — Mesh's own outbound replies go out via the delivery-URL POST,
not back in through `/generic/events` — but if a caller re-injects a Mesh outbound
message, the `(install, conversation_key, message_id)` idempotency key and the
outbound-id namespace (`mesh_out_...`) make it droppable.

## Implementation seam

* **Route:** `mux.Handle("POST /generic/events", genericIngress)` alongside the
  Slack and Telegram routes in `newMux` (`internal/app/app.go`).
* **Ingress:** `internal/app/generic_ingress.go` — authenticate (HMAC + timestamp),
  decode, normalize into `event.InboundMessage`, claim via `connector_event_claims`,
  ack, then hand to the shared `dispatcher` (the same instance Slack/Telegram hold,
  so ceilings and drain are process-wide).
* **Connector package:** `internal/connector/generic/` — `Normalizer`,
  `Outbound` (`Renderer` + `Send` = the signed delivery POST), no `Reactor`.
* **Render:** `internal/render/generic/` — near-identity Markdown renderer that
  owns chunking + escaping so a delivery body always satisfies the `Rendered`
  parts contract.
* **Provisioning:** allow `generic` as a standalone connector in the agent wizard;
  `on_enable` mints install id + secret, `on_disable` revokes, `on_secret_capture`
  validates a rotated secret, `health_check` optionally pings the delivery URL.
* **Migration:** no new tables — a generic install is a `connectors` row
  (`type='generic'`) with its secret in the connector-scoped secret store and its
  delivery URL in the connector `metadata` JSON.

## First implementation goal

Enable a `generic` connector on an agent with no other connector, POST one signed
message to `/generic/events`, watch it become a turn in that agent's perspective,
and receive one signed reply POST at the registered delivery URL. Then prove an
inbound-only install (no delivery URL) still persists the turn. That is the whole
shape end to end, on the existing ingress, with no new runtime concepts.


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