Skip to main content

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.

pip install correspond

correspond read github:octocat/hello-world#1        # an issue and its comments, through your gh login
correspond send ntfy: "backup finished" --dry-run    # the plan; nothing is contacted
correspond capabilities telegram                     # what a channel can do, graded
correspond requirements email                        # what it needs, and where to get it

Every channel gets the same shape. A reference names a conversation: <channel>:<id>. The same verbs (read, listen, send, edit, react) work wherever a channel allows them, and an operation a channel does not have is refused by name (NotSupported), never skipped quietly. Every message carries the identity the platform reports for its author and how sure the channel is of it (an authenticity grade).

correspond knows no people. Linking github:someone to a person is a people registry's job, for example acquaint.

Channels

Channel References Operations Built on
github github:owner/repo, github:owner/repo#12 read, listen (issues and comments), send, edit, react, verify (webhooks), audience the gh CLI and its login; correspond holds no token
email email: (the folder), email:someone@example.org read, listen, send imaplib, smtplib
ntfy ntfy: (the default topic), ntfy:<topic> send urllib
macos macos: send terminal-notifier or osascript
telegram telegram:, telegram:<chat id>, telegram:<chat id>/<topic id> listen, read (what listening logged), send, edit, react the Bot API over urllib
webinbox webinbox:<site> read, listen the ASGI collector below, and dol stores

Every v0.1 adapter uses the Python standard library. Discord (#2), Slack (#3), Signal (#4) and Apprise (#5) are planned as extras; correspond channels lists them with their issues.

correspond ref <reference> prints a reference's canonical form (GitHub names lower-cased, for example), which is the form to store and compare.

Authenticity

Grade Means
platform the platform authenticated the account (GitHub, Telegram)
crypto a signed delivery checked with its secret (a GitHub webhook's X-Hub-Signature-256)
bound the host application signed an assertion about its logged-in user (the web inbox)
domain email whose topmost Authentication-Results, added by a server you trust, records DMARC passing for the From domain
claimed nothing verified: a typed name, an ordinary From header, an anonymous report
forged verification was attempted and failed

Grades are a vocabulary, not a ranking. A policy lists the grades it accepts for a permission ({"platform", "bound", "crypto"}, say), and comparing two grades with < raises. Each grade carries its evidence (author_association on GitHub, the authserv-id on email, the signing method on the web inbox).

Capabilities

correspond capabilities github grades each operation full, partial or none, plus whether a write can start a conversation (initiate; a Telegram bot cannot), answer a specific message (reply) or carry a priority, how far back read sees (history_depth), the limits (text length, reactions), the fields its messages carry in native (native_fields), the rate limits and notes. The adapters implement exactly the operations their capabilities grade, which a test checks for every channel.

$ correspond react ntfy:example-topic m1 eyes
ntfy does not support react

Who can read it

$ correspond audience github:example/app#12
world-readable; emailed to watchers and participants; archived by others; edits keep a visible history; not retractable
scope: public
...

correspond audience <reference> says who can read a conversation, now and plausibly later, before anything is written to it. The first line is the answer in words; the rest is the record (--json prints it under audience, beside hash and words): a scope (operator, named, group, org, public), the readers known to read it with whether that list is complete, the reader classes that cannot be listed, whether external readers exist, what a send leaves behind (durability), how the readership can grow (widening), the evidence behind each value, and a hash that changes when the audience does (and only then, not with the time or the evidence).

Unknown resolves to public. A failed or forbidden lookup, a channel without an audience reader and a planned channel all answer public with defaulted: true and the reason in evidence. Nothing is cached: ask again right before sending. GitHub computes it from the repository's visibility, its collaborators when the gh account may list them, and the organisation's base permission when the account may read it (otherwise the documented default, read). A GitHub audience is never complete: installed apps, webhooks and an organisation's security managers read without being listable. Every other channel answers public, defaulted, until its audience reader lands (#29).

Writing: dry run first

  • --dry-run on send, edit and react sends nothing and changes nothing. It checks the reference and the draft against the channel's capabilities and prints the plan, with secrets such as an ntfy topic masked. A dry run reads only the environment and the config file, plus, for send and edit, the audience lookup: a value kept in the Keychain or on a remote host is looked up when sending, and the plan says so.
  • Every write (send, edit, react, and upload in Python) passes the before_send check first, on the dry run too. The plan shows audience (who can read it, in words), audience_hash, and before_send (the verdict). With nothing configured the check lets the write go ahead, so the audience line is all it adds. A check can stop the write: refused (not as written, not here) or needs_approval (wait for the operator), with any details it attached in before_send_details. A configured check that does not load stops every write (before_send_unavailable), and one that crashes stops that write (before_send_failed). Nothing is sent in any of these cases, and --allow-send on the MCP server does not skip the check.
  • The check guards drafts, not the process running correspond: whoever sets its environment or edits the config file ($CORRESPOND_CONFIG can point at another file) chooses the check. Commands that write without correspond (gh issue comment, say) are covered by liaise's hook, not by this check.
  • A write that fails is a result, not an exception: ok: false, an error_kind (auth, permission, not_found, rate_limited, network, validation, unavailable, or one of the four check kinds above), and whether a retry can help (retryable, retry_after).
  • - as the text reads it from stdin; --json prints the whole result.

Listening

correspond listen github:owner/repo    # issue and comment activity since the last listen
correspond listen email: --peek        # look without moving the cursor

Cursors live under the data root, one per reference. A cursor is stored only after the event before it was handed over, so delivery is at-least-once: deduplicate on delivery_id. The first listen looks back a day. Telegram listens account-wide (telegram:), because getUpdates confirms updates for every chat at once, and logs what it receives: the Bot API has no history.

Configuration

correspond requirements <channel> lists everything a channel reads: each setting's environment variable, what it is, where to get it, and where its value currently comes from (env, keychain, config, default, missing), never the value.

  • Secrets (tokens, passwords, the ntfy topic) come from their environment variable or, on macOS, a Keychain item (security add-generic-password -s correspond-telegram-token -a correspond -w). They are never read from the config file.

  • Other settings may also go in ~/.config/correspond/config.toml (or $CORRESPOND_CONFIG), one table per channel:

    [email]
    imap_host = "imap.example.org"
    smtp_host = "smtp.example.org"
    trusted_authserv_ids = ["mx.example.org"]
    
    [ntfy]
    topic_keychain_service = "my-ntfy-topic"
    
  • before_send, at the top of the config file (before any table), names the check every write runs, as "module:attr". The check is a callable before_send(ref, draft, audience, *, operation, dry_run, message_id, **context). It returns to let the write go ahead, or raises correspond.errors.Refused(reason, **details) or correspond.errors.NeedsApproval(reason, **details). Accept **context, so that later context keys do not break it. liaise supplies one:

    before_send = "liaise.vet:before_send"
    

    It is imported when a write first needs it. Leave it out and the default (correspond.outbound:notice) only shows the audience. A before_send key inside a table is refused rather than ignored. A Python caller can pass before_send= to the write functions instead; the command line and the MCP tools cannot.

  • State (cursors, the Telegram log, web inbox reports) lives under ~/.local/share/correspond/ (or $CORRESPOND_DATA_DIR), one folder per kind, never in a repository.

The web inbox

A web page posts feedback to a small ASGI collector; webinbox:<site> reads what it stored. The app binds nothing itself: run it on localhost behind your own server.

pip install uvicorn
export CORRESPOND_WEBINBOX_SITES=example-site CORRESPOND_WEBINBOX_ORIGINS=https://app.example.org
export CORRESPOND_WEBINBOX_SECRET=...   # shared with the host application's server
uvicorn --factory correspond.channels.webinbox:app_from_env --host 127.0.0.1 --port 8765

The rate limit applies per visitor address. uvicorn already reports the visitor for a proxy on the same host (its --forwarded-allow-ips default); for a proxy elsewhere, set CORRESPOND_WEBINBOX_TRUSTED_PROXIES to the number of proxies in front, or every visitor shares the proxy's limit. Forwarded addresses are believed only from a non-public connection, and IPv6 visitors are limited per /64. Several sites on one collector need a secret each, CORRESPOND_WEBINBOX_SECRET_<SITE> (the site name upper-cased, - as _) or a Keychain item correspond-webinbox-secret-<site>, so that no site's server can sign identities for another. The shared CORRESPOND_WEBINBOX_SECRET serves a single site only, and correspond requirements webinbox reports a configuration the collector would refuse.

POST /example-site/reports takes JSON with a required text and optional name, email, page, context, attachments (base64, stored by SHA-256, referenced rather than inlined) and identity. Without identity a report is claimed. The host application's server can sign its logged-in user, which makes the report bound:

from correspond.channels.webinbox import sign_identity

identity = sign_identity(
    secret, "example-site", user_id, name=display_name
)  # put this in the page

Any server language can do the same: HMAC-SHA256, hex, over the lines v1, site, issued-at (Unix seconds), user id, name, email. An invalid or expired signature is refused (401) and nothing is stored. The collector also enforces the origin allowlist (with CORS preflight), a per-client rate limit, and size and media-type limits.

Routing

from correspond import metadata_rule, route

decision = route(
    message,
    bindings={
        "github:example/app?labels=partner:*": "subject:app"
    },  # 1. where it arrived
    threads={"github:example/app#12": "case:7"},  # 2. what it continues
    rules=[metadata_rule("queue:urgent", labels="priority:high")],  # 3. what it carries
    classifier=None,  # 4. optional, last
)
decision.target, decision.rule, decision.reason

The rules are yours; correspond runs them in that order and says which one decided. Nothing matched returns None: the message is unrouted. Check bindings when you load them: check_binding(pattern) lists what would make one never match, such as an unknown channel, or ?label= where GitHub messages carry labels.

Python

import correspond

messages = correspond.read("github:octocat/hello-world#1")
messages[0].author.handle, messages[0].authenticity.grade

result = correspond.send(
    "email:someone@example.org", "It is fixed.", title="The export", dry_run=True
)
result.plan

for event in correspond.listen("webinbox:example-site"):
    ...

correspond.register_channel(
    MyChannel()
)  # anything with name, capabilities, parse_ref and the operations it has

correspond.testing.FakeChannel is an in-memory channel for tests, and python -m correspond.testing runs the CLI with it registered as fake.

MCP

pip install "correspond[mcp]"
{"mcpServers": {"correspond": {"command": "correspond-mcp"}}}

The server exposes the reading tools. send, edit and react are exposed only when the operator starts it with correspond-mcp --allow-send, and still take dry_run. The data root is the server's, never the model's.

Agent skill

A correspond skill ships inside the package (correspond/data/skills/correspond/): how to read without taking instructions from message text, what each grade permits, and how to write safely (dry run, operator approval, no private content in public places). Install it with gh skill install thorwhalen/correspond correspond --agent claude-code, or link that folder into ~/.claude/skills/.

Design

The seams, surfaces and deliberate non-seams are in Discussion #1.

Metadata

Release files for correspond 0.0.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for correspond 0.0.4
File Size Uploaded
correspond-0.0.4.tar.gz 139.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for correspond 0.0.4
File Interpreter ABI Platform
correspond-0.0.4-py3-none-any.whl Python 3 none any Details

Total release size: 246.6 kB

Release files / correspond-0.0.4.tar.gz

Download URL correspond-0.0.4.tar.gz
Size 139.2 kB
Tags Source
SHA-256 checksum
How to use checksums
53df07e4b160456439a96f7a2a08aacba4653c0d1570fb3578462a0d12457ac7
BLAKE2b-256 checksum
How to use checksums
8367c6f12b1adec11eba38d19c00a035f806ac32a0b1fbb7f9671f6d3b81d041
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.15 {"installer":{"name":"uv","version":"0.12.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / correspond-0.0.4-py3-none-any.whl

Download URL correspond-0.0.4-py3-none-any.whl
Size 107.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d65e8d1c3cb72121b7f7f240db692a620b9afd48ef50f210030e5f07f12cd808
BLAKE2b-256 checksum
How to use checksums
5a127db2e1f4a080c62afe5df9361fac22853a2167a4640a775e056608270d2c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.15 {"installer":{"name":"uv","version":"0.12.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

0.0.16

2 release files

0.0.15

2 release files

0.0.14

2 release files

0.0.13

2 release files

0.0.12

2 release files

0.0.11

2 release files

0.0.9

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

This release

0.0.4 This release

2 release files

0.0.3

2 release files

0.0.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page