Operating Chat Channels

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written on 2026-10-01 against FastAPI 0.142.2, langchain 1.4.3 and langgraph 1.2.12 on Python 3.12, Spring AI 2.0.1 on Spring Boot 4.1.1 with Java 21, Slack Bolt for Python 1.30.0, python-telegram-bot 22.8, the Teams SDK 2.1.x and the Microsoft 365 Agents SDK 1.x (see the version baseline). The snippets are illustrative: they are written from the official documentation of each platform and are not compiled or run by this repository. The numbers in the cost section are invented placeholders for a worked example, not prices; every platform limit is stated as "documented as" with the page it comes from, because limits and policies change often and should be re-read before you rely on them.

A channel adapter that works in a demo is a webhook endpoint on the public internet that triggers paid model calls on behalf of anonymous senders. Operating it means answering seven questions for every channel: who is allowed to call the endpoint, what happens when the platform delivers the same event twice, what you can see when it fails, how a single user or attacker is kept from draining the budget, which personal data you keep and for how long, what the assistant is allowed to say, and what each answer costs. This page gives a checklist per question and ties them to the channel-agnostic core described in Channel-Agnostic Architecture. The channel specifics are on the pages for Microsoft Teams, WhatsApp Business, Apple Messages for Business, Slack, Telegram & Google Chat and RCS Business Messaging; the general service concerns (authentication, rate limiting, versioning) of your own API are in Serving RAG as an API.

flowchart LR P["Platform webhook"] --> V["1. Verify signature or token"] V --> D["2. Deduplicate event ID"] D --> A["3. Acknowledge with 200 quickly"] A --> Q["4. Queue or background task"] Q --> R["5. Rate limit per user and per tenant"] R --> M["6. Moderate input"] M --> C["ask(conversationId, userId, text)"] C --> O["7. Moderate output and log usage"] O --> S["Send via platform API"]

The order matters: verification comes before everything that costs money or touches state, deduplication comes before the slow work, and the acknowledgement is sent before the model is called, because most platforms retry when they do not get a fast 200.

Secrets and signature verification

Each platform authenticates its callbacks differently. The adapter must verify the authenticity of every inbound request on the raw request body (re-serialising parsed JSON changes the bytes and breaks the HMAC), compare in constant time, and reject the request before any parsing side effect. Secrets come from the environment or a secret manager, never from source code or the image; on Azure see Key Vault & Secrets.

Channel What proves the call is genuine (as documented) What to configure and rotate

Slack

X-Slack-Signature is v0= plus the HMAC-SHA256 of v0:<X-Slack-Request-Timestamp>:<raw body> keyed with the app’s signing secret; requests with an old timestamp (the docs suggest five minutes) are rejected to stop replays. Bolt does this for you.

SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN (and an app-level token for Socket Mode); regenerate the signing secret from the app settings if exposed.

WhatsApp Business (Cloud API)

X-Hub-Signature-256 is sha256= plus the HMAC-SHA256 of the raw payload keyed with the Meta app secret. The subscription handshake is a GET carrying hub.mode, hub.verify_token and hub.challenge, answered by echoing the challenge.

WHATSAPP_APP_SECRET, WHATSAPP_VERIFY_TOKEN, WHATSAPP_ACCESS_TOKEN; prefer a system-user token over a personal one.

Telegram

When you call setWebhook with a secret_token, Telegram sends it back in the X-Telegram-Bot-Api-Secret-Token header on each update. There is no body signature, so the header plus HTTPS is the control.

TELEGRAM_BOT_TOKEN (from BotFather; revoke it there if leaked) and TELEGRAM_WEBHOOK_SECRET.

Microsoft Teams (Teams SDK, Microsoft 365 Agents SDK)

An Authorization: Bearer JWT issued by the Microsoft identity platform or Bot Service whose issuer, audience and signature the SDK validates against the published keys. Do not write this validation yourself.

The bot’s Entra app registration (client ID plus a certificate or a secret kept in Key Vault); rotate before expiry.

Google Chat

A bearer ID token on each HTTP request whose audience is your app URL or Cloud project number, per the Chat docs; verify with Google’s auth library.

The configured audience and the service account used for outbound calls.

Apple Messages for Business

Inbound messages arrive through your Messaging Service Provider (MSP) or Apple’s Business Chat API with the authentication scheme documented in Apple’s programme documentation, which is available to approved business accounts. This page documents the principle only.

MSP credentials in a secret manager; the details are on Apple Messages for Business.

RCS Business Messaging

Depends on the agent provider; Google’s RBM webhooks document a signature header computed over the payload with a client secret. Check the provider’s page before relying on it.

Provider API key or service account, webhook secret; see RCS Business Messaging.

Your own SSE or WebSocket API

Your OAuth 2.0 / JWT bearer scheme; browsers cannot set headers on a native EventSource, so use a fetch-based client or a short-lived ticket.

Signing keys and audiences; see OAuth 2.0 Flows Overview.

The two body-signature schemes above are small enough to show. The Python version is a FastAPI dependency that reads the raw body once and rejects bad requests with 401:

import hashlib
import hmac
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]      # placeholder: set in the environment
WHATSAPP_APP_SECRET = os.environ["WHATSAPP_APP_SECRET"]        # placeholder: set in the environment


def verify_slack(timestamp: str, body: bytes, signature: str) -> bool:
    if abs(time.time() - int(timestamp)) > 60 * 5:              # replay window
        return False
    base = b"v0:" + timestamp.encode() + b":" + body
    expected = "v0=" + hmac.new(
        SLACK_SIGNING_SECRET.encode(), base, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


def verify_meta(body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(
        WHATSAPP_APP_SECRET.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header)


@app.post("/channels/whatsapp")
async def whatsapp(request: Request):
    body = await request.body()                                  # raw bytes, before any JSON parsing
    if not verify_meta(body, request.headers.get("X-Hub-Signature-256", "")):
        raise HTTPException(status_code=401)
    ...

The Java equivalent uses javax.crypto.Mac and MessageDigest.isEqual, which is constant-time:

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;

final class MetaSignatureVerifier {

    private final byte[] secret = System.getenv("WHATSAPP_APP_SECRET")   // placeholder
            .getBytes(StandardCharsets.UTF_8);

    boolean isValid(byte[] rawBody, String headerValue) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(rawBody));
        return MessageDigest.isEqual(
                expected.getBytes(StandardCharsets.UTF_8),
                headerValue.getBytes(StandardCharsets.UTF_8));
    }
}

In Spring MVC, read the body as byte[] in the controller (or wrap the request in a caching filter that runs before JSON conversion) so the verified bytes are exactly the signed bytes. The Telegram check is a single comparison of the X-Telegram-Bot-Api-Secret-Token header against TELEGRAM_WEBHOOK_SECRET with the same constant-time function.

Operational rules that apply to all channels:

  • Keep one secret per environment and per channel; a leaked staging token must not be valid in production.

  • Rotate on a schedule and on any suspicion; support two valid secrets during rotation (try the new one, then the old).

  • Never log request headers or bodies verbatim: they contain the signature, bearer tokens and user text. Log IDs and sizes.

  • Serve webhooks over HTTPS only and restrict the route to the platform’s calls where the platform publishes source ranges; do not treat IP allow-lists as a replacement for signatures.

  • Run a secret scanner in CI and on the repository history; the platform tokens above all have recognisable prefixes.

Retries and deduplication

Webhook delivery is at-least-once. Platforms retry when the response is slow or not successful, so the same event can reach you twice, in parallel, or out of order. Slack documents a three-second window to acknowledge an event and retries with X-Slack-Retry-Num and X-Slack-Retry-Reason headers; Meta documents retrying failed webhook deliveries with decreasing frequency for a long period; Telegram retries updates it could not deliver and numbers them with an increasing update_id. The consequence is the same everywhere: acknowledge fast, do the slow work after the response, and make the work idempotent on the platform’s event ID.

Channel Deduplication key

Slack

event_id of the Events API envelope (and client_msg_id of the message); ignore deliveries with X-Slack-Retry-Num once the event ID is known.

WhatsApp

the message ID (wamid…​) of each inbound message in the payload; status callbacks carry the same ID, so key on ID plus type.

Telegram

update_id.

Teams / Microsoft 365 Agents

the activity id together with the conversation ID.

Google Chat

the event’s message name (resource name).

XMPP

the stanza id (or the stanza-id of XEP-0359 when the server adds one).

A reliable pattern is an atomic "set if absent" with a time-to-live in a shared store such as Redis. The key is channel:eventId; the first caller wins, later callers return 200 without doing work:

import os

import redis.asyncio as redis
from fastapi import BackgroundTasks

r = redis.from_url(os.environ["REDIS_URL"])                      # placeholder: redis://redis:6379/0


async def first_delivery(channel: str, event_id: str, ttl_s: int = 24 * 3600) -> bool:
    # SET key value NX EX ttl -> True only for the first caller
    return bool(await r.set(f"dedupe:{channel}:{event_id}", "1", nx=True, ex=ttl_s))


async def handle_slack_event(envelope: dict, background: BackgroundTasks):
    if not await first_delivery("slack", envelope["event_id"]):
        return {"ok": True}                                      # duplicate: acknowledge, do nothing
    background.add_task(answer_and_reply, envelope)              # slow work after the 200
    return {"ok": True}

Details that decide whether this works under load:

  • The window must outlive the platform’s retry horizon. A 24-hour key is a reasonable default; for platforms that retry for days, size it to the documented horizon or accept rare duplicates and make the reply idempotent instead.

  • Process once, reply once. Store the reply’s platform message ID next to the event key so a crash between "model answered" and "reply sent" can be retried without double-posting.

  • Background tasks die with the process. FastAPI BackgroundTasks and Spring @Async are fine for short work; for anything longer than a request, put the event on a queue (a broker, or a Postgres table polled with SELECT …​ FOR UPDATE SKIP LOCKED) so a restart does not lose it. Messaging background is in Messaging Protocols.

  • Ordering is not guaranteed. Serialise work per conversationId (a per-conversation lock or a partitioned queue) so two quick messages from one user do not interleave in the checkpointer; see Conversational RAG & Memory.

  • Outbound retries need backoff. Honour Retry-After and 429 responses when sending, with exponential backoff and jitter, and a cap after which the message goes to a dead-letter store for inspection.

Observability per channel

The model call is only one span of a chat turn. A useful trace starts at the webhook and carries a channel-neutral vocabulary so that one dashboard serves every adapter. The tracing mechanics for the RAG core (spans for retrieval and generation, trace IDs in responses, sampling) are covered in RAG Observability & Tracing; framework hooks are in LangChain: Observability with LangSmith and Spring AI: Observability. The LLMOps & Evaluation sub-section goes further on OpenTelemetry observability, evaluation pipelines and prompt management.

Attach the same attributes to every turn, and add the channel-specific ones from the table:

Scope Attributes and metrics worth recording

All channels

channel, tenant, a pseudonymous user_hash (never the raw handle), conversation_id, trace_id, event-to-ack latency, event-to-first-token latency, event-to-delivery latency, tokens in and out, retrieval hit count, guardrail verdict, outcome (answered, refused, error, duplicate).

Slack

Retry count, 3-second ack misses, Web API 429 count, Block Kit update count per streamed answer.

WhatsApp

Whether the reply fell inside the customer-service window or needed a template, template category used, delivery status callbacks (sent, delivered, read, failed) with the error code.

Telegram

Draft or edit calls per answer, 429 with retry_after, blocked-by-user errors.

Teams

Token acquisition failures, activity send failures, streaming-update cadence.

Google Chat / RCS / Apple MSP

Provider delivery receipts and error codes, reply latency against the provider’s documented response windows.

Own SSE / WebSocket

Open connections, time to first byte, client aborts, proxy buffering symptoms (long gaps then bursts).

Two practical points. First, use one OpenTelemetry trace per turn with the platform event ID as an attribute and the trace ID echoed in error messages shown to operators, never leaked to users. Second, alert on symptoms of the channel contract, not only on exceptions: a rising ack-miss rate on Slack, a falling delivered-to-sent ratio on WhatsApp or a growing dedupe-hit rate each point at a different fault (slow handler, template or policy problem, platform retry storm). A minimal Python instrumentation is a span around the core call:

from opentelemetry import trace

tracer = trace.get_tracer("docsassistant.channels")


async def traced_turn(channel: str, event_id: str, conversation_id: str, user_hash: str, text: str):
    with tracer.start_as_current_span("chat.turn") as span:
        span.set_attribute("chat.channel", channel)
        span.set_attribute("chat.event_id", event_id)
        span.set_attribute("chat.conversation_id", conversation_id)
        span.set_attribute("chat.user_hash", user_hash)          # pseudonymous, not the raw handle
        answer = await ask(conversation_id, user_hash, text)     # the channel-agnostic core
        span.set_attribute("chat.outcome", "answered")
        return answer

Abuse and rate limiting

Chat channels make the sender anonymous and the cost asymmetric: a one-line message can trigger retrieval, several model calls and a long answer. Defences are layered, from cheap to expensive:

  1. Authenticate first. Signature verification (above) removes forged traffic. Then identify the sender in the channel’s own terms: Slack user and workspace, WhatsApp phone number, Telegram user ID, Teams AAD object ID.

  2. Allow-list or gate where the use case permits. Internal Slack and Teams bots can restrict to workspace or tenant members; public WhatsApp and Telegram bots cannot, so they need the limits below.

  3. Rate limit per identity and per tenant. A token bucket of capacity \(b\) refilled at \(r\) tokens per second admits a burst of \(b\) messages and a sustained rate of \(r\); a user who sends \(n\) messages in a window of \(T\) seconds is admitted only while

    Keep the buckets in a shared store (a Redis Lua script or a library on top of it) so all replicas agree, and answer over-limit users with one short message rather than silence, then drop further messages cheaply for a cool-down.

  4. Cap work per turn. Limit input length, number of tool calls, retrieved chunks and output tokens, so one message has a bounded cost regardless of what it asks.

  5. Cap spend per identity per day, and a global circuit breaker that switches the assistant to a fixed "temporarily unavailable" reply when the daily budget is exhausted.

  6. Detect abuse patterns: repeated identical prompts, extremely long inputs, many new identities from one network or number range, attempts to extract the system prompt. Block at the edge (WAF) where possible and log the identity hash for review.

Platform-side limits also constrain you and are documented separately for each platform; treat these as ceilings to design under, not guarantees, and re-read them before launch. Slack rate-limits Web API methods by tier and documents roughly one message per second per channel for posting. Telegram documents about one message per second to a single chat and about thirty messages per second overall for bulk sends, with 429 responses carrying retry_after. WhatsApp applies throughput limits per phone number and business-initiated messaging limits that grow with quality rating. A streamed answer implemented as many edits can exhaust these quickly, so throttle edits (for example one update per second or per few hundred characters) rather than updating on every token.

A short example of a Redis-backed fixed-window limiter is enough to start; replace it with a token bucket when bursts matter:

async def allow(channel: str, user_hash: str, limit: int = 20, window_s: int = 60) -> bool:
    key = f"rl:{channel}:{user_hash}:{int(time.time() // window_s)}"
    count = await r.incr(key)
    if count == 1:
        await r.expire(key, window_s)
    return count <= limit

For the HTTP surface of your own API the same ideas, plus 429 and Retry-After, are in Serving RAG as an API.

PII and data retention

A chat transcript is personal data the moment it is tied to an identity, and channel identifiers (a phone number, a Slack user ID) are personal data on their own. Decide the policy once in the core and let adapters enforce it:

  • Minimise. Map the channel identity to an internal pseudonymous userId at the adapter boundary and store only that in the checkpointer, logs and traces. Keep the mapping table separate and access-controlled.

  • Redact before storing or sending to the model where you can. Mask card numbers, national IDs and similar patterns in the input; the access-control and privacy design of the retrieval layer is in RAG Access Control & Privacy.

  • Set retention per data class: raw transcripts, derived memory, traces and analytics each get their own period, and a deletion path that reaches the checkpointer tables, vector store entries created from chats, logs and backups.

  • Honour deletion and export requests, keyed on the pseudonymous ID.

  • Tell users in the first message of a channel that an AI answers, which data is stored and how to delete it. Some platforms require disclosure of automation; check the policy page of each.

Each platform adds its own rules, which are the part most often missed. The points below summarise what the official documents say as read on 2026-10-01; confirm against the live pages and your contract.

Channel Data-handling points to check

Slack

Messages live in the customer’s workspace and are subject to the workspace’s retention settings; an app sees only what its scopes allow. Directory-listed apps are subject to Slack’s API terms, which restrict storing and using data. Request the narrowest scopes.

WhatsApp

Meta processes message content as a service provider for the business under its business terms; the number is personal data. The business is responsible for consent (opt-in) and for any AI-related provisions of the WhatsApp Business Solution Terms, which this repository could not re-read programmatically on 2026-10-01 (see WhatsApp Business). Do not retain more than needed beyond the customer-service window.

Telegram

Bot chats are not end-to-end encrypted and Telegram stores them on its servers; bots can read messages in groups depending on privacy mode. Telegram’s developer terms apply to stored user data.

Microsoft Teams

Content is governed by the tenant’s Microsoft Purview retention and compliance policies, which may retain or expose bot conversations. Resource-specific consent and Graph permissions determine what the app may read.

Google Chat

Governed by the Workspace administrator’s retention and Vault settings.

Apple Messages for Business and RCS

Apple’s programme requires a registered business and an MSP; message handling and retention terms are contractual with Apple and the MSP. RCS content passes through carriers and the agent provider, so treat it as less private than an end-to-end encrypted channel.

XMPP

Depends entirely on your server: message archive (XEP-0313) retention, whether OMEMO is in use (the bot would need the keys, which makes it an end of the encrypted conversation), and S2S federation, which sends stanzas to other operators.

Do not forward chat text to a model provider, an evaluator or an analytics tool that your data-processing agreements and the platform terms do not cover. The same applies to prompts exported to tracing back-ends: scrub or hash identifiers before export.

Content moderation

Moderation has two directions and a different owner in each.

Inbound. Users will paste secrets, personal data and prompt-injection attempts, and on public channels abuse and sexual or violent content. Before the core runs, apply cheap deterministic checks (length, character classes, blocked patterns), then a classifier or the provider’s moderation endpoint where one is available, and treat the message as untrusted data in the prompt rather than instructions. The injection-specific controls (delimiting, tool permission scoping, indirect injection through retrieved documents) are in RAG Guardrails & Prompt Injection; Spring AI users also have Spring AI: Security & Guardrails and LangChain users LangChain: Guardrails & Security. A broader treatment is in the AI Security & Responsible AI sub-section, starting with Prompt Injection.

Outbound. The assistant’s reply is published under your brand into someone else’s product. Check it for unsupported claims (grounding and citations are in Citations & Grounding), leaked internal URLs or personal data, and policy violations. Some channels add their own content rules that apply to the bot as a publisher: WhatsApp and RCS regulate what business messages may contain and which categories need pre-approved templates, Slack’s directory and Teams' store review apps, and Apple vets business accounts. A refusal path is part of the design: when moderation blocks a turn, reply with a short neutral message and a hand-off (a human, a help link) rather than a silent drop.

Human hand-off deserves the same care as the model path: define when the bot stops (low retrieval confidence, repeated refusals, an explicit request), who receives the conversation, and how the channel is told the bot is no longer answering. The approval patterns for agents are in Human-in-the-Loop.

Testing with sandboxes and emulators

Test in three layers, from fastest to most realistic.

  1. Unit-test the adapter without the network. Feed recorded payloads (with secrets replaced by test values) to the signature check, the dedupe function and the payload-to-ask() mapping, and assert on the outgoing platform calls with a fake client. The channel-agnostic core is tested separately with a scripted model, as in LangChain: Testing and Spring AI: Testing.

  2. Replay and fault-inject. Post each fixture twice and in parallel to prove idempotency, post one with an old timestamp or a wrong signature to prove rejection, make the fake platform return 429 and 5xx to prove backoff, and make the model call slow to prove that the acknowledgement does not wait for it.

  3. Use the platform’s test facilities against a tunnel. Expose the local server with a tunnelling tool and register that URL as the webhook.

Channel Test facility (as documented)

Slack

A free developer sandbox workspace, plus Socket Mode so a local process can receive events without a public URL.

WhatsApp

The Cloud API test phone number and test recipients provided in the Meta app dashboard, and the dashboard’s webhook test button; production needs a verified business, so production-only behaviour (template approval, messaging tiers) is documented, not testable in the sandbox.

Telegram

Create a separate bot with BotFather for each environment; the Bot API also has a test environment with its own accounts. Use long polling locally and webhooks in deployed environments.

Teams / Microsoft 365 Agents

The Microsoft 365 Agents Playground and the Bot Framework Emulator for local runs without Azure registration, then a dev tenant for the real client.

Google Chat

A development Cloud project with the app visible only to your account or domain.

Apple Messages for Business

Apple provides a sandbox-style setup for approved business accounts and MSPs; it cannot be validated without such an account, so rely on the MSP’s test tools and Apple’s documentation.

RCS

The provider’s test devices and tester allow-listing before launch.

XMPP

A local ejabberd or Openfire container, or a disposable Prosody instance, with test accounts.

Keep a small end-to-end suite that runs against the real sandbox on a schedule (a canary): send a message, assert on the reply within a time limit, and alert on failure. It catches the failures unit tests cannot, such as an expired token, a revoked webhook subscription or a changed platform policy. Never point test traffic at production recipients, and never reuse production secrets in tests.

Cost monitoring

Costs have two parts: the model and retrieval work your core performs, and the per-message fees some channels charge. For a period containing \(N\) turns, with average \(\bar{t}_{in}\) input tokens and \(\bar{t}_{out}\) output tokens per turn, model prices \(p_{in}\) and \(p_{out}\) per million tokens, a retrieval cost \(c_{r}\) per turn, and a per-message channel fee \(f\) applied to the fraction \(\phi\) of turns that are billable on the channel:

A worked example with invented numbers: \(N = 100\,000\) turns a month, \(\bar{t}_{in} = 3\,000\) (history plus retrieved chunks), \(\bar{t}_{out} = 400\), \(p_{in} = 1\) and \(p_{out} = 4\) currency units per million tokens, \(c_{r} = 0.0002\), \(\phi = 0.1\) and \(f = 0.03\). The model term is \((3000 \cdot 1 + 400 \cdot 4)/10^{6} = 0.0046\) per turn, so

Even with these made-up figures the lesson is stable: input tokens dominate (history and retrieved context, not the answer), and a channel fee on a small share of turns can rival the model cost. Both are levers. Shorten history with summarisation, retrieve fewer and smaller chunks, and cache repeated answers (see Caching, Latency & Cost); on WhatsApp, keep replies inside the customer-service window so they stay free of template fees, noting that the platform has charged per message rather than per conversation since 2025-07-01 and that rates differ by category and country.

To monitor it, emit the inputs of the formula as metrics rather than computing money in the application, because prices change:

  • tokens in and out per turn, labelled by channel, tenant and model, from the model client’s usage metadata;

  • retrieval calls and reranker calls per turn;

  • billable channel messages by category, from the platform’s delivery callbacks or billing reports;

  • cost per resolved conversation and per active user, computed in the dashboard from a price table you version;

  • budget burn against the daily and monthly caps from the abuse section, with an alert at a fraction of the cap.

Review the per-channel cost per resolved conversation monthly: a channel with a low cost per message and a high retry or re-ask rate can still be the most expensive one.

Operations checklist

  • Every webhook verifies a signature, token or JWT on the raw request before any work, in constant time.

  • Secrets are environment or vault values, per environment and channel, rotatable, and never logged.

  • Events are deduplicated on the platform’s ID; the acknowledgement precedes the model call; replies are idempotent.

  • Work is serialised per conversation and survives a restart (queue, not only in-process tasks).

  • One trace per turn with channel, pseudonymous user, event ID and token usage; channel-specific delivery metrics.

  • Per-identity and per-tenant rate limits, per-turn caps, daily spend caps and a global circuit breaker.

  • Identities are pseudonymised; retention and deletion are defined per data class; platform data rules are reviewed.

  • Input and output moderation, a refusal message and a human hand-off are defined.

  • Unit, replay and fault-injection tests plus a scheduled sandbox canary exist for each channel.

  • Cost inputs are metered per channel, and budgets alert before they are exhausted.