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).
Web search opt-in (allow_web_search)
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_egresson, 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 withallow_egresson, 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_egressdeliberately 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 whileallow_egressis on, so a user can never drive another owner'sfetch_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_searchis 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_byis 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-connectionsecret(checked by the adapter'sverify) remains the real confidentiality boundary. The target gateway must bepurpose='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_keyis 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_connectionrow is the sole config source: a transitional per-platform fallback was removed, so a workspace with no enabled row resolves tonot_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-sidementionsnotify-list — for a@channel/@here/@allbroadcast 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 stampsworkspace_keywith 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_idto an email viaGET /api/v4/users/{id}. The directory email is trusted (Mattermost returnsemail_verified: nullfor admin-provisioned/SSO users, so anemail_verified == truegate would reject them — the adapter checks email presence, not that flag). The deployment must run withShowEmailAddress = true(or a system-admin bot token) so that endpoint returnsemail; 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_keyis that team id (globally unique across connections). Register/agents+/agentin the same team the bot serves; a command invoked from a different team sends a differentteam_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_idis 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 (viaPATCH), and never log it. The gateway compares it constant-time and never logs the token; a rejected request emits one greppableslash token rejectedWARN 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:
- The
?ws=<team_id>query is required. Slack'surl_verificationhandshake body carries noteam_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_idIS the storedworkspace_key. Thewsvalue 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 (
messagewithchannel_type:"im") and channelapp_mentions. A plainmessagein 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_idpresent, or the author is the bot's own user id) so it never answers itself; any messagesubtype(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_mentionroots a thread atthread_ts(fallbackts) and the reply is posted in that thread. - Dedup — Slack's top-level
event_idis 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 theusers:read.emailscope). 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 underauthorizations[]), 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.idis 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), takingmail(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 GraphUser.Read.Allapplication 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; theservice_urlhost is pinned to*.botframework.com/smba.trafficmanager.net(or an operator-allowlisted host) so a drifted host never receives the bearer. - Dedup — the Activity
idis 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_keycolumn and gets atemporaryreply (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.