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 thegeneric 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 thesecretdisposition — 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.
(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:- Parse only enough to read
X-Mesh-Install. - Look up the install → its connector row, its secret, its owning RuntimeAgent.
- Verify
X-Mesh-Signaturewith that install’s secret, constant-time (hmac.Equal), and check the timestamp window.
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-Installis 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 theevent.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 acks2xx 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=7dropped because room A already used7. The key includesconversation_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.
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 ofchat.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}
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 thehealth_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):
httpsonly. Nohttp, 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 rangefc00::/7(which coversfd00::/8), the link-local rangefe80::/10, and IPv4-mapped forms (::ffff:0:0/96) so a v4 private address cannot be smuggled through a v6 literal.169.254.169.254is 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.
Delivery is durable, retried, and ordered (a persistent outbox)
A2xx 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
2xxor the retry ceiling; on ceiling it lands in a dead-letter state surfaced through the sameErrDeliveryFailedincident class as a revoked Slack token (distinct from an execution failure — see ingress dispatch). The receiver dedupes on the Mesh-namespace outboundmessage_id, which is stable across retries. - Ordered within a conversation. Ordering is scoped to the effective
(agent, conversation_key)— the genericconversation_keyis 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 implementconnector.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 sameAuthorIsBot 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 innewMux(internal/app/app.go). - Ingress:
internal/app/generic_ingress.go— authenticate (HMAC + timestamp), decode, normalize intoevent.InboundMessage, claim viaconnector_event_claims, ack, then hand to the shareddispatcher(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), noReactor. - Render:
internal/render/generic/— near-identity Markdown renderer that owns chunking + escaping so a delivery body always satisfies theRenderedparts contract. - Provisioning: allow
genericas a standalone connector in the agent wizard;on_enablemints install id + secret,on_disablerevokes,on_secret_capturevalidates a rotated secret,health_checkoptionally pings the delivery URL. - Migration: no new tables — a generic install is a
connectorsrow (type='generic') with its secret in the connector-scoped secret store and its delivery URL in the connectormetadataJSON.
First implementation goal
Enable ageneric 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.