| 1 | diff --git a/docs/agents.md b/docs/agents.md |
| 2 | index 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. |
| 98 | diff --git a/docs/mcp.md b/docs/mcp.md |
| 99 | index 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 | |
| 121 | diff --git a/docs/webmcp.md b/docs/webmcp.md |
| 122 | index 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). |
| 141 | diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go |
| 142 | index 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", |
| 165 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 166 | index 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 | }) |
| 198 | diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js |
| 199 | index 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.", |
| 224 | diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs |
| 225 | index 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 => { |