A published minion.town weblet whose +/- buttons drive a live, durable
counter remotable over the weblet's own CapTP websocket — using a ~3KB
hand-rolled CapTP client, no SES, no bundler.
Live URL: https://zwopgrfwiu5tfdkxqzhbvrrr4z6zvsqw5tslxj5j7mr4la265bja.ocap.site/
counter— a liveincr/decr/readremotable stored under the pet namecounterin the daemon guest (see counter-caplet.md).- The weblet is published with
powers: "counter", which becomes itsbackcapability. GET <hash>.ocap.site/serves the staticfront(index.html + main.js + app.js + captp.js).WS <hash>.ocap.site/.well-known/endo-captppresents the counter as the CapTP session bootstrap.- app.js (the hand-rolled client) opens that WebSocket and drives
read/incr/decr; the buttons call them and refresh the display.
The front is split into four layers, each depending only on the one below it:
- index.html — the page: a display plus
+/-buttons. Loadsmain.jsas an external module (<script type="module" src="main.js">). - main.js — the boot layer: injects
makeWebSocket(url => new WebSocket(url)) as an endowment, wires the DOM, and callsstart(...). Kept external (not inline) to satisfy the weblet CSPscript-src 'self'. - app.js — the app layer: imports
makeCapTPfromcaptp.js, opens the WebSocket, and wires the buttons. No DOM access at import time. - captp.js — the protocol layer:
makeCapTP(ourId, rawSend, bootstrapObj, opts)returns{ getBootstrap, dispatch, abort, call }, API-shaped like@endo/captp. Transport-agnostic (takes arawSendfunction). - counter-caplet.js — the counter caplet source (a
Far('Counter', …)withincr/decr/read), deployed under the pet namecounter.Faris available as a worker endowment on theevaluateroute, so no import is needed. - weblet-publish-payload.py — emits the
base64
contentpayload for theweblet_publishMCP tool, with a typo guard. - counter-caplet.md — how to deploy the durable counter remotable.
- counter-caplet-buttons.md — the full write-up: dead ends, the win, and the debugging journey.
captp.js speaks just enough of the protocol to call the counter:
- open a WebSocket to
wss://<host>/.well-known/endo-captp CTP_BOOTSTRAP→ get the counter remotable slotCTP_CALL{questionID, target, method}→CTP_RETURN{answerID, result}- minimal marshal:
{body, slots}capdata, primitives + slot refs - slot-direction reversal: the server exports
o+1; the client imports it aso-1(reverseSlot).
- Text-frame decode. The gateway sends
CTP_RETURNas a text frame, soev.datais a string;new TextDecoder().decode(ev.data)threw on a string. Fixed bytypeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data). CTP_BOOSTRAPtypo. A misspelled protocol message type (should beCTP_BOOTSTRAP) made the server ignore the bootstrap, so the client hung. It crept in while hand-assembling the base64 publish payload.- CSP blocks inline scripts. The weblet serves
script-src 'self', so an inline<script type="module">was rejected. Fixed by moving the boot wiring into an externalmain.js. - Sending while CONNECTING. After the refactor,
getBootstrap()ran before the socket opened, throwingInvalidStateError. Fixed by awaiting anopenedpromise (resolved onws.onopen) before sendingCTP_BOOTSTRAP.
- Publish from generated byte strings, never hand-typed base64. Use
weblet-publish-payload.pyand copy its output verbatim. - Verify the published artifact, not just the source.
curl <url>/app.js | grep <token>after publish — the source can be right while the payload is wrong. - Console logging is the debugging instrument. When a WebSocket client hangs browser-but-works-node, the console trace (send vs recv) pinpoints whether the request left or the reply stalled.
- A tiny domain-specific client beats a general bundler for a small surface. For a counter, hand-rolling ~3KB of CapTP was simpler than shipping SES + a 650KB bundle.
- Layering pays off. Splitting the front into boot/app/protocol layers made each bug (CSP, CONNECTING) local and easy to fix, and kept the transport injectable for testing.