Skip to main content

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 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 §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 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, with a Mesh-native single-value key.

Inbound: the wire contract

POST /generic/events

Headers

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.
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, ingress dispatch).

The 3-second ack and idempotency

Same model as Slack (slack.md §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}
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):
  • 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). 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 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). 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.