Metadata-Version: 2.5
Name: correspond
Version: 0.0.6
Summary: A channel facade for AI agents: read, listen, write and identify the sender over GitHub, email, push notifications, Telegram and a web inbox.
Project-URL: Homepage, https://github.com/thorwhalen/correspond
Project-URL: Repository, https://github.com/thorwhalen/correspond
Project-URL: Documentation, https://thorwhalen.github.io/correspond
Project-URL: Issues, https://github.com/thorwhalen/correspond/issues
Author: Thor Whalen
License: mit
License-File: LICENSE
Keywords: agents,channels,claude-code,email,github,messaging,notifications,ntfy,telegram
Requires-Python: >=3.11
Requires-Dist: cw<0.2,>=0.1.1
Requires-Dist: dol
Requires-Dist: xdol
Provides-Extra: dev
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Provides-Extra: docs
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
Requires-Dist: sphinx>=6.0; extra == 'docs'
Provides-Extra: mcp
Requires-Dist: py2mcp; extra == 'mcp'
Description-Content-Type: text/markdown

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

```bash
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](https://github.com/thorwhalen/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 (with cc and bcc), audience | `imaplib`, `smtplib` |
| `ntfy` | `ntfy:` (the default topic), `ntfy:<topic>` | send, audience | `urllib` |
| `macos` | `macos:` | send, audience | `terminal-notifier` or `osascript` |
| `telegram` | `telegram:`, `telegram:<chat id>`, `telegram:@name`, `telegram:<chat id>/<topic id>` | listen, read (what listening logged), send, edit, react, audience | the Bot API over `urllib` |
| `webinbox` | `webinbox:<site>` | read, listen, audience | the ASGI collector below, and `dol` stores |

Every v0.1 adapter uses the Python standard library. Discord ([#2](https://github.com/thorwhalen/correspond/issues/2)), Slack ([#3](https://github.com/thorwhalen/correspond/issues/3)), Signal ([#4](https://github.com/thorwhalen/correspond/issues/4)) and Apprise ([#5](https://github.com/thorwhalen/correspond/issues/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`), carry a `priority` or copy further recipients (`cc`), 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.

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

## Who can read it

```text
$ 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 (Discord, Slack, Signal, Apprise) all answer `public` with `defaulted: true` and the reason in `evidence`. Nothing is cached: ask again right before sending. `correspond capabilities <channel>` grades how much each reader can tell.

| Channel | How the audience is computed |
|---|---|
| `github` (full) | The repository's visibility; its collaborators, when the `gh` account may list them; the organisation's base permission, when the account may read it (otherwise the documented default, read). Never `complete`: installed apps, webhooks and an organisation's security managers read without being listable. |
| `email` (full) | Scope `named`: the address plus `--cc` and `--bcc`, never `complete` (any address may be an alias, a shared mailbox or an auto-forward). A recipient outside the config's `own_domains` makes it `external`. A list-shaped address (under `lists` in the config, or a local part such as `list`, `all`, `team`, `dev`, `announce`, `info`) adds a class for its unlistable members and `list_expansion`, and leaves `external` unknown when every address is internal. A Bcc address is a class of its own. Always `forwarding`, never retractable. |
| `telegram` (partial) | `getChat`. A private chat is `named`, its reader identified by account id. A chat with a public username is `public`. A group, or a channel without a username, is `group`, and members are never listed. A linked discussion group or channel is read too, and makes the audience public when it is. New members read the history unless the chat hides it; forwards widen it unless its content is protected. A chat `getChat` cannot read is public, defaulted. |
| `ntfy` (partial) | `public`, with the classes "anyone who knows the topic name" and whoever runs the server, unless the config says `denies_anonymous_read`. The cache window is a class; its length is evidence. Nothing is asked of the server. |
| `macos`, `webinbox` (full) | `operator`, retractable. |

## 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 the audience lookup (a `gh api` call for GitHub, `getChat` with the bot token for Telegram): a value kept in the Keychain or on a remote host is otherwise looked up when sending, and the plan says so.
- `--cc` and `--bcc` (comma-separated) copy further recipients on channels that grade `cc` (email); elsewhere they are refused by name. Bcc addresses go on the envelope only, never in a header, and they count in the audience.
- **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

```bash
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:

  ```toml
  [email]
  imap_host = "imap.example.org"
  smtp_host = "smtp.example.org"
  trusted_authserv_ids = ["mx.example.org"]
  own_domains = ["example.org"]           # recipients elsewhere are external
  lists = ["@lists.example.org"]          # list addresses, or a whole list server

  [ntfy]
  topic_keychain_service = "my-ntfy-topic"
  denies_anonymous_read = true            # only if your server's auth-default-access denies reads
  cache_duration = "12h"
  ```

- **`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:

  ```toml
  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.

```bash
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`:

```python
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

```python
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

```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

```bash
pip install "correspond[mcp]"
```

```json
{"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](https://github.com/thorwhalen/correspond/discussions/1).
