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.

classmethod from_dict(data: Mapping[str, Any]) Account[source]

The inverse of to_dict().

to_dict() dict[source]

JSON-ready.

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 on content(), never inlined.

content() bytes[source]

The bytes, fetched now through the channel that produced the attachment.

classmethod from_dict(data: Mapping[str, Any]) Attachment[source]

The inverse of to_dict() (the result has no loader).

to_dict() dict[source]

JSON-ready (without the 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().

to_dict() dict[source]

JSON-ready.

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 Support per operation in OPERATIONS, plus three features of writing: initiate (can a write start a conversation; Telegram bots cannot), reply (can a draft answer a specific message) and priority. history_depth says how far back read sees; grades are the authenticity grades the channel can attest; native_fields are the keys its messages may carry in native (what a routing condition can test), or None when 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).

supports(operation: str) Support[source]

The support level for one of OPERATIONS.

to_dict() dict[source]

JSON-ready.

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().

label() str[source]

A short name for transcripts.

to_dict() dict[source]

JSON-ready.

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, …) and parent describe 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).

to_dict() dict[source]

JSON-ready, with the encoded form under ref.

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).

to_dict() dict[source]

JSON-ready.

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 from cursor.

classmethod from_dict(data: Mapping[str, Any]) Event[source]

The inverse of to_dict().

to_dict() dict[source]

JSON-ready.

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’s X-Hub-Signature-256).

A policy names the grades it accepts for a permission. Comparing grades with < raises, because domain is neither stronger nor weaker than platform.

>>> 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 read sees: 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.

classmethod from_dict(data: Mapping[str, Any]) Message[source]

The inverse of to_dict().

to_dict(*, include_raw: bool = False) dict[source]

JSON-ready; raw (the untouched payload) only when asked for.

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.Reactor(*args, **kwargs)[source]

Add a reaction to a message.

class correspond.Reader(*args, **kwargs)[source]

Messages of a conversation, oldest first; limit keeps the most recent.

class correspond.RouteDecision(*, target: str, rule: str, reason: str)[source]

Where a message goes, which rule decided, and why.

to_dict() dict[source]

JSON-ready.

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().

to_dict() dict[source]

JSON-ready.

class correspond.Support(*values)[source]

How well a channel does something: full, partial (with limits in notes), or none.

exception correspond.UnknownChannel(channel: str, *, known: Iterable[str] = (), hint: str = '')[source]

A channel name that no registered adapter answers to.

class correspond.Uploader(*args, **kwargs)[source]

Send a file to a conversation.

class correspond.Verifier(*args, **kwargs)[source]

Grade an inbound delivery (a webhook, a posted report) from its headers and raw body.

class correspond.Writer(*args, **kwargs)[source]

Send a draft to a conversation.

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, plus author and grade). A channel written as a wildcard, or one that does not declare its fields (native_fields is None), 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; UnknownChannel says 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.

cursors defaults to files under the data root. Events can repeat after a crash or an early break: deduplicate on delivery_id.

correspond.metadata_rule(target: str, *, name: str | None = None, **conditions: str) Callable[[Message], str | None][source]

A rule sending messages to target when every field=glob condition 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 (limit keeps 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). replace swaps 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 a Draft) to a conversation; dry_run shows 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 channel from its headers and raw body.