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.
- bud_relay-0.1.0/.gitignore +13 -0
- bud_relay-0.1.0/CHANGELOG.md +44 -0
- bud_relay-0.1.0/CONTRACT.md +574 -0
- bud_relay-0.1.0/CONTRIBUTING.md +39 -0
- bud_relay-0.1.0/LICENSE +21 -0
- bud_relay-0.1.0/PKG-INFO +218 -0
- bud_relay-0.1.0/README.md +177 -0
- bud_relay-0.1.0/SECURITY.md +233 -0
- bud_relay-0.1.0/SETUP.md +190 -0
- bud_relay-0.1.0/THIRD_PARTY_NOTICES.md +29 -0
- bud_relay-0.1.0/pyproject.toml +86 -0
- bud_relay-0.1.0/relay.example.toml +1 -0
- bud_relay-0.1.0/src/bud_relay/__init__.py +3 -0
- bud_relay-0.1.0/src/bud_relay/agents/__init__.py +97 -0
- bud_relay-0.1.0/src/bud_relay/agents/_http.py +153 -0
- bud_relay-0.1.0/src/bud_relay/agents/base.py +158 -0
- bud_relay-0.1.0/src/bud_relay/agents/claude_cloud.py +122 -0
- bud_relay-0.1.0/src/bud_relay/agents/claude_code.py +19 -0
- bud_relay-0.1.0/src/bud_relay/agents/dot.py +152 -0
- bud_relay-0.1.0/src/bud_relay/agents/duet.py +20 -0
- bud_relay-0.1.0/src/bud_relay/agents/fake.py +61 -0
- bud_relay-0.1.0/src/bud_relay/agents/inbox.py +17 -0
- bud_relay-0.1.0/src/bud_relay/agents/muse.py +161 -0
- bud_relay-0.1.0/src/bud_relay/agents/setup/claude-cloud.md +68 -0
- bud_relay-0.1.0/src/bud_relay/agents/setup/claude-code.md +52 -0
- bud_relay-0.1.0/src/bud_relay/agents/setup/dot.md +75 -0
- bud_relay-0.1.0/src/bud_relay/agents/setup/duet.md +61 -0
- bud_relay-0.1.0/src/bud_relay/agents/setup/inbox.md +16 -0
- bud_relay-0.1.0/src/bud_relay/agents/setup/muse.md +53 -0
- bud_relay-0.1.0/src/bud_relay/api.py +382 -0
- bud_relay-0.1.0/src/bud_relay/channel.py +355 -0
- bud_relay-0.1.0/src/bud_relay/cli.py +782 -0
- bud_relay-0.1.0/src/bud_relay/config.py +454 -0
- bud_relay-0.1.0/src/bud_relay/delivery.py +296 -0
- bud_relay-0.1.0/src/bud_relay/demo.py +643 -0
- bud_relay-0.1.0/src/bud_relay/events/__init__.py +20 -0
- bud_relay-0.1.0/src/bud_relay/events/hub.py +339 -0
- bud_relay-0.1.0/src/bud_relay/events/store.py +206 -0
- bud_relay-0.1.0/src/bud_relay/events/webhooks.py +218 -0
- bud_relay-0.1.0/src/bud_relay/keys.py +121 -0
- bud_relay-0.1.0/src/bud_relay/mcp_methods.py +140 -0
- bud_relay-0.1.0/src/bud_relay/models.py +194 -0
- bud_relay-0.1.0/src/bud_relay/oauth/__init__.py +101 -0
- bud_relay-0.1.0/src/bud_relay/oauth/assertions.py +268 -0
- bud_relay-0.1.0/src/bud_relay/oauth/documents.py +116 -0
- bud_relay-0.1.0/src/bud_relay/oauth/provider.py +1539 -0
- bud_relay-0.1.0/src/bud_relay/oauth/testing.py +360 -0
- bud_relay-0.1.0/src/bud_relay/relay.example.toml +63 -0
- bud_relay-0.1.0/src/bud_relay/server.py +223 -0
- bud_relay-0.1.0/src/bud_relay/store.py +917 -0
- bud_relay-0.1.0/src/bud_relay/tools.py +829 -0
- bud_relay-0.1.0/tests/conftest.py +179 -0
- bud_relay-0.1.0/tests/fakes/__init__.py +71 -0
- bud_relay-0.1.0/tests/fakes/dot_client.py +331 -0
- bud_relay-0.1.0/tests/fakes/muse.py +284 -0
- bud_relay-0.1.0/tests/fakes/routine.py +131 -0
- bud_relay-0.1.0/tests/test_agent_claude_cloud.py +136 -0
- bud_relay-0.1.0/tests/test_agent_dot.py +184 -0
- bud_relay-0.1.0/tests/test_agent_dot_roundtrip.py +144 -0
- bud_relay-0.1.0/tests/test_agent_fake.py +62 -0
- bud_relay-0.1.0/tests/test_agent_muse.py +175 -0
- bud_relay-0.1.0/tests/test_channel_server.py +347 -0
- bud_relay-0.1.0/tests/test_core_admin.py +301 -0
- bud_relay-0.1.0/tests/test_core_api.py +291 -0
- bud_relay-0.1.0/tests/test_core_cli.py +282 -0
- bud_relay-0.1.0/tests/test_core_config.py +469 -0
- bud_relay-0.1.0/tests/test_core_connect.py +345 -0
- bud_relay-0.1.0/tests/test_core_delivery.py +327 -0
- bud_relay-0.1.0/tests/test_core_inbox.py +190 -0
- bud_relay-0.1.0/tests/test_core_keys.py +48 -0
- bud_relay-0.1.0/tests/test_core_mcp.py +199 -0
- bud_relay-0.1.0/tests/test_core_principals.py +145 -0
- bud_relay-0.1.0/tests/test_core_server.py +141 -0
- bud_relay-0.1.0/tests/test_core_store.py +512 -0
- bud_relay-0.1.0/tests/test_e2e_demo.py +27 -0
- bud_relay-0.1.0/tests/test_e2e_relay.py +278 -0
- bud_relay-0.1.0/tests/test_events_hub.py +309 -0
- bud_relay-0.1.0/tests/test_events_mcp.py +130 -0
- bud_relay-0.1.0/tests/test_events_webhooks.py +188 -0
- bud_relay-0.1.0/tests/test_oauth_clients.py +393 -0
- bud_relay-0.1.0/tests/test_oauth_dynamic.py +486 -0
- bud_relay-0.1.0/tests/test_oauth_errors.py +395 -0
- bud_relay-0.1.0/tests/test_oauth_flow.py +229 -0
- bud_relay-0.1.0/tests/test_oauth_pairing.py +351 -0
- bud_relay-0.1.0/tests/test_oauth_private_key_jwt.py +456 -0
- bud_relay-0.1.0/tests/test_skeleton.py +38 -0
- bud_relay-0.1.0/tests/test_untrusted_marking.py +172 -0
|
@@ -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.
|