Skip to content

Reyn gateway authoring guide

Gateway framework spec for Reyn integrations (= webhook handlers for Slack / LINE / Discord / GitHub etc.).

This guide is for gateway authors who want to add inbound webhook integrations to Reyn (= e.g. a chat-transport adapter for a service not covered by the bundled samples).

Design rationale

Reyn core stays free of transport-specific protocol code: signing schemes, event payload schemas, and SDK dependencies all live in gateways. The trade-off — operator installs an extra package — is worth it because:

  • Reyn maintainers don't track Slack / LINE / Discord / etc. API drift
  • Each transport's SDK choices stay local to its gateway
  • Operators run only the gateways they need
  • Community can ship gateways independent of Reyn's release cycle

The bundled sample_* gateways under src/reyn/gateway/ exist as reference implementations + quick-start fixtures. Production integrations should fork or replace them.

Gateway shape

A webhook gateway is a Python package that exposes a single callable named register_router (= conventional) via the reyn.webhooks entry point group.

Layout (= external package example)

my-reyn-webhook-line/
├── pyproject.toml
└── my_reyn_webhook_line/
    ├── __init__.py     # exposes register_router
    └── handler.py      # actual route logic

Entry point declaration

# pyproject.toml
[project]
name = "my-reyn-webhook-line"
version = "0.1.0"
dependencies = ["fastapi", "reyn"]

[project.entry-points."reyn.webhooks"]
line = "my_reyn_webhook_line:register_router"

The entry point name (= line) is what operators put in their webhooks.yaml to activate this gateway.

register_router contract

from fastapi import APIRouter

def register_router(config: dict) -> APIRouter | None:
    """Build the gateway's webhook router.

    config: per-instance dict from webhooks.yaml, minus reyn-reserved
            keys (package, enabled). Gateway-defined fields only.
    returns: an APIRouter to mount, or None to skip (= e.g. required
             option missing). When returning None, log a warning so
             the operator can see why.
    """
    ...

The returned APIRouter is mounted on the Reyn web app at the path the gateway chooses. By convention webhook gateways use /webhook/<service> as the route path so operators paste a predictable URL into their service's webhook config UI.

webhooks.yaml schema

Operators activate gateways in webhooks.yaml (= sibling of reyn.yaml at the project root):

# webhooks.yaml

# Short form: just the gateway name (= empty value)
sample_slack:
  target_agent: news_agent      # gateway-defined field

# Long form: explicit reyn-reserved fields + gateway fields
my_other_gateway:
  package: reyn-gateway-line     # optional: disambiguates same-name gateways
  enabled: false                # optional, default true
  some_option: value            # gateway-defined

Reyn-reserved keys

The loader interprets these and removes them from what's passed to register_router:

Key Default Purpose
package unset Disambiguates when multiple packages register the same gateway name. Match against the Python distribution name.
enabled true Set false to deactivate without removing config.

Gateway authors must avoid using these names in their gateway-defined fields.

Per-gateway options

Everything in the per-gateway dict except the reyn-reserved keys is forwarded to register_router as the config argument. The gateway author defines this schema.

Secrets (= API keys, signing secrets) belong in environment variables, never in webhooks.yaml.

Stable gateway API — reyn.gateway.api

The module exposes the stable contract gateway authors consume. Internal session methods (= _put_inbox etc.) may change between Reyn versions; this API stays stable.

Helpers

push_to_agent(*, target_agent, text, sender, reply_to=None, extra_meta=None, registry=None) Deliver a message to a Reyn agent's inbox. Default for webhook gateways. The inbox kind is fixed at TurnOrigin.EXTERNAL_MESSAGE and is deliberately NOT a parameter — see "Slash commands are not exposed to gateways" below.

list_agents(*, registry=None) -> list[str] All agent names known to the project (= sorted disk view). Use at register_router time to validate config or discover targets dynamically.

agent_exists(name, *, registry=None) -> bool Pre-flight check for a single agent name. Defensive: registry error → False.

make_sender(transport, external_id, *, display=None, source_scope=None) -> str Assemble the documented sender attribution string. Prefer over raw f-strings so dispatch attribution label rendering follows the standard format. See examples in the docstring.

Inbound envelope shape + stable gateway API

When the gateway's route receives a webhook, push to the target agent's inbox via the stable gateway API in reyn.gateway.api::

from reyn.runtime.transport import ExternalRef
from reyn.gateway.api import push_to_agent

await push_to_agent(
    target_agent=target_agent,
    text="<message body>",
    sender=f"<transport>:<external_user_id>",
    reply_to=ExternalRef(
        transport="<transport>",       # e.g. "slack", "line"
        destination={                   # transport-specific routing
            "channel": "...",
            "thread_ts": "...",
        },
    ),
)

Do NOT call internal session methods directly. Session. _put_inbox etc. are private API and may change between Reyn versions; reyn.gateway.api is the contract that stays stable.

Slash commands are not exposed to gateways

A message pushed through push_to_agent is content the model reads, never a Reyn operator command. A gateway user sending /reset, /visibility, /plugin or any other registered slash command gets it read by the LLM as text; nothing is executed.

This is a decision, not an omission (#3595 step 1b, owner ruling 2026-08-01: 「現時点では slash に公開不要」 — "no need to expose slash at present"). Until this arc, a gateway push claimed the "user" inbox kind — the claim that a human typed the line at a first-party client — and Session._handle_user_message acted on that claim by handing a /-prefixed line to slash dispatch before any router turn. Anyone able to post to a webhook could therefore run any registered slash command. Gateway pushes now ride TurnOrigin.EXTERNAL_MESSAGE, and #3595 S5 has since deleted the dispatch itself: Session interprets no string, so the hole is closed twice over — once by the kind, once by construction.

If Reyn ever does expose slash to a gateway, the route is the ratified one — the shared client-side slash layer (reyn.interfaces.slash.dispatch, which every reyn client already drives) — not startswith("/") re-introduced at a transport, and not an allow-list of "safe" commands.

Reyn's session dispatch automatically: - Surfaces sender as a state_change history entry (= PR-A attribution) - Captures reply_to for outbound reply routing (= PR-D2)

The gateway API receives an optional registry kwarg for tests (= inject an AgentRegistry stub). Production code omits it and uses the process-shared registry.

Sender attribution as state_change (FP-0041 #489)

push_to_agent's sender argument (e.g. "slack:U456:bob", "cron:morning_news", "a2a:peer_agent") is more than a routing label — Reyn's session dispatch (InboxArbiter.handle_sender_attribution, moved out of Session in #3978 P1, called from run_one_iteration before turn dispatch) compares each inbox item's sender against the PRIOR turn's sender. When they differ, it surfaces the transition to the LLM as a state_change history entry.

This makes the multi-consumer ("humanic") model explicit in the agent's own context: without it, the agent sees one continuous, undifferentiated transcript regardless of who or what is actually driving each turn — it cannot tell "I was just talking to Alice via cron, now Bob from Slack just said something" from an ordinary multi-turn conversation with a single counterpart. A gateway author who calls push_to_agent without ever changing sender across calls will never see this marker fire, which is the correct behavior for a single-counterpart integration — the marker exists specifically for gateways/transports that multiplex several external senders onto one agent inbox.

Outbound replies via MCP

The Reyn-side outbox interceptor (= reyn.runtime.external_routing) routes agent replies whose reply_to is an ExternalRef through an MCP tool. Operators configure transport → MCP tool mapping in reyn.yaml external_transports::

external_transports:
  line:                          # matches ExternalRef.transport
    mcp_tool: line__reply_message
    args_template:
      reply_token: "{destination.reply_token}"
      messages:
        - type: text
          text: "{text}"

The webhook gateway doesn't need to handle outbound — Reyn dispatches via the configured MCP server. Operators install the appropriate MCP server (= e.g. @modelcontextprotocol/server-line) and declare the transport mapping.

Testing

Unit-test the gateway's helpers (= signing verify, envelope mint) directly. Integration-test the route via FastAPI's TestClient + a stubbed AgentRegistry (= see tests/gateway/sample_slack/test_webhook.py for the pattern).

Avoid loading the full reyn.web.server.app in gateway tests; mount the gateway's router on a fresh FastAPI() so tests stay hermetic.

Conflict resolution

When two installed packages both register the same gateway name (= e.g. two slack gateways), the loader logs a warning and uses the first match. Operators should pin package: in webhooks.yaml to disambiguate.

Versioning compatibility

Gateways pin their Reyn version range in pyproject.toml:

dependencies = ["reyn >= 0.1, < 0.2"]

The gateway contract (= register_router(config) -> APIRouter | None, envelope shape, ExternalRef routing) is intended to stay stable across minor Reyn versions; breaking changes will be flagged in the changelog.