Peek workers — features that run without a machine #
A worker is a Peek feature that runs on remote compute instead of inside the Electron app:
no window, no display, no user present, no local datastore.sqlite. It fetches, polls, waits,
and writes; an agent can call its commands and read the results. The point is the work that a
sleeping laptop stalls — a feed that should have been polled at 3am, an API that should have
been checked hourly, a task queue an agent wants to reach when no desktop is running.
This is a design document. Nothing here is built. It describes what a worker is, what it can and cannot do, which existing pieces it reuses, and which pieces do not exist yet.
Contents #
- What already exists, and what a worker actually changes
- What a worker is
- The manifest
- Lifecycle
- The API surface a worker gets, and what it loses
- How a worker reaches Peek data
- How a worker's writes get back to devices
- How an agent addresses and invokes a worker
- What the runtime has to provide
- Runtime evaluation
- Recommendation (superseded — see
docs/worker-runtime-portability.md) - Deliberately out of scope
- Open questions
1. What already exists, and what a worker actually changes #
The load-bearing observation is that a feature never touches Electron directly. It touches
window.app, and every method on window.app is a message.
apps/desktop/main/tile-preload.cts buildAPI() builds that object and exposes it through
contextBridge.exposeInMainWorld('app', api). Every capability method on it is one line of the
same shape: an ipcRenderer.send or ipcRenderer.invoke on a channel named
tile:<domain>:<verb>, carrying an opaque capability token minted for that tile. More than
thirty domains exist today — tile:datastore:*, tile:pubsub:*, tile:network:*,
tile:window:*, tile:commands:*, tile:settings:*, and the rest.
On the other side, apps/desktop/main/tile-ipc-gate.ts registerTileIpc() / runPipeline() is
the single chokepoint every one of those frames passes through: it checks that the channel is
registered, that the sending renderer is the one the token was bound to, that the payload
matches a schema, that the manifest granted the capability the channel requires, that the tile's
lifecycle state permits the call, and that the sender's role is allowed on the channel. Handlers
then live in apps/desktop/main/tile-ipc.ts with per-operation enforcement in the
tile-*-enforcement.ts modules.
That is a capability-scoped RPC protocol. Electron supplies the transport and nothing else.
So a worker runtime does not reimplement Peek. It reimplements one transport and one host:
- the transport under
window.app— instead ofipcRendererto a local main process, a client to the remote Peek backend; - the host that loads the feature module and runs it — instead of a
BrowserWindownavigating topeek://<id>/background.html, a plain JS runtime importingbackground.js.
Everything else is already transport-agnostic in shape: the manifest schema
(apps/desktop/main/tile-manifest.ts TileManifest, validateTileManifest()), the capability
grant (resolveCapabilities() → CapabilityGrant), the command dispatch protocol (pubsub topic
cmd:execute:<name>), and the ready/shutdown lifecycle (apps/desktop/main/tile-lifecycle.ts
over the FSM in tile-fsm.ts).
What does not exist today, and is the honest size of this project:
- No feature has ever run outside a Chromium renderer. Every tile, including every one whose
manifest calls it
background, is a fullBrowserWindowcreated byapps/desktop/main/tile-launcher.tscreateTileBrowserWindow()— hidden (show: false), sandboxed, but a renderer. There is noutilityProcess, noworker_threads, no child-process path inapps/desktop/main/. "Lazy" defers when the window is created, never whether (apps/desktop/main/tile-lazy.tsloadLazyTile()calls the samelaunchTile()). apps/desktop/main/headless-policy.tsis not prior art. It sets the macOS activation policy so test runs do not steal focus. A full UI is running. It shares a word and nothing else, and this document avoids the word for that reason.- Nothing has been designed for this before. The idea is old:
notes/design-vision.mdwants "a decentralized compute option for running your scripts outside of your clients and syncing the data" and has a one-line## Cloud Visionsection;archive/peek-todo.mdandarchive/TODO.mdcarry "agent mode: explore running application logic in a headless node" under "Server Backend". Neither was ever expanded.notes/extensibility.md, which is the closest thing to a prior extension-architecture document, does not mention remote or server-side execution at all. So there is nothing here to contradict, and equally nothing to build on. - The remote backend has never been proven end to end.
handoff-mcp.mdrecords phase 1 ofdocs/mcp-remote-backend-design.mdas landed onmainwith the two halves never having talked over a real socket — every test stubs one side. Everything in §6 below leans on that seam, so its first real exercise is a prerequisite, not a detail.
2. What a worker is #
A worker is a feature whose manifest declares a worker entry point instead of (or alongside)
its tiles, and whose granted capabilities are the subset that means anything with no display.
Concretely, from a feature author's point of view:
| Desktop feature | Worker | |
|---|---|---|
| Entry point | tiles: [{ id: "background", url: "background.html" }] |
worker: { url: "background.js" } |
| Host | hidden BrowserWindow, tile-preload.cts injects window.app |
JS runtime, host module injects globalThis.app |
| Transport under the API | ipcRenderer → tile-ipc.ts |
HTTPS/WebSocket → apps/server |
| Data | local datastore.sqlite via tile:datastore:* |
/mcp/* on apps/server (§6) |
| Capability enforcement | tile-ipc-gate.ts runPipeline() |
server-side, bound to the grant credential |
| Invoked by | command palette, shortcut, pubsub | agent, schedule, pubsub (§8) |
| Lives as long as | the app session | the schedule, or a wake (§4) |
The feeds feature is the honest test case, and it is close. apps/desktop/features/feeds/
declares network, datastore (items, item_events), pubsub and commands; its
background.js polls on a setInterval, fetches each feed, and writes entries with
api.datastore.addItemEvent(). Four things stop it being portable as written, and sorting them
by who pays is most of §9:
- It calls
DOMParserto parse the feed XML. That is a browser API, not a JS-language one, and §10 finds it absent from every candidate runtime — so this one the feature pays, by bundling an XML parser. - It imports
peek://cmd/nouns.js— a cross-feature ES module over thepeek://scheme. - It calls
api.shortcuts.register('Option+Shift+F', …)andapi.window.open(…). Both are meaningless with no display (§5). - It calls bare
fetch(url), notapi.network.fetch(url)— so its declarednetwork: { domains: ["*"] }capability is decorative in the current host, because the renderer's ownfetchis gated by thebackground.htmlCSP rather than by the manifest. A worker host that wants the manifest to mean anything must intercept the global.
3. The manifest #
Decision: extend the existing manifest, do not invent a second one.
apps/desktop/main/tile-manifest.ts already validates a v3 manifest — validateTileManifest(),
parseManifestFile(), resolveCapabilities() — with limits (MAX_TILES_PER_MANIFEST,
MAX_NETWORK_DOMAINS, MAX_DATASTORE_TABLES), privileged capabilities
(PRIVILEGED_CAPABILITIES = filesystem) and builtin-only ones (BUILTIN_ONLY_CAPABILITIES =
sync). A worker manifest is that manifest with one new top-level key:
{
"manifestVersion": 3,
"id": "feeds",
"worker": {
"url": "worker.js",
"schedule": "every 15m",
"wake": ["item:created"]
},
"capabilities": {
"datastore": { "tables": ["items", "item_events"] },
"network": { "domains": ["*"] },
"pubsub": { "scopes": ["global"], "topics": ["item:created", "feeds:*"] },
"commands": true
},
"commands": [
{ "name": "refresh feeds", "description": "Refresh all feeds",
"action": { "type": "execute" } }
]
}
worker.urlis a JavaScript module, not an HTML file.background.htmlexists in the desktop model only because aBrowserWindowneeds a document to navigate to; it does nothing butimport extension from './background.js',await api.initialize(), callinit(), and registerapi.onShutdown(). A worker host does those four things itself.worker.scheduleandworker.wakeare the two ways a worker starts (§4). At least one is required; a worker with neither can never run.capabilitiesis unchanged in shape and meaning. The set that is grantable to a worker is smaller (§5), and enforcement moves fromtile-ipc-gate.tsto the server (§6), but an author writes the same block.tilesandworkermay both appear. That is the intended migration path forfeeds: keep the reader window on the desktop, move the polling to a worker, and let the manifest describe one feature with two places it runs.
One thing the current schema would have to give up for workers: TileCommand.action.type has
three values ('window' | 'publish' | 'execute'). 'window' is meaningless in a worker and
must be rejected at validation time rather than failing at dispatch.
4. Lifecycle #
The desktop lifecycle is an explicit FSM — apps/desktop/main/tile-fsm.ts holds the transition
table, tile-lifecycle.ts drives it, and tile-ipc-gate.ts refuses calls made in the wrong
state. A worker keeps the same states and the same rule that capability calls are only legal in
READY; what changes is who supplies the triggers.
| Desktop | Worker |
|---|---|
REGISTERED — in registry.json, no window |
installed, no instance running |
LOADING — launchTile() created the window, loadURL in flight |
runtime cold-started, module imported, init() not returned |
READY — renderer sent tile:lifecycle:ready |
host resolved init() |
shutdown — shutdownTile() sends tile:lifecycle:shutdown, waits TILE_SHUTDOWN_GRACE_MS (500ms) for the api.onShutdown() callback, then closes the window |
host calls the registered shutdown callback, waits the same grace, then lets the instance exit |
Three ways a worker instance starts:
- Scheduled —
worker.schedule. The runtime wakes the worker,init()runs, the work happens, the instance goes away. This is the common case and the one that pays for the whole design. - Woken by an event —
worker.wakelists pubsub topics. This is the same idea asTileEntry.lazyEventsandapps/desktop/main/tile-lazy.tsinstallLoadOnDispatchHook(), which intercepts a publish, boots the tile, waits for ready, then lets the publish continue. - Called by an agent — a
cmd:execute:<name>for a command the worker declares. Identical mechanism to the wake case;tile-lazy.tsalready treats command dispatch and event wake as one hook.
The consequence authors must be told about: a worker is not a process that stays up. The
desktop model quietly guarantees that init() runs once and module-level state survives for the
session, which is why feeds can hold pollTimer in a module variable and timers can hold
live countdowns in memory. A worker that cold-starts per invocation loses all of that.
Decision: workers are stateless between invocations, and any state that must survive lives in
the Peek store. Not because long-lived instances are impossible on every candidate runtime,
but because assuming them makes a worker unportable between runtimes (§9) and because Peek
already has exactly one correct place to put durable state. feeds illustrates that this is
cheap: its poll cursor is already metadata.lastPolledAt on the feed item, and pollTimer is
the only thing it holds in memory — which a schedule replaces outright.
The rule this implies, and which a worker linter should enforce: setInterval is not the way a
worker repeats. worker.schedule is. A setInterval in a worker either does nothing (the
instance is torn down before it fires) or silently pins an instance that should have exited.
5. The API surface a worker gets, and what it loses #
window.app has around forty namespaces. Sorting them by whether they mean anything with no
display, no local disk and no user:
Granted. These are the worker surface:
| Namespace | Notes |
|---|---|
api.datastore |
The subset backed by /mcp/* — see §6. Not the full ~50 methods. |
api.network |
fetch, gated on capabilities.network.domains. |
api.pubsub / api.publish / api.subscribe |
Over a duplex channel to the backend (§8). SELF scope is meaningless and collapses to a no-op. |
api.commands |
register / unregister. This is how an agent calls in (§8). |
api.settings |
Feature settings; already a store read. |
api.log |
Goes to the runtime's log sink, not a renderer console. |
api.initialize / api.onShutdown |
Kept, and supplied by the host rather than the manifest's HTML. |
Absent. api.window, api.shortcuts, api.izui, api.escape, api.theme,
api.darkMode, api.screen, api.dialogs, api.nav, api.modes, api.context,
api.adblocker, api.chromeExtensions, api.session, api.filesystem, api.shell,
api.quit / api.restart, api.app, api.backup, api.profiles, api.features, api.mcp,
api.sync.
Decision: absent namespaces are absent, not stubbed. typeof api.window === 'undefined'.
The alternative — a stub that resolves { success: false, error: 'no display' } — is worse for
the same reason docs/mcp-remote-backend-design.md §7.1 refuses a stale cache: a feature that
gets a plausible negative answer keeps going and does the wrong thing, while a feature that gets
a TypeError on the first call fails at the point of the mistake. It also makes the failure
visible to static analysis: a worker linter can flag api.window. in a module the manifest
declares as a worker entry point, before it is ever deployed.
The two that will actually be missed, and the answer for each:
api.window.open— a worker cannot open a window, but it can ask a desktop to. Publishing a topic the desktop's copy of the feature subscribes to is the honest shape, and it already works:TileCommand.action.type: 'publish'exists, andtile-lazy.tshandleCommandDispatch()short-circuits'window'-type commands intowindow:reopen-request. A worker that wants a window publishes and moves on; whether a desktop is listening is not its problem.api.shortcuts.register— no answer, and none is needed. A global hotkey is a property of a machine with a keyboard. The manifest'sshortcutsblock stays valid and is simply not granted to the worker half of a feature.
api.filesystem deserves a separate note: it is the one capability already in
PRIVILEGED_CAPABILITIES in tile-manifest.ts, requiring explicit user approval. Denying it to
workers outright is the conservative call — a worker's filesystem is a scratch disk on a machine
the user does not own, so a feature reading ~/Documents on the desktop and something unrelated
on a worker is a trap, not a feature.
6. How a worker reaches Peek data #
Decision: a worker's api.datastore is remote-store.js against the /mcp/* API on
apps/server, authenticated with an mcp_grants credential. Not MCP, and not a third path.
The pieces exist. apps/desktop/features/mcp-server/store/index.js openStore() selects an
implementation from config: sqlite-store.js against a local datastore.sqlite, or
remote-store.js when a remote URL is set. remote-store.js is an HTTP client against
apps/server's /mcp/* routes, holds no local state, and — per its own header comment — has no
cache, no read-through and no write queue, so an unreachable server is a thrown error naming the
server rather than an empty result. The routes it calls are registered in apps/server/index.js
with business logic in apps/server/mcp-items.js: GET /mcp/items, /mcp/items/search,
/mcp/items/resolve, /mcp/items/:idOrPrefix, /mcp/session, /mcp/tags, /mcp/whoami, and
the writes POST /mcp/items, PATCH/DELETE /mcp/items/:id, POST /mcp/items/:id/tags,
DELETE /mcp/items/:id/tags/:name, POST /mcp/items/:id/events,
POST /mcp/tasks/:id/complete.
Why this and not remote MCP. MCP is a protocol for an agent to talk to a tool provider.
A worker is not an agent; it is application code that needs a store. Wrapping its every read in
JSON-RPC tool calls adds a protocol layer, a session, and a tool-schema round trip to something
that already has a typed store interface (apps/desktop/features/mcp-server/store/types.js PeekStore) with an HTTP
implementation already written against it. docs/mcp-remote-backend-design.md §1 makes exactly
this argument for exactly this reason — phase A "removes the requirement" of a local install for
"a scheduled agent and a cloud session", and only "the phone case genuinely needs" MCP over HTTP,
because a phone cannot spawn a child process. A worker is the scheduled-agent case, not the
phone case.
MCP remains the right answer for the other direction — an agent calling into a worker (§8).
What this choice costs, stated plainly:
-
A worker's datastore is a subset.
api.datastoreintile-preload.ctsexposes roughly fifty methods — addresses, visits, content, tags, item visits, frecency queries, genericgetRow/setRow/getTable.PeekStoreinapps/desktop/features/mcp-server/store/types.jshas seventeen, two of which aredescribe()andclose()— so fifteen actual store operations. Everything outside those fifteen either does not exist remotely or would have to be added to/mcp/*first. This is the largest single gap in the design. In particulargetRow/setRow/getTable— the generic table accessdocs/api.mddocuments and several features use — has no/mcp/*equivalent, so a feature that keeps state in a custom table cannot be a worker without one being added. -
Item events were the sharpest edge of that subset, and
feedsfell off it. It has the routes now. Measured bytools/feeds-worker-spike/run.mjs. The tile API has seven event methods —addItemEvent,getItemEvent,queryItemEvents,getLatestItemEvent,countItemEvents,deleteItemEventanddeleteItemEvents;/mcp/*covers four of them, where it used to cover one write and no read at all.What the write used to do:
POST /mcp/items/:id/eventsreachedrecordEvent()inapps/server/mcp-items.js, which put the event's type name in the row'scontentcolumn, hardcodedvalueto 0 and stampedoccurredAtwith the server's clock — so a feed entry's link, publication time and metadata had to travel as one JSON string inmetadata.notes.content,occurredAtandmetadataare now optional body fields that store into the columns of those names directly, and a{type, value}body still writes the row it always did.What the read used to be: fetching the parent item with
?events=N, one request per item, withfetchEvents()capping the result at 20 however many were asked for.pollFeed()inapps/desktop/features/feeds/background.jsdeduplicates by reading its last 100 entries and skipping guids it already has, and against that API it saw 20 — so with a 25-entry feed the first poll wrote 25 and every poll after it wrote the 5 it could not see, indefinitely.GET /mcp/items/:id/eventsis a paged read with a ceiling of 200, and the same run against the same feed on the deployed server now writes 25 once, then 0 on every poll after.Cleanup works, with one ordering rule.
DELETE /mcp/items/:id/eventsclears an item's events andDELETE /mcp/items/:id/events/:eventIdremoves one, sounsubscribeFeed()reaches the feed item and a scheduled worker can clean up after itself.deleteItem()inapps/server/db.jsis still a soft delete that setsdeletedAtand never touchesitem_events— deliberately, sinceitem_eventshas no tombstone column — so a caller deletes the events first. Deleting the item first strands them, and only an id captured beforehand reaches them again.Three gaps remain, none of which stops a feed worker.
getItemEvent,getLatestItemEventandcountItemEventshave no routes. The paged read'ssince/until/orderhave no query parameters, so a caller applies them to the rows it read rather than in SQL. Anditem_eventshas one column for what the API calls an event'scontentand what it calls itstype, so a write that suppliescontentkeeps it and drops the type label — a caller that needs its events distinguishable by kind carries the label inmetadata. -
api.networkandapi.datastorediverge in where they are enforced. On the desktop both are checked bytile-ipc-gate.tsagainst the manifest grant. For a worker, datastore scope and the readonly bit are properties of the credential, enforced server-side — that is the decision indocs/mcp-remote-backend-design.md§4.1, and §4.2 explains why a client-side check is not sufficient. Network domains have no server in the path at all and can only be enforced by the worker host interceptingfetch. -
Item ids are server ids.
apps/server/db.jssaveItem()mints its own id and stores the desktop's insyncId. A worker sees server ids; the desktop UI shows local ones. Thepeek-mcp-initskill template already carries this warning for remote MCP sessions and a worker author needs the same one. -
Some fields are local-only.
favicon,visitCountandlastVisitAtare named as staying local indocs/mcp-remote-backend-design.md§9;docs/sync-coverage-gaps.mdis the standing inventory. A worker cannot read or write them. -
Images are refused.
create_itemwithtype: 'image'against the remote store is an explicit error (§9 of the same document). A worker cannot produce image items. -
The store itself is proven; the worker path over it is not.
tools/socket-proof.mjs,tools/socket-proof-ambiguous.mjsandtools/socket-proof-scaffolded.mjsdriveremote-store.jsagainst a deployedapps/serverover a real socket, so credential resolution, header shape and error mapping are no longer open. What has not been exercised is a feature on top of that store — the seam this section's other bullets describe — andtools/feeds-worker-spike/run.mjsis the harness for it.
Credential. The same mcp_grants bearer token, minted by POST /admin/mcp-grants and
resolved by hash, carrying one scopeTag, one readonly bit and one profile_id. A worker gets
its own grant — never a device credential, never a shared one — so it is revocable individually
and its blast radius is its scope tag. The unexpanded-${VAR} guard remote-store.js already
implements matters more for a worker than for a local child process, because a worker's
environment is configured once in a deploy and never seen by a human again.
That credential is not adequate as it stands, and its replacement has its own document.
scopeTag plus readonly plus profileId is the whole authorization vocabulary on /mcp/*,
while the same feature running on the desktop is checked per call against the tables, topics and
domains its manifest declared. A worker would therefore hold a less bounded credential on rented
compute than it holds on the user's own machine. docs/workers-capabilities.md works out what it
should hold instead — what makes the reference unforgeable, how the manifest's declaration becomes
the attenuation, how delegation and revocation work, and what has to change in remote-store.js,
PeekStore and apps/server for "the credential is the authority" to be true rather than
aspirational. Its conclusion is a narrower grant record rather than a signed token scheme, and it
gives the reasoning.
7. How a worker's writes get back to devices #
This has a short answer, and the short answer is the reason §6 chose the store it did.
docs/sync.md: "The server acts as the source of truth. All clients push and pull data via the
server's REST API." A worker writes through /mcp/* into the same per-user, per-profile
database the desktop syncs against. apps/desktop/main/sync.ts pullFromServer() →
mergeServerItem() finds no local row with that syncId and inserts one — so the item appears
on the laptop at the next sync with no new machinery.
The failure modes are inherited, not new, and all three are already written down:
- The tag race.
docs/mcp-remote-backend-design.md§7.2:apps/server/db.jssaveItem()deletes and re-inserts an item's whole tag set on thesyncId-matched push path, so a tag added remotely in the window between one sync's pull and its push is dropped.syncAll()pulls before it pushes, which covers the common case; the remaining window is accepted and narrowed rather than fixed. A worker that tags things — which is most of what a useful worker does — sits directly in that window, and does so unattended, so it hits it more often than a human at an MCP session would. This is the one inherited risk that a worker meaningfully worsens. - Push failures are not retried.
docs/sync.md"Known Limitations": a failed push is logged, butlastSyncTimestill advances, so the item is not picked up next cycle. This is a desktop bug, not a worker one, but a worker makes it likelier to matter because it increases the volume of server-side writes a desktop must reconcile. - Local-only fields.
docs/sync-coverage-gaps.mdis the inventory. Nothing in this design changes a"sync": falsedecision.
What a worker must not do: write to a local store. There is no local store. This is stated because the failure it prevents is silent — a worker that keeps a poll cursor in a runtime-local KV instead of on the item is correct until the runtime reschedules it elsewhere, and then it re-fetches everything or nothing.
8. How an agent addresses and invokes a worker #
Three things an agent wants: find out what workers exist, call one, get the result.
Invocation reuses the protocol the command palette already uses. A command is not a
main-process callback registry — api.commands.register() in tile-preload.cts subscribes the
tile to the pubsub topic cmd:execute:<name>, and when a message arrives with
{ expectResult: true, resultTopic: '<topic>' } it runs the handler and publishes the return
value (or { error }) to resultTopic. That is already a request/response protocol over a
message bus, with a correlation id in all but name.
So a worker invocation is: publish cmd:execute:<name> with the parameters and a resultTopic,
wait for the reply. The runtime's job is to deliver that publish to a worker instance, cold-starting
one if none is live — which is exactly what tile-lazy.ts installLoadOnDispatchHook() does
locally today.
The agent-facing surface is MCP, and this is where MCP belongs. An agent should not learn a
Peek-specific pubsub protocol. docs/mcp-remote-backend-design.md §1 already names phase 2:
POST /mcp/rpc on apps/server, JSON-RPC over streamable HTTP, grant token as bearer, consumed
natively by Claude Code as {"type": "http", "url": …, "headers": {"Authorization": …}} with no
local process. A worker's commands become tools on that endpoint:
list_workers→ the installed workers in the grant's scope and their declared commands, from the same manifestcommandsblock the palette reads.call_worker(worker, command, params)→ publishcmd:execute:<command>, wait on theresultTopic, return the result.
This is additive to phase 2 rather than a separate server. It also means the grant credential does double duty: the same token that scopes what data a worker can touch scopes which workers an agent can call.
Addressing. A worker is addressed as <feature id> within a grant's scope — not a URL, not a
runtime-assigned instance id. The instance is the runtime's business; two invocations may or may
not hit the same one, and per §4 nothing may depend on which.
What this does not solve, and should not pretend to: a long-running worker call. An agent's
tool call has a timeout; a worker that polls forty feeds does not fit inside one. The shape that
works is the one Peek already has — the command returns immediately, the work publishes progress,
and the results are items the agent reads back through the store. A call_worker that blocks for
four minutes is a design error, not a runtime limitation.
9. What the runtime has to provide #
Derived from §2–§8, and from what real features do. Not a feature table — the things whose absence makes a worker either impossible or unportable.
Hard requirements.
- ES modules, with a resolver for cross-feature imports.
apps/desktop/features/feeds/background.jsopens withimport { registerNoun } from 'peek://cmd/nouns.js'. Bundling at install time is the obvious answer and turns this into a build-step requirement rather than a runtime one — but it has to be decided, because "just run the file" does not work for a real feature. fetch, interceptable. Outbound HTTP is most of the point. It must also be replaceable by the host, orcapabilities.network.domainsmeans nothing (§2).- A scheduler the runtime owns.
worker.schedulehas to be honoured by something durable — not asetIntervalinside the instance, which per §4 is the thing workers give up. - A duplex channel to the backend. Pubsub is not request/response: a worker both publishes
and subscribes, and
cmd:execute:<name>arrives unsolicited. Long-poll, SSE or WebSocket — the shape matters, the mechanism does not. - Cold start fast enough to sit under an agent's tool call. §8's
call_workerboots an instance if none is live. Sub-second is fine; ten seconds is not. - Secret storage for the grant credential that is not the feature bundle.
Soft requirements — wanted, not required.
- Some browser-shaped globals.
DOMParserinfeedsis the concrete case, and it is instructive that none of the runtimes in §10 has it — it is not in the Workers API surface and it is not a Node API either. So this is a tax the feature pays, not the runtime:feedsas a worker bundles an XML parser. Enumerating the rest is a survey nobody has done, and the real answer is a compatibility list produced by trying to port the six features whose manifests declarenetwork—entities,features-manager,feeds,lex,websearch,wonderwall— since those are the ones that were already reaching outward. - Concurrency control per worker. Two instances of a polling worker racing produce duplicate
items. Peek's
syncIddedup absorbs some of this; a runtime that can guarantee one instance per worker removes the question.
Explicit non-requirements, and each one is a deliberate narrowing that widens the runtime field:
- No persistent local storage. State lives in the Peek store (§4, §7).
- No filesystem. Not granted (§5).
- No inbound HTTP listener. Workers are reached through the backend, not addressed directly (§8). A worker with a public URL is an attack surface with no compensating benefit.
- No long-lived process. Assumed absent so that a runtime which cannot provide one is not excluded.
10. Runtime evaluation #
Three candidates, judged against §9 rather than against each other's feature lists.
Sourcing. Every platform claim below was fetched on 2026-08-18 and carries its URL. Where a page states a "last updated" date it is given, because these numbers move. Anything that could not be found on a fetched page is marked not published rather than guessed. No claim here comes from recall.
10.1 celld #
Sources: https://celld.dev/, /docs, /docs/cloudflare-compat, /docs/limitations,
/docs/security, all fetched 2026-08-18. No page carried a date. Repository:
https://github.com/denoland/celld, Apache-2.0.
What it is, in its own words: "self-hosted, distributed Durable Objects." From /docs: "celld
is a stateful distributed system. It runs server-side JavaScript on your machines. It keeps all
shared data in an S3-compatible or Google Cloud Storage bucket that you own. The JavaScript
API is the same API that Cloudflare Workers and Durable Objects supply." The unit is a "cell"
— "a small server with a name and a private SQLite database" — one V8 isolate per cell, one
thread per cell, so "two requests to the same cell never run at the same instant."
Against §9: ES modules yes (deploys via celld deploy, using esbuild for Worker code). Outbound
network yes — a cell "serves HTTP, holds WebSocket connections, sets alarms, and makes outbound
connections"; no numeric outbound limit is not published. Scheduler: durable alarms, the
Cloudflare setAlarm()/alarm() API. No cron — /docs/cloudflare-compat puts "cron
triggers" under "Not planned" ("platform surface; celld has its own durable alarms") and states
"No scheduled (cron), queue, tail, or email handlers." Duplex channel: WebSockets, with a caveat
from /docs/limitations that matters here — "An outbound Durable Object WebSocket keeps its
cell resident, and the connection does not continue when the cell moves to a different node."
Cold start: the landing page self-reports "Wake a hibernated cell: ~4 ms" and "Durable write
latency (region-local): ~90 ms"; these are the vendor's own benchmarks, not guarantees.
Persistent state: per-cell SQLite replicated to the operator's bucket. R2 and KV are "not
planned"; D1 is "planned."
Maturity is the problem, and the site is candid about it. /docs/limitations opens "The
boundaries of the current alpha:" and lists, verbatim: "A fleet runs one application deployment.
There is no multi-tenant scheduler, no account service, or managed ingress"; "celld requires an
S3-compatible bucket or a Google Cloud Storage bucket, also on a laptop. There is no local
filesystem mode"; "The peer HTTP protocol does not terminate TLS. Run the fleet on a private
network or an encrypted overlay"; "Windows is not available, and Intel Macs get no prebuilt
binaries"; "celld has no central placement controller, so it does not rebalance the fleet when a
node joins"; "Updates are manual." /docs/security states "celld is an alpha. It is not safe
for hostile multi-tenant use" and "The operator API does not authenticate its requests." An
example release tag of v0.0.1 is given on the install page; no dated release history was
opened.
One inconsistency worth recording rather than resolving: the landing page claims "Resident cells
/ 8 GB node: 2,500" and "~$0.02" per resident cell-month, while /docs says "One 8 GB node
holds 1,000 resident cells, so one resident cell costs approximately $0.05 each month." The site
disagrees with itself by 2.5×. Cost comparisons on the landing page are celld's own marketing
against Cloudflare's list price, not an independent measurement.
10.2 Cloudflare Workers and Durable Objects #
Sources: https://developers.cloudflare.com/workers/platform/limits/ (last updated Jul 28,
2026), /durable-objects/platform/limits/ (Jun 1, 2026), /durable-objects/api/alarms/
(Apr 21, 2026), /durable-objects/api/namespace/, /workers/runtime-apis/nodejs/, fetched
2026-08-18.
Against §9, the numbers that decide things:
- CPU time. Workers paid: 5 minutes maximum per HTTP request, 30 seconds by default; free 10 ms. Cron Triggers on paid: 30 seconds for intervals under an hour, 15 minutes at an hour or more. Durable Objects, SQLite-backed: "30 seconds (default) / configurable to 5 minutes of active CPU time," with the footnote that matters for §4 — "Each incoming HTTP request or WebSocket message resets the remaining available CPU time to 30 seconds… If you consume more than 30 seconds of compute between incoming network requests, there is a heightened chance that the individual Durable Object is evicted and reset."
- Wall time. No limit for an HTTP request while the client is connected;
waitUntil()extends 30s past the response. Cron Trigger, Queue consumer, and Durable Object alarm handler: 15 minutes. A polling worker is almost all I/O wait, so this is the budget that actually binds, and 15 minutes is generous for it. - Memory. 128 MB per isolate, "including the JavaScript heap and WebAssembly allocations… per-isolate, not per-invocation."
- Subrequests. Free 50 per invocation; paid 10,000. Simultaneous outgoing connections
per request: 6 on both plans, covering
fetch(), KV, R2, TCPconnect()and outbound WebSockets, counted only while headers are pending. A forty-feed poll is fine serially and needs a concurrency cap of six if parallelised. - Code size. 3 MB compressed free, 10 MB paid; startup time limit 1 second. A bundled feature is nowhere near either.
- Alarms. "Each Durable Object is able to schedule a single alarm at a time by calling
setAlarm()." At-least-once execution, automatically retried whenalarm()throws, "exponential backoff starting at a 2 second delay from the first failure with up to 6 retries allowed." Alarms survive eviction: "if the Durable Object wakes up after being inactive, the constructor is invoked before the alarm handler," and a handler may "be re-instantiated on another machine" and "run from the beginning." A maximum scheduling distance into the future is not published. The docs draw the distinction this design needs: "A Worker can have up to three Cron Triggers configured at once, but it can have an unlimited amount of Durable Objects, each of which can have an alarm set." - Addressing.
idFromName(name)"creates a unique DurableObjectId which refers to an individual instance";getByName(name)returns a stub. A stable name is the address, which is exactly the addressing model §8 asks for. - Storage. SQLite-backed DO: 10 GB per object; 2 MB max row/string/BLOB; 100 columns per table; 100 KB max statement. Soft throughput limit of 1,000 requests per second per object.
- What runs. Not arbitrary Node.
nodejs_compatprovides "a subset of Node.js APIs," some full implementations, "a few partially supported," with unenv polyfills that raise[unenv] <method> is not implemented yet!for the gaps.
The relevant repo history: notes/cloudflare-vs-railway-evaluation.md (last modified 2026-02-02)
evaluated moving apps/server to Cloudflare and concluded "Stay on Railway for now, but
architect for future portability," rating the database migration "HIGH" complexity because
better-sqlite3 is synchronous and file-based and does not map onto this.ctx.storage.sql.
notes/server-portability-plan.md is the follow-on adapter plan.
That conclusion does not transfer to workers, and the reason is worth stating so the two documents do not look contradictory. It was a verdict about a component whose entire substance is a SQLite database. A worker holds no database at all — §4 makes it stateless and §6 puts its state behind an HTTP client. The single named blocker is absent by construction.
10.3 A plain container #
Taken as Fly.io Machines, as one container-shaped platform with published numbers. Sources:
https://fly.io/docs/machines/overview/ and https://fly.io/docs/about/pricing/, fetched
2026-08-18; neither page showed a date.
"Fly Machines are fast-launching VMs with a simple REST API." Creating one takes "maybe low
double digit seconds" (registry fetch plus root filesystem build); starting an already-created,
stopped Machine "usually takes well under a second." Machines "Stop automatically when a program
exits" and can "Stop or start quickly, either manually or automatically based on traffic" — the
phrase "scale to zero" does not appear, but the behaviour is described. Ephemeral storage by
default, "a blank slate on every startup," with Fly Volumes for persistence at "$0.15/GB per
month of provisioned capacity," charged whether attached or not. Compute is per-second against a
CPU/RAM preset; shared-cpu-1x at 256 MB is "$0.0028" per hour, "$2.02" per month. A stopped
Machine still bills its root filesystem at "$0.15" per GB per 30 days.
Against §9 a container clears every hard requirement trivially — arbitrary Node, no CPU cap, any scheduler, any socket. What it does not supply is the three things it would then have to grow:
- A scheduler.
worker.schedulebecomes a cron daemon or an external trigger service that someone has to build, monitor, and make durable across restarts. - A wake path. §8's
call_workeron a stopped Machine pays the start cost, and §4's cold-start-per-invocation model means paying it often. - Isolation between features. This is the real one. Peek features are installable from
atproto (
apps/desktop/main/atproto-source.tsresolves anat://URI to aspace.peek.feature.releaserecord and installs the bundle throughfeature-installer.tsinstallFromBundle()). Running two users' third-party feature code in one container is not isolation; running one container per feature per user turns a $2/month preset into a bill that scales with installs, and reintroduces the cold-start problem at every one of them.
A container is the right answer for a component that is one trusted program with a database.
That is apps/server, which is why it is on Railway. It is the wrong shape for many small
untrusted programs that mostly sleep.
10.4 Summary #
| §9 requirement | celld | Cloudflare Workers + DO | Container (Fly Machines) |
|---|---|---|---|
| ES modules + bundle step | yes (celld deploy, esbuild) |
yes | yes |
Outbound fetch |
yes; limits not published | yes; 6 simultaneous, 10k subrequests paid | yes, unrestricted |
| Durable scheduler | alarms; no cron | DO alarms, unlimited objects, at-least-once + retry | must be built |
| Duplex channel | WebSocket; breaks on cell move | WebSocket, incl. hibernation | trivial |
| Cold start | "~4 ms" self-reported | 1s startup ceiling, isolates | "well under a second" from stopped |
| Isolation for untrusted code | "not safe for hostile multi-tenant use" | isolate-per-tenant, the platform's premise | not provided |
DOMParser and browser globals |
no | no | no |
| Operational maturity | alpha, v0.0.1, manual updates, unauthenticated operator API |
GA, published limits | GA |
| Ownership | self-hosted, own bucket, Apache-2.0 | vendor, lock-in on DOs | portable |
11. Recommendation (superseded) #
Superseded by
docs/worker-runtime-portability.md. The recommendation in this section — adopt the Cloudflare Workers and Durable Objects API as the worker runtime contract — is no longer the design. The contract is Peek's own: a small host interface, with Cloudflare, celld, a bare Node process and a container each an adapter behind it. Cloudflare remains the likely first hosted target; it stops being the contract. The reasoning below is kept rather than deleted, because most of it survives the change and §11.2 says which parts do.
11.1 The recommendation as it stood #
Build against the Cloudflare Workers and Durable Objects API as the worker runtime contract. Deploy on Cloudflare first. Keep celld as the self-hosted escape hatch, and say so in writing now rather than discovering later that the code cannot move.
The distinction between the API and the vendor is the whole recommendation, and it is available only because celld exists: "The JavaScript API is the same API that Cloudflare Workers and Durable Objects supply." Targeting that API is not a bet on one company. It is a bet on one interface with a GA implementation and an Apache-2.0 self-hosted one, which is a materially better position than either alone.
Concretely:
- One Durable Object per installed worker, addressed by
getByName()on a name derived from the grant and the feature id. That gives §8 its addressing model, §9.8 its one-instance-at-a-time guarantee (a DO serialises its own requests), and §4 its lifecycle for free. worker.schedulecompiles tosetAlarm(), not to a cron trigger. Cron is capped at three per Worker and celld does not implement it at all; DO alarms are per-object, unbounded in count, at-least-once with automatic retry, and survive eviction. The at-least-once semantics are the reason §4 insists on state living in the Peek store: an alarm handler that may "run from the beginning on the other machine" must be safe to run twice.- The 15-minute alarm-handler wall-clock budget is the design constraint to publish to worker authors, together with the 30-second-CPU-between-network-requests eviction warning. Both are generous for polling and both are hard walls for anything that computes.
- The duplex channel of §9.4 is a WebSocket from the DO to
apps/server. The celld caveat — an outbound WebSocket pins a cell and does not survive migration — is survivable precisely because §4 already forbids depending on instance continuity: a dropped socket is a reconnection, not lost state.
Not a plain container, for §10.3's third reason. Isolation between untrusted features is not a thing to add to a container later; it is the thing an isolate runtime is.
Not celld first, for one reason stated in celld's own documentation: "It is not safe for
hostile multi-tenant use," alongside an operator API that "does not authenticate its requests,"
a peer protocol that does not terminate TLS, and manual updates. A worker holds a bearer
credential scoped to a user's entire Peek store. That is not the payload to put on an alpha
whose security page says this. Nothing here says celld is the wrong destination — it is the
right one for notes/design-vision.md's "decentralized compute option for running your scripts
outside of your clients," and it is why the API contract is worth honouring from day one. It is
the wrong first deployment.
What this recommendation is not. It is not a claim that Peek should move to Cloudflare.
notes/cloudflare-vs-railway-evaluation.md's decision to keep apps/server on Railway stands
untouched, and the architecture here depends on it: the worker is a client of apps/server
(§6), not a replacement for it. A worker fleet on Cloudflare talking to a Hono server on Railway
is the intended shape, not a transitional one.
11.2 What replaced it, and what survived #
Three things broke §11.1 as a contract, and docs/worker-runtime-portability.md §1 argues each
in full:
- It excludes the host that already works. A plain Node process cannot serve the Workers and
Durable Objects API, but it can run a Peek worker —
tools/hello-worker/run.mjsdoes, in 137 lines. Making that API the contract makesworkerdwith a Cap'n Proto config the only way to run a worker on a Linux box. - The part of the Durable Objects API a worker uses is three operations wide. A Durable Object's substance is its per-object SQLite, and §4 and §7 of this document already put every durable byte in the Peek store instead. What is left to want is an alarm, a single-instance guarantee and an address.
- A worker module never sees that API. Feature code calls
api.*and exports an entry function. It never receivesenv, never touchesDurableObjectState, and never writesexport default { fetch, scheduled }. The Workers API is the vocabulary the host speaks to the platform, so naming it the worker contract describes a seam the worker is not on.
What survives unchanged, and is carried into the new document:
- Cloudflare first among hosted targets, for isolation. §10.3's argument against a container is untouched: an isolate runtime is the thing that isolates untrusted feature code. That is a requirement of the stage where third-party workers run, which is not the first stage (§13.3).
- Schedules compile to alarms, not cron, for the reasons §11.1 gives — and now also because a
DO alarm's retry policy is published while a cron trigger's is not
(
docs/worker-runtime-portability.md§6.1). - Not celld first, for what celld's own documentation says about hostile multi-tenant use.
- Not a plain container, for §10.3's third reason.
- Not a move of
apps/server, andnotes/cloudflare-vs-railway-evaluation.mdstands. - celld and Cloudflare share one implementation. This was §11.1's real economy and it survives as a two-for-one on a single adapter rather than as the contract itself.
What changed is where the seam sits: below the worker host rather than below the worker, so that
the logic turning a manifest grant into an api object is written once and only the platform
primitives — module loading, a restricted fetch, credential custody, the scheduler, the duplex
channel, the log sink — are per-target.
12. Deliberately out of scope #
- Converting any real feature. This document is the prerequisite for that work, not the start of it.
- A worker marketplace or distribution story.
apps/desktop/main/atproto-source.tsalready fetches aspace.peek.feature.releaserecord and installs the bundle throughfeature-installer.tsinstallFromBundle(), which is source-agnostic. That is the obvious path for distributing workers too, and it is a separate design. - Multi-user or multi-tenant workers. One grant, one user, one profile — matching the
mcp_grantsmodel exactly. - Running the desktop headless. Different problem, and the naming collision with
headless-policy.tsis reason enough to keep them apart. - Changing any
"sync": falsedecision (docs/sync-coverage-gaps.md). - A worker calling another worker. Pubsub makes it look free; the failure modes (cycles, cold-start storms, unbounded fan-out) are not, and nothing needs it yet.
13. Open questions #
-
The generic-table gap — answered in the companion document.
docs/workers-capabilities.md§6 finds the fork was mis-framed:getRow/setRow/getTablereach a fixed schema of shared tables rather than feature-owned ones (createTablesintile-manifest.tsis declared and never consulted), so the question splits in two. Per-worker private state wantsfeature_settings, which is already keyed by feature id and therefore self-confining, ported toapps/server. Shared tables get individual routes gated oncapabilities.datastore.tables, added on demand. What is left open is the schema cost —apps/serverhas five tables against the desktop's twenty, and each one a worker needs is a migration plus a sync decision plus a row indocs/sync-coverage-gaps.md. -
Where
capabilities.network.domainsis enforced for a worker. On the desktop it is checked intile-ipc.ts'stile:network:fetchhandler against the grant — andfeedsalready routes around it by calling the renderer's ownfetch. A worker host has no privileged position unless it replaces thefetchglobal before importing the module.docs/workers-capabilities.md§3.3 records this as the one capability whose enforcement sits irreducibly on the untrusted side. Settled by: deciding whether the host shadowsfetch(and whether a feature can get the real one back), or whether network gating for workers moves to an egress proxy, or whether it is abandoned andnetworkbecomes an install-time disclosure rather than a runtime gate.One input, measured.
installNetworkGuard()intools/feeds-worker-spike/worker-api.mjsreplacesglobalThis.fetchbefore the feature is imported, checks the hostname againstcapabilities.network.domains, and throws on a miss. It is about twenty lines, it logged every requestfeedsmade, and the feature did not notice. So shadowing works and is cheap. What it does not do is hold: on Node the feature can reach the network throughnode:httpandnode:httpswithout touchingfetchat all, so the guard is only a boundary if the runtime also withholds those modules. Shadowing is a guard-rail against a mistake, not a boundary against an author — which is the same conclusiondocs/workers-capabilities.md§3.3 reaches, now with the cost of the guard-rail known. -
Who approves a worker install, and what stops a worker exfiltrating. A desktop feature runs on a machine the user controls, behind a capability gate the user consented to at install. A worker runs on rented compute holding a credential scoped to the user's store, with a
networkcapability that can reach anywhere.notes/extensibility.mdalready raised "exfiltration-proofing for sensitive access" as unresolved for local extensions; a worker sharpens it considerably.docs/workers-capabilities.md§2.1 records what a credential design does and does not contribute: it can stop a worker exfiltrating its credential, and it cannot stop a worker misusing what it legitimately holds.Settled. Third-party features can be workers. Publisher verification is what makes that safe, and it already exists:
apps/desktop/main/atproto-verify.tsverifies a publisher's commit signature against the key active at the commit'srevvia the PLC audit log, walks the MST to the record, and re-hashes every block, with no unverified fallback. Provenance, not the credential, is the defence against a hostile author.The first version is nonetheless bundled features only, in the manner of
BUILTIN_ONLY_CAPABILITIESintile-manifest.ts, with third-party workers layered on after. This is a staging decision, not a narrower trust model, and the difference matters for what gets built: the server-side capability vocabulary indocs/workers-capabilities.md§5.3 is earned, because third-party workers are the destination. Building it against bundled-only workers is paying early for a threat that is accepted, not designing for one that was never accepted. -
Whether cross-feature imports are bundled or resolved — settled: bundled. Both were built and run.
tools/feeds-worker-spike/peek-hooks.mjsresolvespeek://live throughmodule.registerHooks(), and resolution itself is a non-issue:peek://cmd/nouns.jsresolved toapps/desktop/renderer/cmd/nouns.jsin about 2ms, and the hook is forty lines.What decides it is not cost but binding.
apps/desktop/renderer/cmd/nouns.jsevaluatesconst api = window.apponce, at its own first import, and holds it for the life of the module. Two instances ofapps/desktop/features/feeds/background.jsimporting the same specifier get one cached module instance, so the second instance'sregisterNoun()calls landed on the first instance's API object — measured, not inferred: after the secondinit(), the first API object held six noun subscriptions and the second held none. A host that runs more than one feature, or one feature twice, cannot give them separate API objects while a shared module captures a global. Bundling per install gives each deployment its own copy of the graph and removes the sharing entirely.The staleness that argued for live resolution is real but is the smaller problem, and it has an ordinary answer: a
cmdupdate reaches deployed workers by rebundling them, which is a deploy, which is a thing a fleet already has to do. Live resolution also does not survive packaging —electron-builder.ymlsetsasar: true, and stock Node cannot read insideapp.asar, so a live resolver would need the unpacked tree ororiginal-fs. Thepeek://module graph is a versioned dependency. -
Whether the worker API object is
window.app-shaped at all — settled: same shape, and §5's absent-not-stubbed rule holds.feeds/background.jswas imported unmodified and run under both answers, intools/feeds-worker-spike/run.mjs.The shared code is real.
parseFeed(),parseRSS(),parseAtom(),pollFeed(),pollAllFeeds(),getFeeds(),getEntries(),subscribeFeed()all ran untouched against an API object that was the desktop shape minuswindowandshortcuts. A deliberately different object would have bought nothing here and cost the port.The split is not between the two halves of the feature, though — it is between the feature's lifecycle and its work.
init()is the only thing that needs the absent namespaces, and it dies atapi.shortcuts.register, having already registered the noun and therefresh feedscommand and not yet reached itsitem:createdsubscription or its timer.uninit()dies the same way atapi.shortcuts.unregister. Every entry point a scheduled worker actually calls is clean. So the port is: keep the object, dropinit()/uninit(), and let the manifest'sworker.schedulename the exported function to run.Absent beats stubbed, and the run shows why. With the namespaces stubbed to no-ops
init()returned successfully and installed a 900000ms interval and a 5000ms timeout — a worker holding itself alive for fifteen minutes to do nothing, with no error anywhere to say the shortcut it registered will never fire. With them absent the failure is aTypeErrornaming the namespace, at the line responsible. The cost of absence is that the failure is partial:init()had already half-registered before it threw, and a host that catches and continues gets a feature in an undefined state. A worker runtime should treat a throw out of the lifecycle as fatal to the instance rather than recoverable.DOMParserneeded a shim and one sufficed:linkedom, already a dependency of both the root andapps/desktop, parsed a real RSS feed and a real Atom feed with no change toparseFeed(), including the escaped-namespacedc\:creatorselector and Atom'slink[rel="alternate"]href. The one thing the host must inject beyond the API object isglobalThis.window, because bothbackground.jsandnouns.jsreadwindow.appat import time — §5'sglobalThis.appalone fails on the module's first line. -
The tag race at unattended write volume.
docs/mcp-remote-backend-design.md§7.2 accepts a window in which a remotely-added tag is dropped by a desktop push, on the reasoning thatsyncAll()pulls before it pushes and the exposure is small. A worker tagging items on a schedule, with nobody watching, samples that window far more often than a human at an MCP session does. Settled by: measuring it once a worker exists, against the mitigations that section already names (stampingitems.updatedAton tag routes; set-difference instead of delete-and-reinsert on the push path). -
Cold-start budget for
call_worker— the bootstrap is cheap, the poll is not, and the reason is the store round trips. Measured bytools/feeds-worker-spike/run.mjson Linux, Node 24.Bootstrap is not the problem. Process launch through the feature's module graph being imported and ready — Node's own startup,
linkedom,remote-store.js, thepeek://resolver, and bothbackground.jsandnouns.js— is ~145ms with no server in the path. That is unbundled, from source, with a live resolver; a bundle can only be faster.The poll is where the budget goes, and it does not sit flat. Measured against the deployed
apps/serverover a 25-entry feed already in the store, so the poll writes nothing: process launch to first completed poll is 775–879ms over four runs, of which the poll itself is 365–399ms — and a second poll in the same process is 179–195ms, the difference being the first connection to the feed's own host. A single/mcp/*request costs ~50–60ms from this box, and thedescribe()at open costs ~200ms.That per-request figure is the whole story, because
pollFeed()issues oneGET /mcp/items, oneGET /mcp/items/:id/events, onePATCH /mcp/items/:id, and onePOST /mcp/items/:id/eventsper new entry, serially, in aforloop. A poll that finds nothing new costs three requests flat. A poll with thirty new entries costs thirty-three, roughly 1.8 seconds of pure round trip, and that count grows with entries and again with the number of feeds subscribed.What changed with the paged read is which of those a worker pays repeatedly. The per-entry write is now paid once per entry, on the poll that first sees it; before the read could page, every poll re-wrote the entries it could not see, so a feed's steady state was five writes forever rather than none.
Two consequences for §8. A worker whose work is one write, or a poll of a feed with nothing new, fits inside a tool call comfortably. A worker whose work is a fan-out of writes does not, and no amount of runtime tuning changes that, because the cost is in the API shape rather than the boot. Either
/mcp/*grows a batch write, orcall_workeris fire-and-read-back for anything that writes per item — which is what a scheduled worker does on the polls where a feed has actually moved.These are one box against one deployment; the shape is the finding, not the milliseconds.
-
Who operates the fleet. celld runs "one application deployment" per fleet with the operator's own bucket; Cloudflare is an account with a bill. Whether a Peek user brings their own compute, or a hosted Peek runs everyone's workers, changes the isolation requirements in §10.3 and §11 and the credential model in §6. This design assumes the hosted case because it is the stricter one, but the assumption is not settled.
-
Nothing in §6 has been proven over a real socket. Restating
handoff-mcp.mdhere because it is a precondition rather than a caveat:remote-store.jsandapps/server's/mcp/*routes have never talked to each other outside tests that stub one side. Settled by: the end-to-end run that document already names as its first next step — deploy, mint a grant, scaffold a project, confirm a scoped session lists and writes items. No worker work should start before that passes.