correspond
correspond: a channel facade for AI agents.
Read, listen, write and identify the sender over GitHub, email, push notifications, Telegram and a web inbox, through one model and one set of verbs:
>>> import correspond
>>> messages = correspond.read("github:octocat/hello-world#1")
>>> messages[0].author.handle, messages[0].authenticity.grade.value
('octocat', 'platform')
>>> correspond.send("ntfy:", "backup finished", dry_run=True).plan
A conversation reference is <channel>:<id>. Each channel implements the operations it
can (read, listen, send, edit, react, upload, verify); asking
for one it lacks raises NotSupported, and capabilities() says so in advance.
correspond knows no people: a message’s author is what the platform attests, and its
authenticity is how sure the channel is.
The command line (correspond read github:octocat/hello-world#1) and the MCP server use
the same verbs, through correspond.tools.
- class correspond.Account(*, channel: str, id: str, acts_as: str = 'user')[source]
The credentialed endpoint correspond acts through: whose name a write goes out under.
- class correspond.Attachment(*, ref: str, media_type: str = 'application/octet-stream', name: str | None = None, size: int | None = None, sha256: str | None = None, loader: Callable[[], bytes] | None = None)[source]
A file that came with a message: referenced by
ref, fetched only oncontent(), never inlined.- classmethod from_dict(data: Mapping[str, Any]) Attachment[source]
The inverse of
to_dict()(the result has no loader).
- class correspond.Authenticity(*, grade: Grade, evidence: Mapping[str, ~typing.Any]=<factory>)[source]
A grade and its evidence, computed on correspond’s side of the hop and never read from a payload.
>>> Authenticity(grade="bound", evidence={"method": "hmac-sha256"}).to_dict() {'grade': 'bound', 'evidence': {'method': 'hmac-sha256'}}
- classmethod from_dict(data: Mapping[str, Any]) Authenticity[source]
The inverse of
to_dict().
- class correspond.Capabilities(*, channel: str, read: Support = Support.NONE, listen: Support = Support.NONE, send: Support = Support.NONE, edit: Support = Support.NONE, react: Support = Support.NONE, upload: Support = Support.NONE, verify: Support = Support.NONE, initiate: Support = Support.NONE, reply: Support = Support.NONE, priority: Support = Support.NONE, history_depth: HistoryDepth = HistoryDepth.NONE, listen_modes: tuple[str, ...] = (), grades: tuple[Grade, ...] = (), max_text_length: int | None = None, max_title_length: int | None = None, edit_max_age_s: int | None = None, reactions: tuple[str, ...] = (), reactions_per_message: int | None = None, max_upload_bytes: int | None = None, formats: tuple[str, ...] = ('plain',), native_fields: tuple[str, ...] | None = None, rate_limits: tuple[str, ...] = (), notes: tuple[str, ...] = ())[source]
What a channel can do, graded, with its limits.
One
Supportper operation inOPERATIONS, plus three features of writing:initiate(can a write start a conversation; Telegram bots cannot),reply(can a draft answer a specific message) andpriority.history_depthsays how far backreadsees;gradesare the authenticity grades the channel can attest;native_fieldsare the keys its messages may carry innative(what a routing condition can test), orNonewhen the channel does not declare them.- classmethod from_dict(data: Mapping[str, Any]) Capabilities[source]
The inverse of
to_dict().
- property operations: tuple[str, ...]
The operations the channel has at all (full or partial).
- exception correspond.ChannelError(message: str, *, kind: str, retryable: bool = False, retry_after: float | None = None)[source]
A platform call that failed, classified so the caller can decide what to do.
- class correspond.ChannelIdentity(*, channel: str, native_id: str, handle: str | None = None, display_name: str | None = None, is_bot: bool = False, is_self: bool = False, authority: str | None = None)[source]
Who a channel says sent something: its native id and what the platform attests about it. Not a person.
- property address: str
the form a people registry resolves.
>>> ChannelIdentity(channel="github", native_id="583231", handle="octocat").address 'github:octocat'
- Type:
<channel>:<handle>, or the native id when there is no handle
- classmethod from_dict(data: Mapping[str, Any]) ChannelIdentity[source]
The inverse of
to_dict().
- class correspond.ConversationRef(*, channel: str, id: str = '', kind: str = '', parent: ConversationRef | None = None)[source]
Where a conversation lives: a channel and that channel’s own id, encoded
<channel>:<id>.Two references are equal when channel and id are.
kind(issue,discussion,chat,address, …) andparentdescribe the conversation and are filled in by the channel’s adapter, so a freshly parsed reference equals its normalised form.>>> ConversationRef.parse("telegram:-4001/7") == ConversationRef(channel="telegram", id="-4001/7", kind="topic") True >>> ConversationRef.parse("nonsense") Traceback (most recent call last): ... correspond.errors.InvalidRef: 'nonsense' is not a conversation reference: the form is <channel>:<id>, e.g. github:octocat/hello-world#1
- property encoded: str
The stable string form,
<channel>:<id>.
- classmethod from_dict(data: Mapping[str, Any] | str) ConversationRef[source]
The inverse of
to_dict(); a bare encoded string also works.
- classmethod parse(text: str | ConversationRef) ConversationRef[source]
Split
<channel>:<id>without consulting the channel (its adapter normalises the id).
- exception correspond.CorrespondError[source]
An expected failure, with a message meant for the person or agent that asked.
- class correspond.Draft(*, text: str, title: str | None = None, reply_to: str | None = None, priority: str | None = None)[source]
What to write: the text, and the few things channels share (a title, the message answered, a priority).
- class correspond.Editor(*args, **kwargs)[source]
Replace the text of a message correspond’s account wrote.
- class correspond.Event(*, kind: str, channel: str, delivery_id: str, cursor: str | None = None, message: Message | None = None, payload: Mapping[str, ~typing.Any]=<factory>)[source]
Something that happened on a channel, as a listener reports it: dedupe on
delivery_id, resume fromcursor.
- class correspond.Grade(*values)[source]
How sure the channel is about who sent a message: a vocabulary, not a ranking.
forged: verification was attempted and failed.claimed: nothing verifiable (a typed name, an unauthenticated From header, anonymous input).platform: the platform authenticated the account (GitHub, Telegram).domain: email authenticated by your own receiving server’s Authentication-Results.bound: a host application signed an assertion about its logged-in user.crypto: a signed payload (a GitHub webhook’sX-Hub-Signature-256).
A policy names the grades it accepts for a permission. Comparing grades with
<raises, becausedomainis neither stronger nor weaker thanplatform.>>> Grade.CLAIMED < Grade.PLATFORM Traceback (most recent call last): ... TypeError: authenticity grades are not ranked; test membership in the grades a policy accepts
- class correspond.HistoryDepth(*values)[source]
How far back
readsees: all history, a 24-hour buffer, only what correspond has seen since it was linked or started listening, or nothing.
- exception correspond.InvalidRef[source]
A conversation reference that does not parse, or that its channel rejects.
- class correspond.Listener(*args, **kwargs)[source]
Events since
cursor, oldest first, each carrying the cursor to resume after it.
- class correspond.Message(*, id: str, conversation: ConversationRef, author: ChannelIdentity, authenticity: Authenticity, sent_at: datetime, text: str, body: str | None = None, body_format: str = 'plain', attachments: tuple[~correspond.model.Attachment, ...]=(), reply_to: str | None = None, thread_root: str | None = None, edited_at: datetime | None = None, url: str | None = None, native: Mapping[str, ~typing.Any]=<factory>, raw: Any = None)[source]
One message in a conversation, normalised, with the channel’s own fields kept in
native.
- exception correspond.MissingRequirement(channel: str, missing: str, *, fix: str, kind: str = 'unavailable')[source]
Something the channel needs is not here: a credential, a binary, an extra, an OS.
- exception correspond.NotSupported(operation: str, channel: str, *, alternatives: Iterable[str] = ())[source]
The channel does not have this operation (or this feature of it). Never a silent no-op.
- class correspond.Reader(*args, **kwargs)[source]
Messages of a conversation, oldest first;
limitkeeps the most recent.
- class correspond.RouteDecision(*, target: str, rule: str, reason: str)[source]
Where a message goes, which rule decided, and why.
- class correspond.SendResult(*, ok: bool, channel: str, conversation: str, operation: str = 'send', dry_run: bool = False, message_id: str | None = None, url: str | None = None, account: Account | None = None, plan: Mapping[str, ~typing.Any]=<factory>, error: str | None = None, error_kind: str | None = None, retryable: bool = False, retry_after: float | None = None)[source]
What a write did, or would do (
dry_run), or why it failed and whether a retry could help.- classmethod failure(error: ChannelError, *, channel: str, conversation: str, operation: str = 'send', dry_run: bool = False, plan: Mapping[str, Any] | None = None) SendResult[source]
A result carrying a
ChannelError’s classification.
- classmethod from_dict(data: Mapping[str, Any]) SendResult[source]
The inverse of
to_dict().
- class correspond.Support(*values)[source]
How well a channel does something:
full,partial(with limits innotes), ornone.
- exception correspond.UnknownChannel(channel: str, *, known: Iterable[str] = (), hint: str = '')[source]
A channel name that no registered adapter answers to.
- class correspond.Verifier(*args, **kwargs)[source]
Grade an inbound delivery (a webhook, a posted report) from its headers and raw body.
- correspond.capabilities(channel: str, *, registry: Mapping[str, Any] | None = None) Capabilities[source]
What a channel can do, graded, with its limits.
- correspond.channel_registry() MutableMapping[str, Any]
The process registry: built on first use, then shared.
- correspond.check_binding(pattern: str, *, registry: Mapping[str, Any] | None = None) list[str][source]
What would make a binding never match, found when bindings are loaded instead of by messages quietly going unrouted.
Reports a pattern without a channel, an unknown channel, and a condition on a field the channel’s messages never carry (its
native_fields, plusauthorandgrade). A channel written as a wildcard, or one that does not declare its fields (native_fieldsisNone), is not checked for fields.>>> from correspond.channels.github import GitHub >>> check_binding("github:example/app?labels=bug", registry={"github": GitHub()}) [] >>> check_binding("github:example/app?label=bug", registry={"github": GitHub()})[0].split(":")[0] 'the condition label=bug never matches'
- correspond.check_requirements(channel: str, *, registry: ~collections.abc.Mapping[str, ~typing.Any] | None = None, config: ~collections.abc.Mapping[str, ~typing.Any] | None = None, run: ~collections.abc.Callable[[...], ~typing.Any] = <function run>) dict[source]
What a channel needs and what is missing: the install command, binaries, platform, and every setting’s source. Never a secret’s value.
- correspond.edit(ref: str | ConversationRef, message_id: str, text: str, *, dry_run: bool = False, registry: Mapping[str, Any] | None = None) SendResult[source]
Replace the text of a message correspond’s account wrote.
- correspond.get_channel(name: str, *, registry: Mapping[str, Any] | None = None) Any[source]
The adapter registered under
name;UnknownChannelsays what to do if there is none.
- correspond.listen(ref: str | ConversationRef, *, cursors: MutableMapping[str, str] | None = None, limit: int | None = None, commit: bool = True, registry: Mapping[str, Any] | None = None) Iterator[Event][source]
Events since the cursor stored for
ref; each cursor is stored once the consumer moves past its event.cursorsdefaults to files under the data root. Events can repeat after a crash or an earlybreak: deduplicate ondelivery_id.
- correspond.metadata_rule(target: str, *, name: str | None = None, **conditions: str) Callable[[Message], str | None][source]
A rule sending messages to
targetwhen everyfield=globcondition holds.>>> from correspond.testing import demo_message >>> rule = metadata_rule("urgent-queue", labels="priority:high") >>> rule(demo_message(labels=["priority:high"])), rule(demo_message()) ('urgent-queue', None)
- correspond.parse_ref(ref: str | ConversationRef, *, registry: Mapping[str, Any] | None = None) ConversationRef[source]
A reference normalised by its channel’s adapter (kind and parent filled in, the id validated).
- correspond.react(ref: str | ConversationRef, message_id: str, reaction: str, *, dry_run: bool = False, registry: Mapping[str, Any] | None = None) SendResult[source]
Add a reaction to a message (the channel’s capabilities list the reactions it accepts).
- correspond.read(ref: str | ConversationRef, *, since: str | datetime | None = None, limit: int | None = None, registry: Mapping[str, Any] | None = None) list[Message][source]
The messages of a conversation, oldest first (
limitkeeps the most recent ones).
- correspond.register_channel(adapter: Any, *, name: str | None = None, replace: bool = False, registry: MutableMapping[str, Any] | None = None) Any[source]
Add an adapter (tests, or a channel defined outside correspond).
replaceswaps out an existing one.
- correspond.route(message: Message, *, bindings: Mapping[str, str] | Iterable[tuple[str, str]] | None = None, threads: Mapping[str, str] | None = None, rules: Iterable[Callable[[Message], str | None]] = (), classifier: Callable[[Message], str | tuple[str, str] | None] | None = None) RouteDecision | None[source]
Run the chain (bindings, thread continuity, metadata rules, classifier) and return the first decision, or
None.
- correspond.send(ref: str | ConversationRef, text: str | Draft, *, title: str | None = None, reply_to: str | None = None, priority: str | None = None, dry_run: bool = False, registry: Mapping[str, Any] | None = None) SendResult[source]
Send
text(or aDraft) to a conversation;dry_runshows the plan and contacts nothing.
- correspond.unregister_channel(name: str, *, registry: MutableMapping[str, Any] | None = None) None[source]
Remove a channel from the registry.
- correspond.upload(ref: str | ConversationRef, name: str, data: bytes, *, media_type: str = 'application/octet-stream', dry_run: bool = False, registry: Mapping[str, Any] | None = None) SendResult[source]
Send a file to a conversation.
- correspond.verify(channel: str, headers: Mapping[str, str], body: bytes, *, registry: Mapping[str, Any] | None = None) Authenticity[source]
Grade an inbound delivery on
channelfrom its headers and raw body.