Skip to content

Chat bridge API

The chat bridge lets a user in Mattermost, Microsoft Teams or Slack talk to a saved Myra AI Workspace agent from inside their chat client: they message the bot, the bridge runs the configured agent, and the answer comes back in the same thread.

This page documents the channel-agnostic core and its inbound trust boundary. Each chat platform is a thin adapter (Mattermost first) that plugs into this core; the core is identical across platforms.

Architecture — one core, thin adapters

Everything between "a message arrived" and "a reply must be sent" is identical across platforms. Only the two edges differ per platform, and they live behind a five-function adapter interface:

Adapter function Responsibility
extract_workspace_key pull the workspace/team id from the raw event — body or Request-URL query (pre-auth)
verify validate the platform signature/secret (fail-closed)
parse turn the raw event into a normalized message, an ignore, or a synchronous respond (handshake)
resolve_identity map the platform user to a verified email (may call the platform API)
render_and_send post the reply back into the thread

The core owns identity mapping, thread↔conversation binding, agent selection, invocation, and reply mapping. A message is a { platform, workspace_key, platform_user_id, thread_key, text, event_id } record; a reply is { text, status }.

The inbound endpoint

POST https://ai-api.myra.eu/chat-bridge/{platform}/events
Content-Type: (platform-specific: JSON or form-encoded)

(platform-native event payload)

Accepted request shape:

Part Accepted Rejected → status
Method POST only any other → 405
Path /chat-bridge/{platform}/events, {platform} a registered adapter unknown/malformed → 404
Body raw bytes, valid UTF-8, ≤ 32 KB (the adapter decodes) > cap → 413; non-UTF-8 → 400
Workspace must resolve to an enabled chat_bridge_connection row unknown → 403
Signature must pass the adapter's verify invalid → 403
Rate per-source-IP and per-(platform, workspace) sliding windows exceeded → 429

A real message is acknowledged 200 immediately and processed out-of-band — chat replies take seconds (far past a platform's few-second ACK deadline) and are posted back through the platform API, not on the inbound connection. A handshake (e.g. Slack URL verification) is answered synchronously with the body the adapter dictates. A bot echo or non-message event is ignored with a 200.

Rejection behaviour (fail-closed, no enumeration)

Every rejection does no work. To avoid revealing which workspaces exist, an unconfigured workspace and a failed signature return an identical 403 {"error":"forbidden"}. If the detached worker cannot be spawned (server busy), the endpoint returns a retryable 500 without acknowledging, so the platform redelivers.

Identity — fail-closed, tenant-pinned

The chat user is mapped to a gateway user by email, scoped to the tenant that owns the resolved connection:

  • The email is resolved by the adapter (resolve_identity) and looked up only within the connection's tenant — an email that matches a user in a different tenant is not accepted.
  • The user must be active (not soft-deleted), not under a GDPR Art. 18 processing restriction, and not a viewer (viewers are barred from inference). Any failure returns a short, generic message to the chat and runs nothing.
  • An unmapped user is told their chat account isn't linked; the gateway never invokes on their behalf. The client is never the authorization boundary — tenant, gateway, agent and owner are all resolved server-side from the deployment's configuration.

Execution — run-as-owner, no-egress

The agent for a workspace defaults to the server-configured default_agent_slug; in a direct message a user may switch it per conversation to another agent they are entitled to (see the Agent picker section below), but never to a private or cross-tenant agent. It runs as the agent's owner — the same run-as-owner model as the scheduler and webhook triggers — via the normal inference pipeline (guardrails, PII and routing all apply unchanged).

Because the connection's gateway guardrails apply to every bridge turn, a gateway configured with a fail-closed PII detector (pii_protector with fail_open: false) masks personal data in the message before it reaches an external model and masks model-generated personal data in the reply — recommended for any bot reachable from a chat workspace. (A bridge turn served wholly by a first-party local Myra/EU model is not masked — the data never leaves Myra; see PII Protector — Local model legs are not masked.) The connection's bound gateway (gateway_slug) is the sole enforcement point: bind the bot to a gateway that carries the fail-closed detector (typically a dedicated PII gateway), not to a shared unguarded one. If the PII analyzer is unavailable while fail_open is false, the turn is blocked, not degraded: the bot posts its localized generic error reply instead of answering — never a silent drop, and never an unmasked answer.

Every bridge run is invoked with the no-egress tool profile forced on: an external, model-unauthenticated channel cannot steer the agent into externally-egressing tools (web fetch, external MCP, image generation, code interpreter, sub-agent delegation).

The one exception is web search, which the connection owner can enable per bot via allow_web_search (default off). It is the only egress tool the bridge will re-enable, because it egresses only model-generated search queries — PII-scrubbed and routed to the EU-resident search provider — never an attacker-supplied URL and never code. When it is on, the bridge additionally sends X-AIG-Allow-Web-Search: 1, which permits web_search for that run and nothing else: fetch_url / agentic_fetch, code_interpreter, external MCP, image generation and sub-agent delegation remain blocked. The scope is the agent's own tool configuration — if the agent does not have web search enabled, the flag has no effect. A Tier-1 (local-only) project or knowledge area still blocks web search entirely (its query must not leave the estate), and PII masking on the search leg is unaffected.

Security note. Enabling web search widens the bot's outbound surface on an unauthenticated channel: a workspace user (or injected content) can prompt the agent to run a search. The query is masked and EU-resident, so the residual risk is a search being triggered — not data exfiltration to an arbitrary endpoint. Leave it off unless the bot needs current information.

Owner-knowledge note. The configured agent answers with its owner's knowledge areas and connected tools, and those answers are visible to every user mapped in that workspace. No-egress stops outbound tool calls, not what the agent chooses to say. Only configure an agent whose knowledge you are willing to expose to that workspace's members.

Full-tool opt-in (allow_egress)

For a bot whose channel members are fully trusted, the connection owner can lift the no-egress restriction entirely per connection via allow_egress (default off). When it is on, the bridge sends neither X-AIG-No-Egress nor X-AIG-Allow-Web-Search, so the invoke is agent-authoritative: the agent's own tool configuration governs all tools — code_interpreter, fetch_url / agentic_fetch, external MCP connectors, image generation and sub-agent delegation — exactly as in the /easy app. allow_egress supersedes allow_web_search (that flag becomes moot when egress is fully open). Defense-in-depth that does not depend on this header still applies: a Tier-1 (local-only) project or knowledge area still blocks egress, the fetch_url SSRF guard still pins targets, and PII masking on tool-call arguments is unaffected.

Security note — read before enabling. This is a materially larger widening than web search. The bridge invoke runs as the connection's owner (a trusted-automation service token), so with allow_egress on, any member of the chat workspace — and any content injected into a shared thread — can drive the owner's full toolset: execute code, fetch arbitrary URLs, and read/write through the owner's MCP connectors, all under the owner's credentials. On an unauthenticated external channel this is a real data-egress and lateral-movement surface. Enable it only when you trust every member of the workspace, and prefer an agent whose tools and connectors are scoped to what that channel legitimately needs. It is default-closed on every connection and must be opted in explicitly per connection.

Agent picker — choose an assistant per DM

In a direct message with the bot, a user can switch which agent that conversation talks to, instead of the connection's fixed default_agent_slug. The advertised form is a plain message directive the relay delivers as text; Mattermost can also drive the same picker through registered native slash commands (/agents + /agent) — see Native slash commands under the Mattermost adapter:

Command Effect
!agents List the assistants this user may pick (slug + name).
!agent <slug> Switch this DM conversation to <slug>.
!agent Show the conversation's current assistant + usage.

Why ! and not /. The Mattermost client (and Slack/Teams) intercepts any /-prefixed message as a slash command and, for an unregistered trigger, blocks the post with a "command not found" nag instead of delivering it. ! is ordinary text to those clients, so it always reaches the relay. The /-prefixed forms (/agents, /agent <slug>, /agent) are still accepted for back-compat and for users who reach the picker via the client's "send as a message" fallback — but ! is the advertised, reliably-working form.

Accepted / rejected. A command is exactly a single leading ! or / immediately followed by agents / agent / agent <slug>, with <slug> matching ^[a-z0-9-]+$. Anything else — a bare word (agents), a doubled/mixed prefix (!!agents, /!agents), a space after the prefix (! agents), a multi-word !agent please help, or a question !agents do you support X? — is treated as a normal message, never a rejected command. The slug is validated against the user's entitlement and the URL slug is always the resolved agent id's slug — never the raw text.

Per-user entitlement (the authz gate). A user can only select an agent their mapped gateway user is entitled to: own ∪ user-share ∪ group-share ∪ org-approved, minus flagged/disabled. Private and cross-tenant agents are never listed or selectable. The selection is validated at pick time and re-validated every turn — if access is later revoked or the agent is deleted/disabled, the DM silently falls back to the default assistant.

Scope. DM-only. In a shared channel the commands are not interpreted and the default assistant is used (a channel is multi-user with one conversation, so per-user selection is incoherent). The selection persists per thread (chat_thread_map.agent_id), keyed on the ownership-verified conversation — a user cannot rebind another user's DM.

Audit. A picked agent still runs as its owner (service-token; no-egress by default — egress only under the allow_egress scoping rule below), but the turn's request_log.invoker_id records the driving Mattermost user — so who-drove-what is auditable even when the agent is owned by someone else. GDPR Art.17 erasure scrubs a driver's id + message content on those rows.

Interaction with allow_egress. On a connection with allow_egress on, full egress is scoped to the agent's identity, not granted blanket to whatever agent is picked:

  • The connection's default (preselected) agent runs agent-authoritative for any driver — the admin who set default_agent_slug + allow_egress deliberately authorized that agent's toolset for the whole workspace (the "preselected connection" grant).
  • A picked (non-default) agent runs agent-authoritative only when the driving user owns it (agent.created_by == the mapped driver). Any other picked agent — e.g. an org-shared agent the driver can see but does not own — is forced back to no-egress (X-AIG-No-Egress) even while allow_egress is on, so a user can never drive another owner's fetch_url / code_interpreter / MCP connectors under that owner's credentials. The same shared agent therefore has different tool capability depending on who drives it — by design (this closes the confused-deputy / credential-laundering vector).

allow_web_search is unaffected: it is a gateway-scoped, PII-scrubbed carve-out (not owner-credential egress), so it still applies to a forced-no-egress turn. Escape hatch: to grant every driver full egress on a specific agent, make it the connection's default agent. All inputs to this decision are server-side (created_by is set from the authenticated creator, the driver from the verified identity mapping) — the client cannot influence it.

Conversations and continuity

A chat thread maps to a reused chat_conversation (thread_key → conversation_id), so replies in the same thread continue the same conversation. History lives in the normal message store — the bridge adds no second copy. Each turn replays the recent, bounded history to the (stateless) invoke; the user and assistant turns are written together, only after a successful answer, so a failed turn never corrupts the thread.

Produced-file awareness (cross-turn). When a prior assistant turn produced a downloadable file (via the code interpreter or write_file) or a generated image, the replay narrates that fact back to the model on later turns — e.g. "In this reply you created these downloadable file(s), which the user can still open: report.csv". This mirrors the web chat surface, so a follow-up like "reopen it and re-check the totals" does not make the model deny a file it actually delivered. (File-only replies with no prose are still replayed. Filenames are model-originated, so bracket delimiters in them are neutralized before the note is composed.)

Thread ownership (private DMs). For a private DM thread, the mapped conversation is reused only for its owning gateway user: if a request resolves to a different user than the one who owns that DM's conversation, the bind is refused (fail-closed, opaque error — the reply never reveals that the thread exists). This scopes a DM's history to its owner, so a forged thread_key cannot pull another user's DM history into the invoke context. A channel thread is intentionally shared — every participant continues the same conversation — so its multi-user continuity is unaffected. The DM-vs-channel signal is the platform's own (Mattermost/Slack channel type, Teams conversationType), derived fail-closed (an unknown Teams type is treated as a DM and gated).

Delivery semantics

Delivery is at-most-once with durable de-duplication. Each event is claimed by a durable, node- and reload-safe record keyed by (platform, workspace, event_id), so a platform redelivery (retries, or a redelivery to another node) is answered exactly once — no double billing, no double reply. Each adapter therefore must supply a platform-stable unique message id as event_id (e.g. Mattermost post_id); a content hash is not acceptable (it would drop a legitimately repeated message).

Two residuals: if a worker crashes during inference, that single reply is lost (the claim is taken but never delivered) and the user re-asks; and if the durable claim store itself is unreachable, the bridge fails open (it answers rather than staying silent), so a redelivery during a database outage can be answered twice. Guaranteed delivery is a planned enhancement.

Configuration — per tenant, in the database

The bridge is configured per tenant, not per deployment: one row per workspace in chat_bridge_connection, so org A and org B each point their own Mattermost/Teams/Slack workspace at their own agent. config.resolve(platform, workspace_key) looks the inbound event's workspace up in that table — the environment is no longer the source of truth.

Each connection carries: the owning tenant_id + gateway_id, the platform and workspace_key (the lookup key), the tenant's chosen default_agent_id, the bot's own bot_self_id (echo guard), the enabled flag, the allow_web_search opt-in (default 0 — see Web search opt-in), the allow_egress opt-in (default 0 — see Full-tool opt-in), and — encrypted at rest with the AES-256 master key — the platform credentials blob ({secret, bot_token, base_url}) and a dedicated loopback invoke service token (a user_id IS NULL gateway token, in its own encrypted column so a bot-token rotation can't clobber it and the token is never exposed in the creds blob).

  • Ownership boundary. UNIQUE(platform, workspace_key) means one connection owns a workspace globally, so an inbound event resolves to exactly one tenant's config; the per-connection secret (checked by the adapter's verify) remains the real confidentiality boundary. The target gateway must be purpose='production' to route.
  • Two values are deployment-global, because they are identical for every tenant and not secret: the bridge's public host and the internal loopback target the invoke call uses. Both are set by your operator for the deployment. The public host doubles as the origin of the Mattermost native-slash-command Request URL (https://<host>/chat-bridge/mattermost/slash), so it is the single source of truth for the bridge's public host.
  • Resolve cache. Because workspace_key is attacker-controlled and resolution runs per inbound event, results are cached in two separate per-node shared dicts: a positive cache of the encrypted row (30 s) and a separate negative cache (10 s) so a flood of unknown keys cannot evict live entries. A connection create/delete invalidates the cache immediately on that node; other nodes converge within the TTL.
  • Managing connections. Creating and removing connections is a tenant-admin, self-service operation (API + UI). The chat_bridge_connection row is the sole config source: a transitional per-platform fallback was removed, so a workspace with no enabled row resolves to not_configured → 403 (fail-closed).

Managing connections — the tenant-admin API

A tenant-admin manages their own connections through /admin/v1/chat-bridge/connections (also surfaced in the SPA under Chat Integrations). Every route requires the tenant-admin role; the owning tenant_id is always derived from the caller's session, never the request body. Credentials are write-only — no response ever returns bot_token, secret, base_url, or the encrypted blobs.

Method + path Purpose Accepted → Rejected
GET /chat-bridge/platforms the platform picker list — { "platforms": [ { "name", "active" }, … ] }, active-first then alphabetical. Filtered to the active set (the active_chat_platforms setting, default ["mattermost"]) for a non-platform-admin; a platform admin sees every platform flagged active/inactive. Served (not filtered client-side) because the picker's tenant admins cannot read /settings. —
GET /chat-bridge/connections list this tenant's connections (masked) —
GET /chat-bridge/connections/{id} one connection (masked), tenant-scoped unknown/other tenant → 404
POST /chat-bridge/connections create see below
PATCH /chat-bridge/connections/{id} enable-toggle / rotate bot token see below
POST /chat-bridge/connections/{id}/provision-slash (re)provision the Mattermost native slash commands unknown → 404; non-Mattermost → 400; rate → 429
DELETE /chat-bridge/connections/{id} remove (revokes the service token first) unknown → 404

Provision-slash (Mattermost only). Idempotent, non-destructive: the gateway lists the workspace's custom commands and, per trigger (agent, agents), adopts an existing command whose trigger and URL match the bridge slash endpoint (regenerating its token) or creates a fresh one, then stores the resulting verification tokens on the connection. It never deletes a command and never touches one whose URL is not the bridge endpoint. The Mattermost API responses are treated as untrusted input: a command id is validated (26-char [a-z0-9]) before it is used in any URL, tokens are shape-checked, and no response body is logged. Returns 200 { "provision": { "agent": <reason>, "agents": <reason> } } where each reason ∈ ok | public_url_unconfigured | mm_forbidden | mm_unreachable | mm_bad_response | trigger_taken; a non-ok reason is not an HTTP error (the connection stays usable). Requires the bot's manage_slash_commands permission and Enable Custom Slash Commands on the Mattermost server; mm_forbidden signals one of those is missing. The public Request URL is derived from the deployment's public bridge host (https://<host>/chat-bridge/mattermost/slash); when that host is not configured the reason is public_url_unconfigured.

Create body: { platform?, workspace_key, gateway_slug, default_agent_slug, allow_web_search?, allow_egress? } plus the selected platform's credential fields (below). platform is optional and defaults to mattermost when omitted (an unrecognised value is rejected). The owning tenant and the bot's own id (bot_self_id, the echo guard) are derived server-side — never sent. Each adapter DECLARES a provisioning spec that validates its own fields fail-closed; the credential shape lives in exactly one place per platform. A missing/invalid field is a 400, a platform that cannot be reached while verifying the bot is a 502, and a workspace that is already configured is a 409.

Shared fields (every platform):

Field Accepted Rejected → status
platform "mattermost", "slack", or "teams" any other → 400
gateway_slug / default_agent_slug an agent that belongs to a gateway in the caller's tenant unknown → 400
workspace_key per-platform charset (below); not already configured bad → 400; duplicate → 409
allow_web_search optional boolean (default false) — owner opt-in letting the bot use web search (only that egress tool) non-boolean → 400
allow_egress optional boolean (default false) — owner opt-in giving the bot its agent's full tool set (all tools; supersedes allow_web_search). See Full-tool opt-in non-boolean → 400
rate per-tenant limit on the create/rotate exceeded → 429

Mattermost — credentials { base_url, bot_token, secret }; workspace_key = the MM team id. The slash_token / slash_list_token (for the /agent and /agents native slash commands) are auto-populated by the gateway on create — it registers the commands via the Mattermost API and captures their tokens (see Provision-slash above). They may still be supplied explicitly at create or rotated via PATCH (write-only), but you no longer create the commands by hand.

Field Accepted Rejected → status
base_url an https URL that resolves to a public IP http://, a private/link-local/metadata IP, or a non-resolvable host → 400; the gateway can't reach the Mattermost API → 502
bot_token / secret any non-empty string (write-only) missing → 400
slash_token / slash_list_token optional — the Mattermost-generated tokens for the /agent and /agents native slash commands (write-only); may be set at create or added later via PATCH (see Native slash commands) present-but-empty → 400

base_url is tenant-supplied and fetched server-side (at create and on every inbound event), so it is SSRF-guarded: https-only, the connect IP is pinned after a private-range check, and the operator allowlist is not honoured for tenant URLs. On create the gateway calls GET /api/v4/users/me only to derive the bot's own id (the echo guard) — it does not treat that call as proof of workspace ownership; the per-connection secret remains the real confidentiality boundary. This SSRF gate + get_me run only on the Mattermost path.

Slack — credentials { signing_secret, bot_token }; workspace_key = the Slack team id. bot_self_id (the bot's own U… user id) is derived via auth.test, which also verifies the token. There is no base_url — the Slack Web API host is the fixed https://slack.com constant.

Field Accepted Rejected → status
workspace_key ^[A-Z0-9]+$, ≤ 191 else → 400
signing_secret / bot_token any non-empty string (write-only) missing → 400; token not usable via auth.test → 502

Microsoft Teams — credentials { bot_app_id, bot_app_type, bot_home_tenant_id, client_secret, service_url }; workspace_key = the Entra tenant GUID. bot_self_id = 28:<bot_app_id> (derived, no network).

Field Accepted Rejected → status
workspace_key / bot_home_tenant_id / bot_app_id a canonical Entra tenant / app GUID non-GUID → 400
bot_app_type "multitenant" or "singletenant" any other → 400
service_url a Bot Connector host (*.botframework.com / smba.trafficmanager.net) any other host → 400
client_secret any non-empty string (write-only) missing → 400

The Graph / AAD / JWKS hosts are the fixed Microsoft public-cloud endpoints (dev base overrides are honoured only when operator-allowlisted, empty in prod), so a tenant admin cannot redirect the trust root or egress.

Patch accepts { enabled } (toggle), { allow_web_search } (web-search opt-in toggle, a boolean — non-boolean → 400), { allow_egress } (full-tool opt-in toggle, a boolean — non-boolean → 400), and a per-platform secret rotation: Mattermost { bot_token } and/or the optional { slash_token } / { slash_list_token } (the native slash-command tokens — set or rotate them here); Slack { bot_token } and/or { signing_secret }; Teams { client_secret }. A patch carrying only allow_web_search or only allow_egress is valid and takes effect immediately (within the ~30 s config-cache TTL), and is not treated as a credential rotation (so it is not rate-limited). Every other field — workspace_key, gateway_slug, default_agent_slug, platform, and any non-rotatable credential (Mattermost base_url/secret; Teams bot_app_id/bot_app_type/bot_home_tenant_id/service_url) — is immutable after create: a body carrying one is a 400, never a silent ignore. A rotation decrypts the stored blob, swaps only the named secret(s) (the rest is preserved), and re-encrypts. It does not change bot_self_id — a rotation is a new credential for the same bot; changing the underlying bot is a delete + recreate, not a rotation. The resolve cache is invalidated so the new secret takes effect immediately on that node (others converge within the 30 s TTL).

Mattermost adapter

The Mattermost adapter delivers the natural DM + @mention experience. Because the gateway is inbound-HTTP only, a small relay service (holds the Mattermost bot websocket) forwards events to POST /chat-bridge/mattermost/events as a normalized JSON body:

{ "secret": "…", "workspace_key": "<team id>", "user_id": "<26-char id>",
  "channel_id": "<26-char id>", "is_dm": true, "root_id": "", "post_id": "<26-char id>",
  "text": "<message, @mention-stripped>" }
  • What the relay forwards — exactly {direct messages to the bot} ∪ {messages that @mention the bot in a channel the bot is a member of}, mention-stripped, nothing else (a bot only receives websocket events for its DMs and channels it has joined; a channel post that doesn't address the bot never bills an inference). A 1:1 DM is always to the bot; a group DM or channel requires an explicit @<botusername> token in the message text every turn. This decision is made from the message TEXT, not Mattermost's server-side mentions notify-list — for a @channel/@here/@all broadcast the server expands that list to every online member including the bot's own id, so a notify-list gate made the bot answer broadcasts nobody addressed to it. A broadcast is therefore ignored; only a post that literally names @<botusername> engages the bot. It stamps workspace_key with its configured team id (Mattermost DMs carry no team id) and drops the bot's own + system posts. Runbook + bot setup: docs/internal/mattermost-relay.md.
  • Threading — a DM keys the conversation by its channel (the 1:1/group channel is the conversation); a channel @mention roots a thread and the reply is posted in it.
  • Identity — the adapter resolves user_id to an email via GET /api/v4/users/{id}. The directory email is trusted (Mattermost returns email_verified: null for admin-provisioned/SSO users, so an email_verified == true gate would reject them — the adapter checks email presence, not that flag). The deployment must run with ShowEmailAddress = true (or a system-admin bot token) so that endpoint returns email; otherwise every user is silently unmapped. Required bot capability: read users + create posts.

Security — the relay secret is a tenant-wide credential

The secret is the sole authenticator and the relay asserts user_id, so a holder of the secret can drive inference as any mapped gateway user in that tenant, from the public endpoint. It is compared constant-time (SHA-256 of both sides) and must be a high-entropy, rotated value carried only in the request body (which is therefore never logged). The relay→gateway hop must be network-restricted (IP-allowlist or mTLS on /chat-bridge/*); do not rely on the body secret alone over the open vhost.

Mattermost connection fields

A Mattermost connection carries workspace_key (team id), the tenant's default_agent, bot_self_id (the bot's MM user id, for the echo guard), and the encrypted credentials { secret, bot_token, base_url } — plus, optionally, the two native slash-command tokens { slash_token, slash_list_token } (see Native slash commands below). These are stored in chat_bridge_connection (see Configuration — per tenant above), set via the tenant-admin API . Every known adapter is registered unconditionally at worker boot — the endpoint is always live, and a workspace with no configured connection simply fails closed with a 403. The websocket relay + its secret/token provisioning ship with the relay service.

Native slash commands — /agents + /agent

The DM agent picker (above) is a plain message directive (!agents / !agent <name>), because the Mattermost client intercepts a /-prefixed message. It additionally supports real, registered Mattermost slash commands for the same picker, on a separate synchronous endpoint (not the websocket relay):

POST https://ai-api.myra.eu/chat-bridge/mattermost/slash
Content-Type: application/x-www-form-urlencoded

token=<command token>&command=/agent&text=<name>&team_id=<team>&channel_id=<dm>&user_id=<user>&…

A tenant admin creates two custom slash commands in Mattermost — /agents (list) and /agent (pick/show) — both pointing at this URL with request method POST, then copies each command's Mattermost-generated token into the connection (slash_list_token for /agents, slash_token for /agent) via a PATCH (see Managing connections). Until both tokens are set the endpoint fails closed (403) for the unconfigured command, while the relay and the ! directives keep working.

The reply is ephemeral (only the invoking user sees it), and uses the same picker grammar and per-user entitlement gate as the ! path — there is no second command parser. The reply text advertises the native / form.

Command (Mattermost) Effect
/agents List the assistants this user may pick.
/agent <name> Switch this DM to <name>.
/agent Show the current assistant + usage.

Accepted request shape (the whole body is untrusted until the token verifies — fail closed):

Part Accepted Rejected → status
Method POST only any other → 405
Path /chat-bridge/mattermost/slash other platform → 404
Body application/x-www-form-urlencoded, ≤ 32 KB > cap → 413
team_id a non-empty string that resolves to an enabled connection (it is the workspace_key) empty / unknown → opaque 403
token must equal a configured slash token (slash_token or slash_list_token) for that connection, constant-time compared missing / mismatch / none configured → opaque 403
command / user_id / channel_id non-empty strings (a valueless or duplicate form key is treated as missing); channel_id ≤ 191 chars (the DB thread-key column width) missing / channel_id > 191 → opaque 403
text optional (empty for /agents and bare /agent) —
Rate per-(mattermost, team) sliding window (its own budget, separate from /events) exceeded → 429

Identity, binding, execution are identical to the message path: the user_id is resolved to a tenant-pinned gateway user (fail-closed, Art. 18 / viewer gated); an unmapped / restricted user gets a localized ephemeral, never silence. The pick binds the same DM conversation the relay uses (thread_key = channel_id), so it controls the relay DM's agent. The endpoint performs no inference — it only lists or switches — so no egress happens here.

The picker is for direct messages. A slash command carries no channel-type signal, so the endpoint does not distinguish a DM from a shared channel without an extra API round trip it deliberately avoids (to stay inside Mattermost's short slash-command deadline). Invoked in a shared channel it binds a per-user conversation keyed on that channel that the relay's channel threads (keyed on channel_id:root) never consult — so the selection is effectively a no-op for that channel, and a second user's invocation is refused fail-closed. Use /agents / /agent in a DM with the bot.

Precondition — register the commands in the connection's team. A Mattermost custom slash command is team-scoped, and the connection's workspace_key is that team id (globally unique across connections). Register /agents + /agent in the same team the bot serves; a command invoked from a different team sends a different team_id, which resolves to no connection and fails closed (403) — it can never bind or act across tenants.

Security note — the slash token is an impersonation-grade credential. Unlike the relay hop, this endpoint must be internet-reachable (Mattermost's servers POST to it), so it cannot be network-restricted; the token is the boundary. The form's user_id is asserted by the caller and identity is derived from it, so a leaked slash token lets a holder act as any workspace user for the picker: enumerate that user's visible agent names and rebind their DM's agent. It grants no inference or tool egress (strictly less than the relay secret), but treat it with the same care — keep it secret, rotate it (via PATCH), and never log it. The gateway compares it constant-time and never logs the token; a rejected request emits one greppable slash token rejected WARN with the workspace only.

Slack adapter

The Slack adapter delivers the DM + @mention experience over the Slack Events API (inbound HTTP — not Socket Mode). Slack itself POSTs every event to the one public Request URL; there is no relay or websocket. The tenant configures their Slack app's Request URL as:

POST https://ai-api.myra.eu/chat-bridge/slack/events?ws=<team_id>
Content-Type: application/json
  • The ?ws=<team_id> query is required. Slack's url_verification handshake body carries no team_id, so the workspace is taken from the Request-URL query (Slack preserves the query string on every delivery, including the handshake and retries). team_id IS the stored workspace_key. The ws value is only a resolution hint — it is attacker-controlled and is not a trust boundary; the request signature is the sole authenticator.

Request signing — the only trust boundary

Every request must carry a valid Slack request signature:

Header Meaning
X-Slack-Signature v0=<hex> — HMAC-SHA256(signing_secret, "v0:" + timestamp + ":" + raw_body)
X-Slack-Request-Timestamp unix seconds; rejected if more than 5 minutes from now (replay guard)

The signature is verified over the raw request body exactly as received, constant-time, against the tenant's signing_secret, before the body is parsed. A wrong/forged ws selects a wrong-or-absent signing secret, so the HMAC fails and the request is rejected. An unknown workspace, a disabled connection, and a bad signature all return the identical opaque 403 {"error":"forbidden"} — the endpoint is not a tenant-existence oracle, and the url_verification challenge is echoed only for a correctly-signed request.

Part Accepted Rejected → status
?ws= uppercase-alnum team id that resolves to an enabled Slack connection missing / bad / unknown → 403
Signature valid v0= HMAC over the raw body, fresh timestamp absent / malformed / stale / mismatch → 403
Event event_callback with a message in a DM (channel_type:"im") or an app_mention in a channel, or a url_verification handshake anything else — a plain channel/group message with no @mention, bot echo, edits/joins, other types → ignored 200

Events, threading, identity

  • What's handled — direct messages to the bot (message with channel_type:"im") and channel app_mentions. A plain message in a public/private channel or group DM (no @mention) is deliberately ignored — otherwise the bot would answer (and bill) every message in any channel it is a member of; the Slack app's event-subscription config is treated as the client, never the admission boundary. The bot's own posts are dropped (bot_id present, or the author is the bot's own user id) so it never answers itself; any message subtype (edits, joins, bot messages) is ignored. The leading <@bot> mention is stripped from the text.
  • Threading — a DM keys the conversation by its channel (the DM is the conversation, reply un-threaded); a channel app_mention roots a thread at thread_ts (fallback ts) and the reply is posted in that thread.
  • Dedup — Slack's top-level event_id is the durable de-duplication key, so a Slack retry is answered exactly once.
  • Identity — the adapter resolves the Slack user to an email via users.info (the bot needs the users:read.email scope). Slack emails are workspace-managed (SSO/enterprise) so the directory email is trusted, then looked up only within the connection's tenant — the same fail-closed, tenant-pinned mapping as every adapter.

Slack connection fields

A Slack connection carries workspace_key (the Slack team_id), the tenant's default_agent, bot_self_id (the bot's own Slack user id U…, for the echo guard), and the encrypted credentials { signing_secret, bot_token }. The Slack Web API host is the fixed https://slack.com constant — it is never taken from tenant config (Slack has no per-tenant host; a tenant-supplied host would be an email-forgery / SSRF vector), so no base_url is stored for Slack.

A tenant-admin creates a Slack connection through the self-service API/UI (see Managing connections), which stores exactly the {signing_secret, bot_token} shape and derives bot_self_id via auth.test. The adapter is also exercised end-to-end via the dev-instance Slack E2E harness.

Slack limitations

  • Enterprise Grid / externally-shared channels are unsupported: their events may omit a top-level team_id (identity sits under authorizations[]), so they are ignored. Standard single-workspace apps only.

Microsoft Teams adapter

The Teams adapter delivers the DM + @mention experience over the Bot Framework messaging endpoint (inbound HTTP — not websockets, not a relay). An Azure Bot POSTs every Teams message as an Activity to the one public messaging URL; Teams' own routing means the endpoint only ever receives 1:1 (personal) messages and channel @mentions, so there is no admission surface to filter. The tenant points their Azure Bot's messaging endpoint at:

POST https://ai-api.myra.eu/chat-bridge/teams/events
Authorization: Bearer <Bot Framework JWT>
Content-Type: application/json

The workspace is channelData.tenant.id (the Entra tenant GUID) taken from the Activity body. It only selects the candidate connection — it is unsigned; the JWT's aud is the real cross-tenant gate.

Inbound JWT — the only trust boundary

Every request must carry a valid Bot Framework JWT in the Authorization: Bearer header. It is validated before the body is parsed, fail-closed, using the same RS256 envelope as OIDC login (one shared implementation — RS256 pinned, crit/embedded jwk/jku/x5u rejected, kid required, RSA ≥ 2048, canonical base64url):

Check Rule
Signature RS256 over the token, verified against the pinned Bot Framework JWKS (keys fetched only from the derived well-known URL, SSRF-gated + cached)
iss multitenant → exactly https://api.botframework.com; singletenant → exactly https://login.microsoftonline.com/<bot_home_tenant_id>/v2.0 (or the https://sts.windows.net/<tid>/ v1 form) — never accept-any-tenant
aud must equal the connection's bot_app_id (a distinct bot app id per workspace — the cross-tenant boundary)
serviceurl mandatory; after normalize (lowercase, strip trailing /) must equal the connection's configured service_url — this binds the signed token to our reply target (an SSRF pin: a forged serviceUrl in the body can never redirect our reply)
exp / nbf / iat each must be a number; exp present and not past, nbf/iat within a 300 s skew

An unknown tenant, a disabled connection, a bad/expired/forged token, a wrong aud, a wrong issuer, and a serviceUrl mismatch all return the identical opaque 403 {"error":"forbidden"} — the endpoint is not a tenant-existence oracle.

The connection declares bot_app_type (multitenant or singletenant); a bad/missing value is rejected (never accept-any). Single-tenant is supported because DACH M365 enterprises frequently mandate single-tenant Entra app registrations.

Accepted Activity shape

Field Accepted Rejected → behaviour
type "message" anything else (conversationUpdate, typing, reactions…) → ignored 200
channelData.tenant.id a canonical Entra tenant GUID (the workspace) absent / non-GUID → opaque 403
from.aadObjectId a canonical GUID (resolved to an email via Graph) absent / malformed → ignored 200
from.id any id except the bot's own 28:<bot_app_id> the bot's own id (echo) → ignored 200
conversation.id alnum + : ; = @ . _ - only, ≤ 191 chars contains / ? # % / whitespace / control, or > 191 → ignored / temporary reply
text non-empty after <at>…</at> mention markup is stripped empty after strip → ignored 200
id the platform-stable Activity id (durable dedup key) empty → 400

Threading, identity, delivery

  • Threading — conversation.id is both the conversation-binding key and the reply target (POST <service_url>/v3/conversations/<id>/activities), so there is no separate thread map. A DM's 1:1 conversation id keeps one continuous conversation; a channel @mention's thread conversation id posts the reply in that thread.
  • Identity — the adapter mints the bot's AAD app-only token and calls Microsoft Graph GET /v1.0/users/<aadObjectId>?$select=mail,userPrincipalName (bearer attached only to Graph's fixed host or an operator-allowlisted E2E host, connect-IP-pinned), taking mail (or a routable, non-#EXT# userPrincipalName). The email is then looked up only within the connection's tenant — the same fail-closed, tenant-pinned mapping as every adapter. Precondition: the bot's Azure app holds the Graph User.Read.All application permission (admin-consented).
  • Reply — posted via the Bot Connector with a freshly minted Connector app token (minted from the botframework.com directory for a multitenant bot, or the bot's home tenant for a singletenant one). The reply is UTF-8-safe-truncated to a Connector-safe byte budget with a … (truncated) marker; the service_url host is pinned to *.botframework.com / smba.trafficmanager.net (or an operator-allowlisted host) so a drifted host never receives the bearer.
  • Dedup — the Activity id is the durable de-duplication key, so a Bot Framework retry is answered exactly once.

Teams connection fields

A Teams connection carries workspace_key (the Entra tenant GUID), the tenant's default_agent, bot_self_id (28:<bot_app_id>, for the echo guard), and the encrypted adapter_config { bot_app_type, bot_app_id, client_secret, bot_home_tenant_id, service_url }. The Graph / AAD / JWKS hosts are the fixed Microsoft public-cloud endpoints and are never taken from tenant config in production; dev-only base overrides (graph_base, aad_authority_base, bf_jwks_uri) are honoured only when their host is on the operator-configured egress allowlist (empty in prod), so a tenant admin can never redirect the trust root or egress.

A tenant-admin creates a Teams connection through the self-service API/UI (see Managing connections), which stores exactly this five-field adapter_config and derives bot_self_id as 28:<bot_app_id>. The dev-only base overrides above are not accepted by the create API — they are seed/E2E knobs. The adapter is also exercised end-to-end via the dev-instance Teams E2E harness (dev-instance.sh teams-bridge-e2e) against a fake Bot Framework (real RS256-signed inbound JWTs verified against its JWKS).

Teams limitations

  • Public cloud only — government clouds (*.us, *.cn) use different issuers/JWKS hosts and are out of scope.
  • A conversation id longer than 191 characters exceeds the shared thread_key column and gets a temporary reply (widening that column touches the whole bridge core — a separate ticket).
  • Markdown replies only (textFormat:"markdown"); Adaptive Cards are not yet emitted.

Known limitations

  • No rich cards yet (replies are text). Per-DM agent switching is supported — see the Agent picker section above.