correspond.channel#
Serve listeners as a Claude Code channel: new messages pushed into a running session, replies through the ordinary tools.
Run correspond-channel (install the extra first: pip install "correspond[mcp]"). It
is an MCP stdio server that declares experimental['claude/channel'], polls
correspond.listen() for the conversations it was given, and emits one
notifications/claude/channel per new message, from an allowlisted sender, as:
{"content": "<the text>",
"meta": {"channel": "github", "conversation": "github:example/demo#1",
"message_id": "...", "sender": "github:ada", "grade": "platform"}}
which Claude sees as <channel source="correspond" channel="github" ...>text</channel>.
Meta keys are identifiers only (Claude Code silently drops a key with a hyphen).
Replies are the ordinary correspond tools (correspond.tools, through
correspond.mcp.mk_server()): the reads by default, the writes only with
--allow-send, each write dry-run first and through the operator’s before_send check.
Senders. Only messages from senders on the allowlist (--allow github:ada, matched on
the sender’s attested address or <channel>:<native id>, never the room) are forwarded;
an empty allowlist forwards nothing unless --allow-any-sender opens it. A forged
message is never forwarded, the bot’s own messages never loop back, and events without an
attested sender (a bare reaction) are not forwarded.
Permission relay (claude/channel/permission). With --relay-to <ref> (which needs
--allow-send, because relaying is a send) the server declares the permission capability;
each notifications/claude/channel/permission_request is sent to that conversation as a
prompt ending in Reply "yes <id>" or "no <id>"; and a reply in that conversation of
that shape becomes a notifications/claude/channel/permission verdict, never chat, but
only when its sender is on the explicit allowlist (--allow-any-sender does not count),
its authenticity grade is one of APPROVE_GRADES (authenticated: not claimed or
forged), and the id is one this server relayed and has not yet answered.
How the Python MCP SDK does this (mcp 1.27, fastmcp 3.3, verified by the tests):
Declaring the capability: the low-level
mcp.server.lowlevel.Servertakes it ascreate_initialization_options(experimental_capabilities={"claude/channel": {}}); FastMCP (whatpy2mcpbuilds) keeps a publicexperimental_capabilitiesdict that its low-level server merges into the same call. This module sets that dict.Emitting a custom method:
ServerSession.send_notificationonlymodel_dump``s its argument into a ``JSONRPCNotification, so any model withmethodandparams(mcp.types.Notification) goes out unvalidated. A background task has no session handle in FastMCP, so this module writes the sameSessionMessage(JSONRPCNotification(...))onto the transport’s write stream, after the client’snotifications/initialized.Receiving a custom method: the SDK validates incoming notifications against the closed
ClientNotificationunion and drops anything else with a warning, sopermission_requestis taken off the read stream by a tap in front of the session.
Module Attributes
identifiers only. |
|
everything but a failed verification. |
|
the authenticated ones. |
|
Where the channel server keeps its listen cursors, apart from |
Functions
|
The |
|
Serve the channel over stdio (see |
|
One poll of every ref: the notifications its new events become, deduplicated on delivery id. |
|
|
|
The text sent to the relay conversation for one permission request. |
|
Run the channel over stdio until the client goes away; |
|
Run the channel over a pair of MCP message streams (stdio in |
|
The |
Classes
|
Who gets through, as a pure policy: the allowlist, the grades forwarded, and who may approve. |
- correspond.channel.APPROVE_GRADES = frozenset({Grade.BOUND, Grade.CRYPTO, Grade.DOMAIN, Grade.PLATFORM})#
the authenticated ones.
- Type:
Grades whose sender may approve tool use
- correspond.channel.CURSOR_KIND = 'channel-cursors'#
Where the channel server keeps its listen cursors, apart from
correspond listen’s.
- correspond.channel.FORWARD_GRADES = frozenset({Grade.BOUND, Grade.CLAIMED, Grade.CRYPTO, Grade.DOMAIN, Grade.PLATFORM})#
everything but a failed verification.
- Type:
Grades forwarded by default
- class correspond.channel.Gate(allow=frozenset({}), allow_any=False, forward_grades=frozenset({Grade.BOUND, Grade.CLAIMED, Grade.CRYPTO, Grade.DOMAIN, Grade.PLATFORM}), approve_grades=frozenset({Grade.BOUND, Grade.CRYPTO, Grade.DOMAIN, Grade.PLATFORM}), relay_to=None)[source]#
Bases:
objectWho gets through, as a pure policy: the allowlist, the grades forwarded, and who may approve.
allowholds sender addresses (github:ada) or<channel>:<native id>; empty forwards nothing unlessallow_any.relay_tois the canonical relay conversation (verdicts are read there only); approval needs the explicit allowlist and a grade inapprove_grades, which may hold only authenticated grades.
- correspond.channel.META_KEYS = ('channel', 'conversation', 'message_id', 'sender', 'grade')#
identifiers only.
- Type:
The
metakeys of every channel notification, in order
- correspond.channel.channel_notification(event)[source]#
The
notifications/claude/channelmessage for one event that carries a message.>>> from correspond.testing import demo_message >>> note = channel_notification(Event(kind="message.created", channel="fake", delivery_id="d1", ... message=demo_message())) >>> note["method"], note["params"]["content"] ('notifications/claude/channel', 'The export drops the last row.') >>> note["params"]["meta"] {'channel': 'fake', 'conversation': 'fake:example/demo', 'message_id': 'm1', 'sender': 'fake:ada', 'grade': 'platform'}
- Return type:
- correspond.channel.main(argv=None)[source]#
Serve the channel over stdio (see
options()for the arguments).- Return type:
- correspond.channel.notifications(refs, *, gate, cursors=None, registry=None, seen=None, pending=None, limit=None, seen_limit=10000, log=<function _log>)[source]#
One poll of every ref: the notifications its new events become, deduplicated on delivery id.
Lazy on purpose:
correspond.listen()stores an event’s cursor only when the next one is asked for, so a consumer that emits each notification before asking for the next one gets at-least-once delivery, andseenmakes it once per process. A ref that fails is logged and skipped; the others are still polled.
- correspond.channel.parse_verdict(text)[source]#
("allow" | "deny", request_id)for a reply likeyes abcde, elseNone.>>> parse_verdict("Yes ABCDE"), parse_verdict("no qwxyz"), parse_verdict("yes"), parse_verdict("yes hello") (('allow', 'abcde'), ('deny', 'qwxyz'), None, None)
- correspond.channel.permission_prompt(params)[source]#
The text sent to the relay conversation for one permission request.
>>> print(permission_prompt({"request_id": "abcde", "tool_name": "Bash", ... "description": "List files", "input_preview": '{"command": "ls"}'})) Claude wants to run Bash: List files {"command": "ls"} Reply "yes abcde" or "no abcde"
- Return type:
- correspond.channel.serve(**kwargs)[source]#
Run the channel over stdio until the client goes away;
kwargsas forserve_streams().- Return type:
- async correspond.channel.serve_streams(read_stream, write_stream, *, refs, gate, allow_send=False, interval=30.0, cursors=None, registry=None, send=None, server=None, log=<function _log>)[source]#
Run the channel over a pair of MCP message streams (stdio in
serve(), memory in tests).serverdefaults tocorrespond.mcp.mk_server()with the channel instructions;send(the relay’s outbound) defaults tocorrespond.send()onregistry.- Return type: