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, audience, label, unlabel, close, reopen); asking for one it lacks raises NotSupported, and capabilities() says so in advance. audience() is the exception: it never refuses, because an audience nobody can compute is public. 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.

Functions

audience(ref[, draft, registry])

Who can read a conversation, asked of its channel now.

capabilities(channel, *[, registry])

What a channel can do, graded, with its limits.

channel_registry()

The process registry: built on first use, then shared.

check_binding(pattern, *[, registry])

What would make a binding never match, found when bindings are loaded instead of by messages quietly going unrouted.

check_requirements(channel, *[, registry, ...])

What a channel needs and what is missing: the install command, binaries, platform, and every setting's source.

close(ref, *[, reason, dry_run, registry, ...])

Close a conversation (an issue or a discussion), with an optional reason in the channel's vocabulary; the before_send check sees the reason (or close) as the draft's text.

edit(ref, message_id, text, *[, dry_run, ...])

Replace the text of a message correspond's account wrote; the before_send check runs first, as for send().

get_channel(name, *[, registry])

The adapter registered under name; UnknownChannel says what to do if there is none.

label(ref, labels, *[, dry_run, registry, ...])

Add labels to a conversation; the before_send check sees them, comma-joined, as the draft's text.

listen(ref, *[, cursors, limit, commit, ...])

Events since the cursor stored for ref; each cursor is stored once the consumer moves past its event.

metadata_rule(target, *[, name])

A rule sending messages to target when every field=glob condition holds.

parse_ref(ref, *[, registry])

A reference normalised by its channel's adapter (kind and parent filled in, the id validated).

react(ref, message_id, reaction, *[, ...])

Add a reaction to a message (the channel's capabilities list the reactions it accepts); the before_send check sees the reaction as the draft's text.

read(ref, *[, since, limit, registry])

The messages of a conversation, oldest first (limit keeps the most recent ones).

register_channel(adapter, *[, name, ...])

Add an adapter (tests, or a channel defined outside correspond).

reopen(ref, *[, dry_run, registry, before_send])

Reopen a closed conversation; the before_send check sees reopen as the draft's text.

route(message, *[, bindings, threads, ...])

Run the chain (bindings, thread continuity, metadata rules, classifier) and return the first decision, or None.

send(ref, text, *[, title, reply_to, ...])

Send text (or a Draft) to a conversation; dry_run shows the plan and sends nothing.

unlabel(ref, labels, *[, dry_run, registry, ...])

Remove labels from a conversation; the before_send check sees them, comma-joined, as the draft's text.

unregister_channel(name, *[, registry])

Remove a channel from the registry.

upload(ref, name, data, *[, media_type, ...])

Send a file to a conversation.

verify(channel, headers, body, *[, registry])

Grade an inbound delivery on channel from its headers and raw body.

Classes

Account(*, channel, id[, acts_as])

The credentialed endpoint correspond acts through: whose name a write goes out under.

Attachment(*, ref[, media_type, name, size, ...])

A file that came with a message: referenced by ref, fetched only on content(), never inlined.

Audience(*, ref, scope[, readers, complete, ...])

Who can read a conversation, now and plausibly later, as far as its channel can tell.

AudienceReader(*args, **kwargs)

Who can read a conversation, now and plausibly later, asked of the platform at the time of the call.

Authenticity(*, grade[, evidence])

A grade and its evidence, computed on correspond's side of the hop and never read from a payload.

Capabilities(*, channel[, read, listen, ...])

What a channel can do, graded, with its limits.

ChannelIdentity(*, channel, native_id[, ...])

Who a channel says sent something: its native id and what the platform attests about it.

ConversationRef(*, channel[, id, kind, parent])

Where a conversation lives: a channel and that channel's own id, encoded <channel>:<id>.

Draft(*, text[, title, reply_to, priority, ...])

What to write: the text, and the few things channels share (a title, the message answered, a priority, copies).

Editor(*args, **kwargs)

Replace the text of a message correspond's account wrote.

Event(*, kind, channel, delivery_id[, ...])

Something that happened on a channel, as a listener reports it: dedupe on delivery_id, resume from cursor.

Grade(*values)

How sure the channel is about who sent a message: a vocabulary, not a ranking.

HistoryDepth(*values)

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.

Closer(*args, **kwargs)

Settle a conversation (close an issue or a discussion), optionally saying why.

Labeler(*args, **kwargs)

Add labels to a conversation, apart from its opening send.

Listener(*args, **kwargs)

Events since cursor, oldest first, each carrying the cursor to resume after it.

Message(*, id, conversation, author, ...[, ...])

One message in a conversation, normalised, with the channel's own fields kept in native.

Reactor(*args, **kwargs)

Add a reaction to a message.

Reader(*args, **kwargs)

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

Reopener(*args, **kwargs)

Unsettle a closed conversation.

RouteDecision(*, target, rule, reason)

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

Scope(*values)

The widest class of reader a conversation can reach.

SendResult(*, ok, channel, conversation[, ...])

What a write did, or would do (dry_run), or why it failed and whether a retry could help.

Support(*values)

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

Unlabeler(*args, **kwargs)

Remove labels from a conversation.

Uploader(*args, **kwargs)

Send a file to a conversation.

Verifier(*args, **kwargs)

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

Writer(*args, **kwargs)

Send a draft to a conversation.

Exceptions

ChannelError(message, *, kind[, retryable, ...])

A platform call that failed, classified so the caller can decide what to do.

CorrespondError

An expected failure, with a message meant for the person or agent that asked.

InvalidRef

A conversation reference that does not parse, or that its channel rejects.

MissingRequirement(channel, missing, *, fix)

Something the channel needs is not here: a credential, a binary, an extra, an OS.

NeedsApproval(reason, **details)

Draft to operator: the write waits for the operator's approval.

NotSupported(operation, channel, *[, ...])

The channel does not have this operation (or this feature of it).

Refused(reason, **details)

Block: this draft does not go to this conversation as written.

Stopped(reason, **details)

A write the before_send check did not let leave: error_kind says how, reason says why.

UnknownChannel(channel, *[, known, hint])

A channel name that no registered adapter answers to.

class correspond.Account(*, channel, id, acts_as='user')[source]#

Bases: object

The credentialed endpoint correspond acts through: whose name a write goes out under.

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

Account

to_dict()[source]#

JSON-ready.

Return type:

dict

class correspond.Attachment(*, ref, media_type='application/octet-stream', name=None, size=None, sha256=None, loader=None)[source]#

Bases: object

A file that came with a message: referenced by ref, fetched only on content(), never inlined.

content()[source]#

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

Return type:

bytes

classmethod from_dict(data)[source]#

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

Return type:

Attachment

to_dict()[source]#

JSON-ready (without the loader).

Return type:

dict

class correspond.Audience(*, ref, scope, readers=(), complete=False, classes=(), external=None, retractable=False, durability=(), widening=(), as_of=_now(), evidence=(), defaulted=False)[source]#

Bases: object

Who can read a conversation, now and plausibly later, as far as its channel can tell.

scope is the widest class of reader it can reach. readers are the channel identities known to read it, a lower bound unless complete; classes name, in words, the readers that cannot be listed. external says whether readers outside the operator’s own accounts exist (None: unknown). durability is what a send leaves behind (DURABILITY), widening how the readership can grow (WIDENING). evidence records the calls made and why anything was assumed; defaulted is true when an unknown resolved to public, which it always does.

Set-valued fields (readers, classes, durability, widening) are stored deduplicated and sorted, so the same audience compares and hashes alike whatever order a platform listed it in. A durability or widening flag this version does not know is kept, never dropped. A public audience is never complete, retractable or free of external readers, and a defaulted one is always public.

>>> a = Audience(ref="github:example/app#12", scope="public", classes=["watchers and participants receive the body by email"],
...              durability=["indexed", "archived_by_others", "copies_pushed", "edit_history_visible"], as_of="2026-09-15T12:00:00Z")
>>> a.in_words()
'world-readable; emailed to watchers and participants; archived by others; edits keep a visible history; not retractable'
>>> Audience.from_dict(a.to_dict()) == a
True
classmethod from_dict(data)[source]#

The inverse of to_dict(); unknown keys are ignored.

Return type:

Audience

property hash: str#

The SHA-256, in hex, of the canonical JSON of to_dict() without AUDIENCE_UNHASHED.

data is exactly what to_dict() returns, derived readers[].address included. Canonical JSON is json.dumps(data, sort_keys=True, separators=(",", ":")) (Python’s default ASCII escaping), encoded as UTF-8. Approvals bind to this value, so the construction is a contract. A to_dict output can be hashed as it stands; any other dict should go through Audience.from_dict(d).hash, which normalises.

in_words()[source]#

One line: the scope, the readers and reader classes, what a send leaves behind, whether it can be withdrawn.

Return type:

str

to_dict()[source]#

JSON-ready.

Return type:

dict

classmethod unknown(ref, *reasons)[source]#

The audience nobody could compute: public, incomplete, every durability and widening flag, not retractable, defaulted, with reasons as evidence.

Return type:

Audience

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

Bases: Protocol

Who can read a conversation, now and plausibly later, asked of the platform at the time of the call.

class correspond.Authenticity(*, grade, evidence=dict())[source]#

Bases: object

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)[source]#

The inverse of to_dict().

Return type:

Authenticity

to_dict()[source]#

JSON-ready.

Return type:

dict

class correspond.Capabilities(*, channel, read=Support.NONE, listen=Support.NONE, send=Support.NONE, edit=Support.NONE, react=Support.NONE, upload=Support.NONE, verify=Support.NONE, audience=Support.NONE, label=Support.NONE, unlabel=Support.NONE, close=Support.NONE, reopen=Support.NONE, initiate=Support.NONE, reply=Support.NONE, priority=Support.NONE, cc=Support.NONE, notify=Support.NONE, history_depth=HistoryDepth.NONE, listen_modes=(), grades=(), max_text_length=None, max_title_length=None, edit_max_age_s=None, reactions=(), reactions_per_message=None, max_upload_bytes=None, max_attachments=0, formats=('plain',), draft_fields=(), upload_fields=(), native_fields=None, rate_limits=(), notes=())[source]#

Bases: object

What a channel can do, graded, with its limits.

One Support per operation in OPERATIONS (audience: can the channel say who reads a conversation; label / unlabel: can labels be added to or removed from a conversation, apart from its opening send; close / reopen: can a conversation be settled, and unsettled), plus five features of writing: initiate (can a write start a conversation; Telegram bots cannot), reply (can a draft answer a specific message), priority, cc (can a draft copy further recipients, Draft.cc and Draft.bcc), and notify (can the writer guarantee that the people in Draft.notify are notified: a mention it adds, an address it copies). 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; draft_fields are the keys a Draft may carry in its native for this channel (GitHub: labels, assignees, milestone when opening an issue), and any other key is refused rather than dropped; upload_fields are the options upload takes in its native. max_attachments is how many files a send’s Draft.attachments may carry (0: a send takes none, though upload may still send a file on its own).

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

Capabilities

property operations: tuple[str, ...]#

The operations the channel has at all (full or partial).

supports(operation)[source]#

The support level for one of OPERATIONS.

Return type:

Support

to_dict()[source]#

JSON-ready.

Return type:

dict

exception correspond.ChannelError(message, *, kind, retryable=False, retry_after=None)[source]#

Bases: CorrespondError

A platform call that failed, classified so the caller can decide what to do.

class correspond.ChannelIdentity(*, channel, native_id, handle=None, display_name=None, is_bot=False, is_self=False, authority=None)[source]#

Bases: object

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)[source]#

The inverse of to_dict().

Return type:

ChannelIdentity

label()[source]#

A short name for transcripts.

Return type:

str

to_dict()[source]#

JSON-ready.

Return type:

dict

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

Bases: Protocol

Settle a conversation (close an issue or a discussion), optionally saying why.

class correspond.ConversationRef(*, channel, id='', kind='', parent=None)[source]#

Bases: object

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)[source]#

The inverse of to_dict(); a bare encoded string also works.

Return type:

ConversationRef

classmethod parse(text)[source]#

Split <channel>:<id> without consulting the channel (its adapter normalises the id).

Return type:

ConversationRef

to_dict()[source]#

JSON-ready, with the encoded form under ref.

Return type:

dict

exception correspond.CorrespondError[source]#

Bases: Exception

An expected failure, with a message meant for the person or agent that asked.

class correspond.Draft(*, text, title=None, reply_to=None, priority=None, cc=(), bcc=(), native=dict(), notify=(), attachments=())[source]#

Bases: object

What to write: the text, and the few things channels share (a title, the message answered, a priority, copies).

cc and bcc are further recipients, on channels whose capabilities grade cc (email). They count in the conversation’s audience.

>>> Draft(text="hi", cc=["bob@example.org", " "]).cc
('bob@example.org',)

native carries the fields only one channel has, in that channel’s vocabulary – the write-side mirror of Message.native. Which keys a channel takes is Capabilities.draft_fields; send refuses any other key rather than dropping it.

>>> Draft(text="hi", native={"labels": ["question"]}).native
{'labels': ['question']}

notify names who must be notified: handles on a mention platform (octocat or @octocat), addresses on email. The writer guarantees it – a mention added when the text lacks one, an address put in Cc when it is not in To or Cc – and a channel that cannot raises NotSupported("notify") (see correspond.notify).

>>> Draft(text="hi", notify=["octocat", " ", "Octocat", "hubot"]).notify
('octocat', 'hubot')

attachments are files that go with the text, each an Attachment whose content() gives its bytes, on channels whose capabilities grade upload. The before_send check sees them (name, media type, size, SHA-256, ref), never their bytes.

to_dict()[source]#

JSON-ready. native and notify appear only when set, so a draft without them keeps the dict – and the idempotency fingerprint of a key claimed before native existed – it always had.

Return type:

dict

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

Bases: Protocol

Replace the text of a message correspond’s account wrote.

class correspond.Event(*, kind, channel, delivery_id, cursor=None, message=None, payload=dict())[source]#

Bases: object

Something that happened on a channel, as a listener reports it: dedupe on delivery_id, resume from cursor.

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

Event

to_dict()[source]#

JSON-ready.

Return type:

dict

class correspond.Grade(*values)[source]#

Bases: StrEnum

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]#

Bases: StrEnum

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]#

Bases: CorrespondError, ValueError

A conversation reference that does not parse, or that its channel rejects.

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

Bases: Protocol

Add labels to a conversation, apart from its opening send.

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

Bases: Protocol

Events since cursor, oldest first, each carrying the cursor to resume after it.

class correspond.Message(*, id, conversation, author, authenticity, sent_at, text, body=None, body_format='plain', attachments=(), reply_to=None, thread_root=None, edited_at=None, url=None, native=dict(), raw=None)[source]#

Bases: object

One message in a conversation, normalised, with the channel’s own fields kept in native.

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

Message

to_dict(*, include_raw=False)[source]#

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

Return type:

dict

exception correspond.MissingRequirement(channel, missing, *, fix, kind='unavailable')[source]#

Bases: ChannelError

Something the channel needs is not here: a credential, a binary, an extra, an OS.

exception correspond.NeedsApproval(reason, **details)[source]#

Bases: Stopped

Draft to operator: the write waits for the operator’s approval. Raised by a before_send check.

exception correspond.NotSupported(operation, channel, *, alternatives=())[source]#

Bases: CorrespondError

The channel does not have this operation (or this feature of it). Never a silent no-op.

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

Bases: Protocol

Add a reaction to a message.

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

Bases: Protocol

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

exception correspond.Refused(reason, **details)[source]#

Bases: Stopped

Block: this draft does not go to this conversation as written. Raised by a before_send check.

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

Bases: Protocol

Unsettle a closed conversation.

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

Bases: object

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

to_dict()[source]#

JSON-ready.

Return type:

dict

class correspond.Scope(*values)[source]#

Bases: StrEnum

The widest class of reader a conversation can reach.

  • operator: only the operator’s own devices (a notification banner).

  • named: exactly the explicit recipients (an email, a user’s private repository).

  • group: a bounded membership (a Telegram group).

  • org: an organisation or workspace (a private organisation repository).

  • public: anyone. What an unknown audience resolves to.

class correspond.SendResult(*, ok, channel, conversation, operation='send', dry_run=False, message_id=None, url=None, account=None, plan=dict(), error=None, error_kind=None, retryable=False, retry_after=None)[source]#

Bases: object

What a write did, or would do (dry_run), or why it failed and whether a retry could help.

classmethod failure(error, *, channel, conversation, operation='send', dry_run=False, plan=None)[source]#

A result carrying a ChannelError’s classification.

Return type:

SendResult

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

SendResult

to_dict()[source]#

JSON-ready.

Return type:

dict

exception correspond.Stopped(reason, **details)[source]#

Bases: CorrespondError

A write the before_send check did not let leave: error_kind says how, reason says why.

Keyword details (an approval id, a draft hash) travel with the result, in the plan’s before_send_details.

class correspond.Support(*values)[source]#

Bases: StrEnum

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

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

Bases: CorrespondError, LookupError

A channel name that no registered adapter answers to.

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

Bases: Protocol

Remove labels from a conversation.

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

Bases: Protocol

Send a file to a conversation.

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

Bases: Protocol

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

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

Bases: Protocol

Send a draft to a conversation.

correspond.audience(ref, draft=None, *, registry=None)[source]#

Who can read a conversation, asked of its channel now. Never raises: unknown resolves to public.

A malformed reference, an unknown or planned channel, an adapter without an audience reader, and any error while computing all give scope="public", complete=False, defaulted=True, with the reason in evidence. Nothing is cached: call it again right before sending.

Return type:

Audience

correspond.capabilities(channel, *, registry=None)[source]#

What a channel can do, graded, with its limits.

Return type:

Capabilities

correspond.channel_registry()#

The process registry: built on first use, then shared.

Return type:

MutableMapping[str, Any]

correspond.check_binding(pattern, *, registry=None)[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, a ref that is not in the channel’s canonical form (e.g. GitHub references are lower-cased by parse_ref(), so binding_matches, which compares the pattern literally, never matches a differently-cased pattern against it), 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. A ref with wildcards, or one the channel’s own parser rejects outright, is not checked for canonical form – a malformed ref is a different problem than a mis-cased one.

>>> 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'
>>> check_binding("github:Example/App", registry={"github": GitHub()})
["github:Example/App is not in canonical form: github references are normalised to 'github:example/app'; binding_matches compares refs literally, so this pattern will not match github:example/app"]
Return type:

list[str]

correspond.check_requirements(channel, *, registry=None, config=None, run=<function run>)[source]#

What a channel needs and what is missing: the install command, binaries, platform, and every setting’s source. Never a secret’s value.

Return type:

dict

correspond.close(ref, *, reason=None, dry_run=False, registry=None, before_send=None)[source]#

Close a conversation (an issue or a discussion), with an optional reason in the channel’s vocabulary; the before_send check sees the reason (or close) as the draft’s text.

Return type:

SendResult

correspond.edit(ref, message_id, text, *, dry_run=False, registry=None, before_send=None)[source]#

Replace the text of a message correspond’s account wrote; the before_send check runs first, as for send().

Return type:

SendResult

correspond.get_channel(name, *, registry=None)[source]#

The adapter registered under name; UnknownChannel says what to do if there is none.

Return type:

Any

correspond.label(ref, labels, *, dry_run=False, registry=None, before_send=None)[source]#

Add labels to a conversation; the before_send check sees them, comma-joined, as the draft’s text.

Return type:

SendResult

correspond.listen(ref, *, cursors=None, limit=None, commit=True, registry=None)[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.

Return type:

Iterator[Event]

correspond.metadata_rule(target, *, name=None, **conditions)[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)
Return type:

Callable[[Message], str | None]

correspond.parse_ref(ref, *, registry=None)[source]#

A reference normalised by its channel’s adapter (kind and parent filled in, the id validated).

Return type:

ConversationRef

correspond.react(ref, message_id, reaction, *, dry_run=False, registry=None, before_send=None)[source]#

Add a reaction to a message (the channel’s capabilities list the reactions it accepts); the before_send check sees the reaction as the draft’s text.

Return type:

SendResult

correspond.read(ref, *, since=None, limit=None, registry=None)[source]#

The messages of a conversation, oldest first (limit keeps the most recent ones).

Return type:

list[Message]

correspond.register_channel(adapter, *, name=None, replace=False, registry=None)[source]#

Add an adapter (tests, or a channel defined outside correspond). replace swaps out an existing one.

Return type:

Any

correspond.reopen(ref, *, dry_run=False, registry=None, before_send=None)[source]#

Reopen a closed conversation; the before_send check sees reopen as the draft’s text.

Return type:

SendResult

correspond.route(message, *, bindings=None, threads=None, rules=(), classifier=None)[source]#

Run the chain (bindings, thread continuity, metadata rules, classifier) and return the first decision, or None.

Return type:

RouteDecision | None

correspond.send(ref, text, *, title=None, reply_to=None, priority=None, cc=(), bcc=(), native=None, notify=(), attachments=(), dry_run=False, registry=None, before_send=None, idempotency_key=None, sends=None)[source]#

Send text (or a Draft) to a conversation; dry_run shows the plan and sends nothing.

attachments (Draft.attachments) are files that go with the text, on channels that grade upload, each within its max_upload_bytes.

cc and bcc copy further recipients, on channels that grade cc (email). native carries channel-specific fields (Draft.native), checked against the channel’s capabilities.draft_fields: an unknown key is a validation failure, never silently dropped. notify names who must be notified (Draft.notify); the channel’s writer guarantees it (a mention added, an address copied) or the send fails with NotSupported("notify"). before_send(ref, draft, audience) runs first, on the dry run too; when None, the config’s before_send reference, else correspond.outbound.notice().

idempotency_key makes sending the same message again safe: a key that already sent answers that send’s result and posts nothing, and one whose earlier attempt may have gone out is confirmed by reading the conversation back, or refused as unconfirmed (correspond.idempotency). sends is where keys are kept: by default files under the data root (correspond.stores.send_store()). A mapping with a claim(key, record) -> bool (as that store has) is exclusive across processes; a plain one is checked, then set, which covers one process.

Return type:

SendResult

correspond.unlabel(ref, labels, *, dry_run=False, registry=None, before_send=None)[source]#

Remove labels from a conversation; the before_send check sees them, comma-joined, as the draft’s text.

Return type:

SendResult

correspond.unregister_channel(name, *, registry=None)[source]#

Remove a channel from the registry.

Return type:

None

correspond.upload(ref, name, data, *, media_type='application/octet-stream', dry_run=False, source=None, native=None, registry=None, before_send=None, idempotency_key=None, sends=None)[source]#

Send a file to a conversation.

native carries the channel’s own options, the keys its capabilities.draft_fields list (Telegram: {"as_document": true} sends an image as a file, not a photo). idempotency_key keeps a retry from posting the file twice, as for send(); an upload is not read back, so one whose earlier attempt may have gone out is refused as unconfirmed.

The before_send check sees the file name as the draft’s text (operation="upload") and the file’s metadata in draft.native["upload"]: name, media_type, bytes, sha256 and, when the caller read it from disk, source (its path), so a check can hold a large, unknown or out-of-place file. It never sees the contents: a check that must vet them should hold or refuse.

Return type:

SendResult

correspond.verify(channel, headers, body, *, registry=None)[source]#

Grade an inbound delivery on channel from its headers and raw body.

Return type:

Authenticity

Modules

authored

What the sender of an email actually wrote: their text, apart from quoted replies, forwarded blocks, signatures and disclaimers.

channel

Serve listeners as a Claude Code channel: new messages pushed into a running session, replies through the ordinary tools.

channels

The built-in channel adapters, one module each.

errors

What correspond raises, and the vocabulary a failed write reports.

idempotency

Idempotent sends: an idempotency_key keeps a message from going out twice.

mcp

MCP over stdio: the same tools, for Claude Desktop and other local MCP clients.

model

The data model every channel is described in.

notify

Make a draft's notify list a property of sending, not a rule a sender can forget.

ops

The operations: small protocols an adapter implements a subset of, and the verbs that call them.

outbound

The before_send check: what every write runs, with the conversation's audience, before anything leaves.

registry

Which channels exist: the built-in channel table, the registry built from it, and what each channel needs.

render

Turning a tool's result into terminal output: (stdout, stderr, exit code).

routing

Deciding what a message is about: a transparent rule chain.

settings

Where correspond keeps state and reads configuration, and how it finds a secret.

stores

The state stores: listen cursors, the web inbox's reports and blobs, the Telegram log.

testing

An in-memory channel for tests and rehearsals, and python -m correspond.testing: the CLI with it registered.

tools

The single source of truth for every surface: plain functions, flat arguments in, JSON-ready dicts out.