tinyrelaycommit

Commit d96b31a2f758

browse files at this commit | history

1diff --git a/docs/agents.md b/docs/agents.md
2index e48aaf6..62514ef 100644
3--- a/docs/agents.md
4+++ b/docs/agents.md
5@@ -207,6 +207,92 @@ Over MCP, `request_job` builds a request, `job_feedback` and `job_result` build
6
7 A result, or feedback that reports `error` or `payment-required`, wakes the requester's devices in the mentions category with the body `job <kind> <status>`, where the kind is the request's.
8
9+## Callbacks
10+
11+An agent that cannot keep a relay connection open, such as a serverless worker, a scheduled job or an assistant that wakes on demand, can register a callback: an https URL and a filter. The relay POSTs each new event that matches the filter, and that the agent's key may read, to the URL as it arrives. Members may register callbacks for their own key the same way.
12+
13+### Register a callback
14+
15+Call the `addcallback` management method, or the `add_callback` MCP tool, with the URL and the filter:
16+
17+```json
18+{"method": "addcallback", "params": [{"url": "https://worker.example/tiny", "filter": {"kinds": [1621, 1111], "#a": ["30617:<owner pubkey>:tinyrelay"]}}]}
19+```
20+
21+The answer carries the callback's `id` and its `secret`. The secret is shown once; keep it to verify deliveries. Pass your own `secret` of 16 to 128 printable ASCII characters to use it instead of a generated one.
22+
23+The URL must use https and name a public host. Private, loopback and link-local addresses are refused, and the relay does not follow redirects.
24+
25+### The filter
26+
27+The filter is a NIP-01 filter limited to these keys:
28+
29+| Key | Value |
30+| --- | --- |
31+| `kinds` | Required. Up to 32 event kinds. |
32+| `authors` | Up to 8 public keys. |
33+| `#a` | Up to 8 addresses, such as a repository's `30617:<owner>:<identifier>`. |
34+| `#e` | Up to 8 event ids. |
35+| `#p` | Up to 8 public keys, for events that mention or address them. |
36+| `#h` | Up to 8 room ids. |
37+
38+An event matches when its kind is listed and every named tag carries one of the listed values. `since` is accepted and ignored, since a callback only sees events that arrive after it is registered. Other keys are refused.
39+
40+An agent's filter must stay inside its grant: a room in `#h` must be one the grant names, and a repository in `#a` must be one the grant covers. Kinds are not restricted, so an agent can wait for reactions it may not publish itself.
41+
42+Filters for the common cases:
43+
44+| Wake on | Filter |
45+| --- | --- |
46+| New issues, pull requests and comments in one repository | `{"kinds": [1621, 1618, 1111], "#a": ["30617:<owner>:tinyrelay"]}` |
47+| Pushes to the owner's repositories | `{"kinds": [30618], "authors": ["<owner pubkey>"]}` |
48+| Messages in a room that mention the agent | `{"kinds": [9, 11, 12], "#h": ["build"], "#p": ["<agent pubkey>"]}` |
49+| Requests for a decision and answers addressed to the agent | `{"kinds": [7, 9, 1111], "#p": ["<agent pubkey>"]}` |
50+
51+### Delivery
52+
53+Each matching event is one POST with the event's JSON as the body. Nothing is batched. The request carries these headers:
54+
55+| Header | Value |
56+| --- | --- |
57+| `Content-Type` | `application/json` |
58+| `X-Tiny-Callback` | The callback id. |
59+| `X-Tiny-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the request body with the secret. |
60+| `X-Tiny-Relay` | The relay's public URL. |
61+
62+To verify a delivery, compute HMAC-SHA256 over the raw request body with the secret, encode it as lowercase hex and compare it with the value after `sha256=` using a constant-time comparison. Reject the request when they differ. Answer with any 2xx status within 10 seconds; the relay ignores the response body.
63+
64+```js
65+import {createHmac, timingSafeEqual} from "node:crypto";
66+
67+export function verify(body, header, secret) {
68+ const expected = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
69+ return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
70+}
71+```
72+
73+Right before each POST the relay checks that the callback still exists and is not paused and that the key may still read the event, so hiding an event or revoking an agent stops deliveries at once. Events go to a callback one at a time, in the order they arrived as far as possible. A repository state is delivered once its objects have arrived, so a push wakes the agent when the objects can be fetched.
74+
75+### Retries and pauses
76+
77+A response outside 2xx, a timeout or a connection failure counts as a failure. The relay tries the event again after one minute and once more after five, three attempts in all, then drops it. After 20 failures in a row the callback is paused and its status records the reason. A callback is also paused when its key is no longer a member. A successful delivery resets the failure count. Resume a paused callback with `resumecallback`.
78+
79+### Manage callbacks
80+
81+| Method | Effect |
82+| --- | --- |
83+| `listcallbacks` | Lists callbacks with their id, owner, host, filter, state, failure count, last status and last delivery. |
84+| `addcallback {url, filter, secret}` | Registers a callback for the calling key and returns its id and secret once. |
85+| `removecallback <id>` | Deletes the callback. |
86+| `pausecallback <id>` | Stops deliveries until the callback is resumed. |
87+| `resumecallback <id>` | Resumes deliveries and clears the failure count. |
88+
89+Members and agents see and control their own callbacks. The owner and moderators see every callback, with the host but not the path of other keys' URLs, and may pause, resume or remove any of them. Every change is recorded in the audit log.
90+
91+Each key may hold 4 callbacks. The `callbacks` policy field changes the allowance; 0 closes registration for everyone but the owner, who has no limit.
92+
93+The **Manage > Agents** page lists each agent's callbacks on its card with the host, kinds, state and last delivery, and buttons to pause, resume or remove each one. The side panel counts active and paused callbacks. The same controls are available to browser agents and over MCP; see [Browser tools](webmcp.md) and [MCP](mcp.md).
94+
95 ## Discovery
96
97 While at least one agent is active, the relay's information document includes an `agents` capability entry, so clients and other agents can tell that this relay accepts granted agents. The entry carries no agent details.
98diff --git a/docs/mcp.md b/docs/mcp.md
99index 543e354..e124529 100644
100--- a/docs/mcp.md
101+++ b/docs/mcp.md
102@@ -69,6 +69,7 @@ Read tools, which need a key that may read the relay:
103 | `list_agents` | List granted agents with their state, scope and last event, as an owner or moderator. |
104 | `list_jobs` | List long task requests visible to the key with each one's newest feedback status and result. `state` narrows the list to `open`, `done` or `all`; `mine` lists only the key's own requests. |
105 | `read_job` | Read one long task request by `id` with its feedback timeline and results. |
106+| `list_callbacks` | List event callbacks with their host, filter, state and last delivery. Members and agents see their own; the owner and moderators see all. |
107
108 Management tools, which follow the relay's roles:
109
110@@ -87,6 +88,10 @@ Management tools, which follow the relay's roles:
111 | `revoke_agent` | End an agent's grant and remove its role. |
112 | `pause_all_agents` | Pause every active agent. |
113 | `resume_all_agents` | Resume every paused agent. |
114+| `add_callback` | Register an https URL that receives each new event matching a filter, signed with a secret returned once. See [Callbacks](agents.md#callbacks). |
115+| `remove_callback` | Delete a callback by id. |
116+| `pause_callback` | Stop deliveries to a callback until it is resumed. |
117+| `resume_callback` | Resume a paused callback and clear its failure count. |
118
119 Write tools, which publish through the same path as `POST /events`:
120
121diff --git a/docs/webmcp.md b/docs/webmcp.md
122index dfb2758..93e4261 100644
123--- a/docs/webmcp.md
124+++ b/docs/webmcp.md
125@@ -38,4 +38,15 @@ The owner and moderators can also manage [agent grants](agents.md) through the b
126
127 Each control takes the agent's public key and requests a signature. Signing a new grant stays on the **Manage > Agents** page, since the secret key of a generated agent is shown to the person once.
128
129+Four more tools manage [callbacks](agents.md#callbacks), the URLs an agent registers to be woken by new events:
130+
131+| Tool | What it does |
132+| --- | --- |
133+| `tiny.list_callbacks` | Lists callbacks with their id, owner, host, filter, state, failures and last delivery. Members and agents see their own; the owner and moderators see all. Uses the connected signer. |
134+| `tiny.pause_callback` | Pauses a callback by id. Deliveries stop until it is resumed. |
135+| `tiny.resume_callback` | Resumes a paused callback and clears its failure count. |
136+| `tiny.remove_callback` | Deletes a callback by id. |
137+
138+Registering a callback needs the secret shown once to the agent that will verify deliveries, so it is done by the agent itself over MCP or the management API rather than from the browser.
139+
140 Agents that run outside the browser use the same tools over the relay's MCP endpoint. See [MCP](mcp.md).
141diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go
142index bf07727..fac1491 100644
143--- a/internal/daemon/mcp_http.go
144+++ b/internal/daemon/mcp_http.go
145@@ -20,7 +20,7 @@ func (t *Tenant) initMCP() error {
146 t.mcp = &mcp.Server{
147 Tools: tools,
148 Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version},
149- Instructions: "This relay hosts Nostr events, files, Git repositories, chat rooms and a wiki, and admits agents under signed grants. Read tools return the data your key may see; management tools, including the agent controls, follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields. request_decision asks a person for an answer that arrives as a kind 7 reaction. request_job asks for a long task; a serving agent answers it with job_feedback and job_result, and list_jobs and read_job follow the work.",
150+ Instructions: "This relay hosts Nostr events, files, Git repositories, chat rooms and a wiki, and admits agents under signed grants. Read tools return the data your key may see; management tools, including the agent controls, follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields. request_decision asks a person for an answer that arrives as a kind 7 reaction. request_job asks for a long task; a serving agent answers it with job_feedback and job_result, and list_jobs and read_job follow the work. add_callback registers an https URL that is woken with each new event matching a filter, so an agent that cannot hold a connection open still learns of new issues, pushes, room messages and requests.",
151 Authenticate: t.mcpAuthenticate,
152 Challenge: mcpChallenge,
153 Origin: t.mcpOrigin,
154@@ -91,8 +91,8 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
155 "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",
156 "- Headers: `MCP-Protocol-Version: "+mcp.Version+"`, `Mcp-Method` equal to the JSON-RPC method and, for tools/call, `Mcp-Name` equal to params.name. The body's params._meta carries `"+mcp.MetaProtocolVersion+"` and `"+mcp.MetaClientCapabilities+"`.",
157 "- Methods: server/discover, tools/list and tools/call.",
158- "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_jobs and read_job.",
159- "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents and resume_all_agents.",
160+ "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_jobs, read_job and list_callbacks.",
161+ "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, add_callback, remove_callback, pause_callback and resume_callback. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature.",
162 "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000.",
163 "",
164 "## HTTP bridge",
165diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
166index ab37be5..d3aae14 100644
167--- a/internal/daemon/mcp_tools.go
168+++ b/internal/daemon/mcp_tools.go
169@@ -128,6 +128,9 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
170 })
171 add("list_jobs", "List long task requests (NIP-90 job requests) visible to your key, each with its newest feedback status and its result when one exists. state narrows the list to open, done or all; mine lists only your own requests.", mcp.Object(map[string]any{"cursor": mcpText, "limit": mcpLimit, "state": map[string]any{"type": "string", "enum": jobStates}, "mine": map[string]any{"type": "boolean"}}), mcpReads, t.mcpBrowse("browsejobs"))
172 add("read_job", "Read one long task request by event id with its feedback timeline and results.", mcp.Object(map[string]any{"id": mcpHash}, "id"), mcpReads, t.mcpBrowse("browsejob"))
173+ add("list_callbacks", "List event callbacks: id, owner, host, filter, paused state, failures and last delivery. Members and agents see their own; the owner and moderators see every callback. The secret is never listed.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
174+ return t.mcpManage(ctx, call, "listcallbacks")
175+ })
176
177 add("run_job", "Queue an existing job to run now. Read jobs through read_management to check its completion.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
178 return t.mcpManage(ctx, call, "runjob", call.String("id"))
179@@ -153,6 +156,18 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
180 add("send_test_notification", "Send the relay's test notice to the owner's inbox and enabled devices. Owner only.", mcp.Object(nil), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
181 return t.mcpManage(ctx, call, "notifytest")
182 })
183+ add("add_callback", "Register an https URL that receives a POST with each new event matching filter that your key may see. The filter takes kinds (required), authors, #a, #e, #p and #h with at most 8 values each. The answer carries the callback id and, once, the secret used for the X-Tiny-Signature header; keep it. Members and agents may hold up to the relay's callback allowance, 4 by default.", mcp.Object(map[string]any{"url": map[string]any{"type": "string", "description": "An https URL on a public host."}, "filter": map[string]any{"type": "object", "description": "NIP-01 filter subset: kinds, authors, #a, #e, #p, #h.", "minProperties": 1}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "url", "filter"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
184+ return t.mcpManage(ctx, call, "addcallback", call.Arguments)
185+ })
186+ add("remove_callback", "Delete a callback by id. The callback's owner, the relay owner and moderators may do this.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
187+ return t.mcpManage(ctx, call, "removecallback", call.String("id"))
188+ })
189+ add("pause_callback", "Stop deliveries to a callback until it is resumed. The callback's owner, the relay owner and moderators may do this.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
190+ return t.mcpManage(ctx, call, "pausecallback", call.String("id"))
191+ })
192+ add("resume_callback", "Resume a paused callback and clear its failure count.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
193+ return t.mcpManage(ctx, call, "resumecallback", call.String("id"))
194+ })
195 add("pause_agent", "Stop an agent from publishing until it is resumed. The grant stays in place. Requires an owner or moderator key.", mcp.Object(map[string]any{"agent": mcpPubKey}, "agent"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
196 return t.mcpManage(ctx, call, "pauseagent", call.String("agent"))
197 })
198diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js
199index d083fe7..eb944d2 100644
200--- a/internal/webui/webmcp.js
201+++ b/internal/webui/webmcp.js
202@@ -141,6 +141,8 @@
203 object(), reads, (input, signal) => manage("listagents", [], signal));
204 register("tiny.read_agent", "Read one agent's grant and its ten newest events. Requires a signed-in owner or moderator session.",
205 object({agent: pubkey}, ["agent"]), reads, (input, signal) => query("browseagent", input, signal));
206+ register("tiny.list_callbacks", "List event callbacks: id, owner, host, filter, paused state, failures and last delivery. Members and agents see their own; the owner and moderators see every callback. Uses your connected signer.",
207+ object(), reads, (input, signal) => manage("listcallbacks", [], signal));
208
209 function open(path) {
210 const url = new URL(local(path), location.href);
211@@ -228,6 +230,12 @@
212 object({agent: pubkey}, ["agent"]), "resumeagent", input => [input.agent]);
213 control("tiny.revoke_agent", "Revoke an agent's grant. The agent loses its role at once; only a new grant restores it.",
214 object({agent: pubkey}, ["agent"]), "revokeagent", input => [input.agent]);
215+ control("tiny.pause_callback", "Pause an event callback by id. Deliveries stop until it is resumed. The callback's owner, the relay owner and moderators may do this.",
216+ object({id}, ["id"]), "pausecallback", input => [input.id]);
217+ control("tiny.resume_callback", "Resume a paused event callback by id and clear its failure count.",
218+ object({id}, ["id"]), "resumecallback", input => [input.id]);
219+ control("tiny.remove_callback", "Delete an event callback by id.",
220+ object({id}, ["id"]), "removecallback", input => [input.id]);
221 control("tiny.backup_now", "Queue a backup of relay data. Inspect jobs and backups to check completion.", object(), "backupnow", () => []);
222 control("tiny.dump_now", "Queue an event export. Inspect jobs and dumps to check completion.", object(), "dumpnow", () => []);
223 control("tiny.set_connections", "Replace the relay's connection list. Read the current list before editing it.",
224diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs
225index 199d513..148e801 100644
226--- a/scripts/webmcp.test.mjs
227+++ b/scripts/webmcp.test.mjs
228@@ -238,6 +238,28 @@ test("agent tools list through the signer, read through the session and control
229 }
230 });
231
232+test("callback tools list through the signer and control by id", async () => {
233+ const calls = [];
234+ const {tools} = await browser({path: "/r/work/manage/agents", signedFetch: async (url, method, body) => {
235+ calls.push({url, method, body: JSON.parse(body)});
236+ return new Response(JSON.stringify({result: [{id: "cb-1", host: "hooks.example", paused: false}]}));
237+ }});
238+ const list = tools.get("tiny.list_callbacks");
239+ assert.equal(list.annotations.readOnlyHint, true);
240+ const listed = await list.execute({});
241+ assert.equal(listed.structuredContent.result[0].host, "hooks.example");
242+ assert.equal(calls[0].url, "/r/work/manage/rpc");
243+ assert.deepEqual(calls[0].body, {method: "listcallbacks", params: []});
244+ assert.equal(tools.get("tiny.add_callback"), undefined);
245+ for (const [name, method] of [["tiny.pause_callback", "pausecallback"], ["tiny.resume_callback", "resumecallback"], ["tiny.remove_callback", "removecallback"]]) {
246+ const tool = tools.get(name);
247+ assert.equal(tool.annotations.consequentialHint, true);
248+ assert.deepEqual([...tool.inputSchema.required], ["id"]);
249+ await tool.execute({id: "cb-1"});
250+ assert.deepEqual(calls.at(-1).body, {method, params: ["cb-1"]});
251+ }
252+});
253+
254 test("wiki tools read pages and merge requests and open pages in the tenant", async () => {
255 const requests = [];
256 const {tools, sandbox} = await browser({path: "/r/work/wiki", fetch: async url => {

About

ownerc01503e8035f
visibilitypublic
headd96b31a2f758

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs