The TypeScript wire-compatible client: the same protocol the Java and Python
peers speak, from Node's standard library alone (net for TCP, crypto for
Ed25519 and SHA-256; the CBOR codec is in src/cbor.ts). A Peer joins a
Java fleet over TCP in the dial-only posture, answers SWIM pings, introduces
its self-advertisement once, writes signed entries, takes under the LEASE_RACE
claim lattice, and reads spaces back through anti-entropy. (A production client
would re-lease its self-advertisement on the advertisement TTL; the demo client
leans on ping liveness and does not.)
The tests prove this client byte-identical against the SAME
agentspaces-spec/golden.json (vendored here as test/golden.json) the Python client uses, generated from the
Java codec by tools/golden/GoldenVectors.java: every encoding, and every
signature (RFC 8032 signing is deterministic), matches exactly, including the
v0.1.10 vectors (the self-certifying founding document, the GROUP_AD_WANT
and GROUP_AD frames, the AgentCard and SpaceAdvertisement, the aggregate
and gossip-learn capability payloads), and every *_cbor vector is a fixed
point of dumps(loads(x)).
JavaScript has one number type and Java has several, and signatures cover the
bytes, so src/cbor.ts keeps the distinction explicit in both directions:
- Doubles. A plain
numberencodes as a CBOR integer; wrap it inCborDoubleto emit a float64 (an auction bid, an aggregate share). Every float the decoder meets comes back as aCborDouble, never a bare number, so a decoded claim or a histogram'sdouble[]re-encodes to exactly the bytes that were signed. That is what keeps signature verification of decoded claims working. - 64-bit integers. A Java
longoutsideNumber.MAX_SAFE_INTEGER(an aggregate roster token, a claim expiry an adversary set toLong.MAX_VALUE) decodes as abigint, and abigintencodes as a minimal-width CBOR integer, so int64 values round-trip byte-exactly instead of losing precision or throwing. Abigintinside the safe range encodes identically to the equivalentnumberand decodes back as anumber.wire.rosterToken(peerId)returns the participant token of SPEC section 8 as abigint.
A TypeScript peer can give each agent a key of its own, certified by the peer and
renewed at half-life, so the fleet stores its work AGENT_ATTESTED (SPEC §4.2).
Certificates are judged at the signing time of what they certify.
const identity = Identity.loadOrCreate("./keys"); // peer.key + peer.pub, Java's layout
const agent = identity.renewingSubordinate("ts-worker", "PT24H");
const peer = new Peer(identity, group);
const entryId = await peer.takeEntry("tasks", TASK_TYPE, "ts-worker", 60_000, 600, 8_000, agent);
peer.completeEntry("tasks", entryId!, agent); // an attested take completes as its agent
peer.writeEntry("tasks", FINDING_TYPE, finding, "ts-worker", 3_600_000, agent);- Attested transitions. An agent-attested entry's transitions must be agent-signed (A6).
- Persistent agent keys.
Identity.agentKeys(dir, peerId, name)mirrors Java'sAgentKeystore.identity.savePkcs8(path)writes a key as PKCS#8 DER, atomically and owner-only (0600). - Revocations. The peer reads the
revocationsandcredential-revocationsstreams and applies the freeze rule at merge:- founder-rooted peer revocations are accepted;
- agent and agent-key revocations are accepted from the founder or the agent's own peer;
- CA-rooted records are ignored;
- a late joiner learns the revocations issued before it arrived: the peer pulls
both streams once on joining, and every
pullSpacenames them too; - a founder-rooted peer revocation needs the founder known:
joinByGroupIdsets it from the verified founding document, and a peer on a literal group passes it asnew Peer(identity, group, now, founderPeerId)(without it, peer revocations are ignored); - the freeze rule covers every incoming state, held entries included, so a retired agent's later renewals and a revoked holder's completions are refused.
- What the client does not do yet. Content-key epochs and per-agent key wrap are pinned by the golden vectors, but the client does not decrypt sealed spaces.
A group founded with foundGroup(...) (SPEC sections 4.4 and 5.1) has a
self-certifying id: the hash of its signed founding document. A peer that
knows only that id and a seed endpoint joins with
const peer = new Peer(Identity.generate(), groupId);
const founding = await peer.joinByGroupId("hq.example", 7500, groupId);which dials the seed, sends an unaddressed GROUP_AD_WANT naming the group,
and waits for a GROUP_AD whose body passes the four checks of
verifySignedGroupAd: the founder key hashes to the advertisement's issuer,
the founder's signature verifies over the founding fields, the founding
document re-derives to exactly the wanted GroupID, and the id URI names it.
Answers that fail (a different policy under the same id, a forged signature)
are ignored, so a hostile seed can delay the join but never substitute a
group; the promise rejects with a timeout error (default 10 s) when no
verified document arrives, and only after success does the peer introduce
itself. Frames whose envelope ver is not 3 are dropped before any signature
work (SPEC section 9). connect(host, port) remains the path for
literal-founding groups whose id every member derives from shared
configuration.
The same AgentSpace and the same capability services the Java annotations
bind, as stage-3 decorators on plain classes (L3L4-COVERAGE.md §6.5).
TypeScript keeps no parameter types at runtime, so the cue type is the
decorator's first argument. An @entry class names its wire schema; a
Space holds the layer-3 verbs; a Template carries the Java matchers
(eq, ne, gt, gte, lt, lte, oneOf, contains, isNull,
notNull, or any function) over fields and tags; watch is an
EventEmitter and events an async iterator, each delivering WRITTEN,
TAKEN, and COMPLETED once per entry; TakeContext.current() (an
AsyncLocalStorage) is the take a worker runs under.
@entry("ai.badmonkey.agentspaces.examples.fleet.ResearchFleet$ResearchTask#v1")
class ResearchTask { constructor(public topic = "", public priority = 0) {} }
@agent("researcher", { description: "Researches topics", goals: ["research"] })
class Researcher {
@spaceTake(ResearchTask, "tasks", { lease: "30s", where: { priority: gte(1) }, produces: Finding })
research(task: ResearchTask): Finding { ... }
@spaceNotify(Finding, "tasks")
audit(finding: Finding): null { ... }
@bidFunction(ResearchTask, "tasks")
price(task: ResearchTask): number { ... }
@ballot("votes", { prefix: "council:" })
judge(proposal: Proposal): string | null { ... }
@onDecision("votes", { prefix: "council:" })
record(decision: Decision): null { ... }
}
const bound = new Binder(peer, { agent: identity.renewingSubordinate("ts-worker", "PT24H") }).bind(new Researcher());Binder.bind runs the loops the Java AgentBinder runs, on Promises: the
take loop, reactions off the delivery path, the ballot once per proposal, the
decision once at quorum, and the AgentCard (with the declared actions and the
agent certificate) published on the ads stream on bind. vote.propose,
vote.cast, and vote.decision are the procedural forms; the tally rule is
Java's. Numbers: a fractional field is encoded as a float64, an integral one
as an integer, and decoded doubles are unwrapped before matching.
The return conventions are the Java binder's (ISSUE-WorkflowVerbs,
ISSUE-Motion): null writes nothing; new Tagged(value, { region: "eu" })
is written with its tags; an array is the fork, each element written on its
own after the take completes; a new Motion(proposalId, question, options, quorum, space?) opens a vote once per proposal id in its space or the
binder's voteSpace. Two more cues: @propose(Claim, "claims", "votes", ["approve", "deny"], 2, { prefix: "claim:", key: "claimId" }) opens a vote
from a cue once per key (the method returns the question, null to ask
nothing, or a Motion), and @spaceJoin("intake", "claimId", [part(Fraud), part(Coverage), part(Claim, { optional: true })], { resultSpace: ... }) is
the LOCAL join: once per key, when every required part is readable, the
method receives a Joined (key, get, find, all, has). The
fleet-wide LEASED and ORDERED join modes, @SpaceReduce, and @OrderedTake
need the ticket and the ordered log and are not here. Space.entries(template)
is the metadata view (Entry<T>: entryId, value, tags, issuer,
expiresAtMillis, taken), and every event carries its entry's tags. A
watch, and @spaceNotify(..., { on: [...] }), deliver five kinds: WRITTEN,
TAKEN, COMPLETED, EXPIRED (the write lease lapsed while the entry was
open: a leased entry as a timer), and REAPPEARED (a take claim lapsed),
the last two judged by a sweep on the clock, once per entry and lapse. A
new Contribution(epoch, value) return starts the epoch on the binder's
aggregate, and produces on a take or notify takes a list. The
wire is version 3: maps a signature covers (tags, costHints,
spaceBindings) are sorted by canonicalMap, shorter UTF-8 keys first and
bytewise between equal lengths.
capabilities/ speaks the layer-4 pipes (PIPE_DATA frames addressed to a
member): Aggregate (push-sum avg, roster sum and count, min, max,
estimate, Settle, awaitSettled, onEstimate, and the @onEstimate
decorator), Semantic (the hashing embedder with Java's String.hashCode
so the vectors agree, query, remoteQuery over the query pipe, and
answers to other members' queries), and actions and invoke (the actions
the cards on the ads stream declare, invoked through the task space with
the shared-fields correlation). A dial-only peer sends frames to the members
it can reach (the seed it dialed), never to a member it only heard of.
Not here: the ordered log (the Raft client), so ordered takes, reduces, and
the fleet-wide LEASED and ORDERED join modes; the gossip-learning exchange
(its payload is encoded and golden-pinned); and MAJORITY_GOSSIP.
demo/council.ts is the whole of it against the Java CouncilFleet
coordinator; test/layer34.test.ts, test/layer4.test.ts, and
test/verbs.test.ts prove each piece without a socket.
npm install
npm test # tsc + golden vectors via node --test
# the trilingual demo (from the repo root):
clients/run-trilingual-demo.sh
The demo runs a Java coordinator publishing six tasks while a Python worker,
this TypeScript worker, and a LangChain4j worker race for them under the
claim lattice; the coordinator prints each finding with the language-tagged
worker that produced it. Its second act is the council: the Java
CouncilFleet coordinator with the Python and TypeScript council workers
voting, contributing to the push-sum epoch, answering the semantic query, and
serving a remote action. Four runtimes, one space, no broker.
takeEntry accepts an optional exactEntryId argument (before the template
matcher and the bid function). Selection always
filters by the group's space ID, uses the effective renewed write lease, and
rechecks the winning claim before returning. requireHeld validates an exact,
current claim before an application records a result; completeEntry applies
the same guard. The lease-race protocol remains advisory under partitions;
applications must fence external effects separately.
Writes are folded into the issuer's verified local replica before transport.
exportSnapshot contains signed states, claims, and revocations, without keys.
restoreSnapshot re-verifies revocations before claims and states. Applications
own atomic file storage, size limits and single-writer exclusion. A snapshot is
not a remote acknowledgment or a distributed durability guarantee. Configure the
same trusted founding identity before restoring founder-rooted revocations.