AgentSpaces for TypeScript
Write agents as decorated TypeScript classes and run the same peer in Node, a browser tab, Deno, Bun, or a Cloudflare Worker. A TypeScript peer joins the same fleet as the Java, Python, and Clojure peers, over the same signed wire.
Introduction
This part takes you from an empty directory to two TypeScript agents sharing work, then to a crash the fleet recovers from. A few words first, since every later page uses them:
- A peer is one running member of a fleet. A group is the set of peers that trust each other.
- A space is a named, replicated store of entries that every peer in the group sees.
- An entry is one typed record in a space. Every entry has a lease, a time after which it lapses.
- A take claims an entry for a while (the take's own lease). If the taker finishes, the entry is done. If the taker dies, the lease lapses and the entry comes back for someone else.
- An agent is a class whose methods react to entries: take one, notify on one, bid for one.
- A capability is a service a peer provides to the group, such as an ordered log or the content swarm.
Install
The packages @agentspaces/client and @agentspaces/node are not on npm yet. Clone the repository and build the core, then the ordered log, then the Node host (the host depends on the other two):
git clone https://github.com/badmonkeyai/agentspaces-typescript cd agentspaces-typescript && npm install && npm run build cd packages/ordered && npm run build cd ../node && npm run build
Then create your project beside the clone and install the two packages from it:
mkdir shop && cd shop npm init -y && npm pkg set type=module npm install ../agentspaces-typescript ../agentspaces-typescript/packages/node npm install -D typescript @types/node
Standard decorators need no experimentalDecorators flag. Target ES2022 with NodeNext modules:
// tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "outDir": "build", "types": ["node"] }, "include": ["fulfiller.ts"] }
Start a seed
A plain TypeScript Peer only dials out. It needs a seed: a peer that listens and that the others dial first. The Node service host is a seed with no code, configured by the same agentspaces: YAML a Java member reads.
# seed.yaml agentspaces: security: profile: dev-local # signed frames on loopback TCP; never on a shared network bind: 127.0.0.1:7700 tick-millis: 250 groups: - name: shop founding: shop-v1 spaces: [ orders ]
The dev-local profile signs every frame over plain loopback TCP. Use it on a laptop, never on a shared network; production fleets use mtls. Start the seed from the directory that holds both seed.yaml and the clone:
node agentspaces-typescript/packages/node/build/src/cli.js run seed.yamlThe first run creates a keys/ directory beside seed.yaml to hold the seed's identity. Every peer that names the founding text shop-v1 derives the same group id with groupIdFromFounding("shop-v1").
Entries
An entry is a class marked @entry with its wire schema name. The field names are the wire names too, so a Java record or a Python dataclass with the same schema reads the same entry. The decoder copies the wire fields onto an instance without calling the constructor, so keep entry classes to plain data.
// shop.ts import { entry } from "@agentspaces/client"; @entry("shop.Order#v1") export class Order { constructor(public orderId = "", public item = "") {} } @entry("shop.Shipment#v1") export class Shipment { constructor(public orderId = "", public item = "", public by = "") {} } // ... @entry("shop.Invoice#v1") export class Invoice { constructor(public orderId = "", public cents = 0) {} }
Keep money and counts in whole numbers, such as cents. The section on numbers below says why.
Your first agent
An agent is a method. Its parameter is the work it takes, and its return value is the next entry in the space. @spaceTake names the entry class to take, the space, how long the method may hold an entry, and the class it produces. The program below connects to the seed, binds the agent, and, when you pass an item, writes an order and prints the shipment.
// fulfiller.ts import { Binder, Identity, Peer, Space, Template, agent, entry, groupIdFromFounding, spaceTake } from "@agentspaces/client"; import "@agentspaces/client/node"; // Node: registers the tcp and tls dialers @entry("shop.Order#v1") // the wire name; field names match the wire too class Order { constructor(public orderId = "", public item = "") {} } @entry("shop.Shipment#v1") class Shipment { constructor(public orderId = "", public item = "", public by = "") {} } @agent("fulfiller", { description: "Ships orders" }) class Fulfiller { @spaceTake(Order, "orders", { lease: "30s", produces: Shipment }) ship(order: Order): Shipment { return new Shipment(order.orderId, order.item, "fulfiller"); } } const identity = Identity.generate(); const peer = new Peer(identity, groupIdFromFounding("shop-v1")); await peer.connect("127.0.0.1", 7700); // the seed await new Promise((resolve) => setTimeout(resolve, 1000)); new Binder(peer, { agent: identity.renewingSubordinate("fulfiller", "PT24H") }).bind(new Fulfiller()); if (process.argv[2]) { // node build/fulfiller.js kite const orders = new Space(peer, "orders"); orders.write(new Order("o-1", process.argv[2]), "10m"); console.log(await orders.read(new Template(Shipment), "30s")); peer.close(); }
import "@agentspaces/client/node"registers thetcpandtlsdialers. Atls://host:portseed dials TLS 1.3 and presents a channel certificate that the peer's identity key signs, so the peer can join a fleet that requires attestation. The main entry imports no Node module, so leave this line out in a browser or a Worker.Binder.bindruns the agent's loops.renewingSubordinategives the agent a key of its own, certified by the peer and renewed at half-life.Spaceholds the verbs:write,read,take,readAll,watch. ATemplatematches by class and, optionally, by field values.
Two instances and a crash
Compile once and start two instances in two terminals. The first only ships. The second also writes an order, prints the shipment, and exits.
npx tsc node build/fulfiller.js node build/fulfiller.js kite
Either instance can take the order, because both run the same agent. Now try a crash:
- Make
shipasyncwith aPromise<Shipment>result. - At its top, add
console.log("shipping", order.orderId);andawait new Promise((resolve) => setTimeout(resolve, 10_000));. - Start both instances, write an order from the second, and run
kill -9on whichever printsshipping.
If you killed the first, the second prints the shipment. If you killed the second, the first prints shipping o-1 again when the 30 second lease lapses. You wrote no retry code; the lapsed lease handed the order back.
A second verb: bids and notifications
A take hands each entry to whichever agent claims it first. A bid lets agents compete on price: each @bidFunction method returns a number for the entry, and the lowest bid takes it.
// step06.ts @agent("pricer") class Pricer { constructor(readonly name: string, readonly price: (item: string) => number) {} @bidFunction(Order, "auction") bid(order: Order): number { return this.price(order.item); } // the lowest bid wins @spaceTake(Order, "auction", { lease: "30s", produces: Shipment }) ship(order: Order): Shipment { return new Shipment(order.orderId, order.item, this.name); } }
A notification reacts to an entry without taking it, so every agent that listens sees it. Return null to write nothing back.
// step07-agents.ts @agent("receipts") export class Receipts { @spaceNotify(Parcel, "orders") saw(parcel: Parcel): null { console.log(`receipts saw Parcel ${parcel.orderId}`); return null; } }
Where to read next
- The Key features part below covers the hosts (Node, browser, Workers), the ordered log, the stores, and what TypeScript lacks.
- The developer guide explains leases, claims, and capabilities in depth; its TypeScript summary and JavaScript hosts summary compare this client with the Java chapters.
- The architecture guide shows how gossip, anti-entropy, and the claim lattice fit together, and the workflow guide builds multi-step flows.
- The Python guide and Clojure guide build the same shop in those languages.
Key features
Decorators with the cue type first
The client uses standard ECMAScript (stage-3) decorators. TypeScript erases parameter types at run time, so every verb decorator takes the cue type, the entry class it reacts to, as its first argument. Name produces too, because the agent's published card cannot read a return type.
The return value decides what the binder writes:
- an entry: written as the next entry;
null: nothing written;- an array: a fork, each element written on its own after the take completes;
new Tagged(value, { region: "eu" }): written with its tags.
A fork and a join, the two halves of a split-and-merge flow:
// step07-agents.ts @agent("packer") export class Packer { @spaceTake(Order, "orders", { lease: "30s", produces: [Label, Invoice] }) pack(order: Order) { return [new Label(order.orderId, "12 Vine St"), new Invoice(order.orderId, 2500)]; } } @agent("assembler") export class Assembler { @spaceJoin("orders", "orderId", [part(Label), part(Invoice)], { produces: Parcel }) assemble(parts: Joined): Parcel { return new Parcel(parts.key, parts.get(Label).address, parts.get(Invoice).cents); } }
The other decorators follow the Java annotations: @ballot, @onDecision, @propose, @onEstimate, @orderedTake, and @spaceReduce. The developer guide's TypeScript section lists their options.
One core, every JavaScript runtime
The core package imports no Node module. Crypto runs on the noble libraries and bytes are Uint8Array, so the same Peer loads in Node 22, browsers, Web Workers, Deno, Bun, and workerd. The suites run the core in each of those runtimes and in headless Chromium, Firefox, and WebKit.
What a host may carry depends on its residency, the kind of host it is. The host packages add what a plain page cannot do:
| Package | Runs in | Residency | Adds |
|---|---|---|---|
@agentspaces/client | anywhere | visitor | the peer, spaces, agents, LOCAL joins, the store adapters |
@agentspaces/browser | a SharedWorker | resident | one peer per origin, LEASED and ORDERED joins, @spaceReduce |
@agentspaces/workers | a Durable Object | cell | cells on celld, workerd, and Cloudflare; connector duties |
@agentspaces/node | a Node process | service | listeners, relay, the Raft voter, the CLI |
Bind a verb the host cannot carry and bind fails at once, naming the verb, the residency, and the package that can. For example, @spaceReduce on a visitor names @agentspaces/browser, @agentspaces/node, or @agentspaces/workers.
The Node service host and its CLI
A ServiceHost is a Node process that listens on tcp and ws, dials its seeds, serves anti-entropy, and relays for the dial-only peers behind it. One process can be both the seed and the home of agents: bind on host.peer. Pass signals: true to drain and stop on SIGTERM or Ctrl-C.
// peers.ts const host = new ServiceHost(identity, groupIdFromFounding("shop-v1"), { listeners: [{ scheme: "tcp", bind: `127.0.0.1:${PORTS[name]}` }], seeds: name === "A" ? [] : [parseSeed(seed)], // A founds, the others join A spaces, tickMillis: 250, log: null, }); host.peer.channelAuth = "session"; // the dev-local security profile await host.start();
The CLI agentspaces-node reads the agentspaces: subtree of a fleet file, so one file can describe a Java member and a Node member. It ignores Java-only keys and adds one Node-only block, ordered-logs:
# fleet.yaml agentspaces: keystore: keys bind: 127.0.0.1:0 tick-millis: 50 security: profile: dev-local transport: ws: bind: 127.0.0.1:0 allowed-origins: [ "https://app.example" ] groups: - name: council founding: council-v1 seeds: 127.0.0.1:7460, 127.0.0.1:7461 spaces: - { name: tasks, settle-window-millis: 200 } - { name: votes } ordered-logs: - space: tasks members: [ zAlpha, zBeta ]
It has two commands, run <fleet.yaml> and id [keystore-dir], which prints the PeerID of a keystore and creates it when absent. Run it from the clone as node agentspaces-typescript/packages/node/build/src/cli.js.
The browser host
A browser tab reaches the fleet over WebSocket. @agentspaces/browser runs one peer in a SharedWorker for the whole origin, holding a Web Lock so only one peer per identity runs, with its keys and state in IndexedDB. Every tab attaches to that worker, so the fleet sees one member however many tabs are open.
- The agents live in the worker, because a page cannot send a class across a message port. Bind them in
host.onResume, which fires after the first dial and again after any pause. - The seed's
wslistener admits a page only when the page's origin is inallowed-origins. An empty list refuses every browser. - When the last tab hides or freezes, the worker stops renewing and closes its socket. Held claims lapse with their leases, and the worker resumes under the same identity when a tab returns.
- Bundle with
--target=es2022. A lower target leaves the decorators as written and the browser rejects the bundle.
The Examples part builds a working page.
Cloudflare Workers cells and celld
@agentspaces/workers runs a peer inside a Durable Object, called a cell. A cell keeps its identity and a state snapshot in the object's SQLite storage, ticks on the alarm, and holds one outbound wss connection to the fleet's seeds (ws under dev-local). One bundle and one wrangler.json run on celld (a self-hosted Durable Object runtime), workerd, and Cloudflare.
There are two kinds of cell:
AgentSpacesCellbinds decorated agents as a member with its own identity.AgentSpacesConnectorCellpublishes the deployment's own bindings (Durable Objects, KV, D1, R2, Queues, Workflows, routes) as asset cards under a manifest that is the whole allowlist.once(storage, invocationId, fn)makes an operation run once under a retry.
// index.ts @agent("damage-estimator", { description: "Estimates damage from a report's photos" }) export class DamageEstimator { estimated = 0; @spaceTake(DamageReport, "claims", { lease: "1m", pollTimeout: "1s" }) estimate(report: DamageReport): DamageEstimate { this.estimated += 1; return new DamageEstimate(report.claimId, String(report.photos * 250), "damage-estimator"); } } export class Adjuster extends AgentSpacesCell<Env> { protected agents(_env: Env): object[] { return [new DamageEstimator()]; } }
The vars block of wrangler.json names the group, seeds, invite, and profile (AGENTSPACES_GROUP, AGENTSPACES_SEEDS, AGENTSPACES_INVITE, AGENTSPACES_PROFILE). R2PieceStore lets a cell serve a large R2 object to the content swarm by ranged gets. The package's tests run the example under workerd. No celld binary or Cloudflare account was part of those runs; the bundle uses only APIs both list as supported. See packages/workers.
To open an existing celld or Workers deployment without changing its code, run agentspaces-connect-celld beside it. It is a Java 21 connector that publishes the deployment's HTTPS routes as asset cards and calls them with the operator's bearer or mTLS credential, so agents never hold it. Version 0.5.0 builds from source with mvn install. A TypeScript peer, in Node or a browser worker, reads and acts on those cards with @agentspaces/connect:
// connect.test.ts const client = new DataQueryClient(agent, { requester: "analyst" }); const fetched = await client.fetch("orders", { region: "eu" }, "15s"); // ... const result = await invoke(space, card, "reserve", { sku: "A-1", qty: "2" }, { timeoutMillis: 15_000 });
The ordered log, joins, and reduce
A plain take is a race: two agents may both start on an entry under a partition, and the lease decides who completes. When an effect must happen exactly once, use @orderedTake. Its claim goes through a Raft log, and the claim installs only when the log commits it.
// payer.ts @agent("payer") export class Payer { constructor(readonly name: string) {} @orderedTake(Payment, "payments", { lease: "30s", produces: Paid }) pay(payment: Payment): Paid { return new Paid(payment.paymentId, payment.cents, this.name); } }
- The voter is
OrderedLogHostfrom@agentspaces/ordered, run as a duty of a Node service host. Its member list is fixed when you build it and must be identical on every voter, so create the identities first. Node and Java voters can share one log. - Any peer binds
@orderedTakeas a Raft client:new Binder(peer, { ordered: new OrderedLog(new Pipes(peer)) }). @spaceJoinruns LOCAL by default.mode: "LEASED"fires once fleet-wide through a leased ticket, andmode: "ORDERED"through a ticket the log commits. Both need a resident, cell, or service host.@spaceReducefolds entries per key into one accumulator entry:
// verbs-reduce.test.ts @agent("teller") class Teller { // ... @spaceReduce(Payment, Balance, "ledger", { key: "account", lease: "2s", pollTimeout: "100ms", accumulatorLease: "1h" }) apply(balance: Balance | null, payment: Payment): Balance { // ... return new Balance(payment.account, (balance?.cents ?? 0) + payment.cents, (balance?.payments ?? 0) + 1); } }
Stores, WebRTC, and the content swarm
Three packages carry other libraries' traffic over the fleet's signed links. Each needs only the peer's capability pipes, so each runs at the visitor residency, in a page included.
@agentspaces/yjs: a Yjs provider. A document that syncs through y-websocket syncs over the fleet when the pipes take the WebSocket URL's place.@agentspaces/automerge: an automerge-repo network adapter, with the fleet's members as the Repo's peers.@agentspaces/webrtc: a direct data channel between two members that nobody can dial, such as two browsers, signalled over the fleet. On Node it needsnode-datachannel.
// stores const pipes = new Pipes(peer); const doc = new Y.Doc(); const provider = new AgentSpacesProvider(pipes, room, doc, { sweepMillis: 20 }); // ... const adapter = new AgentSpacesNetworkAdapter(pipes, { sweepMillis: 20 }); const repo = new Repo({ network: [adapter], peerId: adapter.fleetPeerId });
The content swarm (aspace:cap/content-swarm, new in 0.5.0) moves large artifacts. A publisher hashes the bytes in fixed pieces and writes a signed manifest to the content space. Fetchers pull pieces from every holder at once and verify each before storing it. MemoryStore runs anywhere, FileStore (Node) resumes after a restart, and the browser host adds OpfsStore.
// step12.ts function provideContent(peer: Peer) { const content = new Content(new Pipes(peer), new Space(peer, "content"), new MemoryStore()); content.run(100); // trade pieces every 100 ms return content; } // ... const published = onA.publish(bytes, { name: "checkpoint-1" }); // ... const toB = onB.fetch(root); if (await toB.await(30_000) && sha256(toB.readBytes()) === sha256(bytes)) {
In a verified run, peer A publishes 4 MiB, peer B fetches it, A stops, and peer C fetches the same artifact from B alone.
Numbers, and what TypeScript lacks
JavaScript has one number type and Java has several, and signatures cover the exact bytes. So the CBOR codec keeps the difference explicit:
- A plain
numberencodes as a CBOR integer. The raw encoder throws on a fraction withwrap doubles in CborDouble. - Wrap a value in
cbor.CborDoubleto send a float64. Every float the decoder meets comes back as aCborDouble, so a decoded claim re-encodes to the bytes that were signed. - In an
@entryfield, the client wraps a fractional value for you, but a whole value such as4.0still travels as the integer 4. Keep shared fields in whole units (cents) so every language reads them the same. - A Java
longbeyondNumber.MAX_SAFE_INTEGERdecodes as abigint.
What the client does not do, and what to use instead:
- Gossip learning. Its payload is encoded and pinned by the golden vectors, but the exchange is not implemented. Run the learner on a JVM peer.
- Sealed spaces. The client does not decrypt them yet.
- **Listening from a plain
Peer.** APeerdials one seed and keeps that one connection (failing over across the seeds you giveconnectAny). Use aServiceHostfor anything that must accept connections. - A per-space auction strategy. The TypeScript bidder never outbids a claim it has already seen, so bids compete inside one settle window and the lowest bid takes the entry.
- Providing the vote capability. Proposals and ballots are entries in the votes space, and
vote.proposewrites a proposal;@ballotand@onDecisionwork as in Java.
Examples
A fleet on Node that survives a crash
This example runs a service host, a worker that takes a task and dies holding it, and a rescuer that joins later and finishes the task. The writer watches the task's events: TAKEN, then REAPPEARED once the two-second lease lapses. Every peer runs in one process over real loopback sockets, with no network and no model key.
// readme.test.ts @agent("doomed") class Doomed { @spaceTake(Task, "tasks", { lease: "2s", pollTimeout: "1s" }) hang(_task: Task): Promise<Finding> { return new Promise(() => undefined); } // never returns } @agent("rescuer") class Rescuer { @spaceTake(Task, "tasks", { lease: "30s", pollTimeout: "1s", produces: Finding }) finish(task: Task): Finding { return new Finding(task.topic, "finished by the rescuer"); } } const hub = new ServiceHost(Identity.generate(), GROUP, { tickMillis: 50, listeners: [{ scheme: "tcp", bind: "127.0.0.1:0" }], spaces: ["tasks"], log: null }); await hub.start(); const doomed = new Peer(Identity.generate(), GROUP); await doomed.connectAny([seedOf(hub)]); new Binder(doomed, { agentName: "doomed" }).bind(new Doomed()); const writer = new Peer(Identity.generate(), GROUP); await writer.connectAny([seedOf(hub)]); const tasks = new Space(writer, "tasks"); const events: string[] = []; tasks.watch(new Template(Task), (e) => events.push(e.kind)); tasks.write(new Task("resilient", 1), "10m"); await until(() => events.includes("TAKEN")); // the doomed worker holds the task doomed.close(); // and dies mid-take await until(() => events.includes("REAPPEARED")); // its 2 s lease lapsed: the task is back const rescuer = new Peer(Identity.generate(), GROUP); await rescuer.connectAny([seedOf(hub)]); rescuer.pullSpace("tasks"); const bound = new Binder(rescuer, { agentName: "rescuer" }).bind(new Rescuer()); const finding = await tasks.read(new Template(Finding), "30s");
until polls a condition and seedOf(hub) turns the host's listening address into a seed; both are small helpers at the top of the file. After building the clone as in Install, run it:
cd agentspaces-typescript/packages/node npm run build && node --test build/test/readme.test.js
The file holds three tests; this one is "README 2". Full source: packages/node/test/readme.test.ts.
A browser tab joins the fleet
This example runs the fulfiller in a browser's SharedWorker. The page writes an order through the worker, the agent ships it, and the page shows kite shipped by fulfiller. Start with a seed that listens on ws and admits the page's origin:
# seed-ws.yaml agentspaces: security: profile: dev-local bind: 127.0.0.1:7701 tick-millis: 250 transport: ws: bind: 127.0.0.1:7702 allowed-origins: [ "http://127.0.0.1:8080" ] groups: - name: shop founding: shop-v1 spaces: [ orders ]
The dev-local profile here signs frames over plain loopback TCP and WebSocket; use it on a laptop, never on a shared network. The entries file and the agent are the same Order, Shipment, and Fulfiller classes as before. The worker binds the agent, and the page talks to the worker:
// worker.ts import { host } from "@agentspaces/browser/worker"; import { Fulfiller } from "./fulfiller.js"; host.onResume(() => host.bind(new Fulfiller())); // the agent runs in the worker, so every tab shares it
// page.ts import { connect } from "@agentspaces/browser"; import { Template } from "@agentspaces/client"; import { Order, Shipment } from "./entries.js"; const host = await connect({ founding: "shop-v1", seeds: ["ws://127.0.0.1:7702/aspace"], worker: new URL("./worker.js", import.meta.url), }); const orders = host.space("orders"); await orders.write(new Order("o-1", "kite"), "10m"); const shipment = await orders.read(new Template(Shipment), 30_000); document.body.textContent = `${shipment?.item} shipped by ${shipment?.by}`;
Install @agentspaces/browser from the clone (npm install ../agentspaces-typescript/packages/browser after building it with npm run build there) and esbuild as a dev dependency. Put an index.html that loads page.js as a module in public/. Then start the seed, bundle, and serve:
node agentspaces-typescript/packages/node/build/src/cli.js run seed-ws.yaml npx esbuild page.ts worker.ts --bundle --format=esm --target=es2022 --outdir=public cd public && python3 -m http.server 8080 --bind 127.0.0.1
Open http://127.0.0.1:8080/index.html. Open a second tab and both tabs share the one worker. Full source and the options connect takes: packages/browser.
Exactly once through an ordered log
This example starts two Node service hosts, each carrying a Raft voter for the orders space, and two clerks on dial-only peers that race for the same three orders. Each clerk binds @orderedTake with the Raft client, so every order is confirmed by exactly one clerk, fleet-wide.
// readme.test.ts @agent("clerk") class Clerk { readonly confirmed: string[] = []; constructor(readonly name: string) {} @orderedTake(Task, "orders", { lease: "30s", pollTimeout: "1s", resultSpace: "receipts", produces: Finding }) confirm(order: Task): Finding { this.confirmed.push(order.topic); return new Finding(order.topic, `confirmed by ${this.name}`); } } const identities = [Identity.generate(), Identity.generate()]; // two Node voters: a log needs a majority const members = identities.map((i) => i.peerId); const voters: ServiceHost[] = []; for (const [i, identity] of identities.entries()) { const host = new ServiceHost(identity, GROUP, { tickMillis: 50, spaces: ["orders", "receipts"], log: null, listeners: [{ scheme: "tcp", bind: "127.0.0.1:0" }], seeds: i === 0 ? [] : [seedOf(voters[0])] }); const log = new OrderedLogHost(host.peer, "orders", members, { residency: "service", tickMillis: 50 }); await log.ready(); host.addDuty(log); // the voter ticks with its host await host.start(); voters.push(host); } const clerks = [new Clerk("one"), new Clerk("two")]; // ... for (const clerk of clerks) { // two workers racing for the same orders const peer = new Peer(Identity.generate(), GROUP); await peer.connectAny([seedOf(voters[0])]); bounds.push(new Binder(peer, { ordered: new OrderedLog(new Pipes(peer)), agentName: "clerk" }).bind(clerk)); peers.push(peer); }
- The voters' member list is built from identities made before the hosts start, because the list is fixed when each log is built.
host.addDuty(log)ticks the voter with its host. A host whose tick drifts steps down to visitor duties and suspends the voter until its cadence recovers.- The test then writes three orders from a third peer and checks that the receipts hold each order once.
It is "README 3" in the same file, run by the same command as the first example. Full source: packages/node/test/readme.test.ts. For the voter's API, see packages/ordered.