correspond 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. correspond-0.0.2/.github/workflows/ci.yml +43 -0
  2. correspond-0.0.2/.gitignore +120 -0
  3. correspond-0.0.2/LICENSE +21 -0
  4. correspond-0.0.2/PKG-INFO +201 -0
  5. correspond-0.0.2/README.md +174 -0
  6. correspond-0.0.2/correspond/__init__.py +113 -0
  7. correspond-0.0.2/correspond/__main__.py +106 -0
  8. correspond-0.0.2/correspond/channels/__init__.py +6 -0
  9. correspond-0.0.2/correspond/channels/_http.py +131 -0
  10. correspond-0.0.2/correspond/channels/github.py +1151 -0
  11. correspond-0.0.2/correspond/channels/macos.py +119 -0
  12. correspond-0.0.2/correspond/channels/mail.py +613 -0
  13. correspond-0.0.2/correspond/channels/ntfy.py +210 -0
  14. correspond-0.0.2/correspond/channels/telegram.py +443 -0
  15. correspond-0.0.2/correspond/channels/webinbox.py +814 -0
  16. correspond-0.0.2/correspond/data/skills/correspond/SKILL.md +100 -0
  17. correspond-0.0.2/correspond/errors.py +111 -0
  18. correspond-0.0.2/correspond/mcp.py +111 -0
  19. correspond-0.0.2/correspond/model.py +705 -0
  20. correspond-0.0.2/correspond/ops.py +497 -0
  21. correspond-0.0.2/correspond/registry.py +601 -0
  22. correspond-0.0.2/correspond/render.py +51 -0
  23. correspond-0.0.2/correspond/routing.py +227 -0
  24. correspond-0.0.2/correspond/settings.py +154 -0
  25. correspond-0.0.2/correspond/stores.py +169 -0
  26. correspond-0.0.2/correspond/testing.py +196 -0
  27. correspond-0.0.2/correspond/tools.py +362 -0
  28. correspond-0.0.2/pyproject.toml +184 -0
  29. correspond-0.0.2/tests/conftest.py +41 -0
  30. correspond-0.0.2/tests/test_dry_run.py +105 -0
  31. correspond-0.0.2/tests/test_email.py +356 -0
  32. correspond-0.0.2/tests/test_github.py +918 -0
  33. correspond-0.0.2/tests/test_model.py +218 -0
  34. correspond-0.0.2/tests/test_no_personal_data.py +303 -0
  35. correspond-0.0.2/tests/test_notifications.py +267 -0
  36. correspond-0.0.2/tests/test_ops.py +236 -0
  37. correspond-0.0.2/tests/test_registry.py +173 -0
  38. correspond-0.0.2/tests/test_routing.py +150 -0
  39. correspond-0.0.2/tests/test_skills.py +96 -0
  40. correspond-0.0.2/tests/test_smoke.py +90 -0
  41. correspond-0.0.2/tests/test_surfaces.py +193 -0
  42. correspond-0.0.2/tests/test_telegram.py +309 -0
  43. correspond-0.0.2/tests/test_webinbox.py +491 -0
@@ -0,0 +1,43 @@
1
+ # wads CI — calls the reusable workflow hosted in i2mint/wads.
2
+ #
3
+ # All configuration comes from this repo's pyproject.toml [tool.wads.ci.*].
4
+ # To customize the workflow itself (rare), replace this file with the
5
+ # full inline template `wads/data/github_ci_uv.yml` from i2mint/wads.
6
+ #
7
+ # Pinning: `@master` floats with wads. If you need version stability for
8
+ # a release-sensitive repo, change `@master` to a wads tag (e.g. `@0.2.15`;
9
+ # tags have no `v` prefix). A stub whose `secrets:` block passes the JSON
10
+ # transport (the default below) needs a tag from a release after 0.2.14 —
11
+ # older tags don't declare that secret and GitHub then rejects the
12
+ # workflow at parse time.
13
+ # CI failure does not block a published release — it blocks the publish
14
+ # step itself — so floating master is generally safe.
15
+ #
16
+ # Permissions: GitHub validates that the caller grants AT LEAST the
17
+ # permissions any job in the called workflow requests — at workflow-parse
18
+ # time, not at run-time, even if the job would be skipped via `if:`.
19
+ # The reusable workflow needs:
20
+ # contents: write for the publish job's version-bump push-back
21
+ # and for the github-pages job's gh-pages branch push
22
+ # pages: write for the github-pages job's REST API Pages config
23
+ # Both default to `write` on org-account GITHUB_TOKEN and need to be
24
+ # granted explicitly on personal-account callers (where the default is
25
+ # read-only). No `id-token: write` needed — the publish-github-pages
26
+ # action uses peaceiris/actions-gh-pages (branch-based) + REST API,
27
+ # not the OIDC `actions/deploy-pages` flow.
28
+ name: Continuous Integration
29
+ on: [push, pull_request]
30
+ jobs:
31
+ ci:
32
+ uses: i2mint/wads/.github/workflows/uv-ci.yml@master
33
+ permissions:
34
+ contents: write
35
+ pages: write
36
+ # Transport (NAMED, legacy): explicitly passes only the secrets
37
+ # listed below (PYPI_PASSWORD + those declared in
38
+ # [tool.wads.ci.env]). Every name must be in the frozen wads
39
+ # superset (wads/ci_secrets.py) or GitHub rejects the workflow
40
+ # at parse time. The default JSON transport has no such limit;
41
+ # regenerate with `wads-migrate ci-to-stub` to switch.
42
+ secrets:
43
+ PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
@@ -0,0 +1,120 @@
1
+ .claude/handoffs/
2
+ .claude/scratch/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+
10
+ .DS_Store
11
+ # C extensions
12
+ *.so
13
+
14
+ # TLS certificates
15
+ ## Ignore all PEM files anywhere
16
+ *.pem
17
+ ## Also ignore any certs directory
18
+ certs/
19
+
20
+ # Distribution / packaging
21
+ .Python
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ downloads/
26
+ eggs/
27
+ .eggs/
28
+ lib/
29
+ lib64/
30
+ parts/
31
+ sdist/
32
+ var/
33
+ wheels/
34
+ *.egg-info/
35
+ .installed.cfg
36
+ *.egg
37
+ MANIFEST
38
+ _build
39
+
40
+ # PyInstaller
41
+ # Usually these files are written by a python script from a template
42
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
43
+ *.manifest
44
+ *.spec
45
+
46
+ # Installer logs
47
+ pip-log.txt
48
+ pip-delete-this-directory.txt
49
+
50
+ # Unit test / coverage reports
51
+ htmlcov/
52
+ .tox/
53
+ .coverage
54
+ .coverage.*
55
+ .cache
56
+ nosetests.xml
57
+ coverage.xml
58
+ *.cover
59
+ .hypothesis/
60
+ .pytest_cache/
61
+
62
+ # Translations
63
+ *.mo
64
+ *.pot
65
+
66
+ # Django stuff:
67
+ *.log
68
+ local_settings.py
69
+ db.sqlite3
70
+
71
+ # Flask stuff:
72
+ instance/
73
+ .webassets-cache
74
+
75
+ # Scrapy stuff:
76
+ .scrapy
77
+
78
+ # Sphinx documentation
79
+ docs/_build/
80
+ docs/*
81
+
82
+ # PyBuilder
83
+ target/
84
+
85
+ # Jupyter Notebook
86
+ .ipynb_checkpoints
87
+
88
+ # pyenv
89
+ .python-version
90
+
91
+ # celery beat schedule file
92
+ celerybeat-schedule
93
+
94
+ # SageMath parsed files
95
+ *.sage.py
96
+
97
+ # Environments
98
+ .env
99
+ .venv
100
+ env/
101
+ venv/
102
+ ENV/
103
+ env.bak/
104
+ venv.bak/
105
+
106
+ # Spyder project settings
107
+ .spyderproject
108
+ .spyproject
109
+
110
+ # Rope project settings
111
+ .ropeproject
112
+
113
+ # mkdocs documentation
114
+ /site
115
+
116
+ # mypy
117
+ .mypy_cache/
118
+
119
+ # PyCharm
120
+ .idea
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.5
2
+ Name: correspond
3
+ Version: 0.0.2
4
+ Summary: A channel facade for AI agents: read, listen, write and identify the sender over GitHub, email, push notifications, Telegram and a web inbox.
5
+ Project-URL: Homepage, https://github.com/thorwhalen/correspond
6
+ Project-URL: Repository, https://github.com/thorwhalen/correspond
7
+ Project-URL: Documentation, https://thorwhalen.github.io/correspond
8
+ Project-URL: Issues, https://github.com/thorwhalen/correspond/issues
9
+ Author: Thor Whalen
10
+ License: mit
11
+ License-File: LICENSE
12
+ Keywords: agents,channels,claude-code,email,github,messaging,notifications,ntfy,telegram
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: cw<0.2,>=0.1.1
15
+ Requires-Dist: dol
16
+ Requires-Dist: xdol
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
19
+ Requires-Dist: pytest>=7.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
21
+ Provides-Extra: docs
22
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
23
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
24
+ Provides-Extra: mcp
25
+ Requires-Dist: py2mcp; extra == 'mcp'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # correspond
29
+
30
+ 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.
31
+
32
+ ```bash
33
+ pip install correspond
34
+
35
+ correspond read github:octocat/hello-world#1 # an issue and its comments, through your gh login
36
+ correspond send ntfy: "backup finished" --dry-run # the plan; nothing is contacted
37
+ correspond capabilities telegram # what a channel can do, graded
38
+ correspond requirements email # what it needs, and where to get it
39
+ ```
40
+
41
+ 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).
42
+
43
+ correspond knows no people. Linking `github:someone` to a person is a people registry's job, for example [acquaint](https://github.com/thorwhalen/acquaint).
44
+
45
+ ## Channels
46
+
47
+ | Channel | References | Operations | Built on |
48
+ |---|---|---|---|
49
+ | `github` | `github:owner/repo`, `github:owner/repo#12` | read, listen (issues and comments), send, edit, react, verify (webhooks) | the `gh` CLI and its login; correspond holds no token |
50
+ | `email` | `email:` (the folder), `email:someone@example.org` | read, listen, send | `imaplib`, `smtplib` |
51
+ | `ntfy` | `ntfy:` (the default topic), `ntfy:<topic>` | send | `urllib` |
52
+ | `macos` | `macos:` | send | `terminal-notifier` or `osascript` |
53
+ | `telegram` | `telegram:`, `telegram:<chat id>`, `telegram:<chat id>/<topic id>` | listen, read (what listening logged), send, edit, react | the Bot API over `urllib` |
54
+ | `webinbox` | `webinbox:<site>` | read, listen | the ASGI collector below, and `dol` stores |
55
+
56
+ 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.
57
+
58
+ `correspond ref <reference>` prints a reference's canonical form (GitHub names lower-cased, for example), which is the form to store and compare.
59
+
60
+ ## Authenticity
61
+
62
+ | Grade | Means |
63
+ |---|---|
64
+ | `platform` | the platform authenticated the account (GitHub, Telegram) |
65
+ | `crypto` | a signed delivery checked with its secret (a GitHub webhook's `X-Hub-Signature-256`) |
66
+ | `bound` | the host application signed an assertion about its logged-in user (the web inbox) |
67
+ | `domain` | email whose topmost `Authentication-Results`, added by a server you trust, records DMARC passing for the From domain |
68
+ | `claimed` | nothing verified: a typed name, an ordinary From header, an anonymous report |
69
+ | `forged` | verification was attempted and failed |
70
+
71
+ 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).
72
+
73
+ ## Capabilities
74
+
75
+ `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.
76
+
77
+ ```text
78
+ $ correspond react ntfy:example-topic m1 eyes
79
+ ntfy does not support react
80
+ ```
81
+
82
+ ## Writing: dry run first
83
+
84
+ - `--dry-run` on `send`, `edit` and `react` contacts 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: a value kept in the Keychain or on a remote host is looked up when sending, and the plan says so.
85
+ - A write that fails is a result, not an exception: `ok: false`, an `error_kind` (`auth`, `permission`, `not_found`, `rate_limited`, `network`, `validation`, `unavailable`), and whether a retry can help (`retryable`, `retry_after`).
86
+ - `-` as the text reads it from stdin; `--json` prints the whole result.
87
+
88
+ ## Listening
89
+
90
+ ```bash
91
+ correspond listen github:owner/repo # issue and comment activity since the last listen
92
+ correspond listen email: --peek # look without moving the cursor
93
+ ```
94
+
95
+ 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.
96
+
97
+ ## Configuration
98
+
99
+ `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.
100
+
101
+ - **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.
102
+ - **Other settings** may also go in `~/.config/correspond/config.toml` (or `$CORRESPOND_CONFIG`), one table per channel:
103
+
104
+ ```toml
105
+ [email]
106
+ imap_host = "imap.example.org"
107
+ smtp_host = "smtp.example.org"
108
+ trusted_authserv_ids = ["mx.example.org"]
109
+
110
+ [ntfy]
111
+ topic_keychain_service = "my-ntfy-topic"
112
+ ```
113
+
114
+ - **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.
115
+
116
+ ## The web inbox
117
+
118
+ 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.
119
+
120
+ ```bash
121
+ pip install uvicorn
122
+ export CORRESPOND_WEBINBOX_SITES=example-site CORRESPOND_WEBINBOX_ORIGINS=https://app.example.org
123
+ export CORRESPOND_WEBINBOX_SECRET=... # shared with the host application's server
124
+ uvicorn --factory correspond.channels.webinbox:app_from_env --host 127.0.0.1 --port 8765
125
+ ```
126
+
127
+ 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.
128
+
129
+ `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`:
130
+
131
+ ```python
132
+ from correspond.channels.webinbox import sign_identity
133
+
134
+ identity = sign_identity(
135
+ secret, "example-site", user_id, name=display_name
136
+ ) # put this in the page
137
+ ```
138
+
139
+ 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.
140
+
141
+ ## Routing
142
+
143
+ ```python
144
+ from correspond import metadata_rule, route
145
+
146
+ decision = route(
147
+ message,
148
+ bindings={
149
+ "github:example/app?labels=partner:*": "subject:app"
150
+ }, # 1. where it arrived
151
+ threads={"github:example/app#12": "case:7"}, # 2. what it continues
152
+ rules=[metadata_rule("queue:urgent", labels="priority:high")], # 3. what it carries
153
+ classifier=None, # 4. optional, last
154
+ )
155
+ decision.target, decision.rule, decision.reason
156
+ ```
157
+
158
+ 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`.
159
+
160
+ ## Python
161
+
162
+ ```python
163
+ import correspond
164
+
165
+ messages = correspond.read("github:octocat/hello-world#1")
166
+ messages[0].author.handle, messages[0].authenticity.grade
167
+
168
+ result = correspond.send(
169
+ "email:someone@example.org", "It is fixed.", title="The export", dry_run=True
170
+ )
171
+ result.plan
172
+
173
+ for event in correspond.listen("webinbox:example-site"):
174
+ ...
175
+
176
+ correspond.register_channel(
177
+ MyChannel()
178
+ ) # anything with name, capabilities, parse_ref and the operations it has
179
+ ```
180
+
181
+ `correspond.testing.FakeChannel` is an in-memory channel for tests, and `python -m correspond.testing` runs the CLI with it registered as `fake`.
182
+
183
+ ## MCP
184
+
185
+ ```bash
186
+ pip install "correspond[mcp]"
187
+ ```
188
+
189
+ ```json
190
+ {"mcpServers": {"correspond": {"command": "correspond-mcp"}}}
191
+ ```
192
+
193
+ 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.
194
+
195
+ ## Agent skill
196
+
197
+ 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/`.
198
+
199
+ ## Design
200
+
201
+ The seams, surfaces and deliberate non-seams are in [Discussion #1](https://github.com/thorwhalen/correspond/discussions/1).
@@ -0,0 +1,174 @@
1
+ # correspond
2
+
3
+ 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.
4
+
5
+ ```bash
6
+ pip install correspond
7
+
8
+ correspond read github:octocat/hello-world#1 # an issue and its comments, through your gh login
9
+ correspond send ntfy: "backup finished" --dry-run # the plan; nothing is contacted
10
+ correspond capabilities telegram # what a channel can do, graded
11
+ correspond requirements email # what it needs, and where to get it
12
+ ```
13
+
14
+ 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).
15
+
16
+ correspond knows no people. Linking `github:someone` to a person is a people registry's job, for example [acquaint](https://github.com/thorwhalen/acquaint).
17
+
18
+ ## Channels
19
+
20
+ | Channel | References | Operations | Built on |
21
+ |---|---|---|---|
22
+ | `github` | `github:owner/repo`, `github:owner/repo#12` | read, listen (issues and comments), send, edit, react, verify (webhooks) | the `gh` CLI and its login; correspond holds no token |
23
+ | `email` | `email:` (the folder), `email:someone@example.org` | read, listen, send | `imaplib`, `smtplib` |
24
+ | `ntfy` | `ntfy:` (the default topic), `ntfy:<topic>` | send | `urllib` |
25
+ | `macos` | `macos:` | send | `terminal-notifier` or `osascript` |
26
+ | `telegram` | `telegram:`, `telegram:<chat id>`, `telegram:<chat id>/<topic id>` | listen, read (what listening logged), send, edit, react | the Bot API over `urllib` |
27
+ | `webinbox` | `webinbox:<site>` | read, listen | the ASGI collector below, and `dol` stores |
28
+
29
+ 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.
30
+
31
+ `correspond ref <reference>` prints a reference's canonical form (GitHub names lower-cased, for example), which is the form to store and compare.
32
+
33
+ ## Authenticity
34
+
35
+ | Grade | Means |
36
+ |---|---|
37
+ | `platform` | the platform authenticated the account (GitHub, Telegram) |
38
+ | `crypto` | a signed delivery checked with its secret (a GitHub webhook's `X-Hub-Signature-256`) |
39
+ | `bound` | the host application signed an assertion about its logged-in user (the web inbox) |
40
+ | `domain` | email whose topmost `Authentication-Results`, added by a server you trust, records DMARC passing for the From domain |
41
+ | `claimed` | nothing verified: a typed name, an ordinary From header, an anonymous report |
42
+ | `forged` | verification was attempted and failed |
43
+
44
+ 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).
45
+
46
+ ## Capabilities
47
+
48
+ `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.
49
+
50
+ ```text
51
+ $ correspond react ntfy:example-topic m1 eyes
52
+ ntfy does not support react
53
+ ```
54
+
55
+ ## Writing: dry run first
56
+
57
+ - `--dry-run` on `send`, `edit` and `react` contacts 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: a value kept in the Keychain or on a remote host is looked up when sending, and the plan says so.
58
+ - A write that fails is a result, not an exception: `ok: false`, an `error_kind` (`auth`, `permission`, `not_found`, `rate_limited`, `network`, `validation`, `unavailable`), and whether a retry can help (`retryable`, `retry_after`).
59
+ - `-` as the text reads it from stdin; `--json` prints the whole result.
60
+
61
+ ## Listening
62
+
63
+ ```bash
64
+ correspond listen github:owner/repo # issue and comment activity since the last listen
65
+ correspond listen email: --peek # look without moving the cursor
66
+ ```
67
+
68
+ 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.
69
+
70
+ ## Configuration
71
+
72
+ `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.
73
+
74
+ - **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.
75
+ - **Other settings** may also go in `~/.config/correspond/config.toml` (or `$CORRESPOND_CONFIG`), one table per channel:
76
+
77
+ ```toml
78
+ [email]
79
+ imap_host = "imap.example.org"
80
+ smtp_host = "smtp.example.org"
81
+ trusted_authserv_ids = ["mx.example.org"]
82
+
83
+ [ntfy]
84
+ topic_keychain_service = "my-ntfy-topic"
85
+ ```
86
+
87
+ - **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.
88
+
89
+ ## The web inbox
90
+
91
+ 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.
92
+
93
+ ```bash
94
+ pip install uvicorn
95
+ export CORRESPOND_WEBINBOX_SITES=example-site CORRESPOND_WEBINBOX_ORIGINS=https://app.example.org
96
+ export CORRESPOND_WEBINBOX_SECRET=... # shared with the host application's server
97
+ uvicorn --factory correspond.channels.webinbox:app_from_env --host 127.0.0.1 --port 8765
98
+ ```
99
+
100
+ 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.
101
+
102
+ `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`:
103
+
104
+ ```python
105
+ from correspond.channels.webinbox import sign_identity
106
+
107
+ identity = sign_identity(
108
+ secret, "example-site", user_id, name=display_name
109
+ ) # put this in the page
110
+ ```
111
+
112
+ 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.
113
+
114
+ ## Routing
115
+
116
+ ```python
117
+ from correspond import metadata_rule, route
118
+
119
+ decision = route(
120
+ message,
121
+ bindings={
122
+ "github:example/app?labels=partner:*": "subject:app"
123
+ }, # 1. where it arrived
124
+ threads={"github:example/app#12": "case:7"}, # 2. what it continues
125
+ rules=[metadata_rule("queue:urgent", labels="priority:high")], # 3. what it carries
126
+ classifier=None, # 4. optional, last
127
+ )
128
+ decision.target, decision.rule, decision.reason
129
+ ```
130
+
131
+ 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`.
132
+
133
+ ## Python
134
+
135
+ ```python
136
+ import correspond
137
+
138
+ messages = correspond.read("github:octocat/hello-world#1")
139
+ messages[0].author.handle, messages[0].authenticity.grade
140
+
141
+ result = correspond.send(
142
+ "email:someone@example.org", "It is fixed.", title="The export", dry_run=True
143
+ )
144
+ result.plan
145
+
146
+ for event in correspond.listen("webinbox:example-site"):
147
+ ...
148
+
149
+ correspond.register_channel(
150
+ MyChannel()
151
+ ) # anything with name, capabilities, parse_ref and the operations it has
152
+ ```
153
+
154
+ `correspond.testing.FakeChannel` is an in-memory channel for tests, and `python -m correspond.testing` runs the CLI with it registered as `fake`.
155
+
156
+ ## MCP
157
+
158
+ ```bash
159
+ pip install "correspond[mcp]"
160
+ ```
161
+
162
+ ```json
163
+ {"mcpServers": {"correspond": {"command": "correspond-mcp"}}}
164
+ ```
165
+
166
+ 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.
167
+
168
+ ## Agent skill
169
+
170
+ 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/`.
171
+
172
+ ## Design
173
+
174
+ The seams, surfaces and deliberate non-seams are in [Discussion #1](https://github.com/thorwhalen/correspond/discussions/1).
@@ -0,0 +1,113 @@
1
+ """correspond: a channel facade for AI agents.
2
+
3
+ Read, listen, write and identify the sender over GitHub, email, push notifications,
4
+ Telegram and a web inbox, through one model and one set of verbs::
5
+
6
+ >>> import correspond # doctest: +SKIP
7
+ >>> messages = correspond.read("github:octocat/hello-world#1") # doctest: +SKIP
8
+ >>> messages[0].author.handle, messages[0].authenticity.grade.value # doctest: +SKIP
9
+ ('octocat', 'platform')
10
+ >>> correspond.send("ntfy:", "backup finished", dry_run=True).plan # doctest: +SKIP
11
+
12
+ A conversation reference is ``<channel>:<id>``. Each channel implements the operations it
13
+ can (``read``, ``listen``, ``send``, ``edit``, ``react``, ``upload``, ``verify``); asking
14
+ for one it lacks raises :class:`NotSupported`, and :func:`capabilities` says so in advance.
15
+ correspond knows no people: a message's ``author`` is what the platform attests, and its
16
+ ``authenticity`` is how sure the channel is.
17
+
18
+ The command line (``correspond read github:octocat/hello-world#1``) and the MCP server use
19
+ the same verbs, through :mod:`correspond.tools`.
20
+ """
21
+
22
+ from correspond.errors import (
23
+ ChannelError,
24
+ CorrespondError,
25
+ InvalidRef,
26
+ MissingRequirement,
27
+ NotSupported,
28
+ UnknownChannel,
29
+ )
30
+ from correspond.model import (
31
+ Account,
32
+ Attachment,
33
+ Authenticity,
34
+ Capabilities,
35
+ ChannelIdentity,
36
+ ConversationRef,
37
+ Draft,
38
+ Event,
39
+ Grade,
40
+ HistoryDepth,
41
+ Message,
42
+ SendResult,
43
+ Support,
44
+ )
45
+ from correspond.ops import (
46
+ Editor,
47
+ Listener,
48
+ Reactor,
49
+ Reader,
50
+ Uploader,
51
+ Verifier,
52
+ Writer,
53
+ capabilities,
54
+ edit,
55
+ get_channel,
56
+ listen,
57
+ parse_ref,
58
+ react,
59
+ read,
60
+ send,
61
+ upload,
62
+ verify,
63
+ )
64
+ from correspond.registry import check_requirements, register_channel, unregister_channel
65
+ from correspond.registry import channels as channel_registry
66
+ from correspond.routing import RouteDecision, check_binding, metadata_rule, route
67
+
68
+ __all__ = [
69
+ "Account",
70
+ "Attachment",
71
+ "Authenticity",
72
+ "Capabilities",
73
+ "ChannelError",
74
+ "ChannelIdentity",
75
+ "ConversationRef",
76
+ "CorrespondError",
77
+ "Draft",
78
+ "Editor",
79
+ "Event",
80
+ "Grade",
81
+ "HistoryDepth",
82
+ "InvalidRef",
83
+ "Listener",
84
+ "Message",
85
+ "MissingRequirement",
86
+ "NotSupported",
87
+ "Reactor",
88
+ "Reader",
89
+ "RouteDecision",
90
+ "SendResult",
91
+ "Support",
92
+ "UnknownChannel",
93
+ "Uploader",
94
+ "Verifier",
95
+ "Writer",
96
+ "capabilities",
97
+ "channel_registry",
98
+ "check_binding",
99
+ "check_requirements",
100
+ "edit",
101
+ "get_channel",
102
+ "listen",
103
+ "metadata_rule",
104
+ "parse_ref",
105
+ "react",
106
+ "read",
107
+ "register_channel",
108
+ "route",
109
+ "send",
110
+ "unregister_channel",
111
+ "upload",
112
+ "verify",
113
+ ]