1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# 0005 — MCP watches: push-driven change notifications
Status: accepted
Date: 2026-11-03
## Context
Users want to tell the agent "watch X and tell me when it changes". X may be
Kubernetes objects, a file, a queue, or any other thing an MCP server
exposes.
Constraints:
- **No polling, anywhere.** Not in asyncai, not in the MCP server, not in
model-written code. Every hop is pushed.
- **Generic.** It must work with any MCP server that follows a documented
pattern, not only the kcp servers.
- **Code mode.** The model sets up watches from `run_code` (ADR 0004) and
may filter events with Python.
MCP already has the push primitive. A server marks content as a *resource*,
the client subscribes to the resource URI, and the server sends
`notifications/resources/updated` when it changes. MCP 2026-07-28 carries
subscriptions on a long-lived `subscriptions/listen` request; rmcp 3.8
implements it.
## Decision
```mermaid
sequenceDiagram
participant S as change source (k8s watch, inotify, ...)
participant M as MCP server
participant A as asyncai WatchManager
participant P as Python filter (Monty)
participant G as agent turn
A->>M: subscriptions/listen {resourceSubscriptions:[uri]}
A->>M: resources/read uri (baseline)
S-->>M: change (pushed)
M-->>A: notifications/resources/updated {uri}
A->>M: resources/read uri
A->>A: extract events (event-log seq or state diff)
A->>P: on_event(event) per event
P-->>A: notice text or None
A->>A: dedupe per object, debounce 3 s (max 15 s)
A->>G: <watch-event> turn
```
### Server contract: what an MCP server must expose
A server is watchable when it does all of the following.
1. **Capability.** `capabilities.resources.subscribe = true`.
2. **Discoverable URIs.** Each watchable thing is listed by `resources/list`
(fixed URIs) or `resources/templates/list` (RFC 6570 templates). Each
gets a description that ends with a code-mode example, e.g.
`Example: await watch_start(name="cm", uri="kcp-events://watch/root:app/configmaps?namespace=*")`.
3. **Subscriptions.** MCP **2026-07-28** (`server/discover` lifecycle) with
`subscriptions/listen` accepting `resourceSubscriptions`. Legacy
`resources/subscribe` is not supported: it only exists on pre-2026
protocol versions, and asyncai does not use legacy paths for watches.
4. **Push.** The server sends `notifications/resources/updated {uri}` after
each change. Notifications carry no payload, may be coalesced, and must
be idempotent.
5. **Native change source.** The server learns about changes by push:
Kubernetes watch, inotify, database `LISTEN/NOTIFY`, webhooks, or its
own write path. A server that polls internally does not meet this
contract.
6. **Read shape.** `resources/read uri` returns JSON text in one of two
shapes.
| Shape | `mimeType` | Body | asyncai behavior |
|---|---|---|---|
| **state** | anything else (`application/json` recommended) | current state | diffs against the previous read |
| **event-log** | `application/vnd.asyncai.events+json` | `{"events": [{"seq": int, "type": str, ...}]}` | delivers events with `seq` greater than the last seen |
Event-log events SHOULD carry `object` and, for `MODIFIED`, `old` (the
previous object). asyncai then computes `changed` and drops no-op updates.
- **State resources** suit small state, such as config, a document, or a
set of names. asyncai diffs:
- JSON lists by item key, the first present of `metadata.uid`, `uid`,
`id`, `metadata.name`, `name`; otherwise the whole item;
- JSON objects by top-level key;
- anything else as whole text.
- **Event-log resources** suit large or fast-changing collections. The
server keeps a bounded ring of recent events with a monotonically
increasing `seq`. Reads are stateless, so many readers can share one log.
When the oldest `seq` in a read is beyond the last seen + 1, asyncai
emits a `GAP` event, so filters know events were missed.
- **Failures.** An event-log server reports source failures in-band as
`{"seq": n, "type": "ERROR", "message": "..."}`. Any server may end the
subscription; asyncai then resubscribes (A3).
- **Writes stay tools.** Resources are read-only.
- **No caching.** Do not set `ttlMs` on watched resources. MCP clients
may serve a cached read, which would hide the change just announced.
### Client (asyncai)
1. **Routing.** A URI goes to the server that lists it exactly, otherwise
the server whose template matches, otherwise the first server that
advertises `resources.subscribe`.
2. **Subscribe.** asyncai connects with `server/discover` (MCP 2026-07-28)
and opens one `subscriptions/listen` per watch. A server that is not on
2026-07-28 can still serve tools: asyncai falls back to `initialize`
for the connection. Watching it fails with an error naming the
required version.
3. **Resubscribe.** When a subscription ends, asyncai resubscribes with
exponential backoff (1 s, 2 s, 4 s, 8 s, 16 s). After 5 failures the
watch fails and the agent is told. This is reconnect-on-failure, not
polling: nothing runs while a subscription is healthy.
### Code-mode built-ins
These are present when at least one MCP server is connected.
| Function | Returns |
|---|---|
| `list_resources()` | `[{"uri", "template", "name", "description", "example", "mime_type", "server", "subscribable"}]`; `uri` is the template when `template` is true; `example` is the description's `Example:` line |
| `read_resource(uri=...)` | parsed JSON, else `str` |
| `watch_start(name=..., uri=..., on_event=None)` | `{"name", "uri", "shape"}` |
| `watch_stop(name=...)` | `bool` |
| `watch_list()` | `[{"name", "uri", "shape", "events", "notices"}]` |
```python
await watch_start(
name="cm",
uri="kcp-events://watch/root:app/configmaps?namespace=*",
on_event='''
m = event["object"]["metadata"]
f'{event["type"].lower()} {m["namespace"]}/{m["name"]}' if event["type"] in ("ADDED", "DELETED") else None
''',
)
```
**Event dict.** `on_event` sees `event`, a dict with:
| Key | Value |
|---|---|
| `watch`, `uri` | watch name and URI |
| `type` | event-log `type`; state diffs: `ADDED` / `DELETED` / `MODIFIED` / `CHANGED`; `GAP` |
| `old` | previous object for `MODIFIED` (event-log: from the server; state: from the diff); `None` otherwise |
| `changed` | `MODIFIED` with `old`: sorted dotted paths that differ, depth ≤ 3, ignoring `metadata.resourceVersion` / `generation` / `managedFields` |
| other keys | event-log: the server's event fields. State: `key`, plus `new` (or `item`) |
A `MODIFIED` event whose `changed` is empty is dropped before the filter.
**Filter.**
- `on_event` is Python source. It runs once per event, in the watch's own
REPL scope, so variables persist across events.
- That scope's catalog is read-only: MCP tools without mutating
annotations, plus `read_resource` and `list_resources`. A filter cannot
write and cannot start watches.
- If `on_event` leaves `None` (and prints nothing), the event is dropped.
Otherwise the value becomes one notice line; a `str` is used as-is, other
values as compact JSON.
- Without `on_event`, the default line is `TYPE namespace/name` for
Kubernetes-like objects, `TYPE key` for state diffs, and compact JSON
otherwise. `MODIFIED` adds up to three changes:
`MODIFIED bid-1 (status.phase: Deciding→Published)`.
- **Dedupe.** A line equal to the last line for the same object (item key)
is dropped. Identical lines in one notice collapse to `line ×N`.
**Validation.** `on_event` must be source text: a function object (it
crosses the sandbox as its `repr`) is rejected. `watch_start` dry-runs
`on_event` on a sample event. A `SyntaxError` or `NameError` fails the call
in the model's turn, and no subscription is made; missing keys on the
sample are fine. Live, the model passed `on_event=fmt`; before this check
every event would have failed.
**Fail fast.** The baseline read happens inside `watch_start`, so a server
that rejects the URI fails the call. Servers should make the first read of
a watched URI wait for their source to start (a signal, not a poll) and
return its error, as the kcp server does. The model can then call
`watch_start` first and learn from the error, instead of exploring.
**Coalescing.** A state resource diffs against the previous read. Changes
that land before one read become one diff, e.g. two quick writes produce
the net change. Event-log resources deliver every event, by `seq`.
**Debounce.** Trailing: each new line moves the flush to 3 s later, capped
at 15 s after the burst's first line. One notice holds at most 20 lines
(`+N more`). A controller's burst (`ADDED`, several `MODIFIED`) becomes one
agent turn.
**Persistence.** With `--watch-state PATH`, watch specs (`name`, `uri`,
`on_event`) are written on every start and stop (temp file + rename) and
restarted at startup. One `restore` notice lists them; changes made while
the agent was down may be missing (baselines are fresh). A spec that can
never start (bad `on_event`, over a limit) is dropped from the file. A
transient failure (server down, subscribe failed) keeps the spec saved and
listed by `watch_list()` with `restore_failed: true` until the agent starts
it again or stops it.
### Delivery to the agent: core integration
Notices enter the core the same way user input does. They do not form a
side channel.
```mermaid
flowchart TD
W["WatchManager (tokio tasks)"] -->|WatchNotice| C["CLI loop: session MonitorEvent + route"]
C -->|Notice{source,text}| N["Orchestrator notice stream (WorkSet: MainWork::Notice)"]
I["stdin / atirc input"] --> In["Orchestrator input stream"]
N --> R["WaitingEngine: AddMessage → RunThread"]
In --> R
R --> RT["AgentRuntime"]
RT -->|idle| Run["new run"]
RT -->|run active| Q["append message + queued_run"]
Q -->|run completes| Run
```
- **Orchestrator.**
- The reactor multiplexes a fifth `WorkSet` stream, `MainWork::Notice`
(`main.system.notice_stream` in NDJSON output), beside input, waiting
rules, agent events, and the runtime.
- `OrchestratorHandle::notice_tx` accepts `Notice { source, text }`. Any
background source can use it; watches are the first.
- A notice goes through the same waiting rules as input:
`AddMessage(role=user)` then `RunThread`.
- **Coalescing comes from the runtime.** A notice that arrives while a run
is active is appended to the thread and sets `queued_run`. When the run
completes, one follow-up run sees every appended message. N notices
during a run produce one extra run, not N.
- **Reply routing.** The CLI tracks inputs that no run has claimed yet,
with the reply channel for each. On `new_thread_run_created`, the new
run claims all of them and replies go to the most recent claimed
channel. Notices use the channel of the last addressed atirc message,
otherwise the first `--channel`. This replaces per-input FIFO
bookkeeping, which drifted whenever inputs coalesced.
- **Shutdown.** A loop is idle when no input is unclaimed and no run is
active.
- **Filters run concurrently with turns.** Each watch has its own REPL
scope (its own pool worker or engine thread), so filtering never blocks
a model turn, and a slow turn never blocks event intake.
A notice becomes this message:
```text
<watch-event name="cm" uri="kcp-events://watch/root:app/configmaps?namespace=*">
added default/final-check
</watch-event>
This is a watch notification, not a user message. Tell the channel only if the change matters to what was asked; otherwise reply exactly NO_REPLY.
```
- **`NO_REPLY`.** A reply that is empty, or has a line that is only
`NO_REPLY`, publishes nothing (`reply.suppressed`). Models rarely emit an
empty reply; a token works. Prompts use it for chatter from other
agents too.
- **atirc mode.** The reply goes to the channel of the last addressed
message, otherwise the first `--channel`.
- **Interactive mode.** The output is printed as usual.
- **Session.** The notice is a `MonitorEvent` (queue-operation +
attachment, ADR 0002) with task id `watch-<name>` and description
`watch <name> <uri>`, never a user record. Its note is the instruction
above, not Claude Code's PushNotification sentence.
### Limits
| Limit | Default |
|---|---|
| watches per agent | 5 |
| filter execution per event | 2 s |
| filter tool calls per event | 10 |
| events processed per update | 100 |
| lines per notice | 20 |
| debounce (quiet) / max wait | 3 s / 15 s |
| resubscribe attempts | 5 (1 s → 16 s backoff) |
### Logging (NDJSON)
Each event below is emitted once for the case it names:
| Event | Emitted when |
|---|---|
| `watch.started` | a watch is created |
| `watch.subscribed` | a `subscriptions/listen` subscription is established |
| `watch.event` | each event is processed; the log records whether the filter dropped or kept it |
| `watch.notice` | a notice is sent to the agent |
| `watch.resubscribing` | a subscription ended and asyncai is reconnecting |
| `watch.failed` | the watch gave up after its resubscribe attempts |
| `watch.stopped` | a watch is removed |
| `watch.restored` | `--watch-state` watches restarted at startup |
| `reply.suppressed` | a `NO_REPLY` reply was not published |
`watch.notice` is logged always. The rest are logged with `--verbose`.
## Reference servers
### kcp: event-log resources
```text
kcp-events://watch/{workspace}/{resource}{?namespace,labelSelector,fieldSelector,kcp}
```
- The server keeps one watcher per URI, shared by all subscribers and
stopped when the last one leaves. Each watcher works like this:
1. **Seed:** `kubectl get <resource> -o json` records known objects
(`uid` → `resourceVersion`). The first read of the URI awaits this
(up to 30 s); a failed seed fails the read with kubectl's error plus
`did you mean: ...`.
2. **Watch:** stream
`kubectl get <resource> -w --output-watch-events -o json --request-timeout=0`.
The output is parsed incrementally as concatenated JSON. `ADDED`
events for already-known `uid` + `resourceVersion` pairs are
suppressed. `metadata.managedFields` is stripped.
3. **Ring:** 256 events with `seq`; `MODIFIED` carries `old` (previous
known object). Each new event triggers
`notifications/resources/updated`.
4. **Reconnect:** when the stream ends (server timeout, token expiry,
network), the watcher waits with exponential backoff, re-lists,
emits synthetic `ADDED` / `MODIFIED` / `DELETED` events for anything
that changed in the gap, then watches again.
- Credentials:
- the read-only server re-mints its workspace token on every reconnect;
- the read-write server uses the admin kubeconfig.
- The read-only ClusterRole already grants `watch`.
- `kcp-api://{workspace}/resources{?kcp}` is a plain readable resource
(same JSON as `get_api_resources`), not watchable.
### asyncai-mcp-simple: state resource
- `memo://notes` is a JSON object.
- The `memo_set(key, value)` tool changes it and notifies listeners.
## Invariants
1. No component polls. Waits are subscriptions, stream reads, debounce
timers after an event, or backoff after a failure.
2. A watch filter can neither mutate state nor start watches.
3. Watch notices reach the model as turns and the session as monitor
events, never as user records.
4. A watch never outlives the agent process. Dropping the executor cancels
every subscription. `--watch-state` restarts specs, not subscriptions.
5. Event-log delivery is at-most-once per `seq`; gaps are reported as
`GAP`.
## Test plan
- **Unit:**
- state diff for lists, objects, and text;
- event-log `seq` and `GAP`;
- URI template matching;
- kcp URI parsing;
- incremental JSON stream parsing with split chunks;
- kcp dedupe and reconcile;
- default notice text, `old` / `changed`, no-op `MODIFIED` dropped;
- debounce batching.
- **Watch manager:** per-object dedupe, trailing debounce and `max_wait`,
`--watch-state` round trip, `NO_REPLY` publishes nothing.
- **Fake `ResourceSource`:**
- start → push → notice;
- filter drop and transform;
- the filter is denied mutating tools;
- `watch_stop`;
- the 5-watch limit;
- `list_resources` / `read_resource`.
- **Simple server over stdio:** `memo_set` → state-diff notice through
`subscriptions/listen`.
- **kcp server over stdio:** a fake `kubectl -w` tails a control file. The
test appends an event → notification → `read_resource` returns it with
`seq`; a duplicate `ADDED` is suppressed.
No test sleeps to wait for a condition. Tests await the notification or
notice channels with a timeout.
## Live verification
```bash
asyncai atirc -c '#alice' --verbose \
--mcp-stdio asyncai-mcp-simple \
--mcp-stdio socialweb-computer-kcp-readonly 2>&1 | jq -c 'select(.event|startswith("watch."))'
```
```bash
ATIRC_TOKEN=$(atirc token) atirc pub -c '#alice' \
'alice: watch configmaps in root:app (all namespaces) and tell me when one appears or disappears'
```
```bash
K=/var/lib/socialweb-computer/swc-bidder/handles/<hash>/kcp/admin.kubeconfig
sudo -u swc-bidder kubectl --kubeconfig $K --server https://127.0.0.1:<port>/clusters/root:app \
-n default create configmap watch-probe --from-literal=a=b
```
Expected: `watch.notice`, a new turn, and a channel post naming
`default/watch-probe`.
## Live results (2026-11-03, local 27B model, real kcp)
| Step | Result |
|---|---|
| "watch configmaps in root:app" | `watch_start` with a model-written `on_event`; `event-log` shape |
| external `kubectl create configmap watch-probe` | `watch.notice` ~2 s later (debounce), one turn, channel post |
| broken `on_event` (SyntaxError) | notice reported the failure; the model replaced the watch in the next turn (now caught at `watch_start`) |
| external `kubectl delete configmap watch-probe` | `DELETED default/watch-probe` → 1-round turn, no tool calls, post |
| generic `memo://notes` (simple server), two writes during one turn | one notice `ADDED owner`, `MODIFIED todo` → one follow-up turn |
## Live results after session review (2026-11-03)
| Step | Result |
|---|---|
| model passes `on_event=fmt` (function) | stored as a repr, accepted (bug) → now rejected at `watch_start`; restore of such specs fails, is reported, and drops them from `--watch-state` |
| restart with 3 good saved watches | `watch.restored` 3/3; restore notice → 1 round → `NO_REPLY` (`reply.suppressed`) |
| vague "can you set up monitors for bidder compute contract events?" with `--prompt atirc-socialweb-computer` | 3 rounds, 4 watches (bidder, bids, computevms, compute statesyncs via `labelSelector`) from the prompt's recipe; before: 9 rounds, no watch |
| `On Start` "set up monitors ..." with no user message, empty state | 4 rounds: checked the bidder exists, started 3 watches, one summary post |
| restart: restore notice + `On Start` | before: two turns, the same summary posted twice → restore notice folded into the `On Start` turn: one turn, 2 rounds, no duplicate watches |
| real RFP from a requester (ComputeContract → our bidder), 2 cycles | phase lines: Bid ADDED → Deciding → Published → Won; StateSync Delivering → Acked; ComputeVM Pending → Preparing → Booting → Running; release: Running → Releasing, DELETED. Posts: bid won (requester), VM running (cpu, memory, disk, guest IP), VM released |
| same, findings fixed | DELETED rendered `None -> Releasing` and was suppressed (recipe prints `gone`; prompt says post it); truncated arrays read the same on both sides (path only now); the model ran `NO_REPLY` as code (now ends the turn); an uncapped local model decoded 71 486 tokens (now `max_tokens` 8192, 600 s timeout) |
| "watch configmaps in root default; only create/delete" | `watch_start` in the first `run_code`, 3 rounds |
| create + 3 patches + delete | one `ADDED` notice (patches dropped by the filter), one `DELETED` notice; each 1 round, one-line post |
| follow-up question on data from the previous turn | 0 MCP calls: the model reused a variable from the scope note |
| `atproprecords` typo | `did you mean: atprotorecords, ...` → fixed in the next round; reply led with the correction, so `prompts/atirc.md` now says answer first, correction after → `- association` then the note |
## Consequences
- Any MCP server becomes watchable by exposing resources and pushing
`updated` notifications. asyncai needs no server-specific code.
- State resources cost one read per change. Large collections should use
event-log resources.
- A notice costs one model turn. Filters and debounce keep that rate down.
- Watches are in memory unless `--watch-state` is set; restored watches
start from a fresh baseline.
## Alternatives considered
- **Polling with diffs.** Rejected: the user requirement is no polling,
and it wastes requests between changes.
- **Streaming events in tool results** (MCP progress notifications on a
long-running tool call). This ties a watch to an open tool call, and
tools are request/response. Resources plus subscriptions are the MCP
primitive for change feeds.
- **Notification payloads** (custom notifications carrying events). These
are not standard. `updated` + `read` works with every MCP client.