A subject is the string that says what a subscription watches. onLocal() and onPersistent() take the same subjects and deliver the same events.
fs:<path or uid>[:<op>]
kv:<key>
kv:<appId>:<key>
kv:<handle>:<key>
notif:<audience>
notif:<appId>:<audience>
/alice/Documents), home-relative (~/Documents), or relative (inbox or ./inbox). A relative path resolves the same way puter.fs paths do, under your app's AppData folder in an app and under home otherwise. A folder covers everything under it, at any depth.uid of a file or folder, to follow one node wherever it moves. A relative name shaped like a uid is read as one. Write ./<name> to mean the path.add, write, move, remove, meta. Leave it off to get all of them. No change emits meta.await puter.events.onLocal('fs:~/Documents', handler); // everything under Documents
await puter.events.onLocal('fs:inbox', handler); // inbox in your app's folder
await puter.events.onLocal('fs:~/Documents/notes.txt:write', handler); // one file, writes only
await puter.events.onLocal('fs:~/Pictures/*.png', handler); // wildcard within one segment
await puter.events.onLocal('fs:~/Projects/**/build.log', handler); // across folders
In a pattern, * matches within one path segment, ** crosses folders, and ? matches one character. A subject can use one * per segment and one ** in total. More is refused with invalid_subject_pattern.
A subject can name a path that isn't there. The subscription anchors on the nearest existing folder and matches the rest of the path, so it behaves as if the path already existed. You get the event when the path appears and, for a folder, every change inside it afterwards. A pattern still matches only the paths it names.
// Nothing at this path yet. The handler runs when it's created.
await puter.events.onLocal('fs:~/Documents/inbox/trigger.json:add', ({ event }) => {
process(event.path);
});
When the node a subscription is attached to is deleted, what happens depends on how it was made:
remove and then ends with reason: 'anchor_deleted'. Subscribe again to watch the path once it's back. onLocal() reports this through onError. A persistent subscription is deleted, and its holder gets an app.events.ended notification on notif:app-user, which you can read with fetch().A kv: subject watches your app's key-value store:
await puter.events.onLocal('kv:cart', ({ event }) => refresh(event.key)); // exactly the key `cart`
await puter.events.onLocal('kv:cart*', handler); // every key starting with `cart`
Exact by default, add
*to widen.kv:cartmatches onlycart, andkv:cart*matches every key starting withcart.puter.kv.list()works the other way and always matches a prefix.
Only a trailing * is allowed. A * anywhere else, or a ?, is refused with invalid_kv_pattern.
The segment after kv: is read as an app id when more segments follow it, so a key that contains : needs the full three-part form:
await puter.events.onLocal('kv:orders:pending', handler); // app `orders`, key `pending`
await puter.events.onLocal(`kv:${puter.appID}:orders:pending`, handler); // your app, key `orders:pending`
The returned anchor names the app whose store is watched, and subject stays as you passed it.
A delivery names the key, not its value. Pass includeValue: true to get the new value as event.value. It's null on a del, and an expire never carries one.
await puter.events.onLocal('kv:cart', ({ event }) => render(event.value), { includeValue: true });
The value is left out, and you read the key yourself, when:
watch.Watching another app's key-value data takes the same consent as reading it. The app must allow data sharing, and the user must grant your app app-data:<appId>:kv:read. Both are checked when you subscribe and on every delivery. Where cross-app watching is off, the subject is refused with events_cross_app_disabled.
Entries the other app wrote with disableSharing are never delivered. Marking a key private produces no event, so a value you already have for it may be stale.
A kv:<key> subject always means your own data. When another user shares part of theirs with you using puter.kv.share(), you watch it through the share's handle, which goes where an app id would go. For a walkthrough, see Send Live Updates to Other Users.
// The owner shares one workspace with another user.
await puter.kv.share(`workspace:${workspaceId}:`, 'bob', 'watch');
// Bob finds the share and watches every key written in that workspace.
// `event.key` is relative to the handle: `messages:1`, not `workspace:<id>:messages:1`.
const { items } = await puter.kv.listShared();
const { handle } = items[0];
await puter.events.onLocal(`kv:${handle}:*`, ({ event }) => render(event.key));
watch, read, or write access, and all three can subscribe. Through the handle, a watch holder can also list the keys without their values with puter.kv.list(). A read or write holder can also read the values with puter.kv.get() and puter.kv.list(), to catch up after a gap or to read a value an event left out. puter.kv.share() lists what each level allows.subject and key in their deliveries are relative to the handle, and kv:<handle>:messages:* narrows to part of the shared data. A subject with no key after the handle, a key starting with :, or a key with a .. segment is refused with invalid_kv_handle_key.read. includeValue works through a read or write share, with the same limits as your own data. At watch, deliveries leave out the value even when the subscription asked for it. The level is checked on every delivery, so raising a share to read starts values on subscriptions already made, and lowering it to watch stops them, including for events a queue subscription is still holding.disableSharing is never delivered through a handle.write share, reach the owner's subscriptions and every holder's. On a holder's own write through the handle, self is true, so self && !viaWorker skips your own changes. See the event.subject_does_not_exist.When a share stops, deliveries through its handle stop. Every persistent subscription on it is suspended with permission_revoked and its backlog is dropped. Every onLocal() subscription on it ends, and its onError gets subscription_ended with permission_revoked. A connection in another region can take up to 20 minutes to be told, and gets no deliveries through the handle in the meantime. If another share to the same holder still covers its keys, live deliveries through the stopped handle can continue for up to 60 seconds.
A share stops when the owner revokes it with puter.kv.unshare(), when the holder removes their own access the same way, when the app whose data it is gets deleted, when the owner removes that app along with its data, and when the holder deletes their account. While a share can't be checked, for example while the owner's account is suspended, deliveries through it pause instead. A queue subscription keeps its backlog and gets it once the share can be checked again.
Where share handles are turned off on the server, handle subjects fail with events_kv_handles_disabled.
A notif: subject watches part of the account's notification mailbox:
notif:app-user: your app's notifications for the user, such as the ones it sends with notify().notif:developer: notifications about an app the user owns, such as a finished worker deploy.notif:account: notifications about the account itself, such as files shared with it. Only a client acting as the account sees them. Subscribing from an app or a website is refused with subject_does_not_exist.The two-segment form is expanded to your app's id for you. Outside an app, notif:app-user and notif:developer cover every app. Notifications are stored, so you can also read missed ones with fetch(), which lists the notification types and their fields.
Subscribing takes the same access as reading. A subject you can't read and a subject that doesn't exist both fail with subject_does_not_exist, so the error doesn't reveal which. Access is checked again on every delivery, so a change you can no longer read isn't delivered.
When access is withdrawn, such as a file share revoked or your app uninstalled, a persistent subscription that relied on it is suspended with permission_revoked, and its holder gets an app.events.ended notification. Access withdrawn on a parent folder of what it watches stops its deliveries without suspending it. A session subscription on files stays open and gets nothing until access is back. A session subscription through a key-value share handle ends.
The handler is called with { event }. A file change carries:
| Field | Type | Description |
|---|---|---|
id |
String | Unique id for the event. |
subject |
String | The node it happened to, as fs:<uid>:<op>. Not the subject you subscribed with. |
op |
String | add (a create, or a copy's destination), write, move (a move or a rename), or remove. |
uid |
String | The uid of the node that changed. |
path |
String | The path of the node that changed. In another user's files, it's a masked path, /<owner>/<uid>/<name>/…, where <uid> and <name> are those of the node the subscription is attached to. |
from |
String | On a move, the path the node left, masked the same way. Only present when the subscription was watching that location. |
self |
Boolean | true when the account holding the subscription made the change. Mail filed into the account's ~/.mail folder is always false, including mail the account sent to itself. |
viaWorker |
Boolean | true when the change came from server-side code running as an account, such as a worker using me.puter or an events handler running in the events worker. A worker using user.puter acts as the user who called it, so its changes are that user's own, with viaWorker set to false. A handler running in a client writes as that client, also with viaWorker set to false. To skip your own changes, check self && !viaWorker, which still lets through the ones a worker made as your account. |
ts |
Number | When it happened, in milliseconds since the epoch. |
seq |
Number | Position within one dispatch, when a change goes to several subscriptions. |
A key-value change carries key instead of uid and path, and different ops:
| Field | Type | Description |
|---|---|---|
id |
String | Unique id for the event. |
subject |
String | kv:<appId>:<key>, naming the key that changed. Through a share, kv:<handle>:<key>. |
op |
String | set for a write, del for a removal, expire when only the key's lifetime changed. |
key |
String | The key that changed. Through a share, relative to the handle. |
value |
Any | The new value, or null on a del. Only with includeValue, and only within the limits above. Never on an expire. |
self |
Boolean | As above. |
viaWorker |
Boolean | As above. |
ts |
Number | As above. |
seq |
Number | As above. |
A notification carries the fields listed on fetch().
Events never say who made a change. On a shared folder, that would tell every subscriber who else has access.
puter.kv.flush() delivers nothing, since the keys a flush can list aren't reliably the keys it removed.
Writes to the same subject within 250 ms arrive as one event carrying the latest state, so a multipart upload or a save loop is one delivery rather than one per write. All of an app's notifications share one subject, so notifications sent within 250 ms of each other arrive as the last of them. fetch() still returns every one.
Limits never make a subscription fail. Instead, a gap marker takes the place of what was dropped. It's an event with op: 'gap', a reason, and no uid, path, or key. Treat it as a signal to re-read what you're watching, never as "nothing changed".
reason |
Cause |
|---|---|
matched_subscription_limit |
The event matched more subscriptions than one event may reach. |
filter_evaluation_limit |
The event had more subscription filters to check than one event may check. |
delivery_rate_limit |
This subscription went over its per-minute delivery rate. |
backlog_overflow |
A persistent subscription's undelivered backlog was full, so the oldest deliveries were dropped. |
handler_rejected |
A queue subscription's handler threw an error that isn't retried. |
suspended_backlog_expired |
A persistent subscription stayed suspended past the time its backlog is kept. |
reconnect |
A session subscription's connection dropped and came back. Changes made while it was down weren't delivered. Sent once, after the subscription is restored. |
await puter.events.onLocal('fs:~/Documents', async ({ event }) => {
if (event.op === 'gap') return refreshEverything();
apply(event);
});
See Rate Limits and Quotas for the numbers behind each limit.