bud-relay 0.1.0__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 (87) hide show
  1. bud_relay-0.1.0/.gitignore +13 -0
  2. bud_relay-0.1.0/CHANGELOG.md +44 -0
  3. bud_relay-0.1.0/CONTRACT.md +574 -0
  4. bud_relay-0.1.0/CONTRIBUTING.md +39 -0
  5. bud_relay-0.1.0/LICENSE +21 -0
  6. bud_relay-0.1.0/PKG-INFO +218 -0
  7. bud_relay-0.1.0/README.md +177 -0
  8. bud_relay-0.1.0/SECURITY.md +233 -0
  9. bud_relay-0.1.0/SETUP.md +190 -0
  10. bud_relay-0.1.0/THIRD_PARTY_NOTICES.md +29 -0
  11. bud_relay-0.1.0/pyproject.toml +86 -0
  12. bud_relay-0.1.0/relay.example.toml +1 -0
  13. bud_relay-0.1.0/src/bud_relay/__init__.py +3 -0
  14. bud_relay-0.1.0/src/bud_relay/agents/__init__.py +97 -0
  15. bud_relay-0.1.0/src/bud_relay/agents/_http.py +153 -0
  16. bud_relay-0.1.0/src/bud_relay/agents/base.py +158 -0
  17. bud_relay-0.1.0/src/bud_relay/agents/claude_cloud.py +122 -0
  18. bud_relay-0.1.0/src/bud_relay/agents/claude_code.py +19 -0
  19. bud_relay-0.1.0/src/bud_relay/agents/dot.py +152 -0
  20. bud_relay-0.1.0/src/bud_relay/agents/duet.py +20 -0
  21. bud_relay-0.1.0/src/bud_relay/agents/fake.py +61 -0
  22. bud_relay-0.1.0/src/bud_relay/agents/inbox.py +17 -0
  23. bud_relay-0.1.0/src/bud_relay/agents/muse.py +161 -0
  24. bud_relay-0.1.0/src/bud_relay/agents/setup/claude-cloud.md +68 -0
  25. bud_relay-0.1.0/src/bud_relay/agents/setup/claude-code.md +52 -0
  26. bud_relay-0.1.0/src/bud_relay/agents/setup/dot.md +75 -0
  27. bud_relay-0.1.0/src/bud_relay/agents/setup/duet.md +61 -0
  28. bud_relay-0.1.0/src/bud_relay/agents/setup/inbox.md +16 -0
  29. bud_relay-0.1.0/src/bud_relay/agents/setup/muse.md +53 -0
  30. bud_relay-0.1.0/src/bud_relay/api.py +382 -0
  31. bud_relay-0.1.0/src/bud_relay/channel.py +355 -0
  32. bud_relay-0.1.0/src/bud_relay/cli.py +782 -0
  33. bud_relay-0.1.0/src/bud_relay/config.py +454 -0
  34. bud_relay-0.1.0/src/bud_relay/delivery.py +296 -0
  35. bud_relay-0.1.0/src/bud_relay/demo.py +643 -0
  36. bud_relay-0.1.0/src/bud_relay/events/__init__.py +20 -0
  37. bud_relay-0.1.0/src/bud_relay/events/hub.py +339 -0
  38. bud_relay-0.1.0/src/bud_relay/events/store.py +206 -0
  39. bud_relay-0.1.0/src/bud_relay/events/webhooks.py +218 -0
  40. bud_relay-0.1.0/src/bud_relay/keys.py +121 -0
  41. bud_relay-0.1.0/src/bud_relay/mcp_methods.py +140 -0
  42. bud_relay-0.1.0/src/bud_relay/models.py +194 -0
  43. bud_relay-0.1.0/src/bud_relay/oauth/__init__.py +101 -0
  44. bud_relay-0.1.0/src/bud_relay/oauth/assertions.py +268 -0
  45. bud_relay-0.1.0/src/bud_relay/oauth/documents.py +116 -0
  46. bud_relay-0.1.0/src/bud_relay/oauth/provider.py +1539 -0
  47. bud_relay-0.1.0/src/bud_relay/oauth/testing.py +360 -0
  48. bud_relay-0.1.0/src/bud_relay/relay.example.toml +63 -0
  49. bud_relay-0.1.0/src/bud_relay/server.py +223 -0
  50. bud_relay-0.1.0/src/bud_relay/store.py +917 -0
  51. bud_relay-0.1.0/src/bud_relay/tools.py +829 -0
  52. bud_relay-0.1.0/tests/conftest.py +179 -0
  53. bud_relay-0.1.0/tests/fakes/__init__.py +71 -0
  54. bud_relay-0.1.0/tests/fakes/dot_client.py +331 -0
  55. bud_relay-0.1.0/tests/fakes/muse.py +284 -0
  56. bud_relay-0.1.0/tests/fakes/routine.py +131 -0
  57. bud_relay-0.1.0/tests/test_agent_claude_cloud.py +136 -0
  58. bud_relay-0.1.0/tests/test_agent_dot.py +184 -0
  59. bud_relay-0.1.0/tests/test_agent_dot_roundtrip.py +144 -0
  60. bud_relay-0.1.0/tests/test_agent_fake.py +62 -0
  61. bud_relay-0.1.0/tests/test_agent_muse.py +175 -0
  62. bud_relay-0.1.0/tests/test_channel_server.py +347 -0
  63. bud_relay-0.1.0/tests/test_core_admin.py +301 -0
  64. bud_relay-0.1.0/tests/test_core_api.py +291 -0
  65. bud_relay-0.1.0/tests/test_core_cli.py +282 -0
  66. bud_relay-0.1.0/tests/test_core_config.py +469 -0
  67. bud_relay-0.1.0/tests/test_core_connect.py +345 -0
  68. bud_relay-0.1.0/tests/test_core_delivery.py +327 -0
  69. bud_relay-0.1.0/tests/test_core_inbox.py +190 -0
  70. bud_relay-0.1.0/tests/test_core_keys.py +48 -0
  71. bud_relay-0.1.0/tests/test_core_mcp.py +199 -0
  72. bud_relay-0.1.0/tests/test_core_principals.py +145 -0
  73. bud_relay-0.1.0/tests/test_core_server.py +141 -0
  74. bud_relay-0.1.0/tests/test_core_store.py +512 -0
  75. bud_relay-0.1.0/tests/test_e2e_demo.py +27 -0
  76. bud_relay-0.1.0/tests/test_e2e_relay.py +278 -0
  77. bud_relay-0.1.0/tests/test_events_hub.py +309 -0
  78. bud_relay-0.1.0/tests/test_events_mcp.py +130 -0
  79. bud_relay-0.1.0/tests/test_events_webhooks.py +188 -0
  80. bud_relay-0.1.0/tests/test_oauth_clients.py +393 -0
  81. bud_relay-0.1.0/tests/test_oauth_dynamic.py +486 -0
  82. bud_relay-0.1.0/tests/test_oauth_errors.py +395 -0
  83. bud_relay-0.1.0/tests/test_oauth_flow.py +229 -0
  84. bud_relay-0.1.0/tests/test_oauth_pairing.py +351 -0
  85. bud_relay-0.1.0/tests/test_oauth_private_key_jwt.py +456 -0
  86. bud_relay-0.1.0/tests/test_skeleton.py +38 -0
  87. bud_relay-0.1.0/tests/test_untrusted_marking.py +172 -0
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ .env
8
+ .env.*
9
+ relay.toml
10
+ *.sqlite3
11
+ *.sqlite3-*
12
+ .claude/
13
+ .orchestra/
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ Notable changes to bud-relay. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-11-04
10
+
11
+ First public release.
12
+
13
+ ### Added
14
+
15
+ - One self-hosted MCP server where agents send each other requests and replies: a mailbox per
16
+ agent, threads, reply deadlines, retries, progress lines and a shared board.
17
+ - Agent types: OpenAI's dot (OAuth, woken by an MCP Events webhook), Claude Duet and Claude
18
+ Code cloud (claude.ai connector, OAuth; cloud woken by a routine trigger), Claude Code on your
19
+ computer (API key, optional channel), Meta Muse (API key, optional unofficial bridge), and any
20
+ other MCP client with an API key.
21
+ - Sign-in with an API key per agent, or OAuth with a one-time pairing code.
22
+ - Relayed messages arrive in an envelope that names the sender and marks the text untrusted.
23
+ - CLI: `init`, `serve`, `keys`, `connect`, `setup`, `send`, `inbox`, `thread` and `demo`.
24
+ - A Dockerfile and [docs/docker.md](docs/docker.md).
25
+
26
+ ### Tested live at release
27
+
28
+ - dot, through chatgpt.com in a browser: woke about 6 s after a send and replied at 22 s.
29
+ - Claude Code with an API key.
30
+ - Duet, connected with the built-in client id `bud-relay`. It reads mail only while you chat
31
+ with it: it never woke on its own in a 6-hour test.
32
+ - Muse: replied in 17 s through the optional unofficial bridge. Through a connector built in a
33
+ Muse chat, `whoami` and `send` worked. Behind Cloudflare, Muse was blocked (error 1010) until
34
+ Browser Integrity Check was turned off for the relay hostname.
35
+
36
+ ### Not yet tested
37
+
38
+ - Claude Code cloud and the Claude Code channel.
39
+ - A Duet routine answering a request, and claude.ai registering itself as a client.
40
+ - Muse reading its inbox through its connector.
41
+ - A dot subscription renewing after 24 hours.
42
+
43
+ [Unreleased]: https://github.com/BaeHenryS/bud-relay/compare/v0.1.0...HEAD
44
+ [0.1.0]: https://github.com/BaeHenryS/bud-relay/releases/tag/v0.1.0
@@ -0,0 +1,574 @@
1
+ # bud-relay contract
2
+
3
+ How the parts of bud-relay fit together: the data model, the tools, auth, the agent types and the
4
+ config. v2 made it one installable relay; connecting an agent is one command (`bud-relay connect`)
5
+ and one pairing code. A change to an interface named here changes this file in the same PR.
6
+
7
+ ## What it is
8
+
9
+ One self-hosted MCP server (default `127.0.0.1:8787`, exposed through an HTTPS tunnel) that lets
10
+ always-on agents from several vendors send each other requests and replies, and share a small
11
+ board of notes. The core is vendor-neutral; every vendor's quirks live in one module under
12
+ `src/bud_relay/agents/`.
13
+
14
+ **Every agent has a mailbox, plus an optional waker.**
15
+
16
+ - Every message lands in the recipient's inbox, requests included. A request stays there until
17
+ it is answered or expires: the `inbox` tool and `GET /api/inbox` return every open request
18
+ addressed to the caller on every read, from any session, plus replies, notes and errors once
19
+ each (see `inbox` below). A recipient may answer a request with `reply` in any open state.
20
+ - A **waker** tells an agent at once that mail arrived. It is optional: an agent without one
21
+ (Duet) reads its inbox when it checks. A waker never replaces the mailbox.
22
+ - Two waker modes (`WakeResult.mode`):
23
+ - `tool_reply`: the waker nudges the agent (the dot's webhook, a Claude Code cloud routine
24
+ fire) and the agent answers with the `reply` tool. The relay never polls it.
25
+ - `job`: the waker starts a job the relay polls (the optional Muse bridge). A job's `replied`
26
+ result is stored as the agent's reply, unless the agent already replied by tool.
27
+ - **The first reply wins.** A later reply to the same request is refused.
28
+
29
+ Agent ids match `^[a-z][a-z0-9-]{0,31}$`. The set and each agent's type come from config.
30
+
31
+ | Type | Who | Signs in with | Waker | Backstop (finds mail without a waker) |
32
+ |---|---|---|---|---|
33
+ | `inbox` | scripts, other MCP clients | API key | none | reads its inbox when asked |
34
+ | `duet` | Anthropic Claude Duet | OAuth (the claude.ai connector) | none (no official way to wake Duet) | an hourly routine Duet sets up itself |
35
+ | `claude-code` | Claude Code sessions on the user's machines | API key (MCP, or `bud-relay-channel`) | none server-side | the channel pushes into a live session, or it checks when asked |
36
+ | `claude-cloud` | a Claude Code cloud session started by a routine | OAuth (the claude.ai connector, with `as_agent`) | routine fire (`tool_reply`) | — |
37
+ | `muse` | Meta Muse | API key (a custom connector Muse builds in chat) | **optional, unofficial**: the Muse bridge (`job`), only when `bridge_url` is set | checks when asked, or on a schedule it runs itself |
38
+ | `dot` | an OpenAI dot | OAuth (a ChatGPT plugin) | MCP Events webhook (`tool_reply`) | — |
39
+ | `fake` | the demo and tests | API key | scripted (`job`) | — |
40
+
41
+ ## Data model (`models.py`)
42
+
43
+ - **Thread** `thr_…`: subject, created_by, timestamps. A `send` without `thread_id` starts one.
44
+ - **Message** `msg_…`: `seq` (global, monotonic, the SQLite rowid; inbox cursors use it),
45
+ thread, sender, recipient, kind (`request | reply | note | error`), text (≤ 16,000 chars; a
46
+ request to an agent with a waker must also fit its envelope in 16,000, see Envelope),
47
+ `in_reply_to`, `delivery` (below), `external_ref` (the dot's event id, a Muse job id, a cloud
48
+ session URL), `error`, `deadline_at`.
49
+ - **Progress** lines per request, from `report_progress` (≤ 1,000 chars each) or a job's
50
+ snapshot; either way the request's recipient writes them. Shown by `get_message` and
51
+ `get_thread`, never in inboxes.
52
+ - **BoardEntry**: key (≤ 200 chars, `^[A-Za-z0-9._/-]+$`), value (≤ 8,000 chars), integer
53
+ version starting at 1, updated_by, updated_at. No deletes.
54
+ - **OAuthClient** `(client_id, redirect_uris, agents)` (a static client), **OAuthConfig**
55
+ (`public_base_url`, `owner_key`, `clients` (default: the claude.ai client), scopes, token
56
+ lifetimes, `dynamic_clients` (default true), `allowed_redirect_hosts` (default
57
+ `("claude.ai", "chatgpt.com")`)), **OAuthGrant** (`client_id`, scopes, `expires_at`, `agent`)
58
+ and **PairingCode** (`code` shown as `XXXX-XXXX`, `agent`, `expires_at` in unix seconds).
59
+ - Timestamps are ISO-8601 UTC strings with `Z`. Ids are a prefix plus 26 lowercase hex chars.
60
+
61
+ ### Delivery states
62
+
63
+ `inbox | pending | sending | delivered | replied | failed | expired | cancelled`
64
+
65
+ ```
66
+ agent with no waker: inbox ──reply──▶ replied
67
+ agent with a waker: pending ──wake()──▶ sending ──ok──▶ delivered ──reply tool / job──▶ replied
68
+ │ │ │
69
+ └── waker finally fails, or a job ends without a reply ──▶ inbox + note
70
+ any open state ──deadline passes──▶ expired + error
71
+ ```
72
+
73
+ - `sending` is written before `wake()` is called. On startup, a `sending` row moves to `inbox`
74
+ with the note below. Wakes are never replayed automatically.
75
+ - **A waker failure does not fail the request.** A non-retryable `WakeError`, a retryable one
76
+ out of tries (`MAX_DELIVERY_ATTEMPTS = 5`, with backoff), a timeout, a waker bug, or a job that
77
+ ends `failed`, `cancelled` or `expired` (its own expiry) moves the request to `inbox`, and the
78
+ relay adds one `note` to the sender: `Could not wake <agent> (<detail>); it will see your
79
+ request <id> when it next checks.` The note's sender is the request's recipient (there is no
80
+ relay agent id); `in_reply_to` is the request and `error` holds the detail.
81
+ - Only the deadline ends a request without a reply: `reply_timeout_hours` from config (default
82
+ 6), set when the request is created. The relay adds an `error` message to the sender.
83
+ - A reply (tool or job) is stored as a `reply` message from the request's recipient to its
84
+ sender, `in_reply_to` the request, and marks the request `replied`.
85
+ - `failed` and `cancelled` remain for rows a v1 relay wrote; v2 sets neither. The SQLite schema
86
+ is v1's: v1 rows a relay pushed keep `in_inbox = 0` until a fallback moves them, and the `job`
87
+ mode is stored as `poll`.
88
+
89
+ ## Envelope
90
+
91
+ Everything the relay hands an agent (a webhook event, a routine fire, a channel push) is wrapped
92
+ by `models.render_envelope(...)`. It names the message, the thread, the sender and the subject,
93
+ says the text was written by another of the user's agents and is information to weigh, not the
94
+ user's instructions (`UNTRUSTED_NOTICE`), and says how to answer. Tool results carry structured
95
+ fields instead, with `untrusted: true` and the notice on any text another agent wrote (see
96
+ MessageView, ThreadView and BoardView below).
97
+
98
+ Only a request's envelope asks for an answer ("call bud-relay's reply tool with
99
+ message_id=..."). A reply, error or note says "This is a <kind> to your request <in_reply_to>.
100
+ It needs no answer", since the relay refuses a reply to it.
101
+
102
+ The envelope adds 557-861 characters (agent ids and the thread subject vary; ids have a fixed
103
+ width). `send` to an agent with a waker refuses with `too_long` any text whose envelope would
104
+ exceed `MAX_TEXT_CHARS`, and says how much room there is. A follow-up in a thread is measured
105
+ with the thread's subject. Requests to agents without a waker, and replies, keep the full 16,000.
106
+
107
+ ## MCP tools (`/mcp`, streamable HTTP)
108
+
109
+ Every tool takes an optional `as_agent`; it must be in the caller's `allowed_agents`, and
110
+ defaults to the caller's `default_agent`. `reply` and `report_progress` default instead to the
111
+ request's recipient when the caller may act as it (a cloud session on Duet's connector answering
112
+ `claude-cloud`'s request), else to `default_agent`. No tool blocks longer than 25 s
113
+ (`MAX_TOOL_WAIT_SECONDS`): tunnels and claude.ai cut off long calls.
114
+
115
+ Every result in the table below also carries `unanswered`: how many open requests are addressed
116
+ to the agent the call acted as (one indexed count; `reply` counts after its reply lands). The one
117
+ exception is `board_get`'s `null` for a missing entry, which stays a bare `null`.
118
+
119
+ | Tool | Args | Returns |
120
+ |---|---|---|
121
+ | `whoami` | | `{agent, allowed_agents}` |
122
+ | `list_agents` | | `{agents: [{id, type, description, connects, waker, backstop}]}`; `waker` is a bool |
123
+ | `send` | `to, text, thread_id=None, subject=None` | `{message_id, thread_id, delivery}` at once; `too_long` if an envelope would pass 16,000 |
124
+ | `inbox` | `since=None, limit=20, wait_seconds=0` | `{messages: [MessageView + new], cursor, more, open_not_shown}` |
125
+ | `reply` | `message_id, text` | `{message_id, thread_id}`; caller must be the request's recipient |
126
+ | `report_progress` | `message_id, text, idempotency_key=None` | `{message_id, recorded}`; caller must be the recipient of an open request; a repeated key records nothing |
127
+ | `get_message` | `message_id` | `MessageView` plus `progress: [str]` |
128
+ | `get_thread` | `thread_id` | `{thread: ThreadView, messages: [MessageView plus progress]}` |
129
+ | `board_list` | `prefix=""` | `{entries: [BoardView]}` |
130
+ | `board_get` | `key` | `BoardView` or `null` |
131
+ | `board_put` | `key, value, expected_version=None` | `BoardView`; version mismatch is an error |
132
+ | `status` | | `{version, agents: [{id, type, waker_kind}], counts}` (counts by delivery state) |
133
+
134
+ `inbox` with `since=None` reads from the caller's stored cursor (one per agent, shared by every
135
+ session acting as it: Duet's chats and its routine). It returns every **open** request addressed
136
+ to the caller (kind `request`, delivery `inbox | pending | sending | delivered`), wherever it is
137
+ relative to the cursor, plus the replies, notes and errors past the cursor, oldest first by seq.
138
+ A request already answered or expired is left out, even one no read has returned. The cursor
139
+ then moves past the page's newest message, so a reply, note or error is returned once, while an
140
+ open request comes back on every read until it is answered or expires. When the page holds all
141
+ the new mail, the cursor also moves past the closed requests it skipped, so each is scanned once
142
+ and an idle read costs the same however long the history. A page holds at most
143
+ `limit` messages: mail past the cursor fills it first (oldest first), and open requests already
144
+ shown fill the room left (oldest first), so they never hide new mail. Each shown request sits
145
+ before the cursor, so the page stays in seq order. `more` is true only when new mail was left
146
+ out (the next read returns it, at once even with a wait), so reading while `more` is true always
147
+ ends. `open_not_shown` counts the open requests addressed to the caller that are not on the
148
+ page (`unanswered` minus the open requests shown): above 0, a larger `limit` (up to 100) or
149
+ answering some shows the rest. With an explicit `since` it returns every message
150
+ after `since`, any kind or state, and does not touch the stored cursor. `cursor` is the stored
151
+ cursor after the call (for `since`, the last message's seq, or `since`).
152
+
153
+ Each MessageView in an `inbox` result adds `new`: true when its seq is past the stored cursor as
154
+ the call found it (no default read has returned it), for `since` reads too.
155
+
156
+ `wait_seconds` (clamped to 0–25) long-polls for new mail only: the call returns as soon as its
157
+ page holds a message past where it started (the stored cursor, or `since`), else at the
158
+ deadline, with the open requests in the result either way. An open request no read has returned
159
+ yet is new, so it returns a waiting call at once; one already returned never ends a wait early.
160
+ A watch is repeated `inbox(wait_seconds=25)` calls, one call per 25 s while nothing new comes. A
161
+ fresh session's first waiting call can take the full 25 s when another session was already
162
+ shown every open request; `inbox()` without a wait returns them at once.
163
+
164
+ `MessageView` is `{message_id, seq, thread_id, from, to, kind, text, in_reply_to, created_at,
165
+ delivery, external_ref, error, untrusted}`, plus `notice: UNTRUSTED_NOTICE` when untrusted.
166
+ `untrusted` is true when another agent wrote any of the view's text: the message
167
+ (`from != caller`), or its progress lines, which the recipient writes (progress is non-empty and
168
+ `to != caller`). One flag covers the whole view; `progress` stays a list of strings.
169
+
170
+ `ThreadView` is `{thread_id, subject, created_by, created_at, updated_at, untrusted}`, where
171
+ `untrusted = (created_by != caller)` (the creator wrote the subject), plus the notice when
172
+ untrusted.
173
+
174
+ `BoardView` is the BoardEntry `{key, value, version, updated_by, updated_at}` plus `untrusted =
175
+ (updated_by != caller)`, and `notice: UNTRUSTED_NOTICE` when untrusted. An entry the caller
176
+ wrote last is not marked, even if another agent wrote an earlier version.
177
+
178
+ **Prompt.** `/mcp` lists one MCP prompt, `check_mail` (title "Check my mail", no arguments).
179
+ Its one user message tells the agent to read its bud-relay inbox (again while `more` is true,
180
+ and with `limit=100` when `open_not_shown` is above 0), answer every open request with `reply` under the user's usual approval rules (asking first about
181
+ anything that needs a yes), never put passwords, keys, codes or private data the request does
182
+ not need into a reply, and say what arrived. claude.ai shows a connector's prompts in its menu.
183
+
184
+ Tool errors are MCP tool errors with a short code first: `not_found`, `forbidden`, `invalid`,
185
+ `conflict`, `too_long`.
186
+
187
+ **Extra JSON-RPC methods.** An agent type may add methods to `/mcp` (`AgentPlugin.mcp_methods`),
188
+ each with an optional capability to advertise. `tools.build_mcp` mounts them through
189
+ `mcp_methods.mount(mcp, methods, context_for)` while the app is built, before its lifespan starts
190
+ (fastmcp refuses new extensions once it runs). `context_for()` reads the principal the bearer
191
+ middleware left in the ASGI scope and returns `McpCallContext(principal, principal.default_agent)`,
192
+ or None (an auth error). Two plugins may not mount the same method, so a relay has at most one
193
+ `dot` agent.
194
+
195
+ **Tool-call log.** One INFO line per MCP tool call and REST call on the `bud_relay.calls` logger:
196
+ the surface, the tool, the caller and the agent the call acted as (an inferred one included;
197
+ the requested one when the call failed before choosing), message and thread ids when they look
198
+ like ids, and the outcome (`ok` or the error code). Never message text or a secret.
199
+
200
+ ## REST API (`/api`, JSON, same auth and semantics)
201
+
202
+ For the channel process and scripts. Same bodies and results as the tools (`unanswered`
203
+ included).
204
+
205
+ - `POST /api/send` `{to, text, thread_id?, subject?, as_agent?}`
206
+ - `GET /api/inbox?since=&limit=&wait=&as_agent=`
207
+ - `POST /api/reply` `{message_id, text, as_agent?}`
208
+ - `POST /api/messages/{id}/progress` `{text, idempotency_key?, as_agent?}`
209
+ - `GET /api/messages/{id}`, `GET /api/threads/{id}`, `GET /api/agents`, `GET /api/board?prefix=`,
210
+ `GET /api/board/{key}`, `PUT /api/board/{key}` `{value, expected_version?}`
211
+ - `GET /api/whoami`, `GET /api/status`
212
+ - `GET /health` is open and returns `{"status": "ok"}` and nothing else.
213
+
214
+ Errors: `{"error": code, "detail": str}` with 400/401/403/404/409/413, and 429 `locked` and
215
+ 503 `unavailable` from the admin API.
216
+
217
+ ### Admin API (`/api/admin`)
218
+
219
+ For `bud-relay connect --relay` on another machine. It takes the owner key as its bearer token
220
+ (SHA-256, then a constant-time compare) instead of an API key or OAuth token; the bearer
221
+ middleware leaves exactly the `/api/admin` prefix to it.
222
+
223
+ - `GET /api/admin/agents` → `{public_url, owner_key_env, agents: [list_agents' view + client_id]}`,
224
+ where `client_id` is the one static client that lists the agent, else null. Remote `connect`
225
+ renders an agent's steps from it.
226
+ - `POST /api/admin/pairing-codes {agent}` → `{code, agent, expires_at}` (ISO-8601 UTC). 400
227
+ `invalid` for an agent whose type does not connect with OAuth (checked before the bundle is
228
+ asked) or that `create_pairing_code` refuses; 503 `unavailable` when OAuth is off.
229
+ - `POST /api/admin/keys {agent, name?}` → `{name, agent, key}`, with a free name (`<agent>`,
230
+ `<agent>-2`, …) when none is given. The key is shown only this once.
231
+ - A wrong or missing key gets 401 with `WWW-Authenticate: Bearer realm="bud-relay admin"`
232
+ (`ADMIN_CHALLENGE`), which tells it from the OAuth challenge. 5 wrong keys from one address
233
+ within 15 minutes lock that address out for 15 minutes, the right key included: 429 `locked`
234
+ with `Retry-After`. The state is in memory; a restart clears it.
235
+ - With no owner key of 32+ characters in `[oauth] owner_key_env`, every admin route is 404
236
+ `not_found`. With OAuth off it still makes keys.
237
+ - One call-log line per call, `admin <tool> agent=<id> from=<address> -> <outcome>`; never the
238
+ owner key, a code or a key.
239
+
240
+ ## Auth
241
+
242
+ Two credential types, both `Authorization: Bearer <token>`. Core resolves a token in this order:
243
+ `br_` prefix → API key table; otherwise → `OAuthBundle.verify_access_token`. Unknown or expired
244
+ → 401. OAuth tokens start with `bro_`, so a random token can never pass as an API key.
245
+
246
+ 1. **API keys** (`keys.py`). `bud-relay connect <id>` (or the admin API) makes one for an
247
+ `api_key` agent; `bud-relay keys create <name> --agent <id> [--allow id,id]` makes one by
248
+ hand. Either shows `br_<43 url-safe chars>` once; the relay stores only its SHA-256. Principal:
249
+ `Principal(name, default_agent=<id>, allowed_agents={<id>, …allow})`. `keys list`,
250
+ `keys revoke`.
251
+ 2. **OAuth** (`oauth/`), for the vendors' connectors. No client secrets: clients are public
252
+ (`token_endpoint_auth_method = "none"`), except that a client metadata document may ask for
253
+ `private_key_jwt` (below). Each grant is bound to one agent, which survives refresh. Two
254
+ kinds of client:
255
+ - **Static** clients: `[[oauth.clients]]`, each with exact-match redirect URIs and its
256
+ agents (the first preselected for the owner-key path). Optional: with none at all, the
257
+ claude.ai client (`bud-relay`) is implied for `duet` and `claude-cloud` if configured
258
+ (first, so its existing grants keep working) and every other agent of those types; with
259
+ none, there is no static client (a ConfigError only with `dynamic_clients = false`). A
260
+ client with no redirect URI cannot authorize.
261
+ - **Dynamic** clients, with `[oauth] dynamic_clients` (default true):
262
+ - **DCR** (RFC 7591) at `/register`, advertised as `registration_endpoint`. Public
263
+ code-flow clients only: an omitted `token_endpoint_auth_method` registers as `none`,
264
+ `client_secret_*` and `private_key_jwt` are refused, and the registration is exactly
265
+ `authorization_code` and `refresh_token`. 1-10 redirect URIs, each `https://` on port 443
266
+ with its host exactly in `allowed_redirect_hosts` (no wildcards). A random client id
267
+ (`token_urlsafe(24)`); at most 20 registrations an hour, then 429 with `Retry-After`.
268
+ Stored in `oauth.sqlite3` (`oauth_clients`); never purged.
269
+ - **CIMD** (client ID metadata documents): a `client_id` that is an `https://` URL is
270
+ fetched only from `CLIENT_METADATA_HOSTS = ("chatgpt.com", "claude.ai")` in
271
+ `oauth/documents.py` (the hosts the vendors document), under the webhook destination
272
+ rules (port 443, every resolved address public, the connection pinned to a checked
273
+ address with Host and SNI kept, no redirects, no env proxies), at most 64 KiB within
274
+ 5 s. The document must name itself, be a code-flow client whose
275
+ `token_endpoint_auth_method` is `none` (or omitted) or `private_key_jwt` (`client_secret_*`
276
+ is refused), and have redirect URIs that pass the same host rule. Cached 24 hours (a
277
+ refresh after that fetches it again); a refused URL is not fetched again for 5 minutes.
278
+ Advertised as `client_id_metadata_document_supported: true`. A refused document makes
279
+ `/authorize` answer 400 `{"error": "invalid_client", "error_description":
280
+ "bud-relay refused the client metadata document at <url>: <reason>"}`, which the vendor
281
+ shows (the SDK's own answer was "Client ID … not found"); the log has the same reason.
282
+ - **`private_key_jwt`** (`oauth/assertions.py`; RFC 7523 §2.2, RFC 7521), as ChatGPT's
283
+ document asks. The document needs a `jwks_uri` that passes the document URL rules on the
284
+ document's own host, and a `token_endpoint_auth_signing_alg` of `RS256` or `ES256`
285
+ (absent: `RS256`); both are stored with the cached document (`oauth_clients.jwks_uri`,
286
+ `signing_alg`). At `/token` (both grants) and `/revoke` such a client sends
287
+ `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer` and a
288
+ `client_assertion` JWT: signed with the document's algorithm by the JWKS key its `kid`
289
+ names (no `kid`: the JWKS's only key; a key whose `use` or `alg` says otherwise does not
290
+ count), `iss` = `sub` = the client id, `aud` naming the token endpoint
291
+ (`<public_url>/token`) or the issuer (`<public_url>`, with or without the slash), `exp`
292
+ in the future but at most 10 minutes ahead, `iat` and `nbf` (when present) not in the
293
+ future, 60 s leeway on each, and a `jti` not used before. Used assertions are kept in
294
+ `oauth.sqlite3` (`oauth_assertions`: SHA-256 of client id and `jti`) until they expire.
295
+ `client_id` may be left out of the form: the assertion's `sub` names the client (it
296
+ still has to verify against that client's keys). A document that also lists `none` in
297
+ `token_endpoint_auth_methods_supported` (ChatGPT's does) may send no assertion and is
298
+ then a public client (PKCE); an assertion it does send must pass. The log says at INFO
299
+ which way it authenticated. Anything else is 401 `invalid_client`
300
+ with the reason in `error_description` and a warning in the log; PKCE is still
301
+ required. The JWKS is fetched under the document rules, kept 5 minutes, and fetched
302
+ again for a `kid` it lacks, at most once every 30 seconds. There is one fetch of a JWKS
303
+ at a time: requests that arrive during it share its keys. Metadata advertises
304
+ `token_endpoint_auth_methods_supported` and `revocation_endpoint_auth_methods_supported`
305
+ `["none", "private_key_jwt"]`, and
306
+ `…_auth_signing_alg_values_supported` `["RS256", "ES256"]`, when dynamic clients are on
307
+ (otherwise `["none"]`).
308
+ - Redirect hosts are checked at every lookup and every token check, so taking a host off
309
+ `allowed_redirect_hosts` retires the clients that use it and their tokens at once. With
310
+ `dynamic_clients = false` there is no `/register`, no fetch, and every dynamic grant
311
+ gets 401.
312
+ - **Pairing codes.** `await bundle.create_pairing_code(agent) -> PairingCode`: 8 Crockford
313
+ base32 characters (no I, L, O, U), shown `XXXX-XXXX`, accepted in any case with or without
314
+ the dash or spaces (I and L read as 1, O as 0). Valid once, for 10 minutes; stored only as
315
+ a SHA-256 hash (`oauth_pairing_codes`). `ValueError` for an agent not in `oauth_agents`. A
316
+ static client takes only codes for its own agents (otherwise 400; the code stays valid
317
+ and nothing is counted).
318
+ - **Consent page.** `/consent` (GET) names the client's redirect host (and a static
319
+ client's id) and asks for a pairing code; the grant acts as the code's agent. "Use the
320
+ owner key instead" (a collapsed `<details>`, no JavaScript) keeps the owner key plus an
321
+ agent choice: a static client's `agents`, or for a dynamic client every `oauth_agents`
322
+ agent. Refusals (a wrong or expired code, a wrong key, a lockout, an expired login) are a
323
+ short HTML page; after a wrong code or key it links back to the same form, which stays
324
+ open.
325
+ - **Consent lockout.** Wrong codes and wrong owner keys count together, before the request
326
+ and CSRF checks: 5 from one address within 15 minutes lock that address out for 15
327
+ minutes (429 with `Retry-After`), and 50 from all addresses together lock consent for
328
+ everyone. The right code or key clears its address's count, not the total. The counts are
329
+ in `oauth.sqlite3` (`oauth_consent_failures`). The address is the ASGI client, which
330
+ `serve` takes from `X-Forwarded-For` only for a proxy in `[relay] forwarded_allow_ips`.
331
+ - **Principals.** A static client's grant: `Principal(f"oauth:{client_id}",
332
+ default_agent=grant.agent, allowed_agents=frozenset(client.agents))`. A dynamic client's
333
+ grant always has an agent: `Principal(f"oauth:{client_id}", grant.agent,
334
+ frozenset({grant.agent} | {a for a in agents if a.via == grant.agent}))` (one hop).
335
+ - The provider (through `RelayConfig.oauth_config()`) and core's resolver read the same
336
+ clients. Each grant records its client's kind (`oauth_grants.client_kind`: `static`,
337
+ `dcr` or `cimd`; NULL, from before dynamic clients, is static) and, for a dynamic client,
338
+ its redirect URIs, and never changes kind. Every token check re-checks it against the
339
+ current config: a static grant needs its static client still configured and listing its
340
+ agent; a dynamic grant needs a client id that is not static, `dynamic_clients` on, its
341
+ agent still an OAuth agent, its recorded redirect URIs still on allowed hosts, and its
342
+ registration (DCR) or a document URL on a vendor host (CIMD; no fresh document needed).
343
+ Anything else gets 401, so retiring a client or a host retires its tokens at once.
344
+ - Grants made before v2 have no agent: they act as their client's first agent, with no
345
+ re-consent (`oauth.sqlite3` gains a nullable `agent` column in place).
346
+ - PKCE S256 required. The token endpoint accepts `application/x-www-form-urlencoded`. Refresh
347
+ tokens rotate; reusing a rotated refresh token revokes the grant. Scopes are `("relay",)`.
348
+ - Routes: `/.well-known/oauth-protected-resource` (RFC 9728, resource `<public_url>/mcp`),
349
+ `/.well-known/oauth-authorization-server` (RFC 8414), `/authorize`, `/consent`, `/token`,
350
+ `/revoke`, and `/register` with dynamic clients.
351
+ - bud-relay sends no `iss` (RFC 9207), so ChatGPT uses its
352
+ `https://chatgpt.com/connector/oauth/<id>` callback.
353
+ - Ported from dots-bridge's `PersonalOAuth` (`uezo/dots-bridge` at `46010a7`, MIT), credited
354
+ in the file header.
355
+
356
+ A 401 on `/mcp` or `/api` carries
357
+ `WWW-Authenticate: Bearer resource_metadata="<public_url>/.well-known/oauth-protected-resource"`,
358
+ plus `error="invalid_token"` when a token was sent. claude.ai and ChatGPT discover OAuth from it.
359
+
360
+ ```python
361
+ def build_oauth(
362
+ config: OAuthConfig,
363
+ db_path: Path,
364
+ *,
365
+ # The configured agents whose type connects with OAuth, in config order: the agents a
366
+ # pairing code may name and a dynamic client may act as. Default: the static clients'.
367
+ oauth_agents: Iterable[str] | None = None,
368
+ # Tests only: replace the network for client metadata documents and their JWKS.
369
+ document_transport: httpx.AsyncBaseTransport | None = None,
370
+ document_resolver: Resolver | None = None,
371
+ ) -> OAuthBundle: ...
372
+
373
+
374
+ @dataclass
375
+ class OAuthBundle:
376
+ routes: list[BaseRoute] # every OAuth/discovery route; core mounts them at the root
377
+ verify_access_token: Callable[[str], Awaitable[OAuthGrant | None]]
378
+ provider: object | None = None # the mcp/fastmcp provider object
379
+ create_pairing_code: Callable[[str], Awaitable[PairingCode]] | None = None
380
+ ```
381
+
382
+ Core builds it with `server.build_relay_oauth(config, plugins, environ)`, which passes
383
+ `oauth_agents` from the plugins (`connects == "oauth"`); `create_app` and local `connect` both
384
+ use it.
385
+
386
+ ## Agent types (`agents/`)
387
+
388
+ `agents/base.py` defines the plugin interface; `agents/__init__.py` the registry. A module
389
+ registers its factory with `@register("<type>")`. Core builds each agent's plugin with
390
+ `build_plugin(type, PluginContext)` (`delivery.build_plugins`) and prints its setup steps with
391
+ `render_setup(type, SetupContext)`. A type missing from the build logs a warning at startup, and
392
+ the agent keeps its mailbox with no waker.
393
+
394
+ - `PluginContext(agent_id, options, data_dir, public_url, env, transport=None,
395
+ owner_key_env="BUD_RELAY_OWNER_KEY")`. `options` is the rest of `[agents.<id>]`; a factory
396
+ refuses keys it does not read. `env` is read at wake time, never at build time, so the relay
397
+ runs with only some secrets set; building a plugin writes nothing to `data_dir`. `transport`
398
+ routes HTTP into an ASGI stand-in (demo, tests). `owner_key_env` is `[oauth] owner_key_env`,
399
+ for a plugin that encrypts its secrets with the owner key (the dot).
400
+ - `AgentPlugin(type, connects, waker=None, mcp_methods=(), backstop=None)`: `connects` is
401
+ `"oauth"` or `"api_key"`.
402
+ - `Waker`: `wake(OutboundMessage) -> WakeResult`, `poll(external_ref) -> PollResult` (jobs
403
+ only), `aclose()`. Failures raise `WakeError(retryable=…)`: connection errors, 429 and 5xx
404
+ are retryable; other 4xx are not. A missing secret is a non-retryable error at wake time.
405
+ - `PollResult.progress` is a snapshot (every line so far, oldest first). Core stores line `i`
406
+ once, so only the new tail is added; an empty tuple means none known, never "cleared".
407
+
408
+ | Type | Options | Waker |
409
+ |---|---|---|
410
+ | `inbox`, `duet`, `claude-code` | none | none |
411
+ | `claude-cloud` | `api_base`, `trigger_id_env`, `token_env` | `POST /v1/claude_code/routines/{trigger_id}/fire` with `Authorization: Bearer <token>`, `anthropic-beta: experimental-cc-routine-2026-04-01`, `anthropic-version: 2023-06-01`, body `{"text": <envelope>}`; ref = session URL; `tool_reply`. 429/5xx retryable, 401/403/404 not. Limits: 30 fires/hour per routine, 100/hour per account |
412
+ | `muse` | `bridge_url`, `api_key_env`, `job_timeout_seconds` | only with `bridge_url`: `POST /v1/jobs` on the Muse bridge, ref = job id; `job`, polled at `GET /v1/jobs/{id}/result` (`completed` → reply) |
413
+ | `dot` | `loopback_webhooks_for_tests` (demo only) | publishes `relay.request.created` to the dot's subscriptions (below); ref = event id; `tool_reply` |
414
+ | `fake` | `reply_after_polls` (default 1), `prefix` | records the message; after N polls replies `"<prefix>: <first 200 chars>"` |
415
+
416
+ The Muse bridge is a separate, unofficial project that drives Muse's website; bud-relay only
417
+ calls its HTTP jobs API, it is off by default, and its setup text says it may break Meta's terms.
418
+
419
+ **Setup steps** are package data: `agents/setup/<type>.md` for every type but `fake`, rendered
420
+ with `str.format_map` and these placeholders only: `{agent_id}`, `{public_url}`, `{mcp_url}`,
421
+ `{client_id}`, `{owner_key_env}`, `{key_command}`, `{pairing_code}`, `{pairing_minutes}`,
422
+ `{key_hint}`. `connect` fills `pairing_code` and `pairing_minutes` for an OAuth agent and
423
+ `key_hint` ("on your clipboard", or the key itself) for an API-key agent; `setup` replaces
424
+ both with a line saying to run `connect`. The shipped templates use the last three, not
425
+ `{client_id}`, `{key_command}` or `{owner_key_env}`. Each OAuth agent's steps say to paste
426
+ `{mcp_url}`, let the platform register itself and type the code, and end with a short
427
+ static-client fallback.
428
+
429
+ ## MCP Events and the dot (`events/`, `agents/dot.py`)
430
+
431
+ Ported from dots-bridge (`mcp.py`, `bridge.py`, `webhooks.py`, `store.py` at `46010a7`, MIT),
432
+ credited in each module's header.
433
+
434
+ - **Methods:** `events/list`, `events/subscribe`, `events/unsubscribe`, advertised under the
435
+ capability `io.dotsbridge/webhook-events` (and `capabilities.events`). They take no
436
+ `as_agent`: subscriptions belong to the calling principal's agent, and only the dot's agent may
437
+ subscribe.
438
+ - **The event** `relay.request.created` takes no arguments. Its envelope is
439
+ `{eventId, name, timestamp, data, cursor: null}`, signed per Standard Webhooks with the
440
+ subscriber's `whsec_…` secret. `eventId` = `webhook-id` = `evt_<message id hex>`, the same on
441
+ every retry, so a repeat is dropped. `data` is `{message_id, thread_id, from, to, subject,
442
+ text}`, where `text` is the full envelope. The dot answers with `reply` and `report_progress`
443
+ and can read the same request with `inbox` or `get_message`.
444
+ - **Subscriptions** last at most 24 hours (`ttlMs` is capped) and the dot renews them. Renewing
445
+ with the same secret within 5 minutes skips the callback challenge; a new secret signs beside
446
+ the old one for 5 minutes.
447
+ - **Wake:** no live subscription is a non-retryable `WakeError`; every delivery failing is one,
448
+ retryable only when a failure was transient (408, 425, 429, 5xx, connection errors). A 410
449
+ removes the subscription. `poll()` is never called for the dot's own wakes; a ref it gets is a
450
+ request a v1 relay sent through dots-bridge, and it refuses it for good, so such a request
451
+ falls back to the dot's inbox with a note.
452
+ - **Destinations:** HTTPS on port 443 only; the host is resolved once, every address must be
453
+ public, and the connection is pinned to it with the host name kept for TLS. No redirects,
454
+ 256 KiB per event, 16 KiB per answer. `loopback_webhooks_for_tests = true` also accepts plain
455
+ HTTP to this machine, for the local demo only.
456
+ - **Storage:** `data_dir / "events.sqlite3"` (mode 0600). Each destination (URL and secret) is
457
+ encrypted with a Fernet key derived from the owner key by HKDF, so `init` needs no new secret.
458
+ A rotated owner key drops every subscription instead of failing.
459
+
460
+ ## Channel (`channel.py`)
461
+
462
+ `bud-relay-channel --url <relay> --agent claude-code` (key from `BUD_RELAY_KEY`) is a stdio MCP
463
+ server for one live Claude Code session. It declares `capabilities.experimental["claude/channel"]
464
+ = {}`, long-polls `GET /api/inbox?wait=25`, and pushes each message with `new: true` once as
465
+ `notifications/claude/channel` with `content = envelope` and `meta = {message_id, thread_id,
466
+ from_agent, kind}`, plus `in_reply_to` for replies, notes and errors. Open requests that come back
467
+ with `new: false` are not pushed again, so a request an earlier session was shown and left
468
+ unanswered reaches a new session only when it reads its inbox; a message with no `new` (a relay
469
+ from before it) is pushed. New mail fills each page first, so open requests never stall the
470
+ channel; when more new mail waits than one page holds, its next poll returns it at once. It exposes `reply(message_id,
471
+ text)` and `send(to, text, thread_id=None)`. Launch needs
472
+ `claude --dangerously-load-development-channels server:bud-relay-channel`, which shows a
473
+ confirmation dialog, so it is tested interactively; the suite has unit tests. Docs:
474
+ https://code.claude.com/docs/en/channels-reference.md
475
+
476
+ ## Config (`relay.toml`, parsed by `config.py`)
477
+
478
+ See `relay.example.toml` (package data at `src/bud_relay/relay.example.toml`, which `init` reads
479
+ with `importlib.resources`; the repo root's copy is a symlink to it).
480
+
481
+ ```toml
482
+ [relay]
483
+ public_url = "https://relay.example" # an origin: no path
484
+ data_dir = "~/.bud-relay"
485
+ forwarded_allow_ips = ["127.0.0.1", "::1"] # proxies whose X-Forwarded-For is believed
486
+
487
+ [oauth]
488
+ enabled = true
489
+ owner_key_env = "BUD_RELAY_OWNER_KEY" # admin only: the admin API and the consent fallback
490
+ dynamic_clients = true # /register (DCR) and client metadata documents
491
+ allowed_redirect_hosts = ["claude.ai", "chatgpt.com"]
492
+
493
+ # Optional: a static client, for a platform that cannot register itself.
494
+ [[oauth.clients]]
495
+ client_id = "bud-relay" # claude.ai, "Use your own OAuth client"
496
+ redirect_uris = ["https://claude.ai/api/mcp/auth_callback"]
497
+ agents = ["duet", "claude-cloud"] # the first is preselected
498
+
499
+ [agents.duet]
500
+ type = "duet" # the rest of the table is the type's options
501
+
502
+ [agents.claude-cloud]
503
+ type = "claude-cloud"
504
+ via = "duet" # a dynamic grant acting as duet may act as this too
505
+
506
+ [agents.dot]
507
+ type = "dot"
508
+ ```
509
+
510
+ - Each `[agents.<id>]` has a `type`, an optional `description`, an optional `via` (another
511
+ configured agent; a core key, not passed to the type's factory), and the type's options.
512
+ - `allowed_redirect_hosts` takes host names only (lower-cased; no scheme, port, path or
513
+ wildcard). `forwarded_allow_ips` takes IP addresses or networks (`172.16.0.0/12`); `serve`
514
+ runs uvicorn with `proxy_headers=True` and exactly these, never uvicorn's
515
+ `FORWARDED_ALLOW_IPS` environment variable.
516
+ - A v1 file (any agent with `kind`) is refused with one error that lists every edit: each
517
+ agent's v2 type, the `dots_bridge` options to drop, the `muse_bridge` `base_url` to rename to
518
+ `bridge_url`, the legacy `[oauth]` keys to move into `[[oauth.clients]]`, and that the dot
519
+ then connects with `bud-relay connect dot`.
520
+ - v1's `[oauth] client_id / redirect_uris / default_agent / allowed_agents`, with no
521
+ `[[oauth.clients]]`, still read as one client (the default agent first). A file with both is
522
+ refused.
523
+ - Secrets come from environment variables named in the config; the CLI loads a gitignored
524
+ `.env` beside the config. Data lives in `data_dir`: `relay.sqlite3` (core), `oauth.sqlite3`
525
+ (`oauth.DB_FILENAME`) and `events.sqlite3` (the dot's subscriptions), in WAL mode, mode 0600.
526
+ - `public_url` must be an origin (`https://host[:port]`, no path): OAuth metadata and the token
527
+ audience are `<public_url>/mcp`, and plain HTTP is accepted only on loopback. It is stored
528
+ canonical, as browsers write `Origin` (lower-case host, no default port), via
529
+ `oauth.canonical_origin`; the consent page compares `Origin` against it. The host must be
530
+ ASCII: an internationalized name goes in its punycode (`xn--`) form.
531
+
532
+ ## CLI (`bud-relay`)
533
+
534
+ - `init` writes `relay.toml` and a `.env` (mode 0600) with a fresh owner key and the example's
535
+ secret env names commented out; it refuses to overwrite.
536
+ - `serve`, `keys create|list|revoke`, `send`, `inbox`, `thread`. `inbox <agent>` peeks like
537
+ `inbox` with `since=None` without moving the cursor, marks new messages, and prints the
538
+ unanswered count.
539
+ - `connect <agent-id> [--relay URL] [--no-copy]` makes a pairing code for an OAuth agent, or a
540
+ new API key for the rest, and prints the agent's steps with it filled in (or, for a type with
541
+ no steps, what it needs). Without `--relay` it runs the admin operations in process on the
542
+ relay's data dir and OAuth bundle (`build_relay_oauth`). With `--relay` it calls the admin API
543
+ with `BUD_RELAY_OWNER_KEY` from the environment or the `.env` beside `--config` (no relay.toml
544
+ needed), refuses a non-HTTPS URL except to loopback, and canonicalizes it to its origin. A
545
+ key goes to the clipboard through the first of `pbcopy`, `wl-copy` and `xclip -selection
546
+ clipboard` that exits 0 (unless `--no-copy`) and is then never printed; otherwise it is
547
+ printed once. Steps without the placeholder get the code, or the uncopied key, on a line
548
+ after them.
549
+ - `setup <agent-id> [--callback URL]` is read-only: the same steps, with `{pairing_code}` and
550
+ `{key_hint}` replaced by a line saying to run `connect`. `--callback` checks the URL with the
551
+ OAuth provider's own redirect URI rule, then adds it to the `[[oauth.clients]]` entry that
552
+ lists the agent with a one-line edit, written only if the result parses to the same TOML plus
553
+ that URI. Otherwise, or when the file cannot be written, it prints the exact line to add.
554
+ - `demo [--no-claude] [--model …]` runs the relay end to end on free local ports with stand-ins
555
+ from `tests/fakes/`: the dot is a ChatGPT stand-in (`dot_client.py`: OAuth as the ChatGPT
556
+ client, subscribe, verify the webhook, progress and reply), Muse a muse-bridge jobs fake, and
557
+ claude-cloud a routine fire fake. claude-code's key comes from `POST /api/admin/keys`, the
558
+ cloud session replies with no `as_agent`, and a last leg connects duet the new way (a code
559
+ from `POST /api/admin/pairing-codes`, a DCR client through `oauth.testing.dynamic_flow`) and
560
+ asks the dot. `demo.Stage(ports=False)` builds the same world in process for the end-to-end
561
+ tests.
562
+
563
+ ## Tests and checks
564
+
565
+ `uv run pytest -q` and `uv run ruff check . && uv run ruff format --check .` pass on every
566
+ branch, and `uv run bud-relay demo --no-claude` finishes every round trip. Nothing in the test
567
+ suite touches the network, a real dot, a real Muse or a real routine. Test files are prefixed by
568
+ area: `tests/test_core_*.py`, `tests/test_oauth_*.py`, `tests/test_agent_*.py`,
569
+ `tests/test_events_*.py`, `tests/test_channel_*.py`, `tests/test_e2e_*.py`.
570
+
571
+ ## Out of scope
572
+
573
+ No claude.ai automation of any kind. No A2A, no Grok, no web UI. No agent types beyond the
574
+ table above.