--- name: ak-dev-new-messaging-integration description: > Step-by-step guide for adding a new messaging platform integration to Agent Kernel. Use this skill when you need to add support for a new chat platform (beyond Slack, WhatsApp, Messenger, Instagram, Telegram, Teams, Gmail). Covers writing the inbound/outbound adapter pair, hosting it, webhook verification, attachments, configuration, and examples. license: Apache-2.0 metadata: author: yaalalabs category: developer --- # Adding a New Messaging Integration This guide walks through adding a new messaging platform integration to Agent Kernel. Use the WhatsApp adapter (`ak-py/src/agentkernel/integration/whatsapp/adapter.py`) as the canonical webhook reference, and Gmail (`integration/gmail/adapter.py`) as the polling one. ## Architecture Overview **A platform integration is two pure translation functions with a queue between them** (spec #524): 1. An **`InboundAdapter`** turns one platform delivery into normalized `InboundRequest` envelopes: it verifies the delivery, extracts the text, downloads and stores attachments, and resolves `session_id` and `request_id` at the edge. It **never runs the agent**. 2. The **pipeline** carries the request: `IntegrationProducer` enqueues it, `AgentRunner` executes it platform-agnostically, and the reply travels back on the output queue with the `integration` attribute and the `reply_`-prefixed reply context. 3. An **`OutboundAdapter`** turns the agent's reply back into platform API calls, using nothing but the flat `reply_context` the inbound half resolved. This is why the webhook answers in milliseconds: a slow agent run can no longer hold the turn open past the platform's delivery timeout and cause a redelivery. Hosting depends on how the platform delivers events: | Source | Host | Entry point | |---|---|---| | `Source.WEBHOOK` (pushed) | `WebhookRESTRequestHandler` | `IOHandler.run(handlers=[WebhookRESTRequestHandler(MyInboundAdapter())])` | | `Source.WEBHOOK` on AWS Lambda | `WebhookRESTRequestHandler` via `LambdaWebhookHost` | `host = LambdaWebhookHost(WebhookRESTRequestHandler(MyInboundAdapter()))` at module scope in the request-handler Lambda, with `Lambda.register(, method="POST")(host.handle)` (and `GET` → `host.challenge` when the adapter declares a `challenge_path`) (`rest_sync`/`rest_async`, `sqs`), with the route in `gateway_endpoints` and `APIGatewayAuthorizer(bypass=WebhookRouteMatcher(...))` behind an authorizer | | `Source.POLLER` (pulled) | `PollerRunner` | `IOHandler.run(pollers=[PollerRunner(MyInboundAdapter())])` on `in_memory`, `PollerRunner.run(adapter)` as its own container on a broker | **Adapters must be mounted inside the pipeline.** `WebhookRESTRequestHandler` sets `requires_pipeline = True`, so `RESTAPI.run([...])` refuses it with an `AKConfigError`: without a queue there would be no runner to drain what it enqueues, and the platform would get its 200 while the user never got a reply. ## Step-by-Step ### 1. Create the Integration Directory ``` ak-py/src/agentkernel/integration// ├── __init__.py └── adapter.py ``` ### 2. Implement the Inbound Adapter ```python # ak-py/src/agentkernel/integration//adapter.py import logging from typing import Any, Dict, List, Optional from fastapi import HTTPException, Request from ...core.config import AKConfig from ...core.model import AgentReply, AgentRequest, AgentRequestImage, AgentRequestText from ...core.multimodal.storage import AttachmentStorageManager from ..adapter.base import ( ATTACHMENTS_DISABLED_ERROR, SESSION_CACHE_ERROR, InboundAdapter, InboundParseResult, InboundRequest, OutboundAdapter, ) from ..adapter.routes import BUILTIN_WEBHOOK_ROUTES NAME = "" _log = logging.getLogger("ak.integration.") class InboundAdapter(InboundAdapter): """ deliveries -> normalized requests.""" name = NAME # A built-in reads its paths from the route table (see step 7), so the Lambda authorizer's # WebhookRouteMatcher lets exactly this route through; a bring-your-own adapter spells it out. webhook_path = BUILTIN_WEBHOOK_ROUTES[NAME].webhook_path challenge_path = BUILTIN_WEBHOOK_ROUTES[NAME].challenge_path # None unless the platform has a GET handshake _log = _log def __init__(self): config = AKConfig.get() self._agent = config..agent or None self._max_file_size = config.api.max_file_size self._client = Client() # your API wrapper async def verify(self, raw: Request) -> None: """Reject a delivery that did not come from the platform. Runs before parse.""" if not self._client.verify(await raw.body(), raw.headers.get("x-platform-signature", "")): raise HTTPException(status_code=403, detail="Invalid signature") async def parse(self, raw: Request) -> InboundParseResult: """One delivery can carry several messages; return one InboundRequest per message.""" body = await raw.json() requests = [r for r in [await self._to_request(m) for m in body.get("messages", [])] if r is not None] return InboundParseResult(requests=requests) async def _to_request(self, message: dict) -> Optional[InboundRequest]: text = message.get("text", "") sender = message["from"] if not text: return None # legitimately ignored: an empty list is not an error requests: List[AgentRequest] = [AgentRequestText(prompt=text)] # ... download attachments into `requests` here (see step 5) ... requests, _ = AttachmentStorageManager.offload( sender, requests, attachments_disabled_error=ATTACHMENTS_DISABLED_ERROR, session_cache_error=SESSION_CACHE_ERROR, ) return InboundRequest( session_id=sender, # the platform's conversation key request_id=message["id"], # the platform's own id: this is what dedupes a retry requests=requests, prompt=text, agent=self._agent, user_id=sender, reply_context={"to": sender}, # flat, string-valued delivery coordinates ) ``` Rules the adapter must hold to: 1. **Never execute.** No `ChatService`, `AgentService` or `Runtime` import. The only side effects allowed are platform API calls and attachment storage. 2. **Read only your own config block**, `AKConfig.get().`. 3. **`verify` before `parse`, `parse` before enqueue.** `verify` is concrete and a no-op on the base: override it only when verification is separable from parsing. (Slack and Teams verify inside their SDK's dispatch, so theirs stays the default.) 4. **`request_id` is the platform's message id** wherever one exists; that is what makes a webhook retry deduplicate instead of running the agent twice. Synthesize a stable one only when the platform gives you none (Slack: `f"slack:{channel}:{ts}"`). 5. **An ignored delivery returns an empty request list**, never an exception. 6. **Override `missing_verification_settings()`** when `verify` skips its check for an unset secret (return e.g. `[".webhook_secret"]` while it is empty). `LambdaWebhookHost` refuses such an adapter on cold start, because behind the authorizer's integration bypass the adapter's own check is the only one. An adapter that refuses to construct without its secret (Slack, Teams) needs no override. ### 3. Implement the Outbound Adapter ```python class OutboundAdapter(OutboundAdapter): """Agent replies -> messages.""" name = NAME MESSAGE_LIMIT = 4096 # the platform's per-message limit; split_reply chunks to it MAX_CHUNKS = None # or a cap, with TRUNCATION_NOTICE appended past it _log = _log def __init__(self): self._client = Client() async def acknowledge(self, reply_context: Dict[str, str]) -> Dict[str, str]: """Edge-side feedback: a typing indicator, a read receipt, a "thinking" message. The returned dict is merged into reply_context, which is how Slack carries the id of its placeholder message through to delivery. """ await self._client.typing(reply_context["to"]) return {} async def deliver(self, reply: AgentReply, reply_context: Dict[str, str]) -> None: """Raising hands the message back for retry, then deliver_error.""" await self._client.send(reply_context["to"], self.split_reply(str(reply))) async def deliver_error(self, message: str, reply_context: Dict[str, str]) -> None: try: await self._client.send(reply_context["to"], [message]) except Exception as e: self._log.error(f"Could not deliver the error message: {e}") ``` - The reply always arrives as an `AgentReplyText`: the Agent Runner serializes the typed reply to its string form before the output queue. - Outbound adapters are **cached and shared across consumer threads**, and each call runs on its own event loop. Keep no per-message state on `self`, and build loop-bound clients (an `httpx.AsyncClient`) per call. - `deliver_error` receives `OutboundAdapter.ERROR_MESSAGE`, not the raw exception: raw error text is logged, never sent to a platform user. ### 4. Reply Context `reply_context` is flat, string-valued delivery coordinates: everything `deliver` needs and nothing else. It travels as `reply_`-prefixed message attributes rather than body fields, because `BaseRunRequest` is `extra="allow"` and an unknown body field would reach the agent as `AgentRequestAny` context. Budget: **8 KB serialized**, enforced in `IntegrationProducer` with a `ValueError` naming the adapter. If the platform's reply address is an object rather than strings, JSON-encode it into one value (Teams does this with its `ConversationReference`). ### 5. Attachments Attachment bytes must **not** ride the queue: brokers cap a message far below `api.max_file_size`. Download at the edge (that is where the platform token is), then call `AttachmentStorageManager.offload`, which stores the bytes in the `AttachmentStore` and replaces each image/file request with an `AgentRequestAttachmentRef`. This makes `multimodal.enabled: true` a requirement for attachment-bearing messages, and rejects `multimodal.storage_type: session_cache` (it writes into a session copy the runner process never sees). Both messages are shared constants; pass them through as shown in step 2. ### 6. Create the `__init__.py` and the Public Alias ```python # ak-py/src/agentkernel/integration//__init__.py from .adapter import InboundAdapter, OutboundAdapter ``` Create `ak-py/src/agentkernel/.py` with a wildcard import (see `ak-py/src/agentkernel/slack.py`): ```python from .integration. import * ``` ### 7. Register the Built-in with the Factory The Response Handler holds only the `integration` attribute string, so **the outbound half is resolved by name**. Add the platform to `IntegrationAdapterFactory` (`integration/adapter/factory.py`): its short name in `_BUILTIN_NAMES`, and an `if/elif` branch in `_builtin` importing the class inside `require_extra`. (The inbound half is never resolved by name: the application constructs it and hands it to a host, so bring-your-own inbound is just passing a different instance.) Add the platform's routes to `BUILTIN_WEBHOOK_ROUTES` in `integration/adapter/routes.py` as well, a `WebhookRoute(name, webhook_path, challenge_path=...)`. It is the one definition the adapter's path attributes and `WebhookRouteMatcher.for_integrations(...)` both read, and `IntegrationAdapterContract.test_a_builtin_is_served_where_the_authorizer_expects` fails when they drift. ### 8. Add Configuration Add a config section to `ak-py/src/agentkernel/core/config.py`, following the existing idiom (`Field` with empty-string defaults). Every platform block carries an `outbound_adapter` override: ```python class _Config(BaseModel): agent: str = Field(default="", description="Agent name to handle messages") bot_token: str = Field(default="", description=" bot token") webhook_secret: str = Field(default="", description="Webhook verification secret") outbound_adapter: str = Field( default="", description="Dotted path to an OutboundAdapter subclass replacing the built-in outbound adapter", ) class AKConfig(YamlBaseSettingsModified): : _Config = Field(description=" related configurations", default_factory=_Config) ``` Configurable through `config.yaml` or `AK___AGENT` / `AK___BOT_TOKEN` environment variables. ### 9. Add Optional Dependencies In `ak-py/pyproject.toml`: ```toml [project.optional-dependencies] = [ "httpx>=0.27.0", # for HTTP API calls (most platforms need this) "platform-sdk>=x.y.z", # platform-specific SDK if available ] ``` The factory imports the built-in inside `require_extra("", ...)`, so a missing SDK reports `pip install "agentkernel[]"` rather than a bare `ModuleNotFoundError`. ### 10. Polling Platforms A platform with no webhook subclasses `PollingInboundAdapter` instead: ```python class InboundAdapter(PollingInboundAdapter): name = NAME poll_interval = 30.0 # read it from your config block in __init__ async def poll(self) -> List[Any]: """Return the raw events to parse this iteration. Must not run the agent.""" def mark_handled(self, raw: Any) -> None: """Called after an event is enqueued, so the next poll skips it.""" ``` `PollerRunner` waits on `ThreadRunner.shutdown_event` between iterations, so a 30-second interval still drains promptly on SIGTERM. Run the poller at **one replica**: `mark_handled` state is per process (see Gmail, where a message stays unread until its reply is sent). ### 11. Usage Pattern ```python # server.py from agentkernel.integration.adapter import WebhookRESTRequestHandler from agentkernel.openai import OpenAIModule from agentkernel.pipeline import IOHandler from agentkernel. import InboundAdapter from agents import Agent agent = Agent(name="general", instructions="You are a helpful assistant.") OpenAIModule([agent]) if __name__ == "__main__": IOHandler.run(handlers=[WebhookRESTRequestHandler(InboundAdapter())]) ``` ### 12. Add Example Create `examples/api//` with: - `server.py` — minimal working example (the pattern above) - `pyproject.toml` — with `agentkernel[api,openai,]` dependency - `config.yaml` — platform configuration - `server_test.py` — health check and basic functional test - `README.md` — setup instructions (bot token, webhook URL, etc.) ### 13. Add Tests Two files: 1. `ak-py/tests/test_integration_adapter_contract.py` — add a `IntegrationAdapterContract` subclass for the platform. The contract covers the invariants the queue hop needs: stable identifiers, an ignorable delivery that is not an error, a flat reply context inside its budget, and a clean round trip through `IntegrationProducer`. 2. `ak-py/tests/test__integration.py` — the platform's own parsing and formatting. Build the adapter via `object.__new__` with a stubbed API client (see `test_whatsapp_integration.py`), and cover: message parsing, ignored deliveries, rejection paths (oversized, unsupported media, download failure), verification, reply chunking and acknowledgement. ### 14. Add Documentation Add `docs/docs/integrations/.md` covering: - Platform setup (creating a bot, getting tokens) - Configuration options, including `outbound_adapter` - Example code using `IOHandler.run(handlers=[...])` - Webhook URL setup - The `multimodal.enabled` requirement if the platform accepts attachments Then update the landing page inventories in `docs/src/components/*/data.tsx`: add a tile to the **Channels & protocols** row in `IntegrationsMarquee/data.tsx` (role `Channel`, `href` to the new page, logo under `docs/static/img/integrations/` or a `react-icons/si` glyph); add `pick("")` to the **Messaging channels** card in `ArchitectureOverview/data.tsx`; add the platform to the **Messaging Channels** card's `tags` and `description` under the Connect tab in `FeatureExplorer/data.tsx`. Logo sourcing and the build check are in `ak-dev-sync-docs-from-branch`, *Docs-Site Landing and Features Pages*. Then the features page: the `MESSAGING_PLATFORMS` list in `docs/src/pages/features.tsx` (logo, link to the new page). Grep `docs/src/pages/*.tsx`, `README.md`, and `docs/docs/intro.md` for the platform roll call ("Slack, WhatsApp, ...") and add the new name wherever the others are listed. ## Checklist - [ ] `ak-py/src/agentkernel/integration//adapter.py` with the inbound/outbound pair - [ ] `verify` (or a documented reason it stays the base no-op) and `challenge` if the platform has a handshake - [ ] `request_id` set from the platform's own message id - [ ] Attachments offloaded with `AttachmentStorageManager.offload`, never inlined - [ ] `reply_context` flat, string-valued, inside the 8 KB budget - [ ] `MESSAGE_LIMIT` (and `MAX_CHUNKS`) set to the platform's limits - [ ] Package `__init__.py` and public alias at `ak-py/src/agentkernel/.py` - [ ] Registered in `IntegrationAdapterFactory._BUILTIN_NAMES` and `_builtin` - [ ] A `WebhookRoute` in `integration/adapter/routes.py::BUILTIN_WEBHOOK_ROUTES`, with the adapter's paths read from it - [ ] `missing_verification_settings()` overridden if `verify` skips its check for an unset secret - [ ] Configuration class in `config.py`, including `outbound_adapter` - [ ] Optional dependency group in `pyproject.toml` - [ ] Example in `examples/api//` mounting through `IOHandler.run` - [ ] `IntegrationAdapterContract` subclass plus the per-platform test file - [ ] Documentation in `docs/docs/integrations/.md` - [ ] Landing page inventories: marquee tile (`IntegrationsMarquee/data.tsx`), `pick()` chip on the Messaging channels card (`ArchitectureOverview/data.tsx`), Messaging Channels card tags (`FeatureExplorer/data.tsx`) - [ ] Platform inventories on `docs/src/pages/features.tsx` (`MESSAGING_PLATFORMS`) and in the README/intro roll calls