Quick start
Two plain classes become a two-peer fleet: a kill-tolerant worker and an auditor that reacts to every result. Nothing here deploys a broker, a registry, or a scheduler; the coordination fabric is a library inside your application.
1. Add the starter
The source lives at github.com/badmonkeyai/AgentSpaces, and the release artifacts are on Maven Central under the group ai.badmonkey.agentspaces, version 0.4.0 (central.sonatype.com). Maven and Gradle resolve Maven Central by default, so no repository configuration is needed. One dependency brings in the starter, the fabric and the annotation binder:
<!-- pom.xml --> <dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>agentspaces-spring-boot-starter</artifactId> <version>0.4.0</version> </dependency>
The bill of materials, agentspaces-dependencies, manages the version of the core AgentSpaces modules (the fabric, the transports, the starter, the console, the A2A gateway, the Embabel extension, and the test support) and of Jackson, Caffeine, Nimbus JOSE+JWT, and BouncyCastle; the bridge projects for Spring AI, Micronaut, and LangChain4j are not in it and carry their own version. Import it once and leave the version off every core AgentSpaces dependency, whether the starter or individual modules (a plain-Java application depends on agentspaces-agent, which brings the fabric with it):
<!-- pom.xml: dependencyManagement --> <dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>agentspaces-dependencies</artifactId> <version>0.4.0</version> <type>pom</type> <scope>import</scope> </dependency>
Under Gradle the same BOM is a platform dependency. Kotlin DSL:
// build.gradle.kts dependencies { implementation(platform("ai.badmonkey.agentspaces:agentspaces-dependencies:0.4.0")) implementation("ai.badmonkey.agentspaces:agentspaces-spring-boot-starter") }
Groovy DSL:
// build.gradle dependencies { implementation platform('ai.badmonkey.agentspaces:agentspaces-dependencies:0.4.0') implementation 'ai.badmonkey.agentspaces:agentspaces-spring-boot-starter' }
A project that inherits from spring-boot-starter-parent or applies the Spring Boot Gradle plugin still imports this BOM, because Spring Boot's dependency management does not know the AgentSpaces artifacts; without it, write 0.4.0 on each dependency. No snapshot repository is published: to build against unreleased sources, run mvn install -DskipTests in a clone and resolve from the local repository (mavenLocal() under Gradle).
2. Describe the fleet, once
The starter reads one YAML block: an identity keystore, a group every member derives the same self-certifying id from, and the spaces the group shares. Start a second JVM with the same block, its own advertise address, and a seed address, and you have a fleet.
# application.yml agentspaces: keystore: ./peer-keys bind: 0.0.0.0:7500 advertise: peer1.example:7500 # the address other peers dial groups: - name: orders-fleet founding: orders-fleet-v1 seeds: ["hq.example:7500"] # omit on the first peer spaces: [ { name: work } ]
3. Write the agents
The worker takes orders under a lease and returns shipments; the auditor reacts to every shipment and returns a receipt. Both are complete as shown.
@SpaceAgent(description = "Ships orders from the shared space") public class Fulfiller { @SpaceRef private Space space; // injected at bind time @SpaceTake(lease = "10m") // crash => order reappears public Shipment ship(Order order) { space.write(new Progress(order.orderId(), "picking"), Lease.of(Duration.ofMinutes(5))); return new Shipment(order.orderId(), order.item(), "fulfiller"); } }
@SpaceAgent(description = "Writes a receipt for every shipment") public class Auditor { @SpaceNotify // react to X, produce Y public Receipt account(Shipment s) { return new Receipt(s.orderId(), s.item() + " shipped by " + s.by()); } }
// entries are plain records; the space replicates them, signed and leased public record Order(String orderId, String item) {} public record Progress(String orderId, String note) {} public record Shipment(String orderId, String item, String by) {} public record Receipt(String orderId, String summary) {}
4. Put work in
spaces.group("orders-fleet").space("work") .write(new Order("ord-1", "kite"), Lease.of(Duration.ofMinutes(10)));
What you now have: the order replicates to every peer, one fulfiller takes it under the default LEASE_RACE strategy (a partition can briefly let two fulfillers work the same order), the shipment and receipt appear as signed entries, and if you kill the working JVM mid-task the lease lapses and the order reappears for another. examples/example-11-quickstart is the plain-Java form of this pair (two in-process TCP peers, an AgentBinder, no Spring); integration-tests/spring-boot-it boots a smaller application through the starter instead: one @SpaceTake researcher over a tasks space and a findings space, alone and as a two-application fleet over TCP.
AgentBinder, register spaces with binder.space("work", space), and call binder.bind(new Fulfiller()). The starter only automates what plain Java does in three lines.One fabric, several ways to build
Choose who decides the next step, then choose how that step is claimed. AgentSpaces supplies typed entries, leases, discovery, identity, and replication. Your application can coordinate through those entries directly, let an Embabel planner compose typed actions, or let a Spring AI model choose fleet tools. A worker can itself use Spring AI, regardless of how its task was created.
The same process may host several roles. Splitting them into peers changes deployment and failure boundaries; it does not require rewriting the request and result types. Keep those types in a shared domain artifact, name your spaces explicitly, and carry a request id through every stage.
Match the coordination pattern to the job
| Pattern | Use it for | Core shape and boundary |
|---|---|---|
| Competing workers | Independent jobs; horizontal worker pools | @SpaceTake on LEASE_RACE. One loop per bound method; crashes release work by lease expiry. Make external effects idempotent. |
| Pipeline / choreography | Stable stages with no central planner | A returned entry feeds the next typed take or notify. Use takes for stages that need retries; notify for broadcast reactions. |
| Fan-out / ensemble | Independent specialists inspect the same input | @SpaceNotify broadcasts and @SpaceJoin joins the branches once per key, with the mode choosing once-per-joiner, once-per-lease, or once-fleet-wide. @SpaceRef writes explicit tasks when each branch needs retry; within only makes the join forget a key whose parts stopped arriving and fires nothing, so use the deadline pattern below for a timeout that must act. |
| Fork | One cue produces several entries, each its own next step | Return Entries from any bound method and list the element types in produces; from a take the binder completes once, then writes each element. |
| Route | Only some entries of a type belong to this worker | where filters on a field, compiled onto the template and judged before a take claims; a sealed return type declares the branches a method chooses between. |
| Gather | A stage waits for several entries of one kind about one case | @SpaceJoin with @Part(atLeast), @Part(countedBy) for a count another part declares, or settle for a number nobody knows; Joined.all(type) hands them over. |
| Running total | A per-case figure that must be exact, current after every entry, and survive a restart | @SpaceReduce: the accumulator is an entry beside its elements, advanced by each element's own atomic completion and serialized per key by a ticket; its lease is the idle timeout. Bounded sets per case are the gather; fleet-wide figures are the aggregate. |
| Deadline | Something must happen if nothing happens in time | @SpaceNotify(on = EXPIRED) over a leased reminder; every replica sees the lapse, so return a Motion or write an entry the next stage dedups on. |
| Settled estimate | Act once the fleet's figure has stopped moving | @OnEstimate fires once per epoch when the push-sum estimate holds within a tolerance for a run of settle steps (gossip rounds); a settle rule does not detect a poisoned contribution. |
| Auction / specialization | Workers differ by price, load, skill, or locality | @BidFunction plus @SpaceTake on AUCTION. One bidder per space per peer; use one comparable cost unit. |
| Panel / approval | Several authorized judgments must agree | @Propose or a Motion return opens the vote; @Ballot and @OnDecision judge and record it. Proposal options, quorum, deadline, and voter granularity are application choices. |
| Ordered work | A fixed quorum must agree on claim order | @OrderedTake with OrderedTakes. Requires quorum availability; does not transact with external services. |
| Goal-driven orchestration | The route to a typed goal depends on available skills | Embabel composes local and generated remote actions. Execution still happens through space entries. |
| Tool-driven orchestration | A model chooses which fleet skill to call next | FleetTools attached to a Spring AI ChatClient. Attach local tools alongside the fleet provider as needed. |
| Model service | Centralize provider credentials or pool model capacity | FleetChatModel + ModelServer. Prompts cross the group; tools execute in the caller’s tool loop. |
| Shared retrieval / RAG | Many agents query one knowledge base | VectorStore connector + discovery data tools. Retrieval results are leased; this does not automatically inject context into prompts. |
| Fleet sensing / learning | Approximate fleet statistics or decentralized models | Contribution / AggregateClient and GossipLearner. Estimates and learning rounds are not transactional decisions. |
| Hybrid / gateway | Expose a curated fleet boundary to another application | Combine an Embabel planner, Spring AI workers, and an MCP or A2A edge. Declare each boundary’s access and routing rules. |
Agents and the binder
An agent is an ordinary object with annotated methods, and the binder is what turns the annotations into running behavior. The annotation model is the top of the stack and the whole API most applications need: fourteen annotations cover identity, work, choreography, routing, fan-in, economics, and access at Layer 3, and voting, settled estimates, exactly-once work, typed clients, and served capabilities at Layer 4. Each maps to protocol behavior the layers below carry for you. This chapter covers the facade and the binder, the identity annotation, and the conventions every annotation shares. Chapter 2 covers work: take, notify, bid, and the space handle. Chapter 3 covers decisions and fleet sensing. Chapter 4 covers exactly-once work and fan-in.
They go on ordinary objects. There is no base class to extend, no interface to implement, and no container of ours to run in: the binder reflects over an instance you hand it, wires whatever it finds, and leaves the rest of the class alone. Constructors, other Spring beans, JPA repositories, and your own fields all stay untouched. That matters for how you organize a fleet, so each annotation below says plainly where it belongs and what else is allowed to sit beside it.
@SpaceAgent is @Component + @AgentSpec) and the post-processor enrolls it after initialization. Without Spring, call binder.bind(instance) or spaces.bind(instance) yourself. Either way you construct the object normally first; the binder never instantiates anything.The facade and the binderAgentSpacesGroupContextAgentBinder
Two objects bind. AgentBinder is the hand-built form and serves one group: it is built from the peer identity, the GroupId, the group's discovery service (or null to publish no cards), and a clock. Register what it may bind to, then bind: space(name, space) for each space, vote(space, capability) for a vote capability, aggregate(pushSum), ordered(space, coordinator), client(type, instance) for a typed client, and bind(instance) or bind(instance, name), which names the agent and lets one class be several agents. The AgentSpaces facade sits over several groups. spaces.group(name) returns a GroupContext that holds the group's spaces (space(name)), its discovery service (discovery()), its typed capability clients (capability(VoteClient.class)), and the registrations the Layer 4 annotations need: provide(capability), vote(space, capability), ordered(space, coordinator). spaces.bind(instance) binds the object into every registered group whose spaces cover the spaces its annotations name, narrowed to the groups the annotations name, and returns one Bound per group; group.bind(instance) binds into one. Under the Spring starter the facade is a bean and the post-processor calls bind for every enrolled bean; the Micronaut enroller and the LangChain4j FleetRuntime.bind do the same.
// examples/example-11-quickstart: one group, hand-built AgentBinder binder = new AgentBinder(identity, runtime.id(), null, InstantSource.system()); binder.space("work", space); AgentBinder.Bound fulfiller = binder.bind(new Fulfiller()); // loops running; no card, because discovery is null // the facade: several groups, one bind AgentSpaces.GroupContext group = spaces.group("orders-fleet"); group.space("work", work).provide(vote).ordered("payments", ordered); List<AgentBinder.Bound> bound = spaces.bind(new Clerk()); // one Bound per group the clerk's spaces live in
Binding runs in one order whichever object does it. @SpaceRef and @CapabilityRef fields are injected first, then bid functions wire, then one pass over the public methods starts each method's take loop, subscription, join, or reduce (in the order reflection returns the methods, which the binder does not fix), and last the AgentCard publishes, built from the identity annotation plus the bound method signatures. The card names the agent, its description and goals, one action per bound method (what it consumes, what it produces, the method's description), its space bindings, and under subordinate keys the agent's public key and certificate. It is TTL-leased and republished on the card cadence (agentspaces.card-refresh-millis) wherever a host calls refreshCards(): the Spring starter, the Micronaut lifecycle, and the LangChain4j runtime do, and a hand-built binder or facade republishes only when its caller does. A peer that dies stops republishing, so its card ages out of every cache. One card per group bound: an object bound into two groups publishes two. The Bound handle answers agentId(), card(), and isRunning(), and close() interrupts the loops and closes the subscriptions. The binder still lists a closed binding, so its card keeps being republished until binder.unbind(agentId) (which also closes it) or the binder's own close(), and then lapses at its TTL.
schemaRegistry(...) only sets the default for groups registered afterward), and a bean naming a space no group registers fails at bind time, by name, which is where a configuration mistake should show.@SpaceAgent and @AgentSpecSpaceAgentAgentSpec
@AgentSpec declares an agent's identity on any class, framework or not. Under Spring, prefer the @SpaceAgent stereotype: it is both a @Component and an @AgentSpec, so component scanning and fleet enrollment are one drop. Binding publishes an AgentCard built from the identity plus the bound method signatures, so other peers can discover what this agent consumes and produces with no extra code. Micronaut has a stereotype of the same name, ai.badmonkey.agentspaces.micronaut.SpaceAgent: a bean-defining annotation (a singleton unless the class says otherwise) that is also an @AgentSpec, so the enroller binds the bean at startup; it carries the same three attributes.
@SpaceAgent(name = "researcher", description = "Researches topics", goals = {"answer research questions"}) public class Researcher { ... }
| attribute | default | meaning |
|---|---|---|
| name | decapitalized class name | the agent's local name; the AgentId is the peer id plus this name; an anonymous class has no simple name to decapitalize, so name it here or in bind(agent, name) |
| description | "" | for humans and semantic discovery; also feeds Embabel planning (Embabel) |
| goals | {} | goal statements published on the card |
@AgentSpec through one level of meta-annotation. Annotate your own annotation with @AgentSpec, give it name/description/goals attributes, and it works exactly like @SpaceAgent does.Conventions they shareAgentBinderTagged
- Space inference. Every
spaceattribute is optional. For@SpaceTake,@SpaceNotify, and@SpaceRef, empty resolves to the group's sole registered space. Vote annotations infer the sole registered vote space;@OrderedTakeinfers the sole coordinated space. Name the space whenever that choice is ambiguous. - Group selection. Every binding annotation takes an optional
group, by application-facing name or GroupId value. A binder for a different group treats the member as not its own and skips it, so one bean can bind methods into several groups at once; the facade'sbind(...)binds the bean into every registered group whose spaces satisfy its bindings, narrowed to the groups its annotations name, and publishes one card per group bound. The facade refuses an unjoined group by name for@SpaceTake,@SpaceNotify,@BidFunction,@SpaceRef,@SpaceJoin,@Propose, and@OnEstimate; a@Ballot,@OnDecision,@OrderedTake, or@SpaceReducenaming one is skipped, so check the name when a ballot, an ordered take, or a reduce never binds. - Durations. Every duration accepts Spring-style simple strings (
"10m","500ms","30s","2h","1d") and ISO-8601 ("PT10M"). - Method shape. Bound methods must be public and take exactly one parameter: the entry, for the vote annotations the
Proposal(@Ballot) and theDecision(@OnDecision), for@OnEstimatethePushSumAggregate.Estimate, and for@SpaceJointheJoinedbag of one key's parts. The one exception is@SpaceReduce, which takes two parameters: the key's accumulator (nullon the first step) and one element.@BidFunctionreturnsdouble,@Ballotthe option as aString(nullabstains), and@Proposethe question as aString(nullasks nothing); the others return the next entry, aContributionto an aggregate epoch, aMotionthat opens a vote, orvoid. A method may also returnTagged.of(entry, tags)to write the entry with tags: the binder unwraps it before any other return handling, a same-space take result goes throughcomplete(taken, entry, lease, tags)atomically with the completion, and the card declares the wrapped type. - Tag filters.
@SpaceTake,@SpaceNotify, and@OrderedTakeaccepttags = {"key=value", "key"}: each filter requires the tag to equal the value, or to be present. The filters compile onto the method's template, so the space judges them after the type comparison and before it decodes the payload. A malformed filter is refused at bind time, naming the method. - Names. An agent's name comes from
@AgentSpec(or the class);bind(agent, name)overrides it, so several instances of one class can be several agents: a council of seats, one class. - Cards for free. Consumed and produced types are collected from the signatures and published on the agent's card, TTL-leased and refreshed on the card cadence by the host (the starter, the Micronaut lifecycle, and the LangChain4j runtime do; a hand-built binder waits for
refreshCards()). - Where the annotation may sit. The binder reads each binding annotation from the method itself, then from the same-signature method of each superclass, then of each interface, the first declaration winning, and
@AgentSpecthe same way. So ajava.lang.reflect.Proxyover an annotated interface, an anonymous subclass of an annotated abstract class (named by@AgentSpec(name = ...)orbind(agent, name), since an anonymous class has no simple name and the binder throws when it must derive one), a LangChain4jAiServicesproxy whose interface carries@SpaceTake, and a Clojurereifyof a generated interface all bind as workers. - Nothing is reserved. The binder reads the annotations and ignores everything else on the class. Other frameworks' annotations, other interfaces, other methods and fields all keep working, which is why an Embabel
@Actionbecomes a fleet worker by adding one annotation rather than by being rewritten.
Choosing between them
Three questions settle almost every case. Should exactly one agent handle this entry? If yes, @SpaceTake; if every interested agent should see it, @SpaceNotify. Are the candidate workers interchangeable? If not, put the space on AUCTION and add a @BidFunction, and let the cheapest one take it. Does one trigger produce more than one entry, or entries elsewhere? Then add @SpaceRef and write them yourself rather than contorting a return value. Is the cue a vote's proposal or its decision, or must an entry be completed at most once fleet-wide rather than once per lease? Then it is @Ballot, @OnDecision, or @OrderedTake, and the space needs its capability registered. Does the cue decide only the question of a vote? Then @Propose. Does it decide the options or the quorum? Then return a Motion. Does one stage wait for several entries of different types about the same case? Then it is @SpaceJoin, a method taking Joined, and its mode says whether each key fires once per joiner, once per lease, or once fleet-wide. Does one cue produce several entries? Return Entries and list them in produces. Should only some entries of the type reach the method? Filter them with where. Is the cue an entry that lapsed? Then @SpaceNotify(on = EXPIRED). Does a stage wait for several entries of one kind? Then a @Part with atLeast or countedBy, or a join settle period. Is the cue a fleet estimate that has stopped moving? Then @OnEstimate.
The two failure models are the thing to hold on to, because they decide where risky work belongs. @SpaceTake retries forever by default: a crash, a kill, or a thrown exception all lapse the lease and the entry comes back. @SpaceNotify does not retry at all: a reaction that throws is logged and gone. A @SpaceJoin in LOCAL mode follows the notify model and in LEASED or ORDERED mode the take model, because the ticket it fires from is a leased take. Put work that must eventually happen behind a take; put reactions that are nice to have behind a notify.
Execution, completion, and recoveryTakeContext
A binding is a runtime resource. Plain Java constructs the object and calls bind; Spring enrolls initialized beans. Space and capability fields are injected during binding, before worker loops start. Keep constructor work independent of those fields. Public inherited methods are visible to the binder; private annotated methods are not worker entry points.
| Binding | Execution scope | Failure behavior |
|---|---|---|
@SpaceTake | Sequential loop per method, on a virtual thread; different methods and instances can run concurrently. | An exception before completion leaves the claim to expire. null or void completes without a result. Return a failure record for a handled terminal error. |
@OrderedTake | The same loop, with each take submitted through the space's ordered-log coordinator; a take lost to a leader election is resubmitted on the next poll. | The take model: an exception leaves the claim to lapse and the entry reappears, and the log guarantees at most one completion per entry generation, fleet-wide. |
@BidFunction | Called inside the claim path for each candidate entry on an AUCTION space, possibly many times per entry; one bid function per space per peer. | No retry and no lease: keep it pure and quick, and keep side effects out of it. A LEASE_RACE space never calls it. |
@SpaceNotify | A virtual thread per WRITTEN event; a bounded, in-memory entry-id dedup set per subscription. | Exceptions are logged. There is no automatic reaction retry or durable dedup across rebinding/restart. Synchronize mutable bean state shared by concurrent reactions. |
@Ballot | Proposal-id dedup per binding; virtual-thread judgment; null abstains. | A failed cast removes the local dedup marker so redelivery may retry. Abstention is not a scheduled reconsideration when more evidence arrives. |
@OnDecision | One reaction per qualifying proposal per subscription. Every subscribed agent can react. | The hook is not a fleet-wide singleton or durable completion record. A failed reaction is logged; move must-run follow-up work into a take queue. |
@OnEstimate | One firing per epoch per replica, on a virtual thread, the first time the settle rule holds inside the aggregate's own tick. | A thrown reaction is logged and dropped, as a notify's is; the epoch does not fire again on this replica. Move must-run follow-up work behind a take. |
@Propose | One virtual thread per cue that passes the subscription's entry dedup and the per-agent proposal-id dedup; the open itself is idempotent per replica. | A thrown reaction or a refused open is logged and the proposal id is released, so the next cue for that id asks again. The binder has no retry of its own and does not seed from cues that landed before the bind. |
@SpaceJoin | One virtual thread per firing, once per key per the mode: LOCAL from an in-memory set, LEASED and ORDERED from a take of the key's JoinTicket. | LOCAL: an exception is logged and the key is released with no retry; without settle, the next part event for that complete key fires the join again. LEASED and ORDERED: an exception leaves the ticket's TAKE lease to lapse, and the ticket reappears until its write lease runs out. |
@SpaceReduce | One drain per key at a time, serialized by the key's JoinTicket; each element is taken under a lease and completed with the new accumulator in one atomic step. | The take model: a method that throws ends the drain and the element reappears by its lease, to be folded once. null consumes the element and leaves the accumulator as it was. |
Keep completion and side effects separate in your reasoning
When a take returns an ordinary entry into the same space, the binder calls complete(taken, result, resultLease). With a different resultSpace, it completes the input first and then writes the output separately. A crash between those calls can lose the output; the input has already been completed. Prefer the same-space handoff for critical stages, or implement application-level reconciliation for cross-space steps.
Ordered claims make replicas agree on winners and completion; they cannot make a database write, payment API call, or email part of that commit. A worker can perform an external effect and fail before recording completion. Use the business request id as an idempotency key at the external service. Use TakeContext.current().orElseThrow().entryId() when the key should follow this particular space entry through reassignment.
TakeContext exists around both take annotations only; notify and ballot handlers have none. renew() extends the active claim. The context is thread-local: custom executor hops must propagate and clear it. Returning a Future or Flux does not make the binder await it; finish the work and return a domain result from the annotated method.
Layer 4: wire the capability before binding the behaviorAgentSpaces.GroupContext
The Layer 4 annotations are @Ballot, @OnDecision, @Propose, and @OnEstimate (chapter 3), @OrderedTake and @SpaceJoin and @SpaceReduce in ORDERED mode (chapter 4), and @CapabilityRef and @ProvidesCapability, with the Contribution and Motion return values beside them; the signatures in those chapters are the checked-in 0.4.0 ones. Each binds a behavior to a capability the peer must already hold, so assembly comes first: the table says what each behavior needs registered before the agent is bound, and what to verify once it is. There is no @Contribute and no learning annotation: aggregation uses the Contribution return value, and gossip learning is driven through the capability API (gossip learning).
| Behavior | Assembly requirement | What to verify |
|---|---|---|
| Ballots, proposals, and decisions | Register VoteCapability with group.provide(vote) or group.vote(name, vote). The starter can supply it. @Propose and a Motion return need the same registration; the binder refuses a declared Motion return at bind time without it. | List the vote space under groups[].spaces (the starter's private vote space is not bindable) and name it on the annotation (vote on @Propose) when several exist. Choose proposal options/quorum and authorized voters. Subordinate identities let ballots be attributed to agents; the authorizer chooses the counting granularity. |
| Ordered workers | Construct OrderedTakes.over(...) with a fixed member set, then group.ordered(spaceName, ordered) before binding. | The space writer and coordinator holder must agree; subordinate workers need a coordinator signing as that agent. The starter’s ordered-log=true switch still refuses automatic assembly: it cannot infer members. |
| Ordered joins and reduces | The same group.ordered(spaceName, ordered) registration, on the join's or the reduce's own space; a peer hosts one ordered log per group. | Put an ORDERED join on the space that has the coordinator and name the parts' spaces on each @Part. A reduce's elements, accumulators, and tickets share one space, so that space carries the coordinator. |
| Typed clients | Use @CapabilityRef for VoteClient, AggregateClient, SemanticClient, or a registered custom client. | Fields resolve by exact client type; name group on shared beans to avoid another binding overwriting the field. Use explicit vote-space registration for multiple vote spaces. |
| Contributions | Register the local PushSumAggregate; the binder recognizes Contribution.to(epoch, value). | The return starts an AVG contribution rather than writing an entry. A missing aggregate fails when the return is processed. A take is completed before its contribution is applied; these are separate operations. |
| Aggregate reads | Call AggregateClient.awaitEstimate(epoch, timeout) or estimate(epoch); call awaitSettled(epoch, Settle, timeout) or bind an @OnEstimate method for an estimate that has held still. | Await returns when an estimate is present, before convergence or any accuracy threshold is proven; a settle rule says the estimate stopped moving on this replica, and nothing more. Registered capabilities use the peer tick; do not add a client tick loop. |
| Served capabilities | A @ProvidesCapability class implementing CapabilityProvider, registered as a bean, through spaces.bind(bean), or with group.provide(provider). | The provider's own capabilityType() governs registration; the annotation's value is for discovery tooling. The runtime starts it, advertises it, and ticks it; nothing else to schedule. |
@ProvidesCapability wrapper advertises the provider, but typed binder wiring recognizes the concrete VoteCapability and PushSumAggregate instances. Merely forwarding capabilityType() and describe() does not make a wrapper into either. Register the concrete delegate for annotated voting or contributions; a custom gossiping wrapper must also forward the tick contract.Work: take, notify, bid, ref
The Layer 3 annotations: the four most applications need and nothing else. @SpaceTake claims work under a lease, @SpaceNotify reacts to what appears, @BidFunction prices work on an AUCTION space, and @SpaceRef hands a method a space when a return value is not enough. All four follow the shape chapter 1 set out: the parameter is the cue, the return is the next entry, and the binder carries the discipline.
@SpaceTake: the kill-tolerant workerSpaceTake
Turns a method into a leased take loop. The parameter type is the template; a normal return completes the take and, for an ordinary result in the same space, writes the result atomically; a thrown exception skips completion so the lease lapses and the task reappears for another worker. The failure path is the happy path with nothing added.
// agentspaces-partybus -- Scouts.FlightScout: one of eight scouts, each a class with one take method @AgentSpec(name = "flight-scout", description = "Finds flights and rail for the outbound and return legs of a trip", goals = {"find transport for a trip"}) public static final class FlightScout { private final Sources sources; // an ordinary constructor dep public FlightScout(Sources sources) { this.sources = sources; } @SpaceTake(space = SPACE, lease = "2m", pollTimeout = "500ms") public FlightOptions scout(FlightRequest request) { List<Link> web = sources.search("flights " + request.from() + " to " + request.to() + " " + request.departureDate() + " return " + request.returnDate(), 8); FlightOptions found = sources.structured("flights", RESEARCHER + "\n" + "Propose up to three itineraries from " + request.from() + " to " + request.to() + ". Preference: " + request.preference() + ".\n" + digest("Search results", web), FlightOptions.class); return new FlightOptions(request.tripId(), request.topic(), found.options(), found.note(), "flight-scout"); // the result entry: completes the take } }
Reach for @SpaceTake whenever work is a queue of units that exactly one worker should do: research requests, document conversions, booking orders, anything you would otherwise put on a job queue. Scale is horizontal and needs no configuration. Start a second process with the same class and the same queue drains twice as fast, because both are racing for the same entries. The lease is the whole failure story: kill a scout mid-search and its TAKE lease lapses, the request reappears, and a sibling picks it up.
@SpaceTake methods over different entry types, or you can give each worker kind its own class, as the partybus scouts do. Constructor dependencies are yours to supply: the binder binds an instance you already built.| attribute | default | meaning |
|---|---|---|
| space | "" (inferred) | the space to take from; empty means the group's sole registered space |
| group | "" (any) | the group this binding belongs to, by name or GroupId; empty means any group whose spaces satisfy it |
| lease | "10m" | the TAKE lease; how long a crash takes to show as reappearance |
| pollTimeout | "1s" | how long each poll waits before looping |
| resultLease | "1h" | write lease for the returned result entry |
| resultSpace | "" (same space) | where the result entry is written |
| tags | {} | tag filters, "key=value" or "key" (present), compiled onto the template and judged before the payload is decoded; a malformed filter is refused at bind time |
| where | {} | field filters, "field op value" with =, !=, <, <=, >, or >=, or "field in a,b,c"; numeric when both sides parse as decimals, string order otherwise, and a null field matches only !=; compiled onto the template and judged before the take claims; the binder refuses an unknown field at bind time |
| produces | {} | the entry types the card lists when the return type does not say: the elements of an Entries fork; a sealed return type lists its permitted subclasses without this |
| description | "" | what this one action does (v0.1.13): the card declares each bound method as an action with its own description, so planners and semantic discovery see per-action text rather than the agent's alone; @SpaceNotify and @OrderedTake take it too |
null (or declare void) to complete the take and write nothing. Throw, and the binder deliberately does not complete: the lease lapses and the entry comes back, which means a task that always throws will be retried forever. Catch what you can handle and write a failure entry for what you cannot. And keep the method's own work shorter than lease, or the entry reappears while you are still working on it and two workers finish the same task.Route by field. where narrows the take to entries whose fields hold given values. Each filter reads "field op value", where op is =, !=, <, <=, >, or >=, or "field in a,b,c" for a list. When the field's string form and the value both parse as decimals the comparison is numeric, so 3.0 equals 3; otherwise it follows string order, and a null field matches only !=. The binder compiles the filters onto the method's template the way it compiles tags, so the space judges them before this worker claims anything: a task addressed to another shop is never leased here and stays in the space for the worker it names. The binder checks every filter at bind time against the parameter type and refuses one that names a missing component, or one it cannot parse, naming the method. Example 18 deploys one shop class per region, and each takes only its own tasks.
// examples/example-18-agentic-workflow -- Quotes.NorthShop: one class per shop, each taking its own tasks @AgentSpec(name = "north-shop", description = "Quotes repairs in the north", goals = {"quote"}) public static final class NorthShop { @SpaceTake(space = Intake.SPACE, where = "shop=north", lease = "30s", pollTimeout = "300ms", resultLease = "1h") public RepairQuote quote(QuoteTask task) { return Quotes.quote(task, "north", 1.1); } }
Sealed returns. A method may declare a sealed interface as its return type. The binder writes whichever permitted subclass the method returns, and the card lists every permitted subclass as produced, so a planner reading the card sees both branches of a decision the method makes per entry. Example 18's registrar returns one of two records: a registered claim that cues every specialist, or a rejection that the ledger closes.
// examples/example-18-agentic-workflow -- Claims.Registration, and the Intake.Registrar take that returns it public sealed interface Registration permits RegisteredClaim, Rejected { } @SpaceTake(space = SPACE, lease = "3s", pollTimeout = "300ms", resultLease = "1h") public Registration register(Claim claim) { hook.beforeRegistering(name, claim); // The vocabulary's constraints, checked where the data enters: a claim // that fails them is quarantined as a Rejected entry, which the ledger // closes, rather than thrown away. if (claim.claimedAmount() <= 0) { return new Rejected(claim.claimId(), "claimedAmount must be positive"); } if (claim.policyId() == null || claim.policyId().isBlank()) { return new Rejected(claim.claimId(), "a claim names its policy"); } return new RegisteredClaim(claim.claimId(), claim.policyId(), claim.claimant(), claim.description(), claim.claimedAmount(), name); }
One take may also return several entries at once. The binder completes the take once and then writes each element of an Entries return as if the method had returned it alone; the fork, under @SpaceNotify, covers that return and the produces attribute that tells the card what is inside it.
@SpaceNotify: choreography as a returning methodSpaceNotify
Invoked for every matching entry written to the space, without consuming it, and a non-void return value is written back as the next entry in the flow. React to X, produce Y; Y is some other agent's cue. The binder carries the discipline so the method body never has to: delivery is deduplicated per entry (within a bounded in-memory dedup window for this subscription), the reaction runs on its own virtual thread so slow work never holds the fabric's delivery thread, and a null return writes nothing.
// agentspaces-partybus -- Dispatcher: reacts to one brief by fanning out a dozen // requests. It writes requests and never answers them. (Two of the twelve research // requests shown, then a day slot per day through the third @SpaceRef.) @AgentSpec(name = "dispatcher", description = "Turns a travel brief and the events of a trip into the fleet's research requests", goals = {"research a trip"}) public final class Dispatcher { static final Lease REQUEST_LEASE = Lease.of(Duration.ofHours(6)); @SpaceRef(Scouts.SPACE) Space research; // the bag the scouts take from @SpaceRef(Party.TRIP) Space trip; // where briefs and events arrive @SpaceRef(Planners.SLOTS) Space slots; // the day auction @SpaceNotify(space = Party.TRIP, lease = "12h") public void dispatch(TravelBrief brief) { String id = brief.tripId(); double nightly = brief.dailyBudget() / 2.0; research.write(new FlightRequest(id, "flights " + brief.from() + " to " + brief.to(), brief.from(), brief.to(), brief.departureDate(), brief.returnDate(), brief.transportPreference()), REQUEST_LEASE); research.write(new HotelRequest(id, "hotels " + brief.to(), brief.to(), brief.departureDate(), brief.returnDate(), nightly, needs), REQUEST_LEASE); // ... ten more research requests, one per remaining specialist ... List<String> interests = List.of(brief.brief().split("[,;.]\\s*")); int day = 0; for (String date : brief.days()) { slots.write(new DaySlot(id, date, brief.to(), interests, day == 0 ? "EASY" : "FULL", 0), REQUEST_LEASE); day++; } } }
Reach for @SpaceNotify when the trigger is "something appeared" rather than "there is work to claim". Nothing is consumed, so every interested agent sees the same entry, which is exactly what you want for fan-out, for derived records, and for side effects like audit and notification. Returning a value is the compact form of a pipeline: an agent that returns Y from an X has declared one edge of a flow, and the next agent's @SpaceNotify on Y is the next edge. Nobody wires the graph; it exists because of what each agent consumes and produces.
The example above shows both halves of the common shape: react with @SpaceNotify, then write through @SpaceRef handles when one reaction needs to produce many entries, or entries in other spaces, rather than the single entry a return value can express.
@SpaceTake, and the two mix in one class: most partybus planners carry a @BidFunction, a @SpaceTake, and a @SpaceRef together. A class with only @SpaceNotify methods is a perfectly good agent; the pet-clinic flagship coordinates entirely this way, with no take anywhere.| attribute | default | meaning |
|---|---|---|
| space | "" (inferred) | the space to watch |
| group | "" (any) | the group this binding belongs to, by name or GroupId |
| lease | "1h" | the subscription lease; the binder renews it at half-lease intervals while bound, so it bounds how long a stale subscription outlives a crashed agent |
| resultSpace | "" (same space) | where a returned entry is written |
| resultLease | "1h" | write lease for a returned entry |
| tags | {} | tag filters, "key=value" or "key" (present), compiled onto the template and judged before the payload is decoded |
| where | {} | field filters, "field op value" with =, !=, <, <=, >, or >=, or "field in a,b,c"; numeric when both sides parse as decimals, string order otherwise, and a null field matches only !=; compiled onto the template and judged before the method runs; the binder refuses an unknown field at bind time |
| produces | {} | the entry types the card lists when the return type does not say: the elements of an Entries fork; a sealed return type lists its permitted subclasses without this |
| on | {WRITTEN} | the event kinds the method reacts to; EXPIRED turns a leased entry into a timer; each entry is delivered once per kind; the binder refuses an empty set at bind time |
| description | "" | what this one action does, published on the card (v0.1.13); empty means the agent description stands in |
X by writing another X is an infinite loop. Make the reaction produce a different type, or filter on a field. Reactions run on their own virtual thread per entry, so two entries can be in flight at once and your method must be safe to re-enter; the binder deduplicates redeliveries of the same entry only; two different entries both arrive. A reaction that throws is logged and dropped: there is no lease to lapse and nothing will retry it, which is the opposite of @SpaceTake and the single most common surprise when moving logic between the two.Route by field. where takes the same filters as @SpaceTake, "field op value" or "field in a,b,c" with the same comparison rules, compiled onto the subscription's template, so the method sees only the entries that pass and a != filter lets everything but one value through. It also breaks the loop the note above warns of when the reaction cannot change the type: filter on a field the reaction never produces. The binder checks every filter at bind time against the parameter type and refuses one that names a missing component, or one it cannot parse, naming the method.
// examples/example-18-agentic-workflow -- Quotes.SeniorReviewer: severe assessments only @AgentSpec(name = "senior-reviewer", description = "Reviews severe claims", goals = {"review"}) public static final class SeniorReviewer { @SpaceNotify(space = Pricing.SPACE, where = "severity=HIGH", resultSpace = Settlement.LEDGER, resultLease = "24h") public SeniorReview review(ClaimAssessment assessment) { return new SeniorReview(assessment.claimId(), "severe: damage " + assessment.damageEstimate() + " against a limit of " + assessment.coverageLimit()); } }
The fork. A reaction that produces several entries can return them together as Entries, built with Entries.of(Object...) or Entries.of(List), and the binder dispatches each element in order exactly as if the method had returned it alone: a plain entry, a Tagged entry, a Motion, or a Contribution. The binder refuses a nested Entries and a null element. From a @SpaceTake or @OrderedTake it completes the take once, before the first element. The card cannot see inside the collection, so the annotation's produces lists the element types; @SpaceTake, @OrderedTake, and @SpaceJoin carry the same attribute. Example 18's quote desk forks one assessment into a request that says how many quotes to expect and one task per shop, which the shops above route on.
// examples/example-18-agentic-workflow -- Quotes.QuoteDesk: one assessment, four entries @AgentSpec(name = "quote-desk", description = "Asks every repair shop for a quote", goals = {"quote"}) public static final class QuoteDesk { @SpaceNotify(space = Pricing.SPACE, resultSpace = Intake.SPACE, resultLease = "1h", produces = {QuoteRequest.class, QuoteTask.class}) public Entries ask(ClaimAssessment assessment) { return Entries.of(new QuoteRequest(assessment.claimId(), SHOPS.size()), new QuoteTask(assessment.claimId(), "north", assessment.damageEstimate()), new QuoteTask(assessment.claimId(), "east", assessment.damageEstimate()), new QuoteTask(assessment.claimId(), "south", assessment.damageEstimate())); } }
produces: the card lists its permitted subclasses.The deadline. on names the event kinds the method reacts to, WRITTEN by default. With on = SpaceEvent.Kind.EXPIRED a leased entry becomes a timer: write a reminder with the lease you are willing to wait, and when it lapses the reaction fires with the entry that expired. Any set of kinds is allowed and the binder delivers each entry once per kind, so {WRITTEN, EXPIRED} sees the write and the lapse as two deliveries; REAPPEARED is a dead worker's signal and COMPLETED says the work finished elsewhere. The binder refuses an empty set at bind time as a method that reacts to no event kind. Example 18's underwriter writes the reminder as it opens the vote, and an escalator reacts to its lapse.
// examples/example-18-agentic-workflow -- Decisions.Underwriter: the reminder is written with the vote @Propose(space = Pricing.SPACE, vote = SPACE, prefix = PREFIX, key = "claimId", options = {Pricing.APPROVE, Pricing.DENY}, quorum = 3, lease = "10m") public String open(PricedClaim priced) { // The deadline is a leased entry: when it lapses, the escalator below // reacts; when the panel decides first, the escalator finds the // decision and does nothing. decisions.write(new Reminder(priced.claimId()), reminderLease); return "Settle claim " + priced.claimId() + "? The adjuster recommends " + priced.recommendation() + " with a reserve of " + priced.reserve(); } // Decisions.Escalator: the lapse is the cue; the motion opens once per claim @AgentSpec(name = "escalator", description = "Escalates claims the panel has not decided in time", goals = {"escalate"}) public static final class Escalator { @CapabilityRef VoteClient votes; @SpaceNotify(space = SPACE, on = SpaceEvent.Kind.EXPIRED) public Motion escalate(Reminder reminder) { if (votes.decision(PREFIX + reminder.claimId()).isPresent()) { return null; // decided in time: nothing to escalate } return Motion.of("escalate:" + reminder.claimId(), "The panel has not decided claim " + reminder.claimId() + " in time; escalate to the chief underwriter?", List.of("escalate", "wait"), 1, Lease.of(Duration.ofMinutes(10))); } }
EXPIRED fires once per bound agent on every peer that holds the entry. Make what it does safe to repeat: return a Motion, which opens once per proposal id however many replicas see the lapse, or write an entry the next stage dedups on. The escalator above also reads the decision first, so a panel that decided in time escalates nothing.@BidFunction: the price of workBidFunction
On an AUCTION space, every take carries a bid and the lowest bid takes the entry. The bid is a plain method over the entry, so cost models are ordinary code: denominate in dollars, tokens, or queue depth, and let FinOps tune it.
// agentspaces-partybus -- Planners.DiningPlanner: bid, worker, and space handle in // one class. The bid decides whether this planner gets the day at all, and the // take method is what it does once it has won. @AgentSpec(name = "dining-planner", description = "Plans a day around the table: markets, long lunches, a reserved dinner", goals = {"fill a day with food"}) public static final class DiningPlanner { @SpaceRef(Scouts.SPACE) Space research; @BidFunction(space = SLOTS) // the lowest bid takes the day public double bid(DaySlot slot) { double bid = 90; if (interested(slot, "food", "wine", "eat", "cook")) { bid -= 35; // this is our kind of day } if (slot.pace().equals("REST")) { bid += 20; // ... but not on a rest day } return bid + slot.round() * 5; // a strike barely touches a lunch } @SpaceTake(space = SLOTS, lease = "1m", pollTimeout = "500ms") public ScheduledActivity plan(DaySlot slot) { String table = research.readAll(Template.of(RestaurantOptions.class) .where("tripId", eq(slot.tripId())), 20).stream() .filter(r -> r.location().equalsIgnoreCase(slot.location())) .flatMap(r -> r.restaurants().stream()).map(r -> r.name()).findFirst() .orElse("a table the scouts are still finding"); return new ScheduledActivity(slot.tripId(), slot.date(), slot.location(), "DINING", "Market morning, long lunch, dinner at " + table, bid(slot), "dining-planner", slot.round()); } }
This is the shape worth studying, because it answers the organizing question directly: the bid function, the worker, and the space handle live in the same object. That is deliberate. A bid is a statement about what this particular agent would charge, so it needs the same state the work needs: the model it would call, the queue it is already sitting on, the region it runs in. Splitting them apart would mean keeping two things in sync for no gain.
Reach for AUCTION when workers are not interchangeable and you want the fleet to sort that out without a router. Several planners bid on every day of a trip; the one whose day it should be bids lowest and takes the day, and nothing anywhere contains a rule saying who plans what. Change a bid function and you have re-tuned the whole allocation. The same shape does cost-aware model routing (cheap capacity sweeps easy work, the expensive model takes the hard entries), locality routing, and load shedding by bidding your own queue depth.
@SpaceTake it prices, on an AUCTION space. One bid function per space per peer, whatever the bean; the binder wires bids before starting worker loops, so a bid is always in place before the first claim. A bean with a bid function but no take method is legal and useless, since it prices work it will never do.ConflictStrategyType.AUCTION consult bids at all; on a LEASE_RACE space a @BidFunction is silently never called, which is a common reason a "broken auction" turns out to be a misconfigured space. And a space consults one cost function: two agents with a @BidFunction on the same space on one peer are refused at bind time, by name, rather than the second silently replacing the first; example-04-auction runs one bidder per peer, and its AuctionFleetFlowTest pins the refusal.| attribute | default | meaning |
|---|---|---|
| space | "" (inferred) | The sole registered space when omitted; configure AUCTION to use the bid. |
| group | "" (any) | Group name or GroupId; other group binders skip the method. |
@SpaceRef: a handle when you need oneSpaceRef
Injects a Space into a field at bind time, for mid-method writes such as progress markers and side outputs. Under Spring, @Scheduled plus a @SpaceRef field is the idiomatic periodic agent; AgentSpaces ships no scheduler of its own. What arrives is the registered space itself or, when the fleet runs with agentspaces.identity.agent-keys=subordinate, this agent's view of it, so everything the agent writes, takes, or completes through the field is signed with the agent's own certified key and attributed to the agent itself rather than only to its host peer (agent certificates). The field's type and your code do not change either way.
@SpaceRef("findings") private Space findings; // injected at bind time // Several are normal: the partybus dispatcher holds three, one per space @SpaceRef(Scouts.SPACE) Space research; @SpaceRef(Party.TRIP) Space trip; @SpaceRef(Planners.SLOTS) Space slots; // The idiomatic periodic agent under Spring: our scheduler is Spring's. // (Illustrative: no checked-in example uses @Scheduled; the API calls are real.) @SpaceAgent(description = "Publishes a backlog reading every thirty seconds") // already a @Component public class BacklogReporter { @SpaceRef("metrics") private Space metrics; private final BlockingQueue<Order> queue; public BacklogReporter(BlockingQueue<Order> queue) { this.queue = queue; } @Scheduled(fixedDelay = 30_000) public void report() { metrics.write(new Backlog(queue.size()), Lease.of(Duration.ofMinutes(2))); } }
Use it whenever one invocation has to produce more than the single entry a return value can express: progress markers mid-task, fan-out like the dispatcher's, side outputs into a different space, or reads that inform the work (the dining planner reads what the scouts found before it writes a schedule). The field is a fully-featured Space with every verb of the Space API.
It is also the answer to "how do I get a space into code that is not a bound method". A @Scheduled method, an HTTP controller method, a message-listener callback: annotate the field, and as long as the enclosing bean is enrolled, the handle is there. AgentSpaces ships no scheduler, no HTTP layer, and no inbox of its own precisely because your framework already has them.
Space (declare it as Space); a field of any other type fails at bind time rather than at first use. Fields need not be public. Injection happens at bind time, so the handle is null in the constructor and in any @PostConstruct: do first-write-on-startup work from a @SpaceNotify, a scheduled method, or an ApplicationReadyEvent listener, never in the constructor.| attribute | default | meaning |
|---|---|---|
| value | "" (inferred) | The space name; omitted only when exactly one space is registered. |
| group | "" (any) | The group whose handle is injected. Name it on a multi-group bean so another binding does not overwrite the field. |
Decide and sense
The Layer 4 annotations for the vote and the aggregate, and the two that reach any capability. @Ballot casts, @OnDecision reacts once when a vote closes, @Propose opens one, @OnEstimate fires when a fleet-wide estimate settles, @CapabilityRef injects a typed client, and @ProvidesCapability serves a capability of your own. Each binds a behavior to a capability the peer must already hold, which is why Layer 4 assembly comes first; chapter 12 covers the services themselves.
@Ballot: one signed vote per proposalBallot
The first of the Layer 4 annotations, and the shape the others follow: the method's parameter is the cue and its return value is what the binder does with it. Here the cue is the vote's own Proposal entry and the return is the option this agent backs; the binder casts through the group's vote capability, using the bound agent's identity with subordinate keys, and deduplicates proposals within this binding, and a null return abstains. Because the cue is the proposal record, the ballot can never be refused as "unknown proposal"; the two-entry race every hand-written panelist had to defend against does not exist.
// agentspaces-example-apps/agentspaces-audit-fleet -- AuditFleet.Panelist @Ballot(space = VOTES, lease = "2h") public String judge(VoteCapability.Proposal proposal) { Finding finding = peer.audit().read(Template.of(Finding.class) .where("findingId", eq(proposal.proposalId())), Duration.ofSeconds(10)).orElse(null); if (finding == null) { return null; // nothing on the record: abstain } double weighted = finding.confidence() * trust(peer.discipline(), finding.discipline()); return weighted >= CONFIRM_THRESHOLD ? "CONFIRMED" : "DISMISSED"; }
One vote space usually carries several decisions, so prefix narrows a method to the proposals whose id starts with it: the Party Bus keys them advisory: and next:. Under agent-keys=subordinate the ballot is the agent's own attested record, so several agents on one peer are several voters wherever the authorizer counts per agent (security).
| attribute | default | meaning |
|---|---|---|
| space | "" (inferred) | the vote space; empty means the binder's sole registered vote space |
| group | "" (any) | the group this binding belongs to, by name or GroupId |
| prefix | "" (all) | only proposals whose id starts with it |
| lease | "1h" | the ballot's write lease; outlive the proposal's decision window |
group.provide(vote) does it (and advertises the capability), group.vote("votes", vote) registers without advertising, binder.vote(...) is the hand-built form. Binding fails fast otherwise, naming that call. The option must be one of the proposal's; casting one that is not is refused by castBallot, which the binder logs and treats as not yet voted, so a redelivery of the proposal may retry.@OnDecision: react once when a vote closesOnDecision
The pair of @Ballot. The method takes the Decision and runs once per proposal per subscription, the first time this replica's tally meets the quorum; a non-null return is written as the next entry in the flow, like @SpaceNotify. Underneath, the binder watches ballots and proposals in the vote space and recomputes the decision on each, so a replica that learns of a vote late still fires once, on its first qualifying ballot. This replaces the decision() poll loop that every consumer of the vote used to write.
// examples/example-05-quorum -- QuorumFleet.Supervisor @OnDecision(space = "votes", prefix = "scale", resultLease = "10m") public ScalingDecision record(VoteCapability.Decision decision) { return new ScalingDecision(decision.proposalId(), decision.winner(), decision.tally().toString(), decision.granularity().name()); // which counting rule closed it }
@Ballot, or a different one: the audit flagship's lead reacts to closed votes and never casts. resultSpace defaults to the vote space; the Party Bus's safety lead writes the advisory into trip. Plain Java without the binder has the same hook, vote.onDecision(proposals, listener, lease), which returns a leased Subscription.| attribute | default | meaning |
|---|---|---|
| space | "" (inferred) | the vote space; empty means the binder's sole registered vote space |
| group | "" (any) | the group this binding belongs to, by name or GroupId |
| prefix | "" (all) | only proposals whose id starts with it |
| lease | "1h" | the subscription lease; renewed at half-lease while the agent stays bound |
| resultSpace | "" (vote space) | where a returned entry is written |
| resultLease | "1h" | write lease for a returned entry |
@Propose: opening a vote as an annotationProposeMotionKeys
The opening side of the vote, for a lead whose options, quorum, and lease are fixed and whose cue decides only the question. The method takes the cue and returns the question as a String; null asks nothing, and the method may return a Motion instead when one cue needs a shape of its own (the Motion return). The proposal id is the prefix plus the cue's key: the key fields read from the cue and joined by :, or the value of the cue tag that keyTag names. One of the two is required unless the method returns a Motion carrying its own id. @Ballot and @OnDecision filter on the same prefix, so the three annotations describe one vote across the classes that open it, judge it, and record it. This replaces the VoteCapability field, the proposed set, and the read-before-propose guard that every lead used to carry.
// examples/opscenter -- RiskPanel.Lead @Propose(space = OpsFleet.EVIDENCE, vote = OpsFleet.REVIEWS, prefix = PREFIX, key = "incidentId", options = {"approve", "reject"}, quorum = QUORUM, lease = "12h", subscriptionLease = "12h") public String open(RemediationProposal proposal) { return "Approve remediation for " + proposal.incidentId() + ": " + proposal.action() + ". Reversible: " + (proposal.reversible() ? "yes" : "no") + ". Rationale: " + proposal.rationale(); // the question; the shape sits on the annotation } // agentspaces-partybus -- SafetyPanel.Lead: a composite key, tripId:location:wave @Propose(space = Watchers.SIGNALS, vote = ADVISORIES, prefix = PROPOSAL_PREFIX, key = {"tripId", "location", "wave"}, options = {"GO", "CAUTION", "AVOID"}, quorum = 3, lease = "12h", subscriptionLease = "12h") public String open(DangerAssessment assessment) { waves.putIfAbsent(Safety.proposalId(assessment.tripId(), assessment.location(), assessment.wave()), assessment); return "Travel advisory for " + assessment.location() + " (" + assessment.wave() + ")"; // every channel's assessment of one wave maps to one id }
The binder owns the rest. It subscribes to the cue as @SpaceNotify does, under a leased subscription renewed at half-lease, with the same bounded per-entry dedup. It derives the proposal id from the cue and invokes the method once per proposal id per bound agent, so several cues that map to one id (every channel's assessment of one wave) ask one question. It opens the proposal once per replica, as the bound agent, and skips a motion whose proposal is already visible on the vote space. It declares an action of kind propose on the agent's card, which nobody can invoke remotely. The key rule is the one Template.where and @SpaceJoin use (Keys): a record component or a getX, isX, or plain x() accessor, resolved at bind time so a missing field fails fast naming the type.
@Ballot needs it (group.provide(vote) or group.vote(name, vote)); binding fails fast otherwise, naming the registration. The lead usually also carries the @OnDecision that records the result, as the opscenter's risk lead and the Party Bus safety lead do. A class may both propose and vote on one space; attribution and the authorizer decide what counts, and the binder's propose tests bind one such proposer that also sits on the panel. Plain Java without the binder opens the same idempotent way with VoteClient.propose(Motion).null return releases the id, so a later cue for the same id may ask. A failed open (a thrown reaction, or a proposal the capability refuses) is logged and releases the id too, so the next cue retries it; the binder has no retry of its own. The binder does not seed from cues that landed before the lead was bound: a cue already in the space when bind runs opens nothing, so bind the lead before the stage that writes its cues, or open from a @SpaceTake that returns a Motion, since a take consumes cues already in the space.| attribute | default | meaning |
|---|---|---|
| space | "" (inferred) | the cue's space; empty means the binder's sole registered space |
| group | "" (any) | the group this binding belongs to, by name or GroupId |
| vote | "" (inferred) | the vote space; empty means the binder's sole registered vote space |
| prefix | "" | the proposal id's prefix, which @Ballot and @OnDecision filter on |
| key | {} | the cue fields whose values, joined by :, complete the proposal id; several fields make one composite key |
| keyTag | "" | the cue tag whose value completes the proposal id instead of fields; exclusive with key |
| options | (required) | the options, two or more |
| quorum | (required) | the ballots that close the vote, positive |
| lease | "1h" | the proposal's write lease; ballots must outlive it |
| subscriptionLease | "1h" | the cue subscription's lease, renewed at half-lease while the agent stays bound |
| tags | {} | tag filters on the cue, "key=value" or "key", judged before decode |
| where | {} | field filters on the cue, "field op value" or "field in a,b,c", as on @SpaceTake |
| description | "" | what this one action does, published on the card |
@OnEstimate: react once when an estimate settlesOnEstimatePushSumAggregate.Estimate
The read side of the Contribution return, and the aggregate's counterpart to @OnDecision. The method takes a PushSumAggregate.Estimate, a record of the epoch id, the operator, the value, and the protocol ticks this replica has run since it learned of the epoch, and fires once per epoch, the first time this replica's estimate has stayed within tolerance (relative, of the larger of one and the last value) for settleTicks settle steps; settleTicks = 0 fires on the first estimate present. A settle step is one gossip round this replica took part in after it first heard the epoch, or the epoch going quiet. Rounds run about once a second under a driven peer whatever the 250 ms tick (PushSumAggregate.ROUND_PERIOD is one second), so settleTicks = 40 is about forty seconds. A non-null return is written as the next entry in the flow, or opens a Motion, as any bound method's return does. The capability evaluates the rule inside its own tick(), so no thread polls, and the listener runs on a virtual thread so a slow reaction never holds the peer tick. This replaces the awaitEstimate loop that every supervisor used to carry.
// examples/example-18-agentic-workflow -- Settlement.Supervisor: forty settle steps, one report @AgentSpec(name = "supervisor", description = "Reports the fleet's average reserve once it settles", goals = {"report reserves"}) public static final class Supervisor { private final String name; public Supervisor(String name) { this.name = Objects.requireNonNull(name, "name"); } @OnEstimate(prefix = "reserve:", settleTicks = 40, tolerance = 0.01, resultSpace = LEDGER, resultLease = "24h") public ReserveReport report(PushSumAggregate.Estimate reserve) { return new ReserveReport(reserve.epochId(), reserve.value(), name); } }
group.provide(aggregate)); the binder refuses the bind otherwise, naming that call. The card declares one action of kind on-estimate that nobody can invoke remotely. Plain Java has the same hook: PushSumAggregate.onEstimate(epochs, Settle, listener) registers a listener and returns the handle that removes it, and AggregateClient.awaitSettled(epochId, Settle, timeout) is the blocking form for code that wants the settled value in line. Settle.after(ticks, tolerance) builds the rule and Settle.FIRST fires on the first estimate present.// agentspaces-capabilities -- PushSumAggregate: the estimate, the rule, and the listener public record Estimate(String epochId, Mode mode, double value, int ticks) { } public record Settle(int ticks, double tolerance) { public static final Settle FIRST = new Settle(0, 0); ... public static Settle after(int ticks, double tolerance) { return new Settle(ticks, tolerance); } } public AutoCloseable onEstimate(Predicate<String> epochs, Settle settle, Consumer<Estimate> listener) { ... } // agentspaces-agent -- AggregateClient: the blocking form public OptionalDouble awaitSettled(String epochId, PushSumAggregate.Settle settle, java.time.Duration timeout) { ... }
settleTicks for the spread of your contributions, and put a vote behind any estimate that commits the fleet to an action. Every bound supervisor fires, once per replica per epoch, so bind one, or return a Motion, which opens once per proposal id, or write an entry the next stage dedups on. A watch registered after an epoch settled starts counting afresh and fires once the rule holds again. A driven peer runs one gossip round about every second whatever its tick period (PushSumAggregate.ROUND_PERIOD), so example 18's forty settle steps are about forty seconds.| attribute | default | meaning |
|---|---|---|
| prefix | "" (all) | only epochs whose id starts with it |
| settleTicks | 8 | settle steps (gossip rounds this replica took part in after it first heard the epoch, about one a second under a driven peer) the estimate must stay within tolerance to count as settled; 0 fires on the first estimate present |
| tolerance | 0.001 | the relative change, of the larger of 1 and the last value, that still counts as unchanged |
| group | "" (any) | the group this binding belongs to; empty means any group whose aggregate satisfies it |
| resultSpace | "" (inferred) | where a returned entry is written; empty means the binder's sole registered space |
| resultLease | "1h" | write lease for a returned entry |
| description | "" | what this one action does, published on the card |
@CapabilityRef: a typed client, injectedCapabilityRefContributionMotion
The sibling of @SpaceRef for Layer 4: a field of a typed client type (VoteClient, AggregateClient, SemanticClient, or anything registered with binder.client(type, instance)) is injected at bind time from the group's typed clients. Constructor injection still works and is still fine; this is for when the agent should not have to be handed its capabilities by whoever constructs it.
// agentspaces-agent tests -- AgentBinderLayer4ClusterTest.Sensor, with the typed-client // injection of AgentBinderTest.Sensor added @AgentSpec(name = "sensor", description = "Contributes readings", goals = {"sense"}) public class Sensor { @CapabilityRef AggregateClient aggregate; // injected by field type at bind time @SpaceRef("readings") Space readings; // A push-sum contribution is a return value; the binder starts the epoch. @SpaceNotify(space = "readings") public Contribution feel(Reading reading) { return Contribution.to(reading.topic(), reading.value()); } } // Assembly (plain Java; the starter does this from agentspaces.capabilities.*): group.provide(aggregate); // registers the aggregate for Contribution returns and AggregateClient group.bind(new Sensor());
The vote has the same kind of return value: a Motion, the vote-side sibling of Contribution. Motion.of(id, question, options, quorum, lease) names the sole registered vote space and Motion.in(space, ...) a particular one, and any bound method may return it. The binder opens it as the bound agent, once per proposal id per replica, and skips a motion whose proposal is already visible; for a take, it completes the taken entry first, so a failed open never makes a finished task reappear. This is the dynamic form of opening, for a lead whose options or quorum come from the cue; when only the question does, @Propose says the same thing as one annotation. Plain code opens the same idempotent way through VoteClient.propose(Motion). A method that declares a Motion or Contribution return type fails at bind time when its capability is missing, naming the registration, so the gap shows before the first value comes back; a declared Motion return also puts VoteCapability.Proposal on the card as what the action produces.
// agentspaces-partybus -- Party.Lead: the options come from the cue, so the method returns the Motion @SpaceRef(DECISIONS) Space decisions; @SpaceNotify(space = Scouts.SPACE, lease = "12h") public Motion open(NextDestinationIdeas ideas) { String proposalId = "next:" + ideas.tripId(); List<String> options = new ArrayList<>(); for (Destination idea : ideas.ideas()) { options.add(idea.name()); } if (decisions.read(Template.of(NextDestinationProposal.class) .where("proposalId", eq(proposalId))).isEmpty()) { decisions.write(new NextDestinationProposal(ideas.tripId(), proposalId, "Where next after this trip?", options, "party-lead"), Lease.of(Duration.ofHours(12))); // the human-readable announcement, a second entry through the handle } return Motion.in(DECISIONS, proposalId, "Where next after this trip?", options, partySize, Lease.of(Duration.ofHours(12))); // the binder opens it once per proposal id }
Contribution from a @SpaceNotify, @SpaceTake, @OrderedTake, @OnDecision, or @SpaceJoin method needs the peer's aggregate registered (group.provide(aggregate)), and returning a Motion needs the vote capability registered (group.provide(vote) or group.vote(name, vote)); the binder checks both when it binds the method. The fleet's estimate is read back with AggregateClient.awaitEstimate(epoch, timeout), or with awaitSettled(epoch, Settle, timeout) once it has stopped moving; there is no manual tick anywhere.| attribute | default | meaning |
|---|---|---|
| group | "" (any) | the group whose client to inject; empty means any binding group, the last one to bind winning, so an agent that spans groups names the group |
@ProvidesCapability: serving a capabilityProvidesCapabilityCapabilityProvider
Marks a CapabilityProvider implementation as a service the fleet should advertise. Bound through the facade, it is registered on the group's CapabilityRuntime, which starts it, signs and publishes its CapabilityAdvertisement, and keeps that advertisement fresh on every card refresh, the same TTL-leased freshness every other card gets. Consumers reach it through a typed client (capability services).
// agentspaces-spring-boot-autoconfigure tests -- AgentSpacesAutoConfigurationTest.EchoCapability, trimmed // A capability type of your own, served by this peer and advertised to the group. @Component @ProvidesCapability("aspace:cap/echo") public class EchoCapability implements CapabilityProvider { private final PeerId self; public EchoCapability(PeerIdentity identity) { this.self = identity.peerId(); } @Override public String capabilityType() { return "aspace:cap/echo"; } @Override public CapabilityAdvertisement describe(GroupId group) { return new CapabilityAdvertisement("aspace://" + group.value() + "/cap/echo/" + self.value(), self, group, Instant.now(), Duration.ofMinutes(15), "aspace:cap/echo", "0.1", "pipe", Map.of(), Map.of()); } // start() and tick() are interface defaults; override them when the protocol needs a driver. }
You need this only when your peer is the one serving a capability. Most peers consume capabilities instead, which needs no annotation at all; see the capability services. And with the starter you rarely write this either: agentspaces.capabilities.* already registers the aggregate, vote, semantic-discovery and key-wrap providers for you. Write the bean when you want a capability the starter leaves off (the ordered log, which needs a member set the starter cannot guess), when you want to configure one differently (a gossip learner with a real training step), or when you are publishing a capability of your own.
CapabilityProvider, registered by spaces.bind(bean) or by being a Spring bean. It is not an agent, so by itself it gets no card, no worker loop, and no @SpaceRef injection; a capability is constructed by its own constructor with whatever it needs (the shipped ones take a space, or the group's pipes and sampler), as above. If you also put @AgentSpec or a bound method on the class, it becomes both: the facade registers the capability and binds the agent, and then @SpaceRef fields are injected too. Mixing is legal but usually less clear than two classes.value is documentation for discovery tooling; the provider's own capabilityType() is what actually governs registration, so a mismatch between them is confusing rather than fatal. The runtime calls start() once before the first advertisement and refreshes the advertisement on every card refresh, and drives registered providers through CapabilityProvider.tick() on the peer tick. In a hand-built runtime without that lifecycle, the host must arrange ticking; the capability model covers the assembly boundary.| attribute | default | meaning |
|---|---|---|
| value | none (required) | the capability type URI, e.g. aspace:cap/vote; informational for discovery tooling, the provider's own capabilityType() governs registration |
| group | "" (all) | the group to advertise into; empty means every registered group that has a capability runtime |
Exactly once and fan-in
Three annotations for the promises a lease race cannot make on its own. @OrderedTake completes an entry at most once fleet-wide, through the ordered log. @SpaceJoin fires once per key when every part of a case has arrived, with a mode that says once per joiner, once per lease, or once fleet-wide, and @Part describes each part. @SpaceReduce folds a stream of entries per key into an accumulator that is itself an entry. The ordered forms need the coordinator of the ordered log registered on the group.
@OrderedTake: the exactly-once workerOrderedTake
@SpaceTake's contract (take, act, complete-or-lapse) with the take routed through the space's ordered-log coordinator instead of the space's own claim race, so the log's commit order decides every take identically on every member and each entry is completed at most once, fleet-wide. The binder owns the loop the coordinator asks of a caller: a take lost to a leader election is simply resubmitted on the next poll. This closes what the space chapter used to call "the one place the annotations cannot reach".
// examples/example-12-exactly-once-desk -- ExactlyOnceDesk.Clerk @OrderedTake(space = PAYMENTS, lease = "30s", pollTimeout = "2s", resultLease = "1h") public PaymentReceipt confirm(PaymentOrder order) { return new PaymentReceipt(order.orderId(), order.payee(), order.cents(), peer.name(), peer.raft().commitIndex()); // completes the take with the receipt, once } // ExactlyOnceDesk.startClerk(...): the coordinator and its log, built together and registered OrderedTakes ordered = OrderedTakes.over(new CapabilityPipes(runtime, codec), codec, identity, name, members, InstantSource.system(), seed, payments); group.ordered(PAYMENTS, ordered); // the log rides the peer tick; role=leader is discoverable
group.ordered(space, coordinator)); binding fails fast otherwise, and also when the coordinator takes as a different agent than the space handle writes as. The member set is still fixed and still yours to supply; that has not changed. The AtomicReference cycle that wired the log to its coordinator is gone: OrderedTakes.over(...) does it.| attribute | default | meaning |
|---|---|---|
| space | "" (inferred) | the coordinated space; empty means the binder's sole coordinated space |
| group | "" (any) | the group this binding belongs to, by name or GroupId |
| lease | "30s" | the TAKE lease; the action completes within it or the entry reappears |
| pollTimeout | "2s" | how long one poll of the coordinator waits before looping |
| resultLease | "1h" | write lease for a returned result |
| resultSpace | "" (take space) | where a returned result is written |
| tags | {} | tag filters, "key=value" or "key", judged before the coordinator is asked for the entry |
| where | {} | field filters, "field op value" or "field in a,b,c", as on @SpaceTake, checked at bind time against the parameter type |
| produces | {} | the entry types the card lists when the return type does not say (the elements of an Entries fork) |
| description | "" | what this one action does, published on the card (v0.1.13) |
@SpaceJoin: fan-in as one annotationSpaceJoinPartJoined
The join of a fan-out, as one method. The method takes a Joined bag and returns the next entry, and the binder invokes it once per key when every required @Part of that key is present in the space. A part names its entry type (value) and, where the defaults do not fit, the space it lives in, the key field or the keyTag that carries the case's key, tags filters that narrow it the way tags narrows a @SpaceTake, and optional when the join should fire without it. The join's own space is where its tickets live in the fleet-wide modes and the default space for its parts; its key is the field every part carries unless a part says otherwise.
// agentspaces/examples/example-18-agentic-workflow -- Intake.Assembler @SpaceJoin(space = SPACE, key = "claimId", resultSpace = Pricing.SPACE, resultLease = "1h", parts = {@Part(RegisteredClaim.class), @Part(FraudFinding.class), @Part(CoverageFinding.class), @Part(value = Instance.class, keyTag = "claimId", // keyed by a tag, written with Tagged tags = "rdf:type=" + Claims.NAMESPACE + Instance.DAMAGE_FINDING)}) public ClaimAssessment assemble(Joined j) { RegisteredClaim claim = j.get(RegisteredClaim.class); Instance damage = j.get(Instance.class); return new ClaimAssessment(j.key(), claim.claimant(), claim.claimedAmount(), damage.number("estimate"), damage.string("severity"), j.get(FraudFinding.class).fraudScore(), j.get(CoverageFinding.class).covered(), j.get(CoverageFinding.class).coverageLimit(), name); // written to the assessment space }
How many times one key fires is the mode:
| mode | one key fires | how |
|---|---|---|
| LOCAL (default) | once per bound joiner | from an in-memory set of fired keys; nothing is written to the space before the result |
| LEASED | once per lease race, per ticket | the binder writes a JoinTicket entry when the key completes, withdraws it when an older ticket for the key already exists, and fires from a take of the ticket under a TAKE lease through the space's own claim race |
| ORDERED | once, fleet-wide | the ticket is taken through the space's ordered-log coordinator; every member records the committed claims in one order, and the first committed ticket per key is the only one that fires |
The binder owns what a hand-written join owns. It subscribes to each part, seeds its per-key state from the space on bind so parts that landed earlier still count, and seeds again for any key it meets for the first time, so a key it forgot is picked up from what the space still holds. When a key completes it re-reads the parts by key and hands the method the newest entry of each type. It forgets a key whose parts stop arriving after within, and keeps at most maxOpen keys open, evicting the least recently touched beyond that. The Joined bag answers key(); get(type) for the newest part of a type, which throws when the part is absent; find(type) for an optional part; all(type) for every matching entry, newest first; has(type); and entry(type) when the issuer, tags, or lease of a part matter. The method returns like every other binding: the next entry, a Tagged entry, a Contribution, or null.
The gather. A part may need more than one entry of its type before the key is complete. @Part(atLeast = n) waits for n of them. @Part(countedBy = "SimpleTypeName.field") reads the count from an int field of another part of the same key, whenever that part lands, before or after the entries it counts, so a fork can declare how many it emitted and the gather waits for exactly that many. @SpaceJoin(settle = "300ms") ends a gather of an unknown number: the key fires once it has been quiet that long and every minimum holds. A join with a single part is allowed when that part gathers or the join sets settle, even with atLeast = 1. The method reads what the key collected with Joined.all(type). Example 18's gatherer waits for as many quotes as the quote desk's request declared and keeps the lowest.
// examples/example-18-agentic-workflow -- Quotes.QuoteGatherer: as many quotes as the request declared @AgentSpec(name = "quote-gatherer", description = "Keeps the lowest of the quotes asked for", goals = {"gather quotes"}) public static final class QuoteGatherer { @SpaceJoin(space = Intake.SPACE, key = "claimId", resultSpace = Settlement.LEDGER, resultLease = "24h", parts = {@Part(QuoteRequest.class), @Part(value = RepairQuote.class, countedBy = "QuoteRequest.expected")}) public RepairEstimate pick(Joined j) { List<RepairQuote> quotes = j.all(RepairQuote.class); RepairQuote lowest = quotes.stream().min(Comparator.comparingDouble(RepairQuote::amount)).orElseThrow(); return new RepairEstimate(j.key(), lowest.shop(), lowest.amount(), quotes.size()); } } // agentspaces-agent tests -- AgentBinderGatherTest.Settled: an unknown number, ended by quiet @SpaceJoin(space = "quotes", key = "claimId", settle = "300ms", parts = {@Part(value = Quote.class, atLeast = 1)})
join that nobody can invoke remotely. In ORDERED mode the join's space also needs a coordinator registered for it (group.ordered(space, coordinator)), as @OrderedTake does. A peer hosts one ordered log per group, so an ORDERED join lives on the space that has the coordinator and its parts may live anywhere: example 18's Intake.OrderedAssembler puts the join on payments, where the fleet's log is, and names space = "intake" on each of its four parts. The Party Bus plan assembler is a cross-space join too, a brief in the trip space and five research results in the research space, with the party roster as an optional part.LOCAL mode every bound joiner fires for every key, so bind one joiner or make the method idempotent against its downstream result; the fleet-wide modes exist for the case where several peers run the same joiner. A method that throws is logged in LOCAL and the binder releases the key without a retry; without settle, the next part event for that complete key fires the join again. In LEASED and ORDERED the ticket's TAKE lease lapses and the ticket reappears for the next poll, so a method that always throws retries until the ticket's write lease (ticketLease) runs out. Do not guard a join by reading for your own result: the next stage's take consumes it, and the guard then sees nothing and fires again. The mode is the guard. A key's parts must be visible to the joiner's replica, so a tag shard that excludes a part's tags hides it from the join as well.| attribute | default | meaning |
|---|---|---|
| space | "" (inferred) | the join's space: the default for parts and where tickets live; empty means the binder's sole registered space |
| group | "" (any) | the group this binding belongs to, by name or GroupId |
| key | (required) | the field (record component or getX/isX accessor) carrying the case's key on every part, unless a part names its own key or keyTag |
| parts | (required) | @Parts, two or more, or one that gathers or a join that sets settle: value, space, key, keyTag, tags, where, atLeast, countedBy, optional |
| mode | LOCAL | how often one key fires: LOCAL, LEASED, or ORDERED |
| name | "" (agent.method) | the join's name on tickets and cards; joiners of one class on several peers share tickets because they share this name |
| within | "1h" | forget a key whose parts have stopped arriving after this long |
| settle | "" (none) | fire a complete key only after no new part has arrived for this long, so a gather of an unknown number has an end |
| maxOpen | 10000 | the most keys kept open at once; the least recently touched is evicted beyond it |
| lease | "1h" | the part subscriptions' lease, renewed at half-lease while the agent stays bound |
| ticketLease | "10m" | LEASED and ORDERED: the ticket's write lease |
| takeLease | "30s" | LEASED and ORDERED: the ticket's TAKE lease; the method completes within it or the ticket reappears |
| pollTimeout | "1s" | LEASED and ORDERED: how long one poll for a ticket waits before looping |
| resultSpace | "" (join space) | where a returned entry is written |
| resultLease | "1h" | write lease for a returned entry |
| produces | {} | the entry types the card lists when the return type does not say: the elements of an Entries fork; a sealed return type lists its permitted subclasses without this |
| description | "" | what this one action does, published on the card |
@Part: one entry type the join waits forPart
Each element of parts is a @Part: the entry type a key needs, where that type's key is read from, and how many of it the key needs. A part's key comes from a field (the join's key by default, or the part's own) or from a tag (keyTag), which is how a type that does not carry the key as a component, an ontology instance for one, still joins. Tag and field filters narrow the part the way tags and where narrow a @SpaceTake.
| attribute | default | meaning |
|---|---|---|
| value | (required) | the part's entry type |
| space | "" (the join's space) | the space this part lives in |
| key | "" (the join's key) | the field carrying the key |
| keyTag | "" | the tag carrying the key instead of a field; exclusive with key |
| tags | {} | tag filters, "key=value" or "key", all of which an entry must satisfy |
| where | {} | field filters, "field op value" or "field in a,b,c", as on @SpaceTake, all of which an entry must satisfy |
| atLeast | 1 | how many entries of this part a key needs before it is complete; the method reads them with Joined.all(type) |
| countedBy | "" | "SimpleTypeName.field": take the count from a field of another part present for the key, so a fork can declare how many it emitted; overrides atLeast once that part is present |
| optional | false | whether the join fires without this part; an optional part is included when present |
@SpaceReduce: the persistent accumulatorSpaceReduceReductions
A fold over an unbounded stream of entries per key, whose state is itself an entry. The method takes the key's current accumulator, null on the first step, and one element, and returns the new accumulator. The binder takes the element under a lease, invokes the method, and completes the element with the new accumulator as the result in the same space: one atomic step, so an element is never folded twice. It then retires the previous accumulator. A JoinTicket per key, taken before a drain and completed after it, serializes the steps of one key; a reducer whose ticket is not the key's oldest live one backs off. Under ORDERED the ordered log decides which ticket holds the key; under LEASED the older-ticket check is exact within one process and best effort across replicas, so only ORDERED guarantees that two reducers never fold one key at once. The accumulator is rewritten under accumulatorLease on every step, so that lease is the key's idle timeout: a quiet key lapses, and the lapse is an EXPIRED event a @SpaceNotify(on = EXPIRED) on the accumulator type can treat as "the session closed". This is the third form of reduce: the gather folds a bounded set per key once, the aggregate folds a number across the fleet, and this folds a stream per key with a value that is always current, exact, durable, and readable as an entry by any peer or client.
// agentspaces-example-apps/agentspaces-petclinic-fleet -- PetClinicFleet.Scheduler: the slot book lives in the space @SpaceReduce(space = CLINIC, name = "slot-book", lease = "30s", accumulatorLease = "7d", produces = Appointment.class) public Entries book(SlotBook book, Triage triage) { SlotBook current = book == null ? new SlotBook(0, 0) : book; // null on the first step boolean urgent = triage.acuity().equals("EMERGENCY") || triage.acuity().equals("URGENT"); String vet = urgent ? ON_CALL : VETS.get(current.routineTurn() % VETS.size()); String slot = urgent ? "immediate" : slotLabel(current.nextSlot()); SlotBook next = urgent ? current : new SlotBook(current.routineTurn() + 1, current.nextSlot() + 1); return Entries.of(next, // the accumulator, written atomically with the take new Appointment(triage.requestId(), triage.petName(), vet, slot, triage.acuity())); // the side output, after it }
The PetClinic scheduler used to keep its slot book in two AtomicIntegers, which a restart reset and a second scheduler duplicated; the flagship's test now runs two schedulers through a restart and books every visit once. The return is the accumulator, a Tagged accumulator (its tags merge beneath the binder's), or an Entries fork holding exactly one accumulator and any side outputs, which the binder writes after the atomic step the way a fork's elements are written; null consumes the element and leaves the accumulator as it was. The key is a field of the element (key) or one of its tags (keyTag); with neither, every element folds into one accumulator, which serializes them all through one ticket. The accumulator lives beside its elements, since the atomic completion needs one space; a stage that wants it elsewhere reacts to it and writes a copy.
| attribute | default | meaning |
|---|---|---|
| space | "" | where the elements, the accumulators, and the tickets live; empty means the sole registered space |
| group | "" (any) | the group this binding belongs to; empty means any group whose spaces satisfy it |
| name | "" | the name the tickets and the accumulator's reduce tag carry; <agent>.<method> by default; may not equal a @SpaceJoin's name on the same agent |
| key | "" | the element's field carrying the key; empty with an empty keyTag folds every element into one accumulator |
| keyTag | "" | the element's tag carrying the key instead of a field; exclusive with key |
| tags | {} | tag filters on the element, "key=value" or "key", as on @SpaceTake |
| where | {} | field filters on the element, "field op value" or "field in a,b,c", as on @SpaceTake |
| mode | LEASED | LEASED takes the key's ticket through the space's claim race; ORDERED takes it, the elements, and the retired accumulators through the space's ordered-log coordinator, so two reducers never fold one key at once |
| lease | 30s | the take lease on an element and on the ticket; the drain renews the ticket before every step and stops when a renewal fails, so one step must fit inside it |
| pollTimeout | 1s | how long a take waits for an element, and how often untaken elements are swept |
| accumulatorLease | 24h | the accumulator's and the ticket's write lease: the key's idle timeout; refused when shorter than lease |
| subscriptionLease | 1h | the element subscription's lease, renewed at half-lease |
| maxOpen | 10 000 | the bound on remembered keys |
| produces | {} | the side-output types of an Entries return, for the card; the accumulator type is always declared |
| description | "" | what this one action does, published on the card |
read returns the older. Reductions.current(space, type, name, key) returns the newest by its steps tag; use it where a step of staleness after a crash would matter. Each step costs one take and one completion and leaves two tombstones, so a key that folds thousands of elements an hour wants the gather over a window instead. The fold takes a key's elements in the order the space hands them out, which is first in, first out only in a LocalSpace; a ReplicatedSpace promises no order, so write a commutative fold or carry a sequence in the element. A method that throws ends the drain and the element reappears by its lease, the @SpaceTake retry. The tags reduce, key, and steps are plaintext under content encryption, as a join's ticket tags are.The Spring Boot starter
The starter (agentspaces-spring-boot-starter) wires the peer from properties and enrolls annotated beans through an ordinary bean post-processor. It deliberately declares no Spring dependency of its own; it compiles against provided-scope stubs and binds to the Spring your application already runs.
PropertiesAgentSpacesProperties
| property | default | meaning |
|---|---|---|
| agentspaces.keystore | (ephemeral) | directory persisting the peer's Ed25519 identity; omit for a fresh identity per run |
| agentspaces.bind | (dial-only) | host:port to listen on; omit for a NAT-restricted, dial-only peer |
| agentspaces.roles | [] | topology roles this peer volunteers for, e.g. RENDEZVOUS, RELAY |
| agentspaces.tick-millis | 250 | protocol tick period (membership probe, ad refresh, anti-entropy) |
| agentspaces.peer-ad-interval-millis | 30000 | how often the node re-floods its own peer advertisement; between refreshes a newcomer receives it directly |
| agentspaces.coalesce-millis | 20 | the frame coalescing window: rumors and pipe frames to one peer batch into one signed frame, toward peers that advertise it; 0 turns it off |
| agentspaces.lazy-push | true | rumors ride a self-pruning tree toward members that understand it; off, the node gossips with random fan-out only |
| agentspaces.card-refresh-millis | 300000 | how often bound agents' cards republish |
| agentspaces.groups[].name | none | application-facing group name |
| agentspaces.groups[].founding | none | founding text; every peer that receives the same text derives the same GroupId from it. The group has no signed founding, so a peer cannot join it by id: give every peer the same text. Exactly one of founding and join identifies the group; neither set means the group name doubles as the founding text |
| agentspaces.groups[].join | none | join an existing self-certifying group by aspace://<groupId> (or bare GroupId): the signed founding advertisement is fetched from the seeds and verified against the id before joining. Some seed must hold a signed founding: a group started from founding text has none, and the join then times out. A join group also needs at least one seeds entry or BeaconLocator bean, or startup fails (finding the group) |
| agentspaces.groups[].join-timeout-millis | 10000 | how long the join-by-GroupId fetch waits for a verified answer |
| agentspaces.groups[].seeds | [] | existing members to dial, host:port |
| agentspaces.advertise | the bind address | the address carried in this peer's self-advertisement, host:port or scheme://host:port: a pod's routable address, a NAT's public side |
| agentspaces.groups[].membership | open | open or invite: under invite a candidate presents a founder-issued or delegated join credential; with founding, the founder is founder or this peer |
| agentspaces.groups[].founder | none | the founder's PeerId of an invite group founded from a string; required on every member but the founder |
| agentspaces.groups[].join-credential | none | the aspace:join credential this peer presents, as JoinCredentials produces |
| agentspaces.groups[].founding-document | none | a signed founding advertisement this peer joins with (base64 CBOR); exclusive with founding and join |
| agentspaces.bootstrap.*, agentspaces.groups[].bootstrap.* | none | the locator chain (agentspaces-bootstrap project): domain, order, dns-srv, url, k8s.*, accept-group-id-from, beacon-peer, the URL guards; see that project's README |
| agentspaces.bootstrap.refresh-min, refresh-max | PT30S, PT10M | how often at least and at most the node re-asks the group's locators while its view is empty (the chain's LocatePolicy; PeerNode.Builder.locateInterval node-wide) |
| agentspaces.bootstrap.require | false | fail startup, naming the group, when the first locate answers no endpoint and no founding |
| agentspaces.bootstrap.trace | false | log every locate's rows at INFO; off, the first locate and every re-ask that changed the endpoints are logged, the rest at DEBUG; the console's traffic panel carries the last hint and trace per group |
| agentspaces.relay.budget-bytes-per-day | none | what this peer forwards as a RELAY per group per day, through RelayPolicy.dailyBudget when no RelayPolicy bean is defined; the counters are on the traffic panel |
| agentspaces.groups[].content-key | none | the group content key for payload encryption (epoch 0 of the group's ring); also what the key-wrap capability serves |
| agentspaces.groups[].content-key-rotation.* | none | rotate-every (unset: never rotates on its own; needs the founder or a key-rotator grant), cutover-delay (two gossip periods plus 30s), writer-grace (1h); members follow rotations whenever content-key is set (content encryption) |
| agentspaces.groups[].spaces[].name | none | a replicated space to attach |
| agentspaces.groups[].spaces[].strategy | LEASE_RACE | LEASE_RACE or AUCTION; ORDERED is not a space strategy but a coordinator over the ordered-log capability (space admission) |
| agentspaces.groups[].spaces[].settle-window-millis | 200 | the take settle window |
| agentspaces.groups[].spaces[].admission | group | group, allowlist, credential, or authorizer (space admission) |
| agentspaces.groups[].spaces[].allowed-agents | [] | the admitted agents under admission: allowlist |
| agentspaces.groups[].spaces[].credential-issuer | this node | under admission: credential, the PeerID whose signed credentials admit agents; only that node may grant and revoke |
| agentspaces.security.profile | mtls | dev-local, mtls, mtls-oidc, zero-trust; selects transports, channel mode, and who answers authorization |
| agentspaces.identity.agent-keys | peer | peer signs every bound agent's records with the peer key; subordinate gives each bound agent a peer-certified Ed25519 key of its own, renewed at half-life, so its records, claims, and transitions are AGENT_ATTESTED, its card carries the key and certificate, and its @SpaceRef handles are per-agent views (content encryption) |
| agentspaces.identity.agent-keystore | none | directory persisting subordinate agent keys (and their X25519 keys) across restarts; needs agent-keys: subordinate |
| agentspaces.identity.agent-certificate-ttl | 24h | each agent certificate's lifetime; certificates re-issue at half-life and are judged at signing time |
| agentspaces.security.oidc.* | none | issuer, audience, jwks-url (all required under the OIDC profiles), plus this node's own token or token-file |
| agentspaces.security.grants.* | (any member) | under the membership profiles, PeerID or AgentID (peer/name) lists narrowing one operation: raft-voter, raft-client (submit to an ordered log without voting), directive-issuer, connector-serve, key-holder, space-write, space-take, vote, model-serve; v0.1.13 adds key-rotator, which nobody holds by default, and which every member must list identically |
| agentspaces.transport.channel-auth | (from profile) | signed signs every frame's envelope; attested negotiates unsigned frames over channels the transport authenticated; session acts as attested where the transport attests, and on a channel it cannot attest, one signed hello per connection sets a session key and frames carry a MAC under it. The profile picks the default: session for dev-local, attested for mtls and mtls-oidc, signed for zero-trust |
| agentspaces.transport.tls.enabled | (from profile) | switch the TCP listener and dialer to TLS with this peer's identity-endorsed channel certificate |
| agentspaces.transport.dtls.enabled | false | register the DTLS binding (needs agentspaces-transport-dtls); with bind it listens and advertises after agentspaces.bind's endpoint, at priority, at advertise if set |
| agentspaces.transport.dtls.attestation | identity | identity attests with the channel certificate (CA mode when a trust store is set); none encrypts only and is refused under require-attestation |
| agentspaces.transport.dtls.connection-id | true | RFC 9146 Connection ID, so an association survives NAT rebinding |
| agentspaces.transport.dtls.mtu / window / idle-timeout | 1200 / 64 / 60s | datagram budget, PDUs in flight, silence before an association fails |
| agentspaces.transport.dtls.max-half-open / hello-rate-per-source | 1024 / 8 | listener flood limits; raise the hello rate for many peers behind one NAT address |
| agentspaces.transport.dtls.protocols | [DTLSv1.2] | DTLS versions offered; DTLS 1.3 joins when BouncyCastle negotiates it |
| agentspaces.transport.ws.enabled / bind / advertise / priority / path | false | the WebSocket listener block (needs agentspaces-transport-http); path defaults to /aspace; follows the tcp rule: any profile, refused under require-attestation |
| agentspaces.transport.ws.allowed-origins | [] | browser origins admitted on upgrade and on the exchange (403 otherwise); empty refuses every browser, * any |
| agentspaces.transport.wss.* / https.* | false | the same blocks inside TLS, with the tls binding's key and trust stores; a certificate-less dialer (a browser) is admitted as unattested |
| agentspaces.transport.http.enabled / bind / path | false | the HTTP exchange of the design's appendix C; path defaults to /aspace/h, also for https; may share ws's bind, one port for both |
| agentspaces.transport.ws / wss / http / https: idle-timeout, ping-interval, long-poll, opens-per-source-per-second | 60s / 30s / 25s / 4 | a connection or exchange session silent for idle-timeout closes; a WebSocket idle for writing sends a ping every ping-interval; an exchange download request is held long-poll when it has nothing to send; opens-per-source-per-second limits how many exchange sessions one source address opens each second, and sets no cap on sessions |
| agentspaces.transport.webrtc.enabled / priority | false | the WebRTC acceptor (needs agentspaces-transport-webrtc): advertises (webrtc, "sig", priority) after every stream endpoint; attests by signed fingerprint, so it satisfies require-attestation |
| agentspaces.transport.webrtc.ice-servers | [] | STUN and TURN servers (urls, username, credential); empty gathers host and mDNS candidates only |
| agentspaces.transport.webrtc.dial-timeout / offers-per-peer-per-minute / candidates-per-session / max-signal-bytes | 15s / 6 / 64 / 65536 | the dial bound and the per-peer signalling rates; none caps the number of sessions or connections |
| agentspaces.transport.webrtc.offload.frames-per-minute / retry-after | 30 / 5m | the relay offload: once more than this many frames crossed a relay to or from one member in a sliding minute, the node dials that member's webrtc endpoint from its tick (never from a send) and sends directly once open; a failed dial is retried no sooner than retry-after. Settings, not caps |
| agentspaces.transport.quic.enabled / bind / advertise / priority | false | the QUIC listener block (needs agentspaces-transport-quic), attesting with the TLS material |
| agentspaces.transport.udp.enabled / allow-plaintext | false / false | bare plaintext UDP; registers only under dev-local or with allow-plaintext |
| agentspaces.multicast.seal | false | seal each group's beacon datagram under its content-key, so bystanders read nothing |
| agentspaces.transport.tls.trust-store | none | setting it switches to enterprise-CA mode: peers attest only when their chain validates to these anchors |
| agentspaces.transport.tls.key-store | none | this node's CA-issued credential (PKCS#12/JKS), with key-store-password / trust-store-password |
| agentspaces.transport.tls.crls | [] | cached CRL documents |
| agentspaces.transport.tls.revocation-strict | false | fetch OCSP and CRL distribution points live instead of relying on the cached CRLs |
| agentspaces.transport.tls.key-store-password, .trust-store-password | unset | passwords for the key store and trust store files when they are protected |
| agentspaces.transport.tls.crl-refresh | 5m | how often the crls files are re-read; a replaced CRL re-judges every live connection |
| agentspaces.transport.tls.revocation-validator | founder | founder-or-ca also accepts a peer revocation the CA's CRL proves, carried as evidence, and makes this node issue one when its CRLs revoke a connected peer (content encryption) |
| agentspaces.transport.tls.require-attestation | false | refuse every frame not arriving on a channel attested for its sender, which is how CA revocation becomes a fleet-wide eject. Any attesting transport satisfies it: TLS, or an enabled DTLS (identity attestation), QUIC, WSS, or WebRTC block. Startup refuses when none of them is enabled, and refuses ws, http, https, udp, and DTLS with attestation: none, which cannot attest |
| agentspaces.crypto.signature-provider | jdk | the Ed25519 implementation by SignatureProvider name; every provider is pinned to the golden signature vectors |
| agentspaces.multicast.enabled | false | LAN bootstrap beacon: announce endpoints on multicast.group (default 239.255.42.99:7787) every interval-millis (5000) and dial peers heard, so a LAN fleet needs no seeds |
| agentspaces.capabilities.enabled | true | the capability runtime as a whole (capability services) |
| agentspaces.capabilities.* | see note | per-capability toggles: aggregate, vote, semantic-discovery, key-wrap default on; gossip-learn defaults off, and true wires the default weight-averaging learner. ordered-log=true always fails startup, because the Raft log needs a fixed member set the starter cannot infer; register a @ProvidesCapability bean that wraps RaftLog with the members instead |
| agentspaces.capabilities.votes-space | votes | the space the vote capability runs over; when the group configures no space by that name the starter builds a private one, which does not count for single-space inference and which @Ballot and @OnDecision cannot bind to: list the vote space under groups[].spaces when agents vote |
| agentspaces.console.enabled | false | serve the fleet console (the fleet console) |
| agentspaces.console.port | 7590 | console HTTP port |
| agentspaces.console.fleet-name | "" | the fleet name the console displays |
| agentspaces.console.command.enabled | false | command-and-control routes; requires a token or a configured identity provider |
| agentspaces.console.command.token | "" | static operator secret gating the command routes; when set it always takes precedence over the identity provider |
| agentspaces.console.command.required-scope | aspace:console:operate | with the token blank and security.oidc.* set, the scope each operator's bearer JWT must carry |
| agentspaces.console.command.control-space | fleet-control | the space directives are written to; workers subscribe here |
| agentspaces.console.command.audit-space | c2-audit | the space each command's audit record is written to, under a 24-hour lease; the starter writes no record unless the group declares this space |
| agentspaces.a2a.enabled | false | serve the A2A gateway over the fleet's AgentCards (bind 127.0.0.1, port 7594, group, space, external-base-url); a routable bind fails at startup without a token or a configured security.oidc.*, whose bearer JWTs must then carry required-scope (default aspace:a2a:client) |
| agentspaces.a2a.token, .fleet-name, .provider, .allowed-webhook-hosts, .task-lease-millis, .result-lease-millis | "", "", AgentSpaces, [], 600000, 3600000 | the static bearer token (takes precedence over OIDC when set), the names the gateway's agent card reports, the hosts push notifications may target, and the leases on the task written and the result kept |
| agentspaces.springai.* | see Spring AI | the Spring AI integration, from the separate agentspaces-springai library: fleet tools, take-lease renewal, chat memory in a space, the model service, embeddings, usage, and MCP export |
| agentspaces.embabel.remote-actions-enabled | true | expose discovered cards to the Embabel planner (Embabel) |
| agentspaces.embabel.remote-action-timeout-millis | 30000 | how long a generated planner action waits for its result |
| agentspaces.embabel.auto-deploy | true | redeploy the generated remote-action agent onto the platform whenever the set of remote actions changes |
| agentspaces.embabel.deploy-poll-millis | 2000 | how often the deployer checks the registry for arrivals and expiries |
| agentspaces.embabel.remote-agent-description | Capabilities other fleet members advertise through AgentCards | description on the generated planner agent |
| agentspaces.embabel.remote-agent-name | remoteFleet | the name of the generated agent |
Finding the group: agentspaces.bootstrap and the node seams
With only seeds configured, the starter dials the list and expands names that resolve to several addresses. Add ai.badmonkey.agentspaces:agentspaces-bootstrap-spring-boot-autoconfigure:0.4.0 beside the starter, and the agentspaces.bootstrap.* keys build each group's chain from the locators. A group's own bootstrap block overrides the fleet-wide block, key by key, and the full key list is in the agentspaces-bootstrap README.
agentspaces: bootstrap: # fleet-wide defaults domain: auto # the host's search domain timeout: PT3S groups: - name: research-fleet founding: research-fleet-v1 # a group started from a text bootstrap: order: [ seeds, dns-sd, url ] # this group's chain url: https://agentspaces.corp.example/.well-known/agentspaces/beacon.json - name: partner-fleet join: aspace://q9c2b... # a group with a signed founding seeds: [ "beacon.partner.example:7500" ] # join: needs a seed bootstrap: order: [ seeds, url ] url: https://partner.example/.well-known/agentspaces/beacon.json beacon-peer: z6Mk...partnerBeacon # the PeerId that must sign it
Every step is a bean the application may define, and each default yields to yours. The core starter collects the BeaconLocator, Transport, PeerNodeCustomizer, and PeerNodeConfigurer beans, and the bootstrap module adds a DnsResolver and a BootstrapChainFactory:
| bean | decides | default |
|---|---|---|
BeaconLocator | an extra way to find a group, for every group or, through appliesTo, for one; @Order ranks them | none |
DnsResolver | how the DNS locators resolve | the JDK's JNDI resolver |
BootstrapChainFactory | how each group's chain is composed from the seeds locator and the other locators | ChainComposer over the properties, or the core's plain chain when no bootstrap key is set |
PeerNodeCustomizer | the node builder's tunables (advertisement TTL, rate limits, channel trust) before build() | none |
PeerNodeConfigurer | the built node, after the transports register and before any group joins | none |
@Configuration class FleetBootstrapConfig { /** An extra locator for every group; @Order ranks it among the others. */ @Bean @Order(10) BeaconLocator cloudMapLocator(DnsResolver dns) { return new SrvLocator( "_aspace._tcp.agentspaces.corp.internal", "tls", dns); } /** Replace the resolver: DNS over HTTPS and DNSSEC. */ @Bean DnsResolver dnsResolver() { return DnsJavaResolver.builder() .doh("https://dns.corp.example/dns-query").dnssec(true).build(); } /** The locators arrive as [seeds, beans...]; compose them as you like. */ @Bean BootstrapChainFactory firstWins() { return (group, locators) -> BootstrapChain.builder().firstWins().addAll(locators).build(); } /** Touch the node builder without replacing the node. */ @Bean PeerNodeCustomizer tenMinuteAds() { return builder -> builder.peerAdTtl(Duration.ofMinutes(10)); } }
BootstrapProperties and BootstrapAutoConfiguration in the bootstrap module, which runs before AgentSpacesAutoConfiguration; BootstrapChainFactory, PeerNodeCustomizer, and PeerNodeConfigurer in the core starter. The Micronaut chapter lists the same seams as beans.BootstrapChainFactory bean wins over everything else. Otherwise the chain is the seeds locator (when order names seeds), then the locators the properties name in order, then your BeaconLocator beans (before the property locators under bean-locators-first). A misconfigured block (an unknown name in order, or a plain-DNS locator in accept-group-id-from without insecure-group-id) fails the context at startup and names the property. A group with join: needs at least one seed (or a BeaconLocator bean) besides its bootstrap keys: in 0.4.0 the starter counts only the configured seeds, BeaconLocator beans, and multicast as locators for a join: group and ignores the locators built from agentspaces.bootstrap.*, so startup fails with "joins by GroupId URI but configures no seeds and no locator" when it finds none. A group started from a founding: text has no such need, and cannot be joined by id, because it has no signed founding.Security profiles and authorizationAuthorizersAuthorizer
One property sets the whole posture. agentspaces.security.profile picks the transports and channel mode a fleet forms over (the default, mtls, means a fleet comes up over TLS with attested channels and no further configuration), and it also picks who answers the one question every privileged operation asks: authorizer.permits(peer, operation, scope).
Under dev-local and mtls the answerer is membership-rooted, one authorizer per group over the group's admitted members, narrowed by the optional grants lists; a grant never outlives admission. Under mtls-oidc and zero-trust it is your identity provider: each peer carries its JWT inside its own signed self-advertisement, the token's agentspaces_peer claim binds it to one PeerID, and scopes of the form aspace:<operation>[:<scope>] decide. A token is valid in every group a node joins, so each group's authorizer also requires the peer to be a current member of that group and refuses a peer or agent the group has revoked; the scopes decide only after those two checks pass. Incomplete OIDC configuration fails at startup rather than at the first refusal. The agentspaces-auth-oidc module supplies that answerer: its OidcAuthorizer validates each peer's bearer JWT against the issuer's JWKS and is the Authorizer the MTLS_OIDC and ZERO_TRUST profiles install.
# application.yml agentspaces: security: profile: mtls-oidc oidc: issuer: https://idp.example.com audience: agentspaces-fleet jwks-url: https://idp.example.com/.well-known/jwks.json token-file: /var/run/secrets/aspace-token # re-read each card refresh groups: - name: research-fleet spaces: - name: tasks admission: authorizer # aspace:space-write:tasks / aspace:space-take:tasks - name: vault admission: credential # issuer-signed SpaceCredential entries admit
The starter threads the authorizer into the key-wrap provider, spaces admitted by authorizer, the vote tally, and the DirectiveGates bean workers use to obey the console. Pass the Authorizer bean, or Authorizers.forGroup(name), to a RaftLog, DataQueryClient, or ConnectorRuntime you wire yourself.
What the starter does with your beansAgentSpacesBeanPostProcessorAgentSpaces
After initialization, any bean declaring an agent identity or carrying bound methods enrolls automatically: @SpaceRef and @CapabilityRef fields inject first, then bid functions wire, worker loops start, subscriptions register, and one card publishes per group the bean bound into. Group selection is by space coverage, narrowed by any group attribute the annotations carry: a bean binds into each configured group whose spaces cover the spaces it names, and a bean naming a space no group registers fails at startup, which is when a configuration mistake should show. A @ProvidesCapability bean registers on the group's capability runtime the same way. A SchemaRegistry bean, when you declare one, is applied to every space the starter builds and to every card it publishes, so records and cards name each type the same way (the schema registry section shows NamespaceSchemaRegistry). Plain beans pass through untouched.
The facade bean is there when you outgrow the annotations: AgentSpaces navigates groups and spaces Embabel-style, with the typed verbs of the Space API as terminal operations.
// Ordinary application code that happens to need the fleet: inject the facade. @RestController public record OrderIntake(AgentSpaces spaces) { @PostMapping("/orders") public void submit(@RequestBody Order order) { spaces.group("orders-fleet").space("work") .write(order, Lease.of(Duration.ofMinutes(10))); } }
group.discovery()) and typed capability clients (group.capability(...)), which is why the Space API and the capability services both start from it.Embabel integration
The embabel-agentspaces extension activates only when Embabel is on the classpath, and it works in both directions: your Embabel agents become discoverable to the fleet, and the fleet's discovered skills become actions for your planner.
Outbound: cards from your @Agent beansEmbabelBinder
Every Embabel @Agent bean's metadata, its description, its @AchievesGoal statements, and its @Action input and output types, becomes a published AgentCard automatically. Worker loops still come from @SpaceTake on the action, so an Embabel action joins the fleet with one added annotation:
// embabel-agentspaces -- EmbabelBinderTest.SpaceBoundEmbabelWorker, trimmed @Agent(name = "spaceWorker", description = "Takes tasks from the work space") public class SpaceBoundEmbabelWorker { @Action @SpaceTake(space = "work") // lease defaults to "10m" public FindingEntry research(TaskEntry task) { return new FindingEntry(task.topic(), "done"); // your action body: ordinary code } }
Inbound: remote actions for the plannerEmbabelRemoteActionsRemoteActions
The remote-actions bridge turns eligible foreign AgentCard actions into typed, invocable actions when their input and output classes resolve locally and their task space can be selected. Invoking one is choreography, never RPC: the bridge writes the input entry where the remote workers take that type from, then awaits a result entry that correlates with the request (by default, equal values on every record component name the input and result share, the convention a Finding carrying its ResearchTask's topic already follows). A remote worker that crashes mid-step lets its lease lapse; whoever finishes, the awaited result still arrives. An invocation nobody serves times out empty with the entry still leased in the space. Since v0.1.13 a card declares its actions, each pairing what one bound method consumes with what it produces and carrying that method's description, so the bridge offers exactly those pairs instead of every consumed-by-produced combination, and falls back to the cross product only for older cards. RemoteAction.declared() exposes the declared action.
// agentspaces-agent -- RemoteActionsTest, trimmed; ResearchTask and Finding are the records below RemoteActions fleet = RemoteActions.over(spaces.group("research-fleet"), peerId) .route(ResearchTask.class, "tasks") // one-space groups need no routes .resultsIn(Finding.class, "tasks"); // Cards arrive by gossip, so the list is empty until a worker's card has landed. RemoteAction research = fleet.producing(Finding.class).stream().findFirst().orElseThrow(); Finding f = research.invoke(new ResearchTask("req-1", "agentic memory"), Finding.class, Duration.ofSeconds(30)) .orElseThrow(); // empty means nobody answered in time
For the GOAP planner, EmbabelRemoteActions generates a class annotated @Agent with one real typed @Action method per remote action (card goals become @AchievesGoal), because the planner conditions on method signatures. The proven consequence: an agent whose local actions cannot chain to a goal type finds no plan alone, and finds and executes the multi-hop plan through remote specialists the moment the fleet's cards are bridged in. Cards are TTL-leased, so the planner's action space tracks the live fleet.
RemoteActions registry as a @Bean (or in assembly code), declare the routes, and hand it to EmbabelRemoteActions. The partybus does exactly this in one method, looping over its request and result types to register routes. Individual agents stay unaware; they are on the serving side of this, taking the entries the bridge writes.Correlation. And because invocation is choreography rather than RPC, a timeout means "nobody produced a correlated result in time": the input entry is still sitting in the space under its lease, and a worker that shows up late will still do the work.Deployment keeps itself current. With agentspaces.embabel.auto-deploy on (the default) a deployer watches the bridge's registry on a virtual thread and redeploys a freshly generated agent whenever the set of actions changes: the first time cards arrive, when a new card brings a new capability, and when an expired card takes one away. A card refresh that changes nothing but the issue time does not redeploy. Embabel's platform exposes no undeploy in the API this module reaches, so an action that disappears is absent from the next generated agent while the previous generation stays registered until the platform restarts; invoking one of its vanished actions times out and throws, which the planner treats as the action failing.
remote-action-timeout-millis throws, which the planner treats as the action failing. Set it above your workers' realistic completion time rather than to your patience.Set up the planner boundary deliberatelyEmbabelAgentSpacesAutoConfiguration
Add ai.badmonkey.agentspaces:embabel-agentspaces:0.4.0 (a module of the core repository) alongside the AgentSpaces starter and your Embabel application dependencies. The extension reads Embabel annotations reflectively; it does not install an Embabel platform. Automatic deployment requires the application’s AgentPlatform bean.
The metadata path and the execution path have different jobs. @Agent, @Action, and @AchievesGoal describe capabilities; they do not start tuple-space workers. Add a one-argument @SpaceTake or @SpaceNotify method for execution. An Embabel action taking OperationContext as a second argument cannot also be a bound space method: move the reusable work behind an injected service and expose a single-domain-argument adapter.
Embabel metadata excludes infrastructure parameters in com.embabel.*, primitives, and String from domain schemas. Use records or domain classes at the fleet boundary. A multi-input planner action may describe several domain types, but the remote bridge generates one-input actions; adapt such work to one request record rather than expecting it to ship an Embabel execution context.
Outbound cards publish into every configured group. A bean with @AgentSpec or its @SpaceAgent stereotype uses the core binder’s card instead of receiving a second Embabel-derived card. The default inbound bridge uses the first configured group and excludes this peer’s cards; local actions remain the local planner’s responsibility. For another group or custom routing, replace the EmbabelRemoteActions bean.
// Application configuration; ResearchTask and Finding are shared domain types.
@Bean
EmbabelRemoteActions researchFleetBridge(AgentSpaces spaces, PeerIdentity identity) {
RemoteActions remote = RemoteActions.over(
spaces.group("research-fleet"), identity.peerId())
.route(ResearchTask.class, "tasks")
.resultsIn(Finding.class, "findings");
return new EmbabelRemoteActions(remote, Duration.ofSeconds(60),
"researchFleet", "Remote research specialists");
}Declare both spaces in that group on the caller and workers. Set agentspaces.embabel.auto-deploy=false for manual deployment through bridge.deployTo(platform); remote-actions-enabled=false disables the default inbound bridge while outbound card publication remains available. This lets a worker-only application advertise capabilities without importing the fleet into a planner.
The remote action contract: shared by Embabel and Spring AICorrelation
The card is an advertisement of a capability rather than a private endpoint. A tool or planner action named after an agent writes a typed entry that any eligible worker may serve. Use a distinct request type or space admission when work must be restricted; selecting a card by name alone does not pin the executor.
| Contract | Implementation behavior |
|---|---|
| Type visibility | The caller must have compatible input and output classes on its classpath. Unresolvable schemas are omitted. Declared ballots and decision reactions are not invocable remote actions; legacy cards without action metadata use consumed/produced pairings. |
| Task routing | Explicit route(type, space), then the declared action’s space, then the card’s type binding, then the sole local space. Multiple spaces with no applicable route omit the action. |
| Result routing | Defaults to the task space. If the worker sets resultSpace, configure resultsIn(outputType, space) on the caller; the result route is not inferred from that annotation over the wire. |
| Correlation | Default Correlation.sharedFields() compares every shared record component. No shared fields means any candidate matches. Share a unique request id and preserve it in the result; do not correlate concurrent requests by a topic alone. |
| Timeout | The wait ends, but the written task retains its own lease (default ten minutes in RemoteActions). A timeout does not cancel it. Retry with an application idempotency policy. |
| Trust | Descriptions become planner/model input. Card filtering controls what is offered; space admission controls who can act. An attested card alone does not turn tuple-space invocation into addressed execution. |
// Put these public records in a shared domain artifact (one source file each).
public record ResearchTask(String requestId, String topic) {}
public record Finding(String requestId, String summary) {}
// Both sides preserve requestId. Only that component is shared here.
// For non-record types or a different correlation rule:
remote.correlation((request, result) ->
((ResearchTask) request).requestId().equals(((Finding) result).requestId()));Spring AI integration
agentspaces-springai is a separate library, in its own repository at github.com/badmonkeyai/agentspaces-springai, built on the published AgentSpaces artifacts. Spring AI gives an application a portable way to call models; the fleet gives a set of processes a shared, leased, signed, replicated space. The library plugs the second into the first through Spring AI's own extension points (tools, advisors, chat memory, ChatModel, EmbeddingModel, observations, and MCP), so an application stays ordinary Spring AI code while its agents coordinate across machines.
It needs no Embabel. The Embabel extension integrates at the level of a planned agent and its actions; this one integrates at the level of a model call and its tools. An application can use either or both. It needs Java 21, Spring Boot 4.1, Spring AI 2.0, and the Spring Boot starter beside it:
<!-- pom.xml: beside agentspaces-spring-boot-starter and a Spring AI model starter --> <dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>agentspaces-springai</artifactId> <version>0.4.0</version> </dependency>
// build.gradle.kts; the Groovy form is implementation '...' without the parentheses dependencies { implementation(platform("ai.badmonkey.agentspaces:agentspaces-dependencies:0.4.0")) implementation("ai.badmonkey.agentspaces:agentspaces-springai:0.4.0") implementation("ai.badmonkey.agentspaces:agentspaces-spring-boot-starter") }
The library is on Maven Central with the core. It releases from its own repository on its own cadence, so it carries its version explicitly; the BOM of the quick start manages the starter's.
Nine feature families, configured under agentspaces.springai. Most use an enabled switch; discovery tools and capability tools have individual switches, and embeddings use embedder.type. Fleet tools and the take-lease advisor are on by default. Everything that widens what a model can reach, or sends prompts across the fleet, is off until you turn it on. The spaces a feature uses must be declared under agentspaces.groups[].spaces like any other; a missing one fails at startup with the YAML that adds it, so the space has the group's admission, encryption, and block exchange from the start.
| feature | beans | what it gives an application |
|---|---|---|
| Fleet tools | FleetTools | the fleet's AgentCards as Spring AI tool callbacks; each request sees the live set |
| Discovery and data tools | FleetDiscoveryTools | find_fleet_agents, list_fleet_assets, fetch_fleet_data, each enabled separately |
| Capability tools | FleetCapabilityTools | propose_vote, cast_ballot, read_tally, read_decision, contribute, and read_estimate over the group's vote and aggregate capabilities, each family enabled separately |
| Crash-safe workers | TakeLeaseAdvisor, SpaceChatMemoryRepository | a take lease renewed on every model round-trip, and chat memory in a replicated space |
| Model service | FleetChatModel, ModelServer | a ChatModel whose provider is the fleet: crash tolerance, routing by model or price, streaming |
| Embeddings | SpringAiEmbedder | semantic discovery ranks with the application's EmbeddingModel |
| Fleet-wide usage | FleetUsage, UsagePanel | token usage from Spring AI's observations as per-peer totals and audit entries in a metrics space, and a console panel |
| MCP export | FleetMcpToolSync | named fleet agents published through Spring AI's MCP server, kept in step with list_changed |
| Patterns | VectorStoreAssetProvider; the patterns module | a VectorStore as a fleet data asset; judges, token-priced auctions, extraction ensembles on the annotations |
Workers: a ChatClient inside a takeTakeLeaseAdvisor
A worker is a @SpaceAgent bean with an injected ChatClient.Builder and a @SpaceTake method, exactly as @SpaceTake describes it. The space distributes the work, the model does it. What the library adds is the lease discipline an agentic loop needs: TakeLeaseAdvisor runs inside Spring AI's tool loop, after the ToolCallingAdvisor, and renews the in-flight take lease once per model round-trip (and on an interval while a streamed round-trip runs). A worker making progress keeps its claim however long its loop takes; a worker process that crashes stops renewing, and its task reappears when the take lease expires. Leases can therefore be short, which makes recovery fast.
// agentspaces-springai/examples/springai-fleet (example 15): one of two workers @SpaceAgent(name = "summarizer", description = "Summarizes a topic into a short briefing") public class Summarizer { private final ChatClient chat; Summarizer(ChatClient.Builder builder) { this.chat = builder.build(); } @SpaceTake(space = "work", lease = "2m") // renewed on every model round-trip public Summary summarize(Brief brief) { return new Summary(brief.topic(), chat.prompt().user("Summarize: " + brief.topic()).call().content()); } }
With memory.enabled, the worker's conversation lives in a replicated space too. SpaceChatMemoryRepository is a Spring AI ChatMemoryRepository that writes each save as the conversation's next leased snapshot and retires the older ones, so an abandoned conversation leaves the space on its own and a snapshot over 64 KiB travels content-addressed. FleetChatMemoryAdvisor keys the conversation by the in-flight take's entry id by default (conversation-id: take; agent and explicit are the alternatives), so a task that reappears after a crash resumes on another peer with the conversation it had.
# application.yml, every worker agentspaces: groups: - name: springai-fleet founding: springai-fleet-example-v1 spaces: - { name: work } - { name: conversations } # the memory space; missing => fails at startup springai: memory: enabled: true conversation-id: take # one conversation per task entry
ChatClient.Builder through Spring AI's ChatClientBuilderCustomizer, so a bean that injects the builder gets them without naming them; the lease advisor does nothing when no take is in flight, which is why it is safe on every client. A ChatClient you construct some other way takes the advisor beans explicitly.lease with no round-trip completing lets the task reappear, so size the lease to the slowest single round-trip you expect rather than to the whole job.Fleet tools: cards as tool callbacksFleetToolsFleetDiscoveryTools
Every AgentCard in the group declares the actions its bound methods consume and produce (the binder). FleetTools turns each into a Spring AI ToolCallback: the tool's input schema is the action's consumed type, its description is the method's, and its name is the prefix, the agent, and the input type (fleet_summarizer_Brief). Calling the tool writes the input as a leased task entry and awaits the correlated result, the same choreography the remote-actions bridge of the Embabel chapter performs. The provider resolves the set for each request, with a snapshot cache (default tools.cache-ttl=1s), so arrivals and expiries become visible after that cache refresh; a FleetToolsChangedEvent announces each change.
// agentspaces-springai/examples/springai-fleet -- SpringAiFleet.Orchestrator, trimmed public class Orchestrator { private final ChatClient chat; Orchestrator(ChatClient.Builder builder, FleetTools fleet) { this.chat = builder.defaultToolCallbacks(fleet.toolCallbacks()).build(); } public String ask(String question) { // the model calls agents on other peers from its own tool loop return chat.prompt().user(question).call().content(); } } // Three Spring Boot applications: two list the first one's address as a seed, // and no application names another. The workers' cards are the tool catalog. String answer = orchestrator.ask("Brief me on tuple spaces, in French too");
Which cards become tools is a deployment decision: include-agents and exclude-agents lists, require-attested to admit only agents signing with a certified key of their own (agent certificates), or a FleetToolFilter bean for a rule of your own. ToolNamingStrategy is the other replaceable default. A tool call waits tools.timeout for its result; on expiry it returns an explanation to the model (on-timeout: result) or throws (throw).
Three more tools let the model look around rather than act. FleetDiscoveryTools offers find_fleet_agents, list_fleet_assets, and fetch_fleet_data over the discovery and connector layers of discovery and the connector SDK, each switched on separately because each widens what the model can reach. And vector-store.enabled serves the application's own Spring AI VectorStore as a fleet data asset through the connector SDK, so any agent in the fleet queries the shared knowledge base with leased, pull-once results: an identical question inside the freshness window reads the answer from the space instead of searching again.
FleetTools, FleetDiscoveryTools, and FleetCapabilityTools are plain beans you attach to a ChatClient yourself, through toolCallbacks(). They are deliberately not ToolCallbackProvider beans: Spring AI's MCP server auto-configuration publishes every ToolCallbackProvider in the context to outside MCP clients, and the fleet crosses that boundary only through the MCP export below, which exposes nothing until named.admission: authorizer and the space-take grant (space admission). Tool descriptions are model input: filter which cards become tools before you trust what they say.Model access as a fleet serviceFleetChatModelModelServer
FleetChatModel is a Spring AI ChatModel whose provider is the fleet. Each round-trip becomes a ModelRequest entry in the model space; a ModelServer on a peer that holds the credentials takes it, stamps the attempt, calls its provider, and completes the take with the response, so the answer and the request's removal land together. The caller needs no API key and no provider dependency. A server that dies mid-call lets its lease lapse and another serves the request with the next attempt number; the response metadata carries agentspaces.servedBy and agentspaces.attempt. Requests route by model name, or by price when the model space runs AUCTION and each server bids its prices.<model>. A streamed request writes the provider's deltas as short-leased chunk entries, assembled in order on the caller; cancelling the Flux writes a ModelCancel that stops the server; and stream.on-restart says whether a stream whose server died fails or restarts on another.
# a server peer: holds the provider credentials, serves named models agentspaces: groups: - name: research-fleet spaces: - { name: model-requests, admission: authorizer } # prompts cross the fleet here; add strategy: AUCTION for price routing springai: model-server: enabled: true models: [gpt-4.1-mini, gpt-4.1] # required: an enabled server with no models fails at startup routing: model # or price, which needs the space on strategy: AUCTION concurrency: 4 # a calling peer: no provider on the classpath, no key anywhere agentspaces: springai: model-client: enabled: true model: gpt-4.1-mini stream: { on-restart: restart }
The design leans on Spring AI 2.0's split between the model and the tool loop. ChatModel.call performs one round-trip and never runs tools, so a server never executes anything on a caller's behalf: tool calls come back in the assistant message and the caller's own ToolCallingAdvisor runs them, with the caller's own tools. Media travels content-addressed over the group's block exchange, like any entry over the inline limit. Four interfaces with one default bean each cover what a deployment might want to change: ModelCatalog (which models this server offers), ModelRequestRouter (by model or by price, or by tenant, region, or load), StreamChunkingPolicy (latency against entry count), and StreamRestartPolicy.
FleetChatModel is a @Fallback bean. On a node with no provider it is the ChatModel Spring AI's ChatClient uses, so application code is unchanged; beside a provider, injection by type resolves to the provider and injection by name resolves to the fleet model. ModelServer is a SmartLifecycle bean on the peers that hold keys, and only those peers ever see a credential.FleetChatModel accepts a response or chunk only from an issuer the authorizer permits MODEL_SERVE for the model: agentspaces.security.grants.model-serve under the membership profiles, the aspace:model-serve[:<model>] scope under the OIDC profiles (profiles and authorization). Anyone admitted to the model space can read the prompts in it, so give that space its own admission and the group a content key (content encryption). And a blocking call's response also reports its attempt, so a caller that must not pay twice for a retried request can see that it was retried.Embeddings, usage, and MCP exportSpringAiEmbedderFleetUsage
Embeddings. Semantic discovery (capability services) ranks with a pluggable Embedder, and the shipped one is lexical. embedder.type: spring-ai replaces it with SpringAiEmbedder over the application's EmbeddingModel: vectors are L2-normalized, cached by the text's content hash so a card is embedded once, and the embedder's identity (spring-ai:<model>) is advertised on the capability, so peers query only peers that rank alike and a fleet never averages two models' notions of similarity.
Usage. With usage.enabled, FleetUsageObservationHandler reads token counts from Spring AI's gen_ai observations and hands them to a UsageSink. The default, FleetUsage, keeps this peer's counts per model and per agent. When usage.metrics-space names a space, it also writes each call there as a leased ModelUsage entry for audit and, on every publish-interval, this peer's running total per model as a ModelUsageTotal under a lease of three intervals (ten seconds at least), so any peer reads fleetTotal(model), the sum of the newest total from each peer, with no collector. Without a metrics space the sink keeps local counts only and fleetTotal is empty. UsagePanel puts the figures on the fleet console (the fleet console). Replace the sink to export to a billing or FinOps system.
MCP export. MCP clients such as Claude Desktop or an IDE agent sit outside the fleet's trust boundary, so nothing is exposed until mcp.include-agents names the agents that may be. Export also needs the FleetTools bean (tools.enabled, the default) and a synchronous MCP server bean (McpSyncServer). FleetMcpToolSync then publishes those agents' tools through Spring AI's MCP server, and on every FleetToolsChangedEvent adds and removes tools and sends notifications/tools/list_changed, so connected clients refresh as cards arrive and lapse. mcp.discovery-tools exports the three discovery tools too, and mcp.capability-tools exports the enabled capability tools.
agentspaces: springai: embedder: { type: spring-ai, model: text-embedding-3-small } usage: { enabled: true, metrics-space: metrics } # declare a "metrics" space in the group mcp: { enabled: true, include-agents: [summarizer, translator] } # needs tools.enabled (default) and a sync MCP server bean
Patterns on the annotations
Nothing in the library is needed for a model to take part in the fleet's coordination; the annotations already do it, and the patterns module shows three shapes, each on its own small fleet over TCP with scripted models so it runs offline. Judges: a panel of @Ballot agents whose vote comes from Spring AI's FactCheckingEvaluator over each judge's own model, and a lead whose @OnDecision records the verdict when the quorum closes; every ballot is a signed entry, so which judge, on which peer, voted how is auditable from the space. Priced by tokens: model workers on an AUCTION space whose @BidFunction is the model's price per thousand tokens times an estimate of the prompt, bidding infinity for tasks harder than the model handles well, so routine work goes to the cheap model and hard work to the premium one with no router. Extraction ensemble: extractors on different models react to every document with @SpaceNotify and write a candidate carrying a confidence, voters back the most confident with @Ballot, and a filer's @OnDecision files the winner with the tally as provenance.
// springai-patterns -- JudgePanel: the judge, and the lead @Ballot(space = "votes", prefix = "claim:", lease = "10m") public String judge(VoteCapability.Proposal proposal) { String[] parts = proposal.question().split(EVIDENCE, 2); boolean holds = evaluator.evaluate(new EvaluationRequest( List.of(new Document(parts.length > 1 ? parts[1] : "")), parts[0])).isPass(); return holds ? "approve" : "reject"; } @OnDecision(space = "votes", prefix = "claim:", resultLease = "1h") public Verdict record(VoteCapability.Decision decision) { return new Verdict(decision.proposalId(), decision.winner(), decision.tally().toString()); } // TokenPricedAuction: the cheapest capable model takes the task @BidFunction(space = "model-tasks") public double bid(ModelTask task) { if (task.difficulty() > maxDifficulty) { return Double.POSITIVE_INFINITY; } return pricePerThousandTokens * estimatedTokens(task.prompt()) / 1000.0; }
ChatClient or an Evaluator built on one by constructor, so a real provider drops in with no other change; the leads, voters, and filers hold only a space handle.PropertiesAgentSpacesSpringAiProperties
All under agentspaces.springai. group names the group the library works in (default: the first configured).
| property | default | meaning |
|---|---|---|
| tools.enabled | true | register FleetTools |
| tools.prefix, .include-agents, .exclude-agents, .require-attested | fleet_, all, none, false | tool naming and which cards become tools |
| tools.timeout, tools.on-timeout | 30s, result | how long a tool call waits for its result, and whether a timeout returns an explanation to the model or throws |
| tools.refresh-interval, tools.cache-ttl | 5s, 1s | change-event cadence and snapshot caching |
| discovery-tools.agents, .assets, .data | false | enable find_fleet_agents, list_fleet_assets, fetch_fleet_data |
| discovery-tools.data-space, .timeout, .max-result-chars | data, 10s, 20000 | the data space, the query timeout, the result cap |
| take-lease.enabled, .stream-renew-interval | true, 30s | the lease advisor, and how often a streamed round-trip renews |
| memory.enabled, .space, .ttl, .conversation-id, .max-messages | false, conversations, 24h, take, 20 | chat memory in the space; take, agent, or explicit keys the conversation |
| model-client.enabled, .space, .model, .timeout, .stream.on-restart | false, model-requests, server default, 120s, fail | the calling side of the model service; restart emits a restart marker and begins the new attempt from its beginning |
| model-server.enabled, .space, .models, .routing, .prices.<model> | false, model-requests, none, model, 1.0 | the serving side; models must name at least one model when enabled, or startup fails; price routing bids on an AUCTION space |
| model-server.lease, .chunk-interval, .chunk-max-deltas, .chunk-lease, .response-lease, .concurrency, .model-beans.<model> | 2m, 100ms, 32, 2m, 10m, 4, none | server tuning; model-beans maps a model name to a ChatModel bean when several providers are present |
| embedder.type, .model, .require-match, .cache-size | hashing, class name, true, 4096 | spring-ai ranks semantic discovery with the application's EmbeddingModel; require-match queries only peers advertising the same embedder |
| usage.enabled, .metrics-space, .entry-lease, .publish-interval | false, none, 24h, 30s | fleet-wide token usage; the metrics space holds the per-call audit entries and each peer's running totals, and the fleet total needs it |
| capability-tools.vote, .aggregate, .ballot-lease, .settle-timeout | false, false, 1h, 10s | the capability tools: vote enables propose_vote, cast_ballot, read_tally, and read_decision; aggregate enables contribute and read_estimate; the lease applies to the proposals and ballots the tools write, and read_estimate waits up to the settle timeout for an estimate |
| mcp.enabled, .include-agents, .discovery-tools, .capability-tools | false, none, false, false | MCP export of the named agents' tools, of the discovery tools, and of the enabled capability tools |
| vector-store.enabled, .space, .asset, .description, .freshness | false, data, knowledge-base, a generic description, 5m | serve the application's VectorStore bean as a fleet data asset |
examples/springai-fleet in the library's repository) runs the summarizer, a translator, and the orchestrator as three Spring Boot applications; examples/springai-patterns holds the three patterns above. The library's README and docs/design.md carry the full feature design and its security notes.Deployment recipes: choose features per role
Start with the smallest role that does the job. Enable features on the peers that own them, and declare every feature space in the selected group. The integration does not need Embabel; an Embabel application can use these same workers or a Spring AI model service alongside its planner.
| Role | Enable / attach | Supply |
|---|---|---|
| LLM worker | @SpaceTake + injected ChatClient.Builder; take-lease advisor defaults on. Optionally enable memory. | A local provider model or enabled fleet model client; work space; conversation space if memory is enabled. |
| Tool orchestrator | Attach fleet.toolCallbacks() to the client. Tools default on, but are not automatically attached. | A model supporting the desired tool flow; shared domain types and routes to worker spaces. Keep the provider object so requests can refresh the tool set. |
| Model client | model-client.enabled=true; select a model by client default or request options. | Model-request space and group access. No provider API key is needed on a caller that uses only the fleet model. |
| Model server | model-server.enabled=true; configure model names and routing. | Provider ChatModel beans and credentials; model-request space; MODEL_SERVE authorization. Map names with model-beans when several providers exist. |
| Retrieval server | vector-store.enabled=true. | An application VectorStore bean and data space; asset name, description, and freshness policy. |
| Retrieval caller | Enable needed discovery-tools and attach discovery.toolCallbacks(). | Data space for fetches; a prompt or application flow that actually uses returned documents. Discovery embeddings rank advertisements, separately from the knowledge base’s vector search. |
| MCP edge | mcp.enabled=true with explicit include-agents. | A configured Spring AI MCP server. To export discovery tools, enable both the individual tools and mcp.discovery-tools. |
Task memory with an explicit conversation outside a worker
Inside a take, the default conversation key follows the entry across workers. In a controller or scheduler there is no take context; take and agent modes cannot invent one. Pass an explicit conversation id, or provide a ConversationIdResolver bean keyed to your application’s session.
// With memory.enabled=true and its configured space declared:
String answer = chat.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user(question)
.call().content();Size the three clocks independently
The task’s write lease bounds how long uncompleted work remains useful. Its take lease bounds how long another worker waits after a claim stops renewing. The caller’s timeout bounds how long it waits for an answer. Set all three for the workflow rather than copying one duration everywhere.
TakeLeaseAdvisor renews before each synchronous model round-trip. A blocking call or tool operation that exceeds the lease can still lose its claim. During streaming, the advisor schedules renewals until the stream terminates, even if no new token arrives; it is not a hung-stream detector. Set provider/stream deadlines, and keep take-lease.stream-renew-interval shorter than the take lease. A manually constructed client must receive the advisors explicitly.
Model streaming has its own recovery contract. The default fail policy raises FleetStreamRestartedException with partial text. restart signals a new attempt and replays from its beginning: downstream consumers must reset the partial answer instead of appending the new attempt as if it were a continuation. Cancelling this model-client stream sends ModelCancel; a fleet tool timeout has no equivalent cancellation guarantee.
A shared retrieval role
# Merge with the peer identity, transport, and group configuration from the Spring Boot starter.
agentspaces:
groups:
- name: research-fleet
founding: research-fleet-v1
spaces:
- { name: work }
- { name: data }
springai:
group: research-fleet
vector-store: # on the peer with the VectorStore bean
enabled: true
space: data
asset: knowledge-base
freshness: 5m
discovery-tools: # on a caller; colocated here for illustration
assets: true
data: true
data-space: data// Attach explicitly to a client used for retrieval.
ChatClient retrieval = builder
.defaultToolCallbacks(discovery.toolCallbacks())
.build();For model servers, protect model and conversation spaces with the group’s admission and content-key policy. The caller verifies response/chunk issuers against MODEL_SERVE for the model; use membership grants or the OIDC model-serve scopes for the selected security profile. Fleet tool filters and MCP allowlists complement these permissions; they do not replace them.
Replace policy through a beanFleetToolFilterModelRequestRouterUsageSink
| Interface | Default policy | Use an override for |
|---|---|---|
FleetToolFilter | Include/exclude names or ids; optional attestation requirement. | Application-specific trust and exposure policy. |
ToolNamingStrategy | Prefixed, sanitized, bounded, deduplicated names. | A consistent tool namespace across deployments. |
ConversationIdResolver | Take, agent, or explicit conversation ids. | Tenant/session keys outside take loops. |
ModelCatalog | Configured model names backed by provider beans. | A model registry or dynamic catalog. |
ModelRequestRouter | Model matching or price bids. | Eligibility and routing by region, tenant, or load. |
StreamChunkingPolicy | 100 ms or 32 deltas per chunk. | Different latency and entry-volume tradeoffs. |
StreamRestartPolicy | Fail or restart. | Application-specific partial-answer recovery. |
UsageSink | Fleet counts and optional audit entries. | A billing/telemetry export with its own retention and accounting semantics. |
Auto-configuration backs off for these application beans. A custom RemoteActions bean also supplies explicit task routes, result routes, and correlation to Spring AI tools. Fleet-wide usage totals need usage.metrics-space; without it the default sink still keeps local counts. Each peer republishes its running total under a lease of three publish intervals (ten seconds at least), so a peer that stops publishing drops out of the fleet total; use retained, deduplicated audit records or a dedicated accounting sink when exact billing matters.
Verify the behavior you rely on
The checked-in examples use scripted models and run without provider credentials. Use them to test coordination first, then add a live provider as a separate integration check. The Spring AI repository includes context tests for configuration and TCP flow tests for tools, crash recovery, streaming, routing, retrieval, usage, and MCP.
| Symptom | Check first |
|---|---|
| No remote action/tool | Is the card from another peer and still live? Do both domain classes resolve locally? Is the action invocable, routed to a registered space, and permitted by the tool filter? |
| Tool exists but times out | Confirm a worker actually takes the input type, result routing matches resultSpace, and request/result correlation preserves the id. Increasing the timeout cannot fix a missing route. |
| Wrong or duplicate answer | Check shared-field correlation and reused business ids; then distinguish retried execution from a repeated notify/decision reaction. Card names do not pin workers. |
| Worker restarts without history | Check memory enablement, space, snapshot lease, conversation id, and whether a snapshot had replicated before failure. Preserve take context across thread hops. |
| Request sits in model space | Check server lifecycle, served model names/provider mapping, matching conflict strategy for price routing, and model-serve authorization. |
| MCP tool missing | Check MCP server setup, export enablement, explicit agent allowlist, and tool refresh events. FleetTools itself is intentionally not an auto-exported provider bean. |
| Layer 4 binding fails | Register the concrete vote capability or ordered coordinator before enrollment; verify group/space names and the ordered holder’s signing identity. |
examples/example-05-quorum, example-08-intake-fleet, example-12-exactly-once-desk, and example-14-gossip-learning. Embabel: EmbabelBinderTest, EmbabelRemoteActionsTest, and FleetPlannerEnablementTest (a minimal type-chaining planner), plus integration-tests/embabel-it/EmbabelFleetPlanningTest for the real Embabel platform. Spring AI: examples/springai-fleet, examples/springai-patterns, CrashSafeWorkerFlowTest, ModelServiceCrashFlowTest, and FleetMcpExportFlowTest. Run the Spring AI example suite with mvn verify -Pexamples once the matching core artifacts are available.Micronaut integration
agentspaces-micronaut is a separate library, in its own repository at github.com/badmonkeyai/agentspaces-micronaut, built on the published AgentSpaces artifacts. It does for a Micronaut microservice what the Spring Boot starter does for a Spring Boot application, and then takes the step the shape of a microservice invites: a service is both an agent and a data source, so its annotated beans publish AgentCards, its data and its HTTP presence publish AssetCards, and other services resolve it through @Client with the fleet as the registry. It needs Micronaut 5.2, which runs on JDK 25; the library itself is Java 21 bytecode.
<!-- pom.xml --> <dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>agentspaces-micronaut</artifactId> <version>0.4.0</version> </dependency>
// build.gradle.kts, under the Micronaut Gradle plugin; the Groovy form is implementation '...' dependencies { implementation("ai.badmonkey.agentspaces:agentspaces-micronaut:0.4.0") annotationProcessor("ai.badmonkey.agentspaces:agentspaces-micronaut-processor:0.4.0") // optional: compile-time enrollment and native-image hints }
The library, its processor, and the LangChain4j and MCP bridges (agentspaces-micronaut-langchain4j, agentspaces-micronaut-mcp) are on Maven Central with the core and carry their version explicitly, since they release from their own repository.
The fabric is configured by the same agentspaces.* properties as the Spring Boot starter, with the same names and defaults: the security profile, transports and TLS, groups founded or joined, spaces with admission, content keys, capabilities, the console, the A2A gateway. One YAML fragment configures a peer under either framework. Durations take ISO-8601 (PT5M) or the short form (5m, 250ms). What the library adds lives under agentspaces.micronaut.
| feature | beans | what it gives a microservice |
|---|---|---|
| Fabric | AgentSpacesFactory, FleetLifecycle, FleetContext | the peer, groups, spaces, and capabilities from agentspaces.*; ticking, card refresh, and shutdown with the context |
| Agents | @SpaceAgent, FleetEnroller | annotated beans found by stereotype at startup, bound, and published as AgentCards |
| Compile-time enrollment | agentspaces-micronaut-processor | beans with only method annotations enroll; native images keep the binder's reflection |
| Assets | AssetConnectors, ServiceAssetCard | every AssetProvider bean served through one connector runtime; the embedded server itself as an AssetCard |
| Registry | FleetDiscoveryClient | @Client("orders") resolves to the peers whose AssetCards advertise orders |
| Management | FleetHealthIndicator, FleetInfoSource, AgentSpacesEndpoint, FleetMeters | /health, /info, /agentspaces, and Micrometer gauges |
| Console and A2A | ConsoleFactory, A2aFactory | the fleet console and the A2A gateway as Micronaut-managed servers |
What you can build with it
A Micronaut service is a natural fleet member because it already has the three things a peer needs: beans that do work, data it owns, and an address other services call. The library turns each into a card, and the cards are what the rest of the fleet, in any framework or language, sees. The shapes below compose; a real service is usually two or three of them at once.
| shape | what you add | what the fleet gets |
|---|---|---|
| A service that is also an agent | @SpaceAgent on a bean with @SpaceTake or @SpaceNotify methods; the controller delegates to it | an AgentCard with one action per method: work arrives through the task space with leases, exactly-once takes, and crash recovery, and HTTP keeps working beside it |
| A data service as an asset | an AssetProvider bean over the service's table, index, or API | an AssetCard any agent discovers by meaning and queries through the data space, with leased, pull-once results and the connector-serve grant deciding who may serve |
| Fleet-resolved HTTP | service-asset.enabled on the server, registry.enabled on the callers | @Client("orders") load-balances across every peer whose card advertises orders, with no registry server and no URL configured anywhere |
| A keyless AI service | an @AiService and model-client.enabled; no provider module, no API key | the fleet's agents as the model's tools, and the model itself served by whichever peer holds credentials, with the take lease, memory, and usage handled |
| A model server | a provider module with its key and model-server.enabled | one place for credentials; every other peer calls the model through the space, Spring AI peers included |
| An MCP gateway | micronaut-mcp's server and mcp.export.enabled | the fleet's agents as tools for Claude Desktop, an IDE agent, or any MCP client, kept in step with the cards and filtered at the fleet's edge |
| An MCP importer | micronaut-mcp's client to an external server and mcp.import.enabled | the external server's tools as a fleet agent any peer invokes through the space, with one peer holding the connection |
| A mixed fleet | Micronaut peers beside Spring Boot ones on the same agentspaces.* configuration | one fleet: the same cards, the same spaces, the same model-service wire, workers and callers on either framework |
Two things make these cheap. The configuration is the starter’s property set, byte for byte, so a team that runs the Spring starter already knows how to configure a Micronaut peer, and a YAML fragment moves between the two. And every feature is a bean behind a @Requires on its property, so a service enables the shapes it plays and pays for nothing else: a pure data service never starts the registry or the bridges, and a pure gateway never enrolls an agent.
Agents and enrollment
Micronaut singletons are lazy, so nothing waits for a bean to be created. On StartupEvent the library finds every bean definition carrying @AgentSpec (directly, or through a stereotype such as @SpaceAgent, which is a bean-defining annotation and an @AgentSpec at once) or @ProvidesCapability, instantiates it, and binds it through the facade: worker loops start, bids wire, and the AgentCard publishes into every joined group whose spaces satisfy the bean's annotations. The annotations are unchanged. A bean naming a space no group registers fails at startup with the bean, the spaces, and the configured groups.
// agentspaces-micronaut/examples/micronaut-fleet (example 16): the orders service's agent @SpaceAgent(name = "orders-service", description = "Prices customer orders") public class Pricer { private final OrdersRepository orders; Pricer(OrdersRepository orders) { this.orders = orders; } @SpaceTake(space = "tasks", resultSpace = "findings", lease = "1m") public PricedOrder price(PriceRequest request) { Order order = orders.find(request.orderId()).orElseThrow(); return new PricedOrder(order.id(), order.qty() * order.unitPrice(), "orders-service"); } } @Controller("/orders") @ExecuteOn(TaskExecutors.BLOCKING) public class OrdersController { private final Pricer pricer; // ... @Post("/price") public PricedOrder price(@Body PriceRequest request) { return pricer.price(request); } }
One implementation, two front doors: HTTP clients POST to the controller, fleet agents write a PriceRequest into the task space, and the service's AgentCard lists price as an action any agent invokes through RemoteActions (remote actions), Spring AI tools included. The agent is a plain bean on purpose. A Micronaut AOP interceptor binding (@Retryable, @Cacheable, @Transactional, a custom @Around) generates a subclass whose overriding methods carry none of the fleet annotations, and the binder reads the runtime class; such a bean fails at startup saying so. HTTP concerns like @ExecuteOn, which is not AOP, belong on the controller that delegates to the agent.
A bean whose only fleet annotations are on methods, such as a plain @Singleton with a @SpaceNotify method, enrolls when the application compiles with agentspaces-micronaut-processor on the annotation-processor path. The processor maps each core method annotation, by name, to a marker that is @Executable(processOnStartup = true), the way Micronaut's own @Scheduled works, and to @ReflectiveAccess; Micronaut then hands every such method to the library before StartupEvent, and the bean is enrolled once. Without the processor, give such a bean @SpaceAgent.
Assets and the service card
Every AssetProvider bean is served through the connector SDK (the connector SDK) on the data space, agentspaces.micronaut.assets.space (default data, registered under the group like any other). The peer runs one connector runtime over all of its providers, because two runtimes on one space would race for the same query, so a query goes to the provider that described the asset, cards publish and refresh on the card cadence, and the profile's authorizer is asked CONNECTOR_SERVE before anything is advertised. TableAssetProvider serves an in-memory table; a provider over a Micronaut DataSource is the shape of a real connector.
With service-asset.enabled, the embedded server itself publishes an AssetCard once it has bound: asset micronaut.application.name, the server's URI (or external-uri behind an ingress), shape http, freshness live, and under access the scheme, the instance id, and whether it answers queries. service-asset.query: get with allowed-paths lets a fleet agent that has no network path to the service GET an allowed path through the space and receive one row (status, content-type, body); it is off by default, bounded by a timeout and a body cap, and authorized like every other connector query. ServiceAssetCustomizer beans adjust the card. Give the service card and any table asset different names.
# application.yml of the orders service micronaut: application: { name: orders } agentspaces: bind: 127.0.0.1:7500 groups: - name: order-fleet founding: order-fleet-v1 spaces: [ { name: tasks }, { name: findings }, { name: data } ] micronaut: service-asset: { enabled: true } # the service's HTTP presence as an AssetCard
The fleet as a service registry
agentspaces.micronaut.registry.enabled adds a Micronaut DiscoveryClient over the group's ad-cache. A service id resolves to every peer whose AssetCard advertises an http asset of that name, from issuers the authorizer permits CONNECTOR_SERVE; Micronaut's composite discovery client includes it, so @Client("orders") load-balances across the peers that serve orders with no URL configured anywhere, and a card that lapses (its TTL passes without a refresh) drops its instance. Registration is the card itself: there is nothing else to register.
// the analyst: resolved through the fleet @Client("orders") public interface OrdersClient { @Get("/orders/{id}") Order get(String id); }
agentspaces.security.grants.connector-serve narrows it; under the OIDC profiles it is the aspace:connector-serve scope (profiles and authorization). Narrow it before you let @Client follow the fleet.Management, console, A2A
With micronaut-management on the classpath the peer reports itself: a readiness HealthIndicator named agentspaces whose details are the fleet summary (peer, profile, groups with member counts and spaces, local and remote agent cards, asset cards, enrolled agents, the connector and its counters); an InfoSource under agentspaces; and the /agentspaces endpoint, sensitive by default, which adds every card the peer sees. It reads and never writes, since command-and-control is the console's job. With Micrometer, gauges agentspaces.members, agentspaces.cards.agents.local, agentspaces.cards.agents.remote, and agentspaces.cards.assets per group, and agentspaces.agents.enrolled.
agentspaces.console.enabled serves the fleet console on agentspaces.console.port with the application's ConsoleViewCustomizer, ConsoleCommand, and ConsolePanel beans, and agentspaces.a2a.enabled serves the A2A gateway with agentspaces-a2a on the classpath. Both are built by the Spring starter's own build methods and keep its rules: the console serves on the loopback interface, and the A2A gateway refuses to bind a routable address without a token or an OIDC gate.
Properties
| property | default | meaning |
|---|---|---|
| agentspaces.enabled | true | switch the whole integration off |
| agentspaces.micronaut.group | first group | the group assets, the registry, and the views work in |
| agentspaces.micronaut.enroll.enabled | true | enroll annotated beans at startup |
| agentspaces.micronaut.assets.enabled / space / freshness | true / data / PT5M | serve AssetProvider beans on the data space |
| agentspaces.micronaut.service-asset.enabled | false | publish the embedded server as an AssetCard |
| agentspaces.micronaut.service-asset.asset / external-uri / description | application name / server URI / generated | what the card says |
| agentspaces.micronaut.service-asset.query / allowed-paths / query-timeout / max-body-bytes | none / none / PT5S / 1 MiB | get answers data queries for allowed paths |
| agentspaces.micronaut.registry.enabled | false | resolve @Client ids through fleet AssetCards |
| agentspaces.micronaut.management.enabled | true | health, info, endpoint, meters (each also needs its module) |
| agentspaces.micronaut.tools.enabled / prefix / include-agents / exclude-agents / require-attested / timeout / on-timeout / refresh-interval / max | true / fleet_ / all / none / false / 30s / result / 5s / 64 | the fleet tool catalogue the LangChain4j and MCP bridges share |
LangChain4j: three ways to use a model on the fleet
agentspaces-micronaut-langchain4j plugs the fleet into LangChain4j's own extension points, the way the Spring AI library does for Spring AI, and the three ways to use it correspond to the three places a model can sit: inside a worker, inside an orchestrator, and on the fleet itself. They combine; example 17 (agentspaces-micronaut/examples/micronaut-ai-fleet) runs all three on three peers.
A worker with a model inside its take
The simplest use needs nothing from the bridge but its lease discipline. A @SpaceAgent injects the peer's ChatModel (or an @AiService) and calls it inside @SpaceTake: the space distributes the work, the model does it, and the result completes the take. The bridge's TakeLeaseListener is a ChatModelListener that renews the in-flight take on every model request and response, and on an interval in between, so the lease can be short. A worker making progress keeps its claim however long its loop takes; a worker that dies stops renewing, and its task reappears when the lease expires. Listener beans reach OpenAI models automatically; the bridge attaches them to Ollama and Anthropic builders, and any other provider needs one BeanCreatedEventListener line on its builder.
// example 17: the summarizer runs on the peer that holds the key @SpaceAgent(name = "summarizer", description = "Summarizes a topic into a short briefing") public class Summarizer { private final ChatModel model; Summarizer(ChatModel model) { this.model = model; } @SpaceTake(space = "tasks", lease = "2m") // renewed on every model round-trip public Summary summarize(Brief brief) { return new Summary(brief.topic(), model.chat("Summarize '" + brief.topic() + "' in " + brief.sentences() + " sentences.")); } }
With memory.enabled, the worker's conversation lives in a replicated space too. SpaceChatMemoryStore keeps each conversation as its latest leased snapshot and retires the older ones, and FleetChatMemoryProvider keys it by the in-flight take by default (conversation-id: take; agent and explicit are the alternatives), so a task that reappears after a crash resumes on another peer with the conversation it had. The snapshot is the shared wire record, so a conversation a Spring AI worker started resumes on a Micronaut worker and vice versa. A service picks the fleet memory with @AiService("fleet"), or every service gets it with memory.auto-apply: true.
An orchestrator whose tools are the fleet
An orchestrator is an ordinary @AiService. The bridge gives it the fleet: FleetToolProvider is a LangChain4j ToolProvider that, on every service invocation, returns the fleet's current foreign AgentCards as tools (fleet_<agent>_<InputType>, a JSON schema from the input record, the card's description and goals as the tool's description), so the tool set is live by construction: a worker that joins is a tool on the next request, and one that leaves is gone when its card lapses. A tool call writes the input into the agent's take space and awaits the result there, so a worker invoked as a tool completes in its take space. micronaut-langchain4j never looks a ToolProvider up on its own, so the bridge applies it to every @AiService as the service is built (tools.auto-apply, on by default); to choose per service, set it off and name FleetAiServiceCustomizer in @AiService(customizer = ...). @AgenticService agents receive the provider by themselves, since the agentic factory looks one up by agent name or default.
// example 17: nothing here is fleet-specific @AiService interface Orchestrator { @SystemMessage("You coordinate a fleet of specialist agents. Use the fleet tools, then answer with a short plan.") String plan(@UserMessage String request); } // the model sees fleet_summarizer_Brief and fleet_pricer_PriceRequest, calls them, and plans from the results // an agentic workflow fans out across the fleet the same way @AgenticService interface OffsitePlanner { @SequenceAgent(outputKey = "plan", subAgents = { Researcher.class, Budgeter.class }) String plan(@V("request") String request); }
Which cards become tools is a deployment decision: agentspaces.micronaut.tools.include-agents and exclude-agents, require-attested to admit only agents signing with a certified key of their own (agent certificates), or a FleetToolFilter bean for a rule of your own; ToolNamingStrategy is the other replaceable default. A tool call waits tools.timeout for its result and then returns an explanation to the model (on-timeout: result) or an error (throw); tools.max caps the tools offered per request for large fleets, most recently seen first. Three more tools let the model look around rather than act: find_fleet_agents, list_fleet_assets, and fetch_fleet_data (discovery-tools.agents, .assets, .data), over the discovery and connector layers of discovery and the connector SDK, each switched on separately because each widens what the model can reach.
admission: authorizer and the space-take grant (space admission). Tool descriptions are model input: filter which cards become tools before you trust what they say.The fleet as the model
The third use removes credentials from most peers. model-server.enabled on a peer that holds a provider key serves its ChatModel beans to the fleet: requests are taken from the model space by the models the peer serves (named by each bean's default model name; models narrows the list, model-beans maps a name to a bean) or by price under AUCTION, with the take lease renewed while the provider runs, streaming in chunks, and an attempt counter a restarted request carries. model-client.enabled on a peer with no key gives it FleetChatModel and FleetStreamingChatModel, which @AiService resolves without configuration when the peer has no provider module: one request and one response per round-trip through the space, tool calls returned to the caller and executed with the caller's own tools, a response trusted only from an issuer the authorizer permits MODEL_SERVE for the model. Streams reorder chunks, fill gaps with a read-through, and follow a server's death by stream.on-restart: fail (the default) ends with FleetStreamRestartedException carrying the partial text, restart replays the new attempt from its beginning.
# the worker peer: holds the key, serves the model langchain4j: open-ai: { api-key: ${OPENAI_API_KEY}, chat-model: { model-name: gpt-4.1-mini } } agentspaces: micronaut: langchain4j: model-server: { enabled: true } usage: { enabled: true } # the orchestrator peer: no key, no provider module agentspaces: micronaut: langchain4j: model-client: { enabled: true, model: gpt-4.1-mini }
The wire records are the Spring AI module's, in the shared agentspaces-model-wire artifact, and the space names an entry type by its class, so a Micronaut LangChain4j peer and a Spring Boot Spring AI peer serve each other's model calls on one fleet: a Micronaut orchestrator can run on a Spring peer's models and a Spring worker on a Micronaut peer's. embedder.type: langchain4j ranks semantic discovery with the peer's EmbeddingModel (normalized, cached, identity langchain4j:<model> on the capability advertisement so peers rank alike), and usage.enabled records every response's token usage per model and agent, sums it across the fleet from each peer's running total in the metrics space (usage.metrics-space), and adds a console panel that, on a mixed fleet, shows both frameworks' usage as one.
MCP: the fleet's edge, in both directions
MCP gives a client a tool list and a transport; the fleet gives a set of processes a shared, leased, signed space. agentspaces-micronaut-mcp connects the two at the fleet's edge, and the two directions are two different services.
Export: a gateway peer for MCP clients
A gateway peer runs micronaut-mcp's server (micronaut.mcp.server.transport, STDIO or HTTP) and mcp.export.enabled. The bridge publishes the fleet's tools, the same catalogue the LangChain4j provider uses, on that server and keeps them in step with the cards: a tool is added when a card appears and removed when it lapses. Over STDIO the server notifies clients of changes when micronaut.mcp.server.tools.list-changed is on; over HTTP micronaut-mcp 2.1 is stateless, and clients see the change on their next tools/list. A call from the client writes the task into the fleet and returns the worker's result, so Claude Desktop, an IDE agent, or any MCP client drives agents anywhere on the fleet, in any framework, through one endpoint, much as the A2A gateway serves A2A clients.
# a gateway peer, adapted from example 17; the example exports every agent, # and this export names two micronaut: server: { port: 8090 } mcp: server: { transport: HTTP, info: { name: ai-fleet, version: 0.4.0 } } agentspaces: micronaut: mcp: export: { enabled: true, include-agents: [summarizer, pricer] } # an MCP client's configuration, pointing at the gateway { "mcpServers": { "fleet": { "url": "http://gateway.example:8090/mcp" } } }
The export has its own include-agents and exclude-agents, separate from the in-process catalogue's, because an MCP client sits outside the fleet's trust boundary: a tool the orchestrator may call is not automatically one the outside world may. Authentication on the HTTP transport is Micronaut Security's, on the gateway's own routes. The bridge also makes the server advertise the tools capability even when it has no tools of its own at startup, since the SDK refuses to add tools to a server built without it and the fleet's tools arrive later, as cards do.
Import: an external server's tools as a fleet agent
The reverse shape brings an MCP server the fleet does not own, a search service, a ticketing system, a vendor's tools, into the fleet without giving every peer a connection to it. An importing peer configures micronaut-mcp's LangChain4j client (micronaut.mcp.client.*) and sets mcp.import.enabled; the bridge publishes one AgentCard per client, agent mcp-<name>, one take action per tool, consuming McpToolCall(server, tool, argumentsJson, callId) and producing McpToolResult. A worker loop takes calls for that server from the import space, runs them through the client, and completes the take with the result. Every other peer, Spring, Micronaut, Python, or TypeScript, sees an ordinary agent and invokes it the ordinary way: through RemoteActions, as a LangChain4j or Spring AI tool, or by writing the entry. Tools are re-listed on each card refresh, so a server that adds tools gains actions, and a server that is not up at startup is retried.
# the importing peer micronaut: mcp: client: transport: HTTP http: search: { url: https://mcp.example/mcp } agentspaces: groups: - name: fleet spaces: [ { name: mcp-calls, admission: authorizer } ] micronaut: mcp: import: { enabled: true, servers: [search] } // any peer: the external tool is a fleet action RemoteAction search = RemoteActions.over(group, self).available().stream() .filter(a -> a.inputType() == McpToolCall.class).findFirst().orElseThrow(); McpToolResult hits = (McpToolResult) search.invoke( new McpToolCall("search", "web_search", "{\"query\":\"offsite venues\"}", "c1"), Duration.ofSeconds(30)).orElseThrow();
McpToolCall entries the import space admits, with its own credentials. Give that space admission: authorizer with the space-write grant, list the servers to import explicitly in production, and treat the external server's tool descriptions as untrusted input to the models that will read them.Deployment recipes: choose features per role
Start with the smallest role that does the job. Enable features on the peers that own them, and declare every feature space under the group. The roles compose on one peer, and a fleet mixes them with Spring Boot peers configured the same way.
| role | enable | supply |
|---|---|---|
| Agent service | @SpaceAgent beans; the processor module for method-only beans | the spaces the annotations name; a plain bean for the agent, interceptor annotations on the controller that delegates to it |
| Data service | AssetProvider beans (assets.enabled defaults on) | the data space; the connector-serve grant on who may serve |
| Fleet-resolved service | service-asset.enabled on the server; registry.enabled on callers | micronaut.application.name, or service-asset.asset, as the service id; external-uri behind an ingress; a different name for any table asset |
| LLM worker | @SpaceTake with an injected model; take lease defaults on; optionally memory.enabled | a provider module or model-client.enabled; the conversation space if memory is on |
| Orchestrator | an @AiService; tools default on and auto-apply; discovery-tools.* as needed | a model (local or the fleet's); shared entry types for the tools' inputs and outputs on the classpath |
| Model server | model-server.enabled | provider beans and credentials; the model space; MODEL_SERVE authorization (grants.model-serve or the OIDC scope) |
| Model client | model-client.enabled with a model | the model space; no key |
| MCP gateway | micronaut-mcp server, mcp.export.enabled with explicit include-agents | Micronaut Security on the HTTP transport; tools.list-changed over STDIO |
| MCP importer | micronaut-mcp client, mcp.import.enabled with explicit servers | the import space with admission: authorizer |
| Operator view | agentspaces.console.enabled; micronaut-management on the classpath | the console port; endpoints.agentspaces.sensitive and Micronaut Security for the management routes |
Size the three clocks independently
A task's write lease bounds how long uncompleted work stays useful; its take lease bounds how long another worker waits after a claim stops renewing; the caller's timeout (tools.timeout, model-client.timeout, a RemoteAction.invoke argument) bounds how long it waits for an answer. Set the three for the workflow rather than copying one duration everywhere. TakeLeaseListener renews around each model round-trip and on take-lease.stream-renew-interval while a streamed one runs; keep that interval shorter than the take lease, and keep provider deadlines on the model calls themselves, since the listener is not a hung-call detector.
Replace policy through a bean
Most decisions a deployment might want to change are an interface with a default the library registers behind @Requires(missingBeans = ...) or as a @Secondary; define a bean of the interface and the library uses yours. The AiServiceCustomizer row is the exception: an application names it per service.
| bean | decides | default |
|---|---|---|
Embedder | how semantic discovery ranks | the hashing embedder, or the LangChain4j one with embedder.type |
InstantSource | the fleet's clock | the system clock; tests inject a TestClock |
FleetToolFilter, ToolNamingStrategy | which cards become tools, and their names | the include and exclude lists and require-attested; fleet_ prefix, sanitized to 64 characters |
ServiceAssetCustomizer | what the service's own AssetCard says | shape http, the server's URI, the instance id |
AiServiceCustomizer (per service) | what one @AiService receives, named in @AiService(customizer = ...) | none registered: tools.auto-apply gives every service the fleet tools through a listener, and FleetAiServiceCustomizer opts one service in when it is off |
ConversationIdResolver | which conversation a model call belongs to | the in-flight take's entry id |
ModelRequestRouter, StreamChunkingPolicy, StreamRestartPolicy | which requests a server takes and how it bids; when it flushes chunks; what a client does on a restart | by model, or by price under AUCTION; 100 ms or 32 deltas; fail |
ModelCatalog | which model beans serve which names | every ChatModel bean by its default model name |
UsageSink | where usage records go | FleetUsage: local totals and, with usage.metrics-space, each peer's running total and the per-call entry |
ConsoleViewCustomizer, ConsolePanel, ConsoleCommand | what the console shows and lets operators do | the generic view of every configured space |
BeaconLocator (any number, ranked by @Order) | an extra way to find a group, for every group or, through appliesTo, for one | none; the seeds and the agentspaces.bootstrap.* locators that the agentspaces-bootstrap-micronaut module's BootstrapFactory builds |
DnsResolver, BootstrapChainFactory | how the DNS locators resolve; how each group's chain is composed | the JDK's JNDI resolver; ChainComposer over the properties, or the core's plain chain when no bootstrap key is set |
PeerNodeCustomizer, PeerNodeConfigurer | the node builder before build(); the built node before any group joins | none |
Verify the behavior you rely on
The library tests its features with ApplicationContext.run(Map) contexts: they prove that the fabric, the registry, management, the console, the A2A gateway, the fleet tools, the model service, the capability tools, and the MCP export stay off until their property is set or switch off with it, that an application Embedder replaces the default, and that the per-service customizer opts one service in. Flow tests across two peers over TCP on free ports cover the paths that cross the fleet; the examples' tests boot two or three peers in one JVM with scripted models. Write the same for your service: a context test for the role it plays, and a two-context test for the path that crosses the fleet. The symptoms below are the ones the library's own build met.
| symptom | cause | fix |
|---|---|---|
| A bean carries fleet annotations and nothing enrolls it, silently | a plain @Singleton with only method annotations, and no processor module | add agentspaces-micronaut-processor to the annotation-processor path, or give the bean @SpaceAgent |
| Startup fails: "intercepted by Micronaut AOP" | an interceptor binding (@Retryable, @Cacheable, @Transactional) on the agent bean hides its annotations from the binder | move the binding to the controller or service that delegates to the agent; @ExecuteOn is not AOP and is fine |
| A fleet tool times out though the worker ran | the worker completes into a resultSpace, while a remote action awaits in the take space | let a worker invoked as a tool complete in its take space, or configure the route |
An @AiService sees no fleet tools | tools.auto-apply is off, or the service is built by hand | name FleetAiServiceCustomizer on the service, or call toolProvider(...) on the builder |
| Data queries reach the wrong provider | two assets share a name, the service card's included | give the service card and any table asset different names |
| Fleet model calls are refused on the caller | the server's issuer is not permitted MODEL_SERVE for the model | agentspaces.security.grants.model-serve, or the aspace:model-serve[:<model>] scope |
| Adding a tool to the MCP server fails with "Server must be configured with tool capabilities" | micronaut-mcp advertises the tools capability only when it has tools at build time, and the SDK refuses addTool without it | the bridge sets the capability when mcp.export.enabled is on; code adding tools by hand needs a BeanCreatedEventListener on ServerCapabilities.Builder calling tools(...) |
The imported agent is named mcp-<uuid> | a client built outside micronaut-mcp's factories, with LangChain4j's default key | set .key(name) on the client builder; the bridge does so for micronaut-mcp's clients |
UnsupportedClassVersionError at startup | Micronaut 5.2 is Java 25 bytecode | run on JDK 25; the AgentSpaces libraries are Java 21 bytecode and load there |
How it is built
The library reuses the Spring starter's wiring without Spring. The published agentspaces-spring-boot-autoconfigure jar carries no Spring classes (it compiles against provided-scope stubs), and its properties POJO, Authorizers, DirectiveGates, and build methods have no Spring type in their signatures, so the JVM loads them and ignores the Spring annotations it cannot resolve. A Micronaut @Factory binds the agentspaces subtree of the environment into that POJO (a Jackson conversion: kebab-case keys to setters, lists of maps to groups and spaces) and calls those methods, so the transport, TLS, group, space, key-ring, and capability wiring lives in one place for both frameworks. Every bean yields to an application-supplied one of the same type. The suite runs with no Spring on the classpath and pins that assumption, so a core release that puts a Spring type in a reused signature fails the library's build before it can reach an application.
Two platform notes. Micronaut 5 is on Jackson 3 and the core on Jackson 2.21.7; both coexist, with jackson-annotations 2.22 for Micronaut. Transport is TCP, TLS, and DTLS (the agentspaces.transport.dtls.* block works unchanged): agentspaces-transport-quic pins Netty 4.1 and Micronaut manages 4.2, so QUIC is not available under Micronaut.
LangChain4j on any host
agentspaces-langchain4j is a separate project, in its own repository at github.com/badmonkeyai/agentspaces-langchain4j, built on the published AgentSpaces artifacts. It gives a LangChain4j application on any JVM host, a command-line agent, an embedded service, a batch job, or a service on a framework this guide has no chapter for, the same fleet features Spring AI and Micronaut get, with no application framework on the classpath. Two artifacts: agentspaces-runtime, a peer in plain Java from the same agentspaces.* configuration the starter reads, with the framework-neutral tool catalogue; and agentspaces-langchain4j, the fleet on LangChain4j's extension points. The Micronaut bridge is wiring over them. Java 21; LangChain4j 1.20 and 1.22 are both verified.
<!-- pom.xml --> <dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>agentspaces-langchain4j</artifactId> <version>0.4.0</version> </dependency>
A peer from one file. PropertiesLoader reads the agentspaces section of a YAML document (or a nested map, or flat dotted keys) into the starter's properties, and FleetRuntime builds the peer from them with the starter's own build methods, which carry no Spring type and run with no Spring present: identity, node, transports by security profile, groups, spaces, capabilities. It then ticks the node, starts each FleetParticipant, and refreshes cards on the card cadence; close() stops it all in reverse. bind(agent) binds the annotated objects, and context(group) gives the FleetContext a feature works in.
FleetRuntime runtime = FleetRuntime.start(PropertiesLoader.yaml(Path.of("agentspaces.yaml")));
runtime.bind(new Summarizer()); // @AgentSpec and @SpaceTake, as anywhere
LangChain4jFleet fleet = LangChain4jFleet.builder(runtime)
.tools() // the fleet's cards as tools
.modelClient(ModelClient.of("gpt-4.1-mini")) // the fleet as the model
.build();
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(fleet.chatModel())
.toolProvider(fleet.tools())
.build();The facade. LangChain4jFleet builds only what its settings enable, so a one-line facade never widens the fleet's reach by accident. tools() is a LangChain4j ToolProvider over the live catalogue (fleet_<agent>_<InputType>, a JSON Schema from the input record, a call that writes the input into the agent's take space and awaits the result); DiscoveryTools.all() adds find_fleet_agents, list_fleet_assets, and fetch_fleet_data. listeners() carries the take-lease listener, which renews the in-flight @SpaceTake on every model round-trip, and the usage listener; pass them to a provider model's builder or wrap a model with listening(model). TakePropagation.wrap(...) and .executor(...) carry the take to another thread, so a renewal from a future or a parallel step still lands. Memory.on() puts chat memory in a replicated space, where a conversation a Spring AI or Micronaut worker started resumes. ModelClient.of("model") makes chatModel() and streamingChatModel() route calls through the space to the peers that serve them, and ModelServer.on() with modelServer(ModelCatalog.of("model", chatModel, streamingModel)) serves this peer's models, on the shared wire records (agentspaces-model-wire), so the three hosts serve each other. FleetRuntime.builder(...).embedder(LangChain4jFleet.embedder(embeddingModel, "name")) puts a real embedding model behind semantic discovery.
The service is the worker. An AiServices interface may carry the fleet annotations beside its LangChain4j ones: a @SpaceTake method that is also a @UserMessage template takes the entry, asks the model, and completes with the answer; a @Ballot method asks the model for the option. The binder reads the annotations through the proxy's interface, so runtime.bind(AiServices.create(Reviewer.class, model)) is the whole of it. Capability tools. .capabilityTools(FleetCapabilityTools.Settings.all()) adds six tools over the group's vote and aggregate capabilities, propose_vote, cast_ballot, read_tally, read_decision, contribute, and read_estimate, so a model can open or join a council and contribute to an epoch; the settings choose the families, the ballot lease, and the settle timeout, and a tool answers with a plain error when the capability is not provided. The Spring AI starter and the Micronaut bridge expose the same tools under their own settings.
The settings are records with defaults, built in code or bound from the tools and langchain4j sections of the same YAML with the Micronaut library's key names (ToolSettings.from(map), LangChain4jSettings.from(map)), so one file configures a peer under any host. The replaceable decisions are interfaces with defaults: FleetToolFilter, ToolNamingStrategy, ConversationIdResolver, ModelCatalog, ModelRequestRouter, StreamChunkingPolicy, StreamRestartPolicy, UsageSink.
FleetRuntime per process, closed on exit, and one LangChain4jFleet per runtime, built before the services that use it. Its participants (the catalogue, a model server, the usage publisher) register with the runtime on their own; built over a bare FleetContext and FleetScheduler instead, the host starts participants() itself. The Micronaut bridge does not use the facade: it builds the same parts (the tool provider, the listeners, the model client and server, the usage publisher) as Micronaut beans through its own factory.examples/langchain4j-fleet in the project runs a worker peer (a summarizer on its own model, a pricer with none, the model served to the fleet) and a keyless orchestrator peer whose tools and model are the fleet, with a flow test that also leaves a worker stuck mid-task and watches another finish it. The security notes of the Micronaut bridge apply unchanged: tool descriptions are model input, requireAttested admits only agents with a certified key, and a caller accepts a model response only from an issuer the authorizer permits MODEL_SERVE.The model wire records: agentspaces-model-wire
agentspaces-model-wire is one small artifact with no dependencies that holds the entry types the model service, chat memory, and usage write, so that the three hosts serve each other. ModelRequest, ModelResponse, ModelChunk, ModelCancel, and ModelAttempt, with the Wire* parts they carry (messages, tool calls and tool responses, tool definitions, options, generations, usage, media), are written by FleetChatModel and ModelServer in agentspaces-springai and in agentspaces-langchain4j, which the Micronaut bridge wires. ConversationSnapshot is chat memory in a space, written by SpaceChatMemoryRepository (Spring AI) and SpaceChatMemoryStore (LangChain4j). ModelUsage is fleet-wide token usage on both. The space names an entry type by its class, so two peers read each other's model calls only when they use the same classes, and that is what the artifact is for: a Micronaut service with no key calls a model a Spring Boot peer serves, a conversation one framework's worker started resumes on the other's after a crash, and a mixed fleet's console sums usage as one. The package stays ai.badmonkey.agentspaces.springai.model.wire, where the records were born, because renaming it would change the schema name on the wire. WireGoldenTest pins the canonical CBOR bytes of every record in wire-golden.properties, shipped inside the jar, so each consumer's suite reads the same vectors. The three libraries bring it in transitively; add it directly only to read or write the records from code of your own.
The Space API
One layer down from the annotations sits the interface they compile to: a typed, leased tuple space. Entries are plain records; every entry is signed by its writer and expires unless renewed; every peer holds a replica that keeps answering through partitions.
Getting a handleAgentSpaces.GroupContext
Before the verbs, the practical question: where does a Space come from? There are four sources, and they return the same object; a space is a space however you reached it.
// 1. Inside an agent: @SpaceRef, injected at bind time. The common case. @SpaceRef("findings") Space findings; // 2. Anywhere else under Spring: the AgentSpaces facade bean, injected normally. @Component public record Coordinator(AgentSpaces spaces) { public void submit(String topic) { spaces.group("research-fleet").space("tasks") .write(new ResearchTask(topic, 3), Lease.of(Duration.ofMinutes(30))); } } // 3. Building one yourself: plain Java, tests, or a peer with no Spring at all. ReplicatedSpace tasks = ReplicatedSpace.builder(runtime, "tasks", identity, "my-agent") .settleWindow(Duration.ofMillis(200)) .build(); LocalSpace scratch = LocalSpace.builder("scratch", identity.agent("my-agent")).build(); // 4. Another agent's view of the same replica: same space, different signer and issuer (agent certificates). Space asAuditor = tasks.as(identity.subordinate("auditor"));
main: nothing about the space API requires an agent. The annotations are a convenience over this interface. Use the facade whenever the caller is ordinary application code that happens to need the fleet, and @SpaceRef when the caller is already a bound agent.LocalSpace is the single-process form: same interface and same leases, with no network and no peer, and so no conflict strategies or admission rules (a take there is exclusive within the process and first in, first out). It is what you want in unit tests, and it is genuinely useful in production for in-process fan-out. Swapping it for a ReplicatedSpace changes no calling code, which is the point: write the logic against Space, test it locally, deploy it replicated. One replica per space name per node: a second ReplicatedSpace for a name that is already open is refused, naming the stream, and close() is what deregisters the first so the name can be opened again; example-02-research-fleet closes and reopens one.The verbsSpace
<T> EntryHandle write(T entry, Lease lease); <T> EntryHandle write(T entry, Lease lease, Map<String,String> tags); <T> Optional<T> read(Template<T> template); <T> Optional<T> read(Template<T> template, Duration timeout); <T> List<T> readAll(Template<T> template, int limit); <T> List<Issued<T>> readAllIssued(Template<T> template, int limit); <T> List<Entry<T>> readAllEntries(Template<T> template, int limit); <T> Optional<TakenEntry<T>> take(Template<T> template, Lease takeLease, Duration timeout); void complete(TakenEntry<?> taken); <R> EntryHandle complete(TakenEntry<?> taken, R result, Lease resultLease); <R> EntryHandle complete(TakenEntry<?> taken, R result, Lease resultLease, Map<String,String> tags); <T> Subscription notify(Template<T> template, SpaceListener<T> listener, Lease lease);
take is destructive and exclusive under the space's conflict strategy; the taker holds a TAKE lease and must complete before it lapses, or the entry reappears. complete with a result validates and prepares the result record before committing the completion, so a task can never end up completed but resultless. Reads run against the local replica and never block on the network.
readAllIssued returns each match as an Issued<T>(T entry, AgentId issuer, Attestation attestation), where the issuer is authenticated by the entry record's signature and the attestation says how: PEER_ASSERTED when the hosting peer's key signed and vouched for the name, AGENT_ATTESTED when the agent's own peer-certified key signed (agent certificates). Any reader that makes a trust decision per entry (a vote tally, an audit reader, a result consumer) uses this rather than a field the entry declares about itself. SpaceEvent carries the same authenticated issuer.
readAllEntries returns each match as a Space.Entry<T> view over its record: value(), entryId(), the authenticated issuer() and its attestation(), the tags() the writer gave, the lease() in force, and the issued() stamp. The four-argument complete(taken, result, lease, tags) writes a tagged result atomically with the completion, which is how a Tagged return from a same-space take lands. A SpaceEvent carries the same view as details(), with tags() as a shortcut; the library's spaces fill it for WRITTEN and REAPPEARED, and for any other kind while they still hold the record, and an event built without the record carries null there and empty tags. Events fire at every replica: a notify subscription, and every annotation built on one, runs on each peer that holds the space and the agent, so what must happen once across the group is a take, a fleet-wide join or reduce, a vote (idempotent per proposal id), or an idempotent write.
notify events arrive at least once with entry deduplication available to you via the event's entry id; listeners must return promptly (the @SpaceNotify binder does both disciplines for you, which is why it is the recommended interface). Event kinds are WRITTEN, TAKEN, COMPLETED, EXPIRED (a write lease lapsed and the entry vanished) and REAPPEARED (a TAKE lease lapsed and the entry came back).
ReplicatedSpace adds a consistency hint on read and readAll. The default, ConsistencyHint.LOCAL, matches the replica as it stands; FRESH triggers exactly one anti-entropy pull toward one random member before matching locally, the cheap "make sure I am current" a late joiner or an operator console wants, without changing the local-first default for everyone else.
Templates and matchersTemplateMatchers
Template.of(ResearchTask.class) .where("kind", eq("summarize")) .where("priority", gte(3)) .whereTag("region", eq("eu")) // a condition on the record's tags .hasTag("claimId"); // the tag is present, whatever its value
Matching is by type plus field predicates plus tag conditions. The matchers (ai.badmonkey.agentspaces.api.space.Matchers): eq, ne, gt, gte, lt, lte, in(values...), contains(fragment) for character sequences, isNull, notNull, and predicate(fn) as the escape hatch. Fields resolve through record components; on a non-record type the usual getX/isX accessors are read instead. An unknown field name throws at where(...) time, listing the fields the type does have.
whereTag(key, matcher) and hasTag(key) condition the entry record's tags. The matcher receives the value stored under the key, or null when the record carries no such tag, so eq, in, and contains fail on an absent key and isNull() selects untagged entries. Both spaces judge tag conditions after the type comparison and before they decode the payload, on every verb and on subscription delivery, so a keyless relay or a tag shard can select by tag without ever opening the record. Tag values stay visible metadata under content encryption; choose neutral tag values where that matters.
Leases and handlesLeaseEntryHandleTakenEntry
Lease.of(Duration) is the only constructor you need. A write returns an EntryHandle with renew(extension) and cancel(); cancellation is issuer-signed, so nobody else can withdraw your entry. Everything shares the one failure idiom: state you stop renewing goes away, and nothing needs a cleanup job.
Conflict strategies: pick per spaceConflictStrategyType
The first two are strategies the space itself decides in its claim lattice, chosen when the space is built. The third is not a space setting at all; see the note below the table.
| strategy | how a take is decided | cost | reach for it when |
|---|---|---|---|
| LEASE_RACE | the first claim holds after one settle window (default 200 ms); at most one completion per entry, ever | one settle window of latency; transient duplicate work possible across partitions | the everyday default; idempotent tasks |
| AUCTION | every take carries a bid from the agent's bid function; the lowest bid takes the entry, converging over gossip | a few gossip rounds; needs a bid function | cost-aware routing: cheap capacity sweeps easy work, premium capacity takes hard work |
| ORDERED | signed take claims are submitted to a Raft log as commands; its commit order arbitrates identically on every member and the first valid committed claim per entry generation takes the entry, exactly once, in one total order | majority quorum for liveness: a partitioned minority cannot take at all, which is precisely the trade the strategy sells | non-idempotent, high-stakes entries: payments, filings |
A space fixes one strategy for all its entries; use several spaces the way you would use several queues (tasks-bulk, tasks-auction, commits).
ConflictStrategyType.ORDERED to the space builder throws, and so does strategy: ORDERED in YAML. Build the space with the default strategy and take through the ordered-log capability instead, as below. Reads, writes, and completions still go through the space as usual; only take moves.Taking through the ordered log
ORDERED takes go through the ordered-log capability, a coordinator registered on the group rather than a space setting. The ordered log covers the quorum, the registration, and what the log guarantees; @OrderedTake is the worker as one annotation.
Space admission: who may write and takeAuthorizer
A space advertises how it admits writers, independently of how the group admits members. Reads stay open to the group in every mode.
- GROUP (the default) admits every admitted member of the group.
- ALLOWLIST admits a fixed set of agents. A local caller outside the list gets a
SpaceAdmissionException; an inbound record or claim from an unlisted agent is dropped and reported as misbehavior. - CREDENTIAL admits whoever holds a live credential from the issuer. A credential is an ordinary leased
SpaceCredentialentry the issuer writes into the space itself, so nothing new appears on the wire: the issuer's node grants withspace.grant(agent, scopes, validity)and revokes withspace.revoke(agent), which is just a signed cancellation. A transition whose credential has not merged yet is dropped without a strike, because the evidence can legitimately arrive after the write it authorizes. - AUTHORIZER hands the decision to the profile's
Authorizer, scoped by the space name, which is how an identity provider's scopes (aspace:space-write:tasks) end up deciding.
Each transition is judged once as it arrives, after its signature verifies. A refused local call throws; a refused remote delta is dropped, and strikes its sender only when the rule says the refusal cannot be a late credential. The judged identity is the acting agent under ALLOWLIST and CREDENTIAL: when several agents of one peer share a replica through views (space.as(identity), agent certificates), each view is admitted or refused on its own agent's standing, so an ALLOWLIST or CREDENTIAL space can admit one agent of a peer and refuse its sibling, except that every agent of a CREDENTIAL space's issuer peer is always admitted. AUTHORIZER asks the authorizer about the acting agent's peer, so every agent of one peer gets the same answer. A record whose spaceId names another space is dropped before any of this, however validly signed.
Building a spaceReplicatedSpaceNamespaceSchemaRegistry
The starter builds spaces from YAML; plain Java builds them directly. LocalSpace is the single-process form (same API, ideal for tests); ReplicatedSpace attaches to a joined group's runtime.
ReplicatedSpace tasks = ReplicatedSpace.builder(runtime, "tasks", identity, "my-agent") .settleWindow(Duration.ofMillis(200)) .writer(identity.subordinate("my-agent")) // sign as the agent's own certified key (agent certificates) .strategy(ConflictStrategyType.AUCTION) // with .bidFunction(fn) .blocks(new BlockExchange(runtime, codec)) // content-addressed payloads .groupKey(key) // AES-256-GCM payload encryption .tagShard(tags -> "eu".equals(tags.get("region"))) // partial replica .admission(SpaceAdmission.allowlist(agents)) // or group/credentials/authorizer .maxLease(Duration.ofHours(24)) // tombstone horizon, see below .schemaHints(ResearchTask.class, Finding.class) // published on the space ad .advertise(publisher) // with .advertisementTtl(ttl) .build();
A space publishes its own advertisement the moment it is created, carrying its strategy, admission, replication mode, and schema hints, when built with .advertise(publisher). It is published once, at construction, and lapses from caches at its advertisementTtl (15 minutes by default) unless the host calls refreshAdvertisement() on a cadence. With founderLease set, the space turns read-only when the founders' lease lapses rather than silently accepting writes nobody will keep. maxLease fixes the longest lease the tombstone sweep allows for: a replica drops completed, withdrawn, and lapsed entries once their lease expired more than twice that long ago. It does not reject a longer lease, and without it the replica uses the longest lease it has observed, up to 24 hours.
.blocks(...) and large entries travel content-addressed: the space replicates a hash reference and peers fetch the bytes on demand, verified against the hash. Because reads never block on the network, an entry whose block has not landed yet is simply not visible yet: the replica starts one background fetch as soon as the record merges (a read that misses it starts one too), a read with a timeout polls within that timeout and sees the entry once the block arrives, a notify subscriber receives the entry's WRITTEN event the moment the block is local without anyone having to read, readAll skips it for that round, and a subscription event fires on the later merge or read that finds the block locally. Nothing is lost; it arrives a moment late.The schema registry: naming types on the wireNamespaceSchemaRegistry
Every type a space writes is named on the wire by its schema registry, <fully.qualified.Name>#v1 by default, and the schema hints and the cards of bound agents carry the same names. NamespaceSchemaRegistry.of(Map.of("org.example.claims", "https://example.org/claims#")) names the classes of the mapped packages by an IRI namespace (https://example.org/claims#Claim) and leaves every other type, the reserved SpaceCredential, vote, ordered-log, and JoinTicket records included, on the default rule. Share one instance: give it to each space builder (.schemaRegistry(schemas)) and to the binder or facade (spaces.schemaRegistry(schemas), or a SchemaRegistry bean under the starter), so the names the spaces write and the names the cards advertise agree. The facade's setting is the default for every group registered afterward; a group's own schemaRegistry(...) and the binder's schemas(...) refuse a registry once that group has bound its first agent. Remote actions resolve a card's names through the registry's classFor. For an IRI under a mapped namespace that no registration produced, classFor tries a class of the mapped package, loaded without initialization, so a peer needs the class on its classpath but no prior registration; an IRI outside the mapped namespaces never loads a class.
// plain Java -- one registry for the spaces and the facade SchemaRegistry schemas = NamespaceSchemaRegistry.of(Map.of("org.example.claims", "https://example.org/claims#")); ReplicatedSpace intake = ReplicatedSpace.builder(runtime, "intake", identity, "registrar") .schemaRegistry(schemas) // org.example.claims.Claim is written as https://example.org/claims#Claim .schemaHints(Claim.class, RegisteredClaim.class) .build(); spaces.schemaRegistry(schemas); // call before registering groups: they inherit it
DiscoveryDiscoveryServiceBeaconLocatorBootstrapChain
Below the space sits the layer that answers "who can do what": signed, TTL-leased advertisements in a per-group cache that gossip keeps fresh. Chapter 12 covers the services built over the same fabric. A peer has to find its group before it can read any card, so the chapter opens with that first contact (which option to pick, bootstrap, the locators, and beacons) and then turns to the cards.
Picking bootstrap options
Every option below answers the same question, "whom does a newcomer dial first", and none of them admits anyone: whatever a locator returns is dialed through the ordinary stranger path, so the choice is about where the answer lives and who maintains it. The chain asks every configured locator at once and ranks their answers in the order you listed them, so you can combine two or three (a seed list for the first day, DNS for the fleet, a beacon document for partners) and let the cheapest one answer first. Start with the simplest option that fits the network you actually have, and add the next one when the seed list goes stale.
| Option | What it gives you | What it needs | Build with it |
|---|---|---|---|
Seed list (groups[].seeds) | a way in with no infrastructure; a name with several addresses dials every address; a member once joined needs the seeds no more | a list to keep current; two seeds per peer so one that is down is only a delay | a service fleet on a fixed set of hosts, Docker Compose, a CI harness, every numbered example |
Multicast beacon (agentspaces.multicast.*, off by default) | peers on one segment find each other with no seeds and no configuration; the datagram can be sealed under the group's content key | UDP on the segment; counts only as a hint under required attestation | a lab bench, a vehicle, a shop-floor cell, a classroom demo, a floor of devices and robots on one network |
DNS SRV (SrvLocator) | the directory the enterprise already runs answers with hosts, ports and priorities; Kubernetes, Consul and Cloud Map publish SRV records | records to register when the seed set moves; an authenticated resolver before DNS may name the group | a fleet across virtual machines in one corporate network, a service that migrates between hosts, a registry-backed platform |
DNS-SD and a TXT pointer (DnsSdLocator, TxtLocator) | zero per-peer configuration: the search domain DHCP hands out carries _agentspaces._tcp, and one TXT record can point at a document that follows the live group | a zone you administer; the beacon prints the records | agents on office desktops and laptops, branch offices, developer workstations that join by domain alone |
Kubernetes headless service (K8sLocator) | the SRV records Kubernetes publishes for a named port, with no Kubernetes client; a UDP port resolves to DTLS | a headless service in the namespace; pods that listen | a fleet of pods per namespace, autoscaled worker pools, a sidecar peer beside an existing service |
Beacon document over HTTPS (UrlLocator, agentspaces-beacon) | one URL where newcomers read the group's current seeds, wire hints and the signed founding, signed by the beacon and pinned by its PeerId | a stable peer with its own keystore, TLS in front of its HTTP port, a pointer record in DNS | partners who hold only an id and a URL, a fleet across organizations, a seed set that changes often, a hosted control plane |
Static document (StaticDocumentLocator) | the same signed document from a file or a classpath resource, read again at every locate | a way to ship the file; a re-issue when the seeds change | air-gapped sites, field kits, appliance images with the document baked in, offline installs |
Rendezvous and relay roles (agentspaces.roles) | not locators but what the others point at: a rendezvous answers discovery for a whole site, a relay carries one signed hop between peers that cannot dial each other | one more peer per site or region; relayed frames are refused under required attestation | a WAN with one well-known address (example 06), laptops and robots on carrier networks, home-office developers without a VPN |
Join by group id (join: aspace://<id>) | a founding verified against the id before the first frame, so the id can travel further than the configuration | a signed founding (a group started from a text has none) and a reachable member that holds it; a ten-second timeout | printed or emailed credentials, partner onboarding, a registry of groups, invitations |
A custom locator (BeaconLocator) | your own registry or cloud API as one element of the chain, with appliesTo to serve one group | one small class | Consul, etcd or ZooKeeper lookups, a cloud instance API, a licence server that hands out seeds |
agentspaces-bootstrap-dns) or the url and document locators are accepted in accept-group-id-from by default. There is no universal bootstrap group and no mDNS: a deployment that needs a directory of groups founds one and seeds from it, or runs a beacon and registers its records in the DNS it already administers.Finding the group: locators, hints, and the chainBeaconLocatorBeaconHintBootstrapChain
A peer that has just started has an empty view of its group and has to dial one member before the fabric can do anything else. A seed is an address a newcomer dials first, and a locator is one way of finding one. BeaconLocator.locate(BeaconRequest) answers with a BeaconHint, or with BeaconHint.none() when the locator has nothing. The request carries what the node knows: the group's name, its GroupId when the group was founded locally or is joined by id, the scheme a bare host:port takes, and every scheme the node can dial. The hint carries endpoints in priority order and, optionally, the group id the locator believes, the signed founding, parameters such as wire hints, a lifetime (BeaconHint.DEFAULT_TTL, five minutes, when the source states none), and the evidence a trace shows.
A BootstrapChain is the locator a group is joined with: an ordered list of locators that it asks all at once, each under its own deadline (timeout, three seconds by default). A locator that throws or times out contributes nothing and stops nothing. The chain merges the hints in order, so the first locator's addresses rank highest, and it removes duplicates, keeps the first founding it finds, accepts a group id claim only from the locators named in acceptGroupIdFrom (url by default on the builder; a chain composed from agentspaces.bootstrap.* defaults to url and document), expands names to addresses last, and gives the merged hint the shortest lifetime among the contributors. firstWins() asks one locator at a time and stops at the first that returns addresses. A locator's appliesTo(groupName) lets one chain element serve a single group.
| type | what it is |
|---|---|
BeaconLocator | name(), locate(request), appliesTo(groupName) (every group by default), and policy() |
BeaconRequest | groupName, groupId (optional), defaultScheme, schemes |
BeaconHint | endpoints, groupId, founding, parameters, ttl, evidence; of(endpoints, evidence) and none() |
LocatePolicy | minInterval, maxInterval, require, trace: how the node paces, requires, and traces one locator's answers |
BootstrapChain | the builder's add, addAll, firstWins, timeout, acceptGroupIdFrom, expandNames, and locatePolicy; lastTrace() lists what each locator did |
SeedsLocator | the configured seed list as a locator: of(List<String>) with host:port or scheme://host:port strings, fixed(endpoints), parse |
MulticastLocator | what the node's multicast beacon heard, so that the datagram takes a place in the chain |
DnsExpansion | one endpoint per address of a seed name: expand and split |
A custom locator is one small class, and the chain composes it with the others:
BeaconLocator registry = new BeaconLocator() { public String name() { return "registry"; } public BeaconHint locate(BeaconRequest request) throws IOException { // your registry call List<PeerAdvertisement.Endpoint> found = lookup(request.groupName()); return found.isEmpty() ? BeaconHint.none() : BeaconHint.of(found, "registry " + request.groupName()); } public boolean appliesTo(String groupName) { return groupName.startsWith("research-"); } }; BeaconLocator chain = BootstrapChain.builder() .add(SeedsLocator.of(List.of("seeds.corp.example:7500"))) .add(registry) .timeout(Duration.ofSeconds(3)) .build(); GroupRuntime group = node.joinGroup(groupAd, GroupMembership.Config.defaults(), chain);
A group is joined in one of three forms, and PeerNode.joinGroup has an overload for each that takes a BeaconLocator in place of a seed list: GroupAdvertisement for a group started from a text, SignedGroupAdvertisement for a peer that holds the signed founding, and GroupId with a timeout to join by id. The node asks the locator when it joins and again while its view of the group is empty, never faster than PeerNode.Builder.locateInterval(min, max) allows and at least once per max (30 seconds and 10 minutes by default); a locator's own LocatePolicy overrides that for its group. lastBootstrapTrace(groupId) returns one BootstrapChain.Trace per locator asked (name, milliseconds, endpoint count, evidence, error), lastBeaconHint(groupId) returns the last merged hint, and leaveGroup(groupId) releases one group and keeps the others.
agentspaces-api (api.spi); the chain, the seeds locator, and name expansion are in agentspaces-peering (peering.bootstrap); the starter and Micronaut compose a chain per group from properties (chapter 5). agentspaces-bootstrap adds the DNS, Kubernetes, and document locators (below).accept-group-id-from.The locators and the DNS resolver
The agentspaces-bootstrap project (ai.badmonkey.agentspaces:agentspaces-bootstrap:0.4.0) supplies the locators an enterprise network can answer, over a small DnsResolver interface, and depends on nothing beyond the AgentSpaces libraries and Jackson. Each locator below asks one directory.
| class | asks |
|---|---|
SrvLocator | the SRV records (RFC 2782) at one owner name, one endpoint per record at the record's priority; records of one priority are ordered weighted-random by weight |
DnsSdLocator | DNS-SD (RFC 6763) under each network domain: the PTR records at _agentspaces._tcp.<domain> name the instances, each instance's SRV records give hosts and ports, and its TXT record gives v, group, id, scheme, url, and multicast. An instance whose group differs from the request's group name is skipped unless anyGroup is set, and a url is followed through a UrlLocator |
TxtLocator | one TXT record at agentspaces.<domain> that holds url= (and optionally id=), followed through a UrlLocator |
K8sLocator | the SRV records Kubernetes publishes for a named port of a headless service, _<port>._<protocol>.<service>.<namespace>.svc.<cluster-domain>, with no Kubernetes client; a UDP port is looked up under _udp and its endpoints default to dtls |
UrlLocator | a beacon document over HTTPS, with a deadline, a size cap, at most two redirects on the same host, no credentials or cookies, private-address targets refused, an optional host allow-list, the signature verified when the document carries one, and a pinned beacon PeerId honored (an unsigned document is refused only when a PeerId is pinned). It selects the group by id when the request has one, else by name |
StaticDocumentLocator | a beacon document from a file or a classpath: resource, read again at every locate |
DomainFinder | the network domains the DNS locators look under: the configured list, the AGENTSPACES_DOMAIN variable, the search and domain lines of /etc/resolv.conf, or on Windows the search list ipconfig /all prints |
DnsResolver | addresses, srv, ptr, txt, and authenticated(). JndiDnsResolver uses the JDK's JNDI provider and authenticates nothing, InMemoryDnsResolver serves tests, and DnsJavaResolver in agentspaces-bootstrap-dns (dnsjava, BSD-licensed) adds servers, doh, dnssec, trustAnchors, and timeout |
BootstrapSettings, LocatorAssembly | the agentspaces.bootstrap.* keys as a POJO, and the locators those keys ask for, in order |
ChainComposer, BootstrapTree | the composition both hosts share, and the nested-map binding of the keys for Micronaut |
DnsResolver dns = new JndiDnsResolver(); String site = "https://agentspaces.corp.example"; URI doc = URI.create(site + BeaconDocument.WELL_KNOWN); UrlLocator url = new UrlLocator(List.of(doc), UrlLocator.Options.defaults(), null); BeaconLocator srv = new SrvLocator( "_aspace._tcp.agentspaces.corp.internal", "tls", dns); BeaconLocator sd = new DnsSdLocator( DomainFinder.of(List.of("corp.example")), dns, false, url); BeaconLocator k8s = new K8sLocator("agentspaces-seeds", "fleet", "aspace", "cluster.local", "tcp", "tls", dns); // DNS over HTTPS with DNSSEC validation: the agentspaces-bootstrap-dns project DnsResolver secure = DnsJavaResolver.builder() .doh("https://dns.corp.example/dns-query").dnssec(true).build();
authenticated() resolver is what lets a DNS answer name the group. LocatorAssembly refuses a plain-DNS locator (dns-sd, dns-txt, dns-srv, k8s) or multicast in accept-group-id-from at startup, naming the property, unless insecure-group-id is set or the resolver authenticates its answers, as DnsJavaResolver does with DNSSEC.Beacons and the beacon document
A beacon is a peer that newcomers can find and dial first: an ordinary member with a stable address, usually playing RENDEZVOUS, with no special role on the wire. The beacon document is the signed JSON it serves at BeaconDocument.WELL_KNOWN, /.well-known/agentspaces/beacon.json. (The multicast beacon is the opt-in LAN datagram, a core feature that appears in a chain as MulticastLocator.) BeaconDocument, in agentspaces-bootstrap, is a record of the format version, the issuer's PeerId and key, the issue time, a time to live, the groups, and an Ed25519 signature over the canonical bytes. Each group entry carries its name, id, seeds, wire hints, multicast group, and free parameters, and it carries the signed founding when the group has one. BeaconDocument.signed(identity, issuedAt, ttl, groups) signs, parse(bytes) reads (unknown fields are ignored), verifies() checks the signature, signer() names the issuer, and toBytes() returns the exact signed JSON. The writer refuses a parameter named like a content key, a credential, or a membership hint, so a document never carries a secret.
The agentspaces-beacon project (ai.badmonkey.agentspaces:agentspaces-beacon:0.4.0) is a ready-made beacon and the classes to build your own:
| class | what it does |
|---|---|
BeaconDocumentSource | builds the document from a running PeerNode: one entry per joined group, the founding when the group has one, the seeds (this node first, then the rendezvous members, then the other members that listen, capped at eight by default), the node's wire hints, and the multicast group; signs it with the node's identity; rebuilds at most once per refresh interval |
BeaconDocumentCustomizer | a hook on each group's entry before signing: add a parameter, replace the seeds, or change the entry's other fields |
BeaconServer | serves the document on the JDK HTTP server: GET and HEAD of the well-known path only, Cache-Control from the TTL, an ETag answered with 304, read-only CORS, a per-source request rate, and an optional file copy. The server rewrites the file atomically inside current(), which runs when a request is served (and once at BeaconMain startup), when the document differs from the last copy written. No timer refreshes it, so an ingress that serves only the file sees a newer document only after a request reaches the beacon's own port |
BeaconPublisher | the interface a host's own web layer implements instead of BeaconServer |
RecordPrinter | the DNS-SD PTR, SRV, and TXT records and the single TXT pointer for a document, as a zone fragment |
BeaconMain | the standalone beacon: --keystore, --founding or --join, --bind, --advertise, --http, --roles, --domain, --url, --write-to, and the rest listed in the project's README |
BeaconDocumentSource source = new BeaconDocumentSource(node, identity, InstantSource.system(), Duration.ofMinutes(5), Duration.ofSeconds(30), 8, null, List.of((draft, group) -> draft.parameter("region", "eu-west-1"))); BeaconServer server = new BeaconServer(source, 50, Path.of("/var/lib/agentspaces/beacon.json")); int port = server.start(new InetSocketAddress("0.0.0.0", 7580)); String records = RecordPrinter.zone(source.get(), "corp.example", "https://agentspaces.corp.example" + BeaconDocument.WELL_KNOWN); byte[] signedJson = source.get().toBytes(); // for a web layer you already run
agentspaces.beacon.*) are not built yet, so an application on either framework wires these classes itself.beacon-peer; without one the id changes at every start. Put TLS in front of the HTTP port, because a reader trusts the hostname. The document follows the live group, and the DNS records do not: SRV records registered by hand name the seeds of the day they were typed, so register only the pointer record (agentspaces.<domain> TXT url=) when the seed set changes. A group started from a text has no signed founding, so its entry carries none, and a peer joins it with the same text. Only a group with a signed founding can be joined by id from the document.Cards, the ad cache, and the ads stream
Everything discoverable is a typed, signed card in a per-group cache that gossip keeps fresh; a card whose issuer stops republishing simply ages out. The advertisement families are peers, groups, spaces (with their admission and replication modes), capabilities, AgentCards (with their space bindings), AssetCards, and revocations. The cache verifies every signature, bounds its own occupancy, and evicts by TTL; a peer volunteering as RENDEZVOUS holds four times as much. Query structurally, or by meaning through the semantic-discovery capability (its reference embedder needs no model at all):
// who can turn a ResearchTask into a Finding? List<AgentCard> researchers = discovery.find(AgentCard.class, card -> card.produces().contains(Finding.class.getName() + "#v1")); // or escalate beyond the local cache: rendezvous peers first, then scoped gossip. // The remote predicate is structural (field name to the expected value's string form, so match on description rather than on the agent id) so it can // travel on the wire, and it is validated against the type before anything is sent. discovery.remoteFind(AgentCard.class, Map.of("description", "Researches topics"), Duration.ofSeconds(2));
Most fleets never call this. Work finds its worker because the worker is sitting on the space taking that type; the routing is the entry's type, and nobody looks anything up. Discovery earns its keep when you need to know who is out there rather than get one task done: a console drawing the room, a planner deciding whether a capability exists before committing to a plan (the Embabel bridge builds on exactly this), an operator asking which peers can still do a thing, or a dispatcher that wants to fail loudly when nobody is listening instead of writing an entry into a space where it will sit until its lease lapses.
Three kinds of card matter to an application. An AgentCard is built by the binder from the identity annotation and the bound method signatures: the agent's name, description, and goals, one declared action per bound method (the consumed type, the produced types, the method's description), its space bindings, and under subordinate keys its public key and certificate; remote actions, fleet tools, and planners read the actions. An AssetCard is published by a connector (the connector SDK, or an AssetProvider bean under Micronaut): the asset's name, shape, freshness, and access, for data queries and for the fleet as a service registry. A CapabilityAdvertisement is a service's card, signed and leased like the others (chapter 12). All of them travel on the ads stream, the group's advertisement gossip, which the Python and TypeScript clients read as ads: rumor spreads a new card fast and anti-entropy brings a late joiner up to date. Every card is leased. The binder republishes agent cards on agentspaces.card-refresh-millis (five minutes by default), the capability runtime republishes advertisements on the same cadence through refreshTick(), a space's advertisement lapses at its advertisementTtl unless the host refreshes it, and a card whose issuer stops republishing ages out of every cache at its TTL. Revocations travel on their own gossip streams, revocations for peers and credential-revocations for agent credentials, and the cache purges the cards of a revoked peer and of a revoked agent.
DiscoveryService: spaces.group("g").discovery() under Spring, or the service you constructed on a plain-Java peer. This is no concern of an agent's: agents publish cards automatically by being bound, and read them only when they want to reason about the fleet.find is a local, in-memory scan of the cache, so call it as often as you like. remoteFind puts a query on the wire and blocks for the wait you give it, so it belongs on a slow path rather than in a loop. Both return what the cache holds now: a card whose issuer died is still there until its TTL runs out, and a card from a peer that just joined may not have arrived yet. Treat the result as a hint about a live fleet, never as an authoritative roster, which is the same discipline the fabric itself applies.Semantic discovery and embeddersSemanticDiscoverySemanticClient
The semantic-discovery capability (aspace:cap/semantic-discovery) is an embedding index over the group's advertisements behind a pluggable Embedder, and it answers "who holds something like this?", a question by meaning, where find takes a structural predicate. query(text, limit) searches the local cache; remoteQuery(text, limit, timeout) escalates over the capability pipes to the peers that advertise the capability and merges what they rank. SemanticClient is the typed client, from group.capability(SemanticClient.class) or a @CapabilityRef field, and the Spring AI, Micronaut, and LangChain4j libraries expose the same query to a model as find_fleet_agents and list_fleet_assets. It is request/response and needs no clock and no model set-up.
// Semantic: find an asset by meaning rather than by name (examples/example-10) var matches = semantic.remoteQuery("who holds customer order history?", 3, Duration.ofSeconds(2)); AssetCard card = (AssetCard) matches.get(0).advertisement();
Matching is only as good as the embedder. The shipped HashingEmbedder needs no model and is a lexical approximation: good enough to find an asset by description, and short of understanding. The Python and TypeScript clients compute its vectors to the bit, so the two sides rank alike. SpringAiEmbedder (embedder.type: spring-ai, Spring AI) wraps any Spring AI EmbeddingModel, and the Micronaut and LangChain4j libraries do the same for a LangChain4j EmbeddingModel (embedder.type: langchain4j, or LangChain4jFleet.embedder(model, name)). Vectors are L2-normalized and cached by the text's content hash, so a card is embedded once, and the embedder's identity (spring-ai:<model>, langchain4j:<model>) is advertised on the capability, so a query goes only to peers that rank alike and a fleet never averages two models' notions of similarity.
Capability servicesCapabilityRuntimeCapabilityProvider
Layer 4 answers "what does the fleet think" and "what must happen exactly once": services built over the same fabric as the space, each a CapabilityProvider on the peer's CapabilityRuntime, advertised with a signed, leased CapabilityAdvertisement, and reached from an agent through an annotation or a typed client. Six ship: vote, aggregate, the ordered log, gossip learning, key wrap, and semantic discovery, which chapter 11 covers with the cards it ranks. This chapter is the model every service follows, then one section per service, then how to serve one of your own.
How a capability is usedCapabilityRuntimeCapabilityAdvertisement
Layer 4 is the part of the system people most often wire wrongly, because a capability is not one thing but three, and a given peer may play any subset of the roles.
- The provider is the object that implements
CapabilityProviderand serves the capability on this peer. Under the starter, the shipped ones are registered for you fromagentspaces.capabilities.*. Otherwise it is a@ProvidesCapabilitybean. - The consumer is whatever calls it. Typed clients resolve off the group context, as
spaces.group("g").capability(VoteClient.class), and need no annotation at all. Most application code is only ever this. - The clock is what advances a continuous capability's own protocol: a push-sum exchange, a gossip-learning offer, a Raft election step. You do not supply it. A capability runtime registers its protocol tick with the group's peer tick when it is constructed, so a provider is driven because it is registered, the same way a replicated space converges because constructing it joins that same clock. Nothing to schedule, nothing to remember.
That last point is the one worth internalising, because it is what makes the annotations enough on their own. One clock runs the whole stack: membership probing, gossip, space anti-entropy, and capability protocols all advance on the peer's tick, at agentspaces.tick-millis (250 ms by default). Set that one property and you set the beat of that clock. Push-sum gossip rounds keep their own pace: under a driven peer they run about once a second whatever the tick (PushSumAggregate.ROUND_PERIOD is one second), so the aggregate's convergence follows its rounds.
// There is no driver to write. Registering the capability is the whole of it: // under the starter this already happened, and under plain Java it happens // the moment the runtime exists. AgentSpaces.GroupContext group = spaces.group("orders-fleet"); group.capabilities().isDrivenByPeerTick(); // true // A test that wants each tick to be an exact, countable step takes the clock // back and drives it by hand -- the same escape hatch a TestClock is for. group.capabilities() .detachFromPeerTick() .cadence(Duration.ofMillis(300)); // what you will really tick at // one tick, deterministically (at 300 ms the aggregate gossips on every third tick) group.capabilities().protocolTick();
Convergence takes a few dozen rounds, so the round period is what turns "converges in O(log N) rounds" into a wall-clock number. Under a driven peer the runtime tells the aggregate the tick period, and the aggregate gossips once per PushSumAggregate.ROUND_PERIOD (one second), which is every fourth tick at the default 250 ms, so a fleet settles in tens of seconds. Driven by hand on a node that is not ticking, a runtime that declares no cadence gossips on every protocolTick(), and one that declares a cadence gossips about once per second of it. Vote and semantic discovery are request/response and need no clock at all, which they declare through requiresTick(); the runtime drives them anyway, because the default tick() is a no-op and costs nothing. Key wrap also reports requiresTick() false but overrides tick() for auto-rotation and for following a holder's epoch announcements, so it rides the peer tick too.
Two clocks exist at this layer and they are deliberately different rates. The protocol clock above runs at the peer tick. The advertisement clock, refreshTick(), republishes leased capability advertisements and runs with the card refresh, on the order of minutes. A method named for one will not do the other.
The six capability services
- Vote (
aspace:cap/vote): proposals and ballots as signed entries; the decision closes at quorum and any member recomputes the tally from the space.QUORUMmode is the canonical, auditable form, counting one ballot per eligible voter; the electorate is any authenticated issuer by default (the starter's choice too), narrowed to fresh AgentCards withVoteCapability.freshAgentCards(discovery)and by the authorizer;MAJORITY_GOSSIPis a cheap approximate temperature check by push-sum. The voter is derived from the entry's authenticated issuer, never from a field the ballot declares. - Aggregate (
aspace:cap/aggregate): push-sum gossip; every member converges on a fleet-wide figure with no collector. Six operators (SUM,AVG,COUNT,MIN,MAX,QUANTILE) over a supplied value or over a template's matching entries.aggregate.start("backlog", localValue), thenestimate("backlog"). - Ordered log (
aspace:cap/ordered-log): a small Raft among volunteers, with the leader lease published as an advertisement; backs ordered takes and any workflow needing a total order. Off by default, since it needs a fixed member set. - Gossip learning (
aspace:cap/gossip-learn): mergeable models exchanged pairwise converge to the fleet mean with no parameter server; content-addressed, with a mass-conserving offer-and-accept exchange. Suits learned bid policies. Off by default; enabling it wires the default weight-averaging learner. - Semantic discovery (
aspace:cap/semantic-discovery, chapter 11): an embedding index over the group's advertisements behind a pluggableEmbedder.query(text, limit)searches locally,remoteQuery(text, limit, timeout)escalates; the shipped hashing embedder needs no model at all, andagentspaces-springaiswaps in any Spring AIEmbeddingModel(semantic discovery). - Key wrap (
aspace:cap/key-wrap): distributes group content keys sealed per member, or per agent underrequestAs(X25519 + HKDF + AES-256-GCM), so encrypted spaces onboard members without any shared secret in transit (security). A holder serves every epoch of its key ring:requestreturns the epoch-0 key, and the rotated epochs come throughfetch,requestAs, orfollow.
Typed capability clients
Consumers do not speak the capability's entries directly. A client type resolves off the group context, and the client finds the providers, speaks the protocol, and hands back ordinary values:
VoteClient vote = spaces.group("orders-fleet").capability(VoteClient.class); vote.propose("rollout-7", "ship the new pricing model?", List.of("yes", "no"), 3, Lease.of(Duration.ofMinutes(10))) .castBallot("rollout-7", "yes", Lease.of(Duration.ofMinutes(10))); Optional<VoteCapability.Decision> decided = vote.decision("rollout-7"); // or tally(id) AggregateClient agg = spaces.group("orders-fleet") .capability(AggregateClient.class); agg.start("backlog", queueDepth()).estimate("backlog");
Register your own with spaces.clientFactory(new MyClient.Factory()), and serve your own capability with a @ProvidesCapability bean (custom capabilities).
Reaching a capability from inside an agentVoteClientAggregateClientSemanticClient
An agent POJO has @SpaceRef for spaces; the equivalent for capabilities is @CapabilityRef, and for what agents do with a capability there are annotations of the same shape as @SpaceNotify: @Ballot and @OnDecision for the vote, @OrderedTake for the ordered log, a Contribution return for the aggregate (chapter 3 and chapter 4). Constructor injection of the capability object still works and is still fine when the caller has it in hand; the annotations are the idiomatic form because the discipline they encode (one ballot per proposal, one reaction per closed vote, the resubmit after an election) is what every hand-written consumer got wrong at least once.
// agentspaces-partybus -- Party.TravelerAgent: contributes to the aggregate and votes, with no vote code @AgentSpec(name = "traveler", description = "A traveler on the trip: reports energy each day and votes on the party's choices", goals = {"have a good trip"}) public static final class TravelerAgent { private final Traveler traveler; private final int stamina; private final PushSumAggregate aggregate; // constructor-injected; @CapabilityRef AggregateClient also works @SpaceRef(TRIP) Space trip; @SpaceNotify(space = TRIP, lease = "12h") public EnergyReport feel(TripDay day) { int energy = energyOn(day, trip.readAll(Template.of(TripEvent.class) .where("tripId", eq(day.tripId())).where("date", eq(day.date())), 20)); aggregate.start(Itinerary.energyEpoch(day.tripId(), day.date()), energy); double spent = 40 + (traveler.about().toLowerCase().contains("food") ? 45 : 20) + day.dayIndex() * 3; aggregate.start(Itinerary.spendEpoch(day.tripId(), day.date()), spent); return new EnergyReport(day.tripId(), day.date(), traveler.name(), energy, spent); } @Ballot(space = DECISIONS, prefix = "next:", lease = "12h") public String decide(VoteCapability.Proposal proposal) { return preference(proposal.options()); // the cue is the proposal itself: no wait, no voted set } }
Under Spring the typed clients resolve from the facade, as spaces.group("trip").capability(VoteClient.class), or arrive by @CapabilityRef; SemanticClient joins VoteClient and AggregateClient. Providing a capability through the facade (group.provide(vote)) is what makes the vote annotations on its space bindable; group.ordered(space, coordinator) does the same for @OrderedTake.
PushSumAggregate per peer serves every agent on it, and one vote capability per vote space casts for every bound agent, as that agent when the agent has a key of its own. There is no driver to get wrong: the capability runtime drives what it registers on the peer tick.VoteVoteCapabilityVoteClient
The vote capability (aspace:cap/vote) runs over a space. A Proposal is a signed entry carrying the proposal id, the question, the options, the quorum, and a lease; each Ballot is a signed entry naming the proposal and the option; and the decision closes when the tally meets the quorum. Any member recomputes the tally from the space, so a decision is auditable afterwards from the entries alone. Two modes: QUORUM is the canonical form and counts one ballot per eligible voter; MAJORITY_GOSSIP is a cheap approximate temperature check by push-sum, and the Python and TypeScript clients carry it too (cast_preference and leader in vote.py, castPreference and leader in vote.ts, over their push-sum aggregate). The electorate is any authenticated issuer by default (the starter's choice too), narrowed to fresh AgentCards with VoteCapability.freshAgentCards(discovery) and by the authorizer, and the voter is derived from the ballot's authenticated issuer, never from a field the ballot declares.
VoteClient is the typed client: propose(id, question, options, quorum, lease), or propose(Motion), which is idempotent per proposal id; castBallot(id, option, lease); tally(id); and decision(id), which answers once the quorum is met with the winner, the tally, and the granularity that closed it. The capability's own onDecision(proposals, listener, lease) returns a leased subscription. Inside an agent the same three steps are annotations: @Propose opens, @Ballot casts, and @OnDecision reacts once when the vote closes. Register the capability with group.provide(vote), which also advertises it, or group.vote(space, vote), which does not. Under the starter agentspaces.capabilities.vote builds one over votes-space, which must be listed under groups[].spaces for agents to bind to it.
granularity(VOTE, space). Under PEER (the default, and every OIDC profile) one peer is one counted ballot however many agent names it writes, so a member cannot multiply itself. Under AGENT, switched on by naming peer/localName AgentIds in agentspaces.security.grants.vote, each named agent counts once, and only when its ballot is AGENT_ATTESTED, signed by the agent's own certified key (agent certificates). A fresh AgentCard is a liveness filter, never an eligibility check. Decision.granularity() says which rule closed the vote. A VoteCapability still votes as the one agent its space handle writes as (space.writer()), and constructing one with another identity throws rather than handing you an object whose every ballot would be silently dropped.Aggregate: sensing the fleet with push-sumPushSumAggregate
Push-sum answers questions no single peer can answer: what is the fleet's average backlog, how many workers are live, what is the p95 of a number every peer holds a piece of. There is no collector and no metrics server; every member ends up holding the same estimate.
Two rules keep the estimate honest, both learned on the wire. A share goes only to a member that takes part: participants(rule) restricts the push, PushSumAggregate.advertisedIn(discovery) admits a member whose capability advertisement is live, and the capability runtime applies that rule to every aggregate it registers, so a bystander peer (a console, a client that has not started the capability) never swallows mass it would not mix back. And a Settle rule counts a step only when the step is a gossip round this node took part in after it first heard the epoch, or when the epoch has gone quiet, which completes the streak: halving leaves a node's own ratio untouched, so a node nobody pushes to is stable by construction and says nothing about the fleet; a node with no participant at all judges its own ticks, its value being the only estimate there is. Rounds run about once a second under a driven peer whatever the 250 ms tick, so a streak of forty steps lasts about forty seconds. The Python and TypeScript aggregates advertise and filter the same way.
// Every member contributes its own number to a named epoch... aggregate.start("backlog", myQueueDepth()); // ...the peer tick exchanges them, and after a few dozen rounds every member // reads the same fleet-wide figure. Empty until the first round completes: // a capability that has not run yet will not pass its own seed off as a // fleet answer. OptionalDouble sensed = aggregate.estimate("backlog"); // A supervisor can then act on what the whole fleet feels rather than on its own view: if (sensed.orElse(0) > BACKLOG_THRESHOLD) { vote.propose("scale-up-1", "Average backlog is " + sensed.getAsDouble() + "; add two workers?", List.of("approve", "reject"), 4, Lease.of(Duration.ofMinutes(10))); }
That pair, sense with aggregate and decide with vote, is the whole of examples/example-05-quorum, and it is the canonical shape for autonomic behavior: the fleet notices its own condition and commits to a response that every member can audit afterwards from the vote space. When the supervisor should act once the figure has stopped moving instead of polling estimate, AggregateClient.awaitSettled(epoch, Settle.after(ticks, tolerance), timeout) blocks until the rule holds, and @OnEstimate reacts to the same rule as an annotation.
estimate returns empty until this peer has taken part in at least one exchange (a tick or a received share, for any epoch); after that a freshly started epoch answers with this peer's own seed until shares arrive, and it returns a converging number before it has settled, so read it a moment after start rather than in the next statement. An estimate that stays empty is the fabric telling you the capability is not being driven, which under the starter should never happen. Over a fully replicated space, prefer AVG, MIN, MAX and QUANTILE in template mode: SUM and COUNT over entries every replica already holds will count each entry once per replica, which is almost never what you meant.The ordered logOrderedTakes
The ordered log (aspace:cap/ordered-log) is a small Raft among a fixed set of volunteers, with the leader lease published as an advertisement, so role=leader is discoverable. It backs the ORDERED claim path: signed take claims are submitted to the log as commands, its commit order arbitrates identically on every member, and the first valid committed claim per entry generation takes the entry, exactly once, in one total order. ORDERED is a coordinator registered on the group, and the space itself keeps the default strategy; OrderedTakes.over(...) wraps the log and the space as the coordinator that takes through it; reads, writes, and completions still go through the space as usual, and only take moves. A peer hosts one ordered log per group, so an ORDERED join or reduce lives on the space that has the coordinator. It is off by default, because it needs a member set the starter cannot guess; the starter's ordered-log switch refuses automatic assembly for that reason.
Exactly-once is one annotation now: @OrderedTake is @SpaceTake with the take routed through the space's ordered-log coordinator, so the loop, the resubmit after a leader election, and the complete-or-lapse are the binder's. What stays yours is the quorum: a fixed member set, one coordinator per space, built with OrderedTakes.over(...) and registered on the group. The exactly-once desk and the Party Bus booking clerks are the worked examples.
// examples/example-12-exactly-once-desk -- ExactlyOnceDesk.startClerk(...) OrderedTakes ordered = OrderedTakes.over(new CapabilityPipes(base.runtime(), codec), codec, identity, name, members, InstantSource.system(), seed, base.payments()); base.group().ordered(PAYMENTS, ordered); // registers the log on the peer tick; @OrderedTake can now bind // ExactlyOnceDesk.Clerk: the exactly-once worker is one method @OrderedTake(space = PAYMENTS, lease = "30s", pollTimeout = "2s", resultLease = "1h") public PaymentReceipt confirm(PaymentOrder order) { return new PaymentReceipt(order.orderId(), order.payee(), order.cents(), peer.name(), peer.raft().commitIndex()); }
Reach for this only where a duplicate is genuinely unacceptable and you would otherwise reach for a distributed lock: charging a card, filing a return, allocating a serial number, sending a message a customer will see once. Everywhere else LEASE_RACE is cheaper and more available, and the duplicate work it can produce across a partition is the price of continuing to run at all.
RaftLog and OrderedTakes are wired at startup, in a @Bean method or the assembly code of a plain-Java peer, and the worker is a @OrderedTake method or a loop you own. If you want the rest of the agent model around it, keep the ordered worker as its own class and let the annotated agents handle everything that is not the exactly-once step, which is how the partybus keeps one small Raft quorum for bookings and leaves thirty-odd other agents on LEASE_RACE.OrderedTakes.over(...) hides a circular wiring both halves need (the log's apply callback must reach the coordinator), which earlier releases made every consumer write by hand with an AtomicReference. Registering the log is the whole of the clock wiring: the peer tick drives elections and heartbeats, the log is told the real cadence so the leader lease it advertises is truthful, and because the runtime republishes an advertisement whose content changed on the very next tick, role=leader is discoverable within a quarter-second of an election rather than on the next five-minute card refresh. A log you construct but never register is never elected, and every take times out empty. Every replica, including one that holds no claim of its own, like the desk, authenticates a completion against the holder's own signed claim, which the log carries through unchanged; that is what lets finished work drain everywhere instead of reappearing when its TAKE lease lapses.Gossip learningGossipLearnerGossipLearning
This is the least obvious capability, so here it is end to end (and as a runnable fleet: examples/example-14-gossip-learning, three peers whose local weight vectors converge on the fleet mean on the peer tick alone, with a test that asserts it). The idea: every peer holds a parameter vector, peers pair off at random and average their vectors, and the fleet converges on the mean without a parameter server and without any raw data leaving any peer. Add a local update step that runs after each merge and you have federated training: train on what you have, average with a neighbour, repeat.
Two things have to be true for it to work, and both are your job: every participant registers the same model id with the same vector length, and the local update (if any) is supplied at construction. The third, something driving the exchange, is the runtime's job, and it is done for you as soon as the provider is registered.
// 1. Build the learner. The starter's gossip-learn toggle gives you a pure // averaging learner; to train, construct your own and publish it as a bean. // (Illustrative bean: the builder call is GossipLearningTest's, the bean shape is // EchoCapability's under @ProvidesCapability; "metrics" must be a space declared under groups[].spaces.) @Component @ProvidesCapability(GossipLearning.TYPE) // "aspace:cap/gossip-learn" public class BidPolicyLearner implements CapabilityProvider { private final GossipLearning learning; public BidPolicyLearner(AgentSpaces spaces) { AgentSpaces.GroupContext group = spaces.group("orders-fleet"); // The builder is the configurable form: a training step, and a metrics // space so endEpoch below has somewhere to write. this.learning = new GossipLearning(GossipLearner.builder( group.pipes(), group.runtime().sampler(), group.identity().peerId(), CborCodec.defaultCodec(), group.clock(), WeightAveraging.INSTANCE) .localUpdate(params -> sgdStep(params, batch())) // the training step .metrics(group.space("metrics"), Lease.of(Duration.ofDays(7))) .build()); } @Override public String capabilityType() { return learning.capabilityType(); } @Override public CapabilityAdvertisement describe(GroupId g) { return learning.describe(g); } @Override public boolean requiresTick() { return true; } // 2. Join the model. Every peer must use the same id and the same length. @EventListener(ApplicationReadyEvent.class) public void join() { learning.start("bid-policy", new double[]{1.0, 0.0, 0.5}); } // 3. Advance the protocol. Called by the peer tick because this bean is // registered -- you write no driver. @Override public void tick() { learning.tick(); } // 4. Read the current parameters wherever you need them -- e.g. from a // @BidFunction, which is what makes the fleet's bid policy learned. public double[] parameters() { return learning.model("bid-policy").orElse(DEFAULTS); } }
// And at the end of a training epoch, write one evaluation per model into the // metrics space, so the fleet's loss curve is itself replicated state // (agentspaces-capabilities -- GossipLearningTest, trimmed): Space metrics = spaces.group("orders-fleet").space("metrics"); List<GossipLearner.Evaluation> written = learning.endEpoch(epoch, params -> lossOn(params, holdoutSet())); // Any member can then read everyone's, attributed to its authenticated author: List<Space.Issued<GossipLearner.Evaluation>> all = metrics.readAllIssued( Template.of(GossipLearner.Evaluation.class).where("epoch", eq(epoch)), 100);
Use it for anything where peers hold private data and want a shared model: learned bid policies (the fleet converges on what work actually costs), drift detectors, shared bandit statistics, federated fine-tuning signals. It is the natural partner of @BidFunction: a bid function is a plain method over numbers, and this is how those numbers learn.
The mechanism is randomized pairwise averaging, which contracts the spread geometrically, so a fleet lands within tolerance of the mean in O(log N) rounds. Each completed pairwise average preserves the fleet mean, so under pure averaging the answer does not drift. The caveats: a lost ACCEPT can move mass one-sidedly, a local update changes the mean by design, and a departing peer takes its share with it.
parameters() off that bean by ordinary injection. For a model that is not a double[], implement MergeableModel and build a GossipLearner<YourType> directly; GossipLearning is just the double[] specialization under WeightAveraging. Its two short constructors cover pure averaging and averaging-plus-update; anything more, such as a metrics space or a block exchange, goes through the builder as above.start for a model id ignore exchanges about it, and a vector of the wrong length is dropped rather than merged, so a rolling deploy that changes the model's dimension silently partitions your fleet into two non-interacting populations. Version the model id when the shape changes. The local update runs after every merge, so keep it to one cheap step; a full training run in there will stall the tick. Models above the 64 KiB inline limit travel content-addressed through the block exchange automatically, but only if the learner was built with one. And endEpoch throws unless the learner was given a metrics space, which is the one reason to prefer the builder over the plain constructors.Key wrap: content keysGroupKeyDistributor
Key wrap (aspace:cap/key-wrap) hands a member the group content key without ever putting it on the wire in the clear: the holder serves the key under the profile's authorizer, and a joining member asks for it sealed to its own X25519 key (X25519, HKDF, AES-256-GCM). The holder serves every epoch of its key ring: request returns the epoch-0 key alone, fetch(holder, epochs, timeout) brings rotated epochs sealed to the peer, requestAs brings them sealed to an agent, and follow(ring, discovery) fetches on each tick whatever epoch the ring lacks. It is request/response and needs no driver, though it rides the peer tick for auto-rotation and for following a holder's epoch announcements. Under the starter, agentspaces.groups[].content-key is both what the capability serves and what seals every payload; content encryption and rotation covers the epochs, the key-rotator grant, and what rotation can and cannot do.
// Key wrap: the holder serves the key under the profile's authorizer... keys.serve(contentKey, authorizer, groupId); // ...and a joining member asks the holder for it, sealed to its own X25519 key. // request returns epoch 0; fetch and requestAs bring the rotated epochs. Optional<GroupKey> key = keys.request(holderPeerId, Duration.ofSeconds(2));
serve time, so revoking a grant takes effect on the very next ask, and a request nobody serves completes empty rather than hanging. The content key is a ring of epochs that rotates, and an agent with a certified X25519 key can ask as itself (requestAs), so a sibling agent on the same peer learns nothing.Custom capabilitiesCapabilityProviderProvidesCapability
A capability of your own is a CapabilityProvider: capabilityType() names it, describe(group) builds its signed CapabilityAdvertisement, start() and tick() are interface defaults you override when the protocol needs a driver, and requiresTick() says whether it does. Mark the class @ProvidesCapability and register it, as a Spring bean, through spaces.bind(bean), or with group.provide(provider); the group's CapabilityRuntime then starts it, publishes its advertisement, refreshes it on every card refresh, and drives its tick() on the peer tick. Speak to peers over group.pipes(), the PIPE_DATA frames addressed to one member that the shipped services use, or through a space. Consumers reach it through a client type registered with spaces.clientFactory(new MyClient.Factory()) and resolved like any other, by group.capability(MyClient.class) or a @CapabilityRef field. The gossip learner above is the worked example of a provider with a tick; the echo capability under @ProvidesCapability is the smallest one.
Peering, identity, and transportsPeerIdentityPeerNode
The floor of the stack. Most applications configure it through the starter and never touch it; this is the API when you wire by hand or embed without Spring.
PeerIdentity identity = PeerIdentity.generate(); // or FileKeystore.loadOrCreate(path) PeerNode node = PeerNode.builder(identity).build(); node.listen(new TcpTransport(), "0.0.0.0:7500"); // and/or new QuicTransport(identity) GroupRuntime runtime = node.joinGroup(groupAd, new GroupMembership.Config(Duration.ofSeconds(30), // member TTL Duration.ofSeconds(2), // ping timeout 2), // indirect probes seeds); node.startTicking(Duration.ofMillis(250));
- Identity. An Ed25519 keypair; the PeerId is the hash of the public key, so identity survives every address and transport change, and every frame, card, entry, and state transition the peer emits is signed with it.
- Groups. A group's id comes from a founding text or from a signed founding. With a text, every member derives the same id from the same string and no signed document exists;
joinGroup(GroupAdvertisement, ...)joins it. With a signed founding, the GroupId is the hash of a founder-signed document, and a newcomer who knows only the id can ask a seed for the document and verify the answer against the id before joining:joinGroup(GroupId, ...)does that, andjoinGroup(SignedGroupAdvertisement, ...)serves a peer that already holds the document. A group started from a text has no signed founding to serve, so it cannot be joined by id (the attempt times out); only a group with a signed founding can. That is thefounding,join, andfounding-documenttrio of the starter, and discovery covers how a peer finds the seeds to ask. - Membership. SWIM-style probing with leased membership: a silent member drops from the view on its own. Groups admit by policy: OPEN, INVITE (founder-signed credential presented at join), or POLICY (your validator SPI). Two gossip channels carry state, rumor for speed and anti-entropy for completeness, the latter paced by the group's own gossip period.
- Topology. Any member may volunteer as RENDEZVOUS (a bigger discovery cache newcomers ask first) or RELAY (forwards signed frames verbatim for NAT-restricted peers). Correctness never depends on either. On a LAN, the opt-in multicast beacon replaces seeds entirely.
- Transports. An in-JVM loopback for tests, TCP, TLS over TCP (the JDK's default protocol set; this binding does not pin a version), QUIC (RFC 9000), DTLS 1.2 (RFC 6347), WebSocket (RFC 6455, as
wsandwss), and the HTTP exchange (httpandhttps) ship; a peer advertises(transport, addr, priority)endpoints and dialers try them in ascending priority, skipping schemes no configured transport serves. Peer identity stays with the signed envelopes on every transport. - QUIC.
agentspaces-transport-quiccarries the fabric's frames over QUIC (RFC 9000) through quiche and Netty's incubator codec, plugged in through the sameTransportSPI as TCP:node.listen(new QuicTransport(identity), address). Built with the identity, it presents an ephemeral channel certificate the peer identity endorses, so the attested mode below works over it; the no-argumentnew QuicTransport()encrypts and attests nobody. The starter builds the identity form whenagentspaces.transport.quic.enabledistrueand the module is on the classpath (startup refuses an enabled block without the module); it is not available under Micronaut, which manages Netty 4.2 while the transport pins 4.1. - DTLS.
agentspaces-transport-dtlscarries the frames over DTLS 1.2 on UDP, on BouncyCastle's DTLS, pure Java with no native library. A small recovery sublayer makes each association reliable and ordered, so nothing above the transport changes, and Connection ID (RFC 9146) keeps an association alive when a peer's address changes behind a NAT. It presents the same channel certificates as TLS and QUIC and attests in the same modes;DtlsTransport.unattested()(propertyattestation: none) encrypts for fleets that use no attestation. Turn it on withagentspaces.transport.dtls.enabledand abind; a fleet that requires attestation may run on DTLS alone. Choose it where QUIC is unavailable (no quiche build for the platform, a validated-crypto policy) or blocked, and on lossy UDP-only links. Two things to know: DTLS 1.2 sends certificates in clear, so a passive observer learns which PeerIDs talk (use QUIC or TLS where that matters), and there is no congestion control beyond a fixed window. The bareudpbinding in the same module runs the sublayer over plaintext UDP for closed test ranges and links already encrypted underneath; it registers only underdev-localorallow-plaintext. - WebSocket and the HTTP exchange.
agentspaces-transport-http(design:srad/agentspaces-js.md) is how a browser, a Node process, a Worker, or anything behind HTTP infrastructure becomes a member.wscarries one binary message per frame under the subprotocolaspace.1;wssis the same inside TLS 1.3 with thetlsbinding's identity modes, and it admits a dialer with no certificate (a browser) as unattested.httpandhttpsare the exchange for intermediaries that break WebSocket:POST /open, batched frames up underAspace-Seq, a 25 s long-poll down underAspace-Ack. One Netty port serves both the upgrade and the exchange. Browsers are admitted only from the listener'sallowed-origins(empty refuses every browser;*is for demonstrations). On an unattested link both ends insessionmode run SPEC §5.6 session attestation inside it, so frames travel tagged rather than signed after the hello;ws,http, andhttpsnever attest, so they register under any profile astcpdoes and startup refuses them underrequire-attestation; onlywssattests. The TypeScript client dials all four with the runtime's ownWebSocketandfetch; Python keepstcpand skips them. - WebRTC.
agentspaces-transport-webrtcand@agentspaces/webrtccarry the frames over a WebRTC data channel, so two browsers behind one seed, or a browser and a Java peer, talk directly and leave the seed out of the data path. Signalling travels through the fleet's own pipes (aspace:cap/rtc-signal): directly when the target is connected, inside aRELAY_FRAMEthrough a relay member otherwise, which is the one relayed frame a node underrequire-attestationadmits. Each side signs the DTLS fingerprint of a certificate it generated for the connection into its signal, so the channel attests the remote PeerID. The Java engine is libdatachannel (a native per platform); the TypeScript side is the browser'sRTCPeerConnectionornode-datachannelon Node. No ICE server is configured by default: only host and mDNS candidates gather untilagentspaces.transport.webrtc.ice-serversnames STUN or TURN. Every bound (offers per peer per minute, candidates per session, bytes per signal, the 15 s dial) is a property; nothing caps connections. A node never dials awebrtcendpoint from inside a send: frames to a member it cannot reach take the relay, and once more thanagentspaces.transport.webrtc.offload.frames-per-minute(30) of them crossed a relay to or from that member in a sliding minute, the node dials the endpoint from its tick and sends directly from then on, so the relay carries none of the pair's frames; a dial that cannot complete falls back to the relay at the dial bound without losing a frame and is retried no sooner thanretry-after(5 min). The TypeScript core counts the same way (peer.onRelayed) and@agentspaces/webrtcdecides oninstall. - Channel authentication. The envelope signature authenticates one hop, so when the transport has already authenticated the channel it is repeated work at roughly a thousand times the cost of serializing the frame. TLS, QUIC, and DTLS present a channel certificate the peer identity endorses, and a node that announces itself willing accepts unsigned frames from the peer that channel attests, the
attestedmode of the starter. Signed and attested nodes mix freely; forcing signed mode is the only downgrade an attacker can cause, and it costs nothing but speed. - Enterprise CA mode. Point the TLS, QUIC, or DTLS binding at a trust store and peers attest only when their chain validates to your anchors, with revocation checked against cached CRLs or fetched live. Under
require-attestationevery inbound frame must arrive on a channel attested for its sender, which is what makes the CA's revocation an authoritative fleet-wide eject rather than advice. - Revocation and rotation. A signed
RevocationAdvertisementgossips like any other advertisement, with anti-entropy so late joiners converge, and a revoked identity is refused at dispatch ahead of every other check, evicted from membership, and its connection closed. Because a self-certifying identity cannot rotate its key in place, rotation is a revocation that names a successor; the successor joins and is authorized like any new peer.GroupRuntime.revoke(...)androtate(...)issue, and the founder-rootedRevocationValidatoris the seam an enterprise deployment replaces. Agents, agent keys, channel leaves, and join credentials are revocable too; see revocation. - One clock.
startTicking(period)is the whole scheduler. Membership probing, rumour and anti-entropy gossip, replicated-space convergence, and capability protocols all advance on that one tick, because each layer registers with it rather than bringing a timer: a space throughgossip().reconcile(...), a capability runtime throughGroupRuntime.onTick(...), which is also where you hang periodic work of your own. Work registered there runs on the tick thread, so it must be quick; work that throws is logged and cannot stop the tick or its siblings. - Containment. Per-issuer rate limits and bounded strike counters, fed only by witnessed violations on fresh authenticated frames, quarantine a misbehaving peer. Replayed frames and unadmitted peers deliberately never strike, which is what keeps quarantine unweaponizable.
What an HTTP intermediary does to a link (ISSUE-Websocket §9.6). The rule: attestation survives anything that forwards TLS bytes unchanged and dies at anything that terminates TLS; a require-attestation fleet uses passthrough or stays off HTTP.
| Intermediary | Fabric effect | Attestation | Operator guidance |
|---|---|---|---|
| Layer-4 pass-through (TCP proxy, NAT) | none | survives | nothing to do |
TLS passthrough on SNI (ingress ssl-passthrough, L4 LB) | none | survives; each end sees the other's channel certificate | dial a hostname; the configuration for require-attestation fleets |
| TLS termination (ingress certificate, L7 LB, CDN) | upgrade forwarded; frames flow | does not survive: both ends read the link as unattested and run session attestation inside it | run ws/http behind the terminator inside a network you trust as far as it; never require-attestation behind termination |
| HTTP forward proxy (CONNECT) | wss/https tunnel through; ws/http may be rewritten | survives for wss | the JVM's https.proxyHost; browsers use their own proxy settings |
| Idle-timing proxies | a silent link is cut (often at 60 s) | n/a | the 30 s ping keeps a WebSocket alive; the long-poll returns within 25 s |
main, a test fixture, or your own bootstrap class. Under the starter you write none of it; the starter’s properties build exactly this and the PeerNode is a bean you can inject if you want to assert the posture you configured. Reach for the hand-wired form when embedding a peer in something that is not a Spring application, when writing tests against a simulated network, or when you need a topology the properties do not express.startTicking: the tick drives membership probing, advertisement refresh and anti-entropy, so a peer that joined but never ticks will look joined and converge with nobody. Join order does not matter and seeds need not be reachable forever; they are a way in. Close the node when you are done; an abandoned peer stays in other members' views until its membership lease lapses.Agent keys and certificates, revocation of everything certified, and content encryption have their own chapter: Security.
Security
Everything in AgentSpaces is signed, and most of what you decide about security is who holds which key, for how long, and who may take it away. This chapter covers peers and agents, the security levels from a laptop to a regulated fleet, where keys live, how agents get certificates, revoking anything that was certified, and content encryption. Each section names the cost of its guarantee. The normative text is SPEC §4.2, §5.6, §11, and §11a; TECH-SPEC §5 and §7 carry the wire detail.
The trust model in one page
- Peers are keys. A
PeerIdis the hash of an Ed25519 public key, so a peer is whoever holds that private key. No registry, no CA required. Every advertisement, record, claim, and state transition a peer emits carries its signature, and so does every frame, except on a link whose peer is attested (frames ride bare there) and underdev-local(a MAC under the session key after one signed hello). - Agents are names on a peer. An
AgentIdis<peerId>/<localName>. By default the peer signs for its agents, and a reader trusts the peer's labelling:PEER_ASSERTED. Give an agent a key of its own, certified by its peer, and the agent signs its own records, claims, and transitions:AGENT_ATTESTED. Readers see which throughSpace.Issued.attestation(). - What each layer proves. A record signature proves who wrote an entry. A claim signature proves who took it. A state signature proves who renewed, cancelled, or completed it, so a forged completion cannot delete work. Channel attestation proves which peer is on the other end of a TLS or QUIC link. Content encryption decides who can read a payload.
- What none of them decide. A signature proves who acted, never that they were allowed to. Eligibility is the
Authorizer's question (profiles and authorization), and admission is each space's (space admission). - An attested agent acts only as itself. Once an agent signs with its own key, its peer can no longer act in its name: every renewal, cancellation, and completion of that agent's work must carry the agent's signature, or receivers refuse it.
Security levels
Each rung adds a guarantee and a cost. Pick the lowest rung that covers your threats; mixing nodes of adjacent rungs in one fleet is supported, and a mixed fleet degrades to the lower rung's guarantee for traffic between them, except under require-attestation, which refuses nodes of lower rungs outright.
| Level | How you get it | Defends against | Does not defend against | Choose it when |
|---|---|---|---|---|
| dev-local | security.profile: dev-local | Forged frames, records, and transitions: records, claims, and transitions stay signed, and after one signed hello per connection each frame carries a MAC under the session key | Eavesdropping; anyone who can reach the port joins an OPEN group | A laptop, a test, a demo |
| signed over TCP | transport.tls.enabled: false under a non-dev profile | The same, with grants narrowing who may do what | Eavesdropping; traffic analysis | A trusted network where TLS is somebody else's job |
| attested channels | mtls (the default): TLS with identity-endorsed certificates (QUIC and DTLS too, from their listener blocks; peering) | Eavesdropping and tampering on the link; frames ride bare on links whose peer is attested | A stolen peer key; an insider who is a legitimate member | Most fleets: no PKI to run, full link security |
| enterprise CA | transport.tls.trust-store plus require-attestation: true | Unenrolled peers: only CA-issued leaves attest, and the CA's revocation ejects a peer fleet-wide | A compromised CA; an enrolled insider until revoked | You already run a CA and want joining to need enrollment |
| identity provider | mtls-oidc or zero-trust | Unauthorized operations: each privilege needs a token scope from your IdP | A stolen token, for its lifetime | Privileges must follow your directory, per operation and scope |
zero-trust signs every frame on every hop even over attested links. That is about a thousand times the cost of serializing the frame, so it is the right setting only where you do not trust your own relays.Keys and where they liveFileKeystorePeerIdentity
- The peer key.
agentspaces.keystore: /var/lib/agentspaceskeepspeer.key(PKCS#8, mode 0600) andpeer.pub(raw 32 bytes). The files are written atomically, a half-written pair is refused with a message naming both files, and a private key that does not sign for the public file is refused. Without a keystore every start is a new peer. Back the directory up as you would a TLS private key; a peer key cannot be rotated in place, so you replace it by revocation with a successor (below). - Agent keys.
agentspaces.identity.agent-keys: subordinategives every bound agent a key of its own; addagent-keystore: ./agent-keysto keep them across restarts at<dir>/<peer-id>/<agent>/agent.key. Without the keystore an agent gets a fresh key on each start: its old records stay verifiable, and it simply has a new key from then on. Agent names that could escape the directory are refused, never sanitized. Python and TypeScript peers read and write the same signing-key layout (agent.key,agent.pub); the X25519 files are Java-only. - Encryption keys. Each peer also holds an X25519 key (
peer-x25519.keybeside the peer key), used only to receive content keys; the signing key never enters a decryption path. A starter-built subordinate agent (or one built with theencryptionKeysoverload ofrenewingSubordinate) gets its own X25519 key, certified alongside its signing key, so a key holder can hand a content key to the agent itself. - Your own key store. To keep keys in an HSM or a secrets manager, declare an
AgentIdentityFactorybean returning your ownAgentIdentity: anything that can sign and present a certificate will do.
Agent certificates
A subordinate agent's certificate is AgentCertificate(agent, agentPublicKey, issued, ttl, peerSignature[, encryptionPublicKey]), the peer's Ed25519 signature over the rest in canonical CBOR. Only the peer an AgentId names can issue one.
# application.yml: every bound agent signs as itself agentspaces: keystore: /var/lib/agentspaces identity: agent-keys: subordinate agent-keystore: /var/lib/agentspaces/agents agent-certificate-ttl: 24h
// by hand: a renewing identity over a fixed key, re-certified at half-life AgentIdentity auditor = identity.renewingSubordinate("auditor", agentKeys, encryptionKeys, Duration.ofHours(24), InstantSource.system()); Space findings = replica.as(auditor); // replica is a ReplicatedSpace: as(...) is on the concrete class // writes, takes, and completions sign as the auditor // examples/example-13-signed-agents: who said it, and how strongly the fleet can hold them to it for (Space.Issued<Finding> issued : reader.findings().readAllIssued(Template.of(Finding.class), 100)) { System.out.println(issued.issuer().encoded() + " (" + issued.attestation() + ")"); }
- When agents get certificates. At bind time, from the starter's factory when
agent-keys: subordinate; from your ownAgentIdentityFactorybean; from the facade's third constructor argument; or by hand throughPeerIdentity.renewingSubordinate(...). The Clojure bindings take:agent-keys :subordinate, and the Python and TypeScript clients haverenewing_subordinate/renewingSubordinate. - Judged at signing time. A receiver checks a certificate at the moment the thing it certifies was signed: a record's issue stamp, a claim's stamp, a transition's
signedAt. That time must lie in[issued, issued + ttl)and may lead the receiver's clock by at most the HLC drift ceiling of ten minutes. History therefore stays verifiable forever: a late joiner months later still accepts an agent's old findings, and a certificate's expiry only stops new signatures. - Renewal. A renewing identity keeps one key and re-issues its certificate at half-life, lazily on the next signature, and keeps its last 256 certificates so a long-held claim stays certifiable. The starter's subordinate agents renew; nothing to schedule.
- The back-dating trade-off. Judging at signing time means a stolen agent key can sign with an old stamp, inside a window that was valid. The HLC drift bound stops it from signing into the future, nothing stops it signing into the past, and that is what revocation with
key-compromiseis for (next section).
| Option | Attestation | Cost | Use when |
|---|---|---|---|
| peer-signed (default) | PEER_ASSERTED | None; the peer's word names the agent | Agents of one peer trust each other and nobody audits them separately |
| fixed subordinate | AGENT_ATTESTED | One certificate; new signatures stop when it lapses | Short-lived jobs and tests (identity.subordinate(name, issued, ttl)) |
| renewing subordinate | AGENT_ATTESTED | A new key each start | Long-running agents whose identity is the process |
| persisted renewing | AGENT_ATTESTED | A key file per agent to protect and back up | An agent's identity must outlive restarts: audit trails, per-agent grants, per-agent key wrap |
Revoking anything that was certifiedRevocationView
A revocation is a signed advertisement that gossips with anti-entropy, so late joiners converge, and a member holds it for as long as it runs; its TTL bounds re-gossip, never the revocation. Each of the two registries (one for peers, one for everything else certified) retains at most 4,096 targets and refuses a new target beyond that with an error in the log, so it never evicts a stored revocation to make room. Exactly one record per target survives, chosen by authority first (the founder, then a CA-rooted record with evidence, then an agent's own peer, then anyone your validator admits), so a later statement from someone less authoritative never erases the founder's.
| Target | Issued by | Call | Effect |
|---|---|---|---|
| a peer | the founder; revoke also from any member carrying CA evidence (founder-or-ca), while rotate stays with the founder | runtime.revoke(peer, reason); rotate(old, successor, reason) | Refused at dispatch, evicted, its link closed; its entries are refused by replicas that do not hold them |
| an agent | its own peer, or the founder | revokeAgent(agent, reason) | Every key of the agent, under the freeze rule |
| an agent key | its own peer, or the founder | revokeAgentKey(agent, key, reason) | Only that key at receivers; a replacement key of the same agent still verifies. Locally the agent is unbound until it restarts with a new key |
| a channel leaf | the founder | revoke(Target.x509Leaf(...), reason, null) | Every link presenting that certificate is cut, now and on each later handshake |
| a join credential | the founder | revokeJoinCredential(credential, reason) | Admits nobody again; the member it admitted is evicted |
- The freeze rule. What a replica already holds stays. Under
key-compromise, nothing the target signs is accepted from then on, whatever its claimed time, because a stolen key can back-date. Underretired,superseded,privilege-withdrawn, orunspecified, signatures made before the revocation's effective instant still verify, so a late joiner receives the agent's history and nothing after. Claims are refused under any reason, so a revoked holder's take lapses and the entry returns to the space. - Where it is enforced. Space merges and the ordered log, discovery (a revoked agent's cards are purged), key wrap (a revoked agent is served no key), the vote tally (its ballots stop counting at the next recount, so a decision can reopen), and each group's authorizer. A revoked local agent is also unbound, and the node refuses to write or take as it.
- CA-rooted revocation. With
transport.tls.revocation-validator: founder-or-ca, a node whose CRLs (re-read everycrl-refresh, 5m) revoke a connected peer's leaf forkeyCompromise,cACompromise,privilegeWithdrawn,cessationOfOperation, orunspecifiedcuts the link and roots a peer revocation itself, carrying the chain and the CRL as evidence. Any member runningfounder-or-caover the same anchors then accepts it on the evidence alone, judged at the revocation's own issue time, so even a member without a fresh CRL converges; a member on the founder-only default ignores it. Any other CRL reason (superseded,affiliationChanged,certificateHold,aACompromise) only stops the leaf from attesting.
// a compromised agent: refuse everything its key signs, from now on runtime.revokeAgentKey(agent, agentPublicKey, CredentialRevocation.KEY_COMPROMISE); // a decommissioned agent: keep its history, accept nothing new spaces.group("fleet").revokeAgent(agent, CredentialRevocation.RETIRED);
rotate(old, successor, "lost") once the replacement has a key, or revoke at once if not. The peer revocation already refuses everything its agents sign, so no per-agent revocation is needed. Then rotate the content key so it reads nothing new.A compromised agent. Its peer revokes the agent key with
key-compromise, then gives the agent a fresh key (delete its keystore entry and restart). Rotate the content key if the agent held it.Decommissioning a node. Revoke its agents as
retired so their history stays and nothing new lands, then let its membership lease lapse. Revoke the peer only if its key may outlive the node.key-rotator grant, belong to the group's founder. A starter group with membership: open, the default, has no usable founder: the starter signs that group's advertisement with a throwaway key it generates and does not keep, so no peer can issue them and the lost-laptop playbook has nobody to run it. Decide this before the fleet holds data. Under membership: invite the founder is the peer named by groups[].founder, or this peer when none is named; outside the starter, a group founded in code with a kept founder key (PeerNode.joinGroup(SignedGroupAdvertisement, ...)) has one too. An open starter group can still use transport.tls.revocation-validator: founder-or-ca, which needs the enterprise-CA trust-store and lets CA evidence revoke a peer, and security.grants.key-rotator, which names the peers that may rotate the content key. An agent's own peer can revoke its agents and agent keys without a founder.key-compromise, a late joiner never sees the compromised key's history, and that is the price of the guarantee. Python and TypeScript peers apply peer, agent, and agent-key revocations; CA-rooted ones are Java-only for now.Content encryption and rotationGroupKey
- Content keys. A group's
content-keyseals every payload with AES-256-GCM. Members without the key still verify, store, and forward everything and read nothing, which is what lets relays and rendezvous peers serve a group they may not read. Thekey-wrapcapability hands the key to members your authorizer permits asKEY_HOLDER, sealed to each member's X25519 key. - Epochs and rotation. Each group has one ring of numbered keys, shared by its spaces. A rotation mints the next epoch with a signed proof and a cutover instant. Members install it only from the founder or a peer granted
key-rotator(under the OIDC profiles, theaspace:key-rotatescope), whoever relays it. Writers switch at the cutover, and readers open each record with the epoch it names. Every member must carry the samekey-rotatorgrants, because each member vouches for rotators by its own configuration. Configuregroups[].content-key-rotation.rotate-everyto rotate on a schedule, or callrotateContentKey(delay); with a keystore, the ring survives restarts. - Why rotate on time. Random GCM nonces bound one key to about 232 encryptions, and that budget is shared by every writer in the fleet. No node can count other nodes' writes, so rotate by period, chosen so the whole fleet's write rate stays well below the bound.
- Per-agent key wrap. A subordinate agent with a certified X25519 key can ask for content keys as itself (
requestAs). The holder then judges the agent itself rather than only its peer, and seals the key to the agent's own key, so a sibling agent on the same peer learns nothing. - What rotation can and cannot do. Rotation is what denies future reads. An agent or peer revoked or no longer granted gets no epoch minted after its removal, but it keeps whatever epochs it already held, and old entries are not re-encrypted.
agentspaces: security: grants: key-rotator: ["<rotator peer id>"] # the same list on every member groups: - name: research-fleet content-key: "<base64 of 32 bytes>" content-key-rotation: rotate-every: 30d # only on a peer that may rotate writer-grace: 1h # a writer missing the new epoch stops sealing after this
Things to think about
- Clocks. Certificates and revocations are judged against hybrid logical clocks with a ten-minute drift ceiling. Keep NTP running; a node whose clock runs more than ten minutes ahead has its agent signatures and revocations refused, and one running far behind sees fresh certificates as not yet valid.
- Mixed versions. v0.1.13 fields are appended and omitted when absent, so a fleet that never opts in keeps every byte. An older node cannot verify agent-signed states or epoch-tagged records and drops them; upgrade every node before turning on
agent-keys: subordinateor rotation. - Plaintext metadata. Encryption covers payloads only. Entry types, tags, issuers, leases, and timing are visible to every member, encrypted group or not. Do not put secrets in tags.
- Accepted risks. SPEC §11 lists what the design accepts: Sybil exposure of OPEN groups, the back-dating window, metadata visibility, and an insider's reach until revoked. Read it once before a security review.
| Level | Checklist |
|---|---|
| dev-local | Never on a shared network; use an INVITE group if the port is reachable |
| attested channels | Set keystore so the PeerId is stable; back up peer.key; set grants for privileged operations (raft-voter, key-holder, key-rotator, directive-issuer) |
| with agent keys | agent-keys: subordinate and an agent-keystore; per-agent grants by AgentId where agents differ in privilege; a revocation runbook for agent keys |
| enterprise CA | trust-store, crls, require-attestation: true; revocation-validator: founder-or-ca so the CA's word ejects; a CRL publication cadence shorter than crl-refresh |
| encrypted groups | Distribute the epoch-0 key out of band; enable content-key-rotation with a rotate-every and identical key-rotator grants (or the aspace:key-rotate scope under OIDC); a keystore on the rotators |
| identity provider | Short token lifetimes with token-file refresh; scopes per operation; remember revocation still applies on top of a valid token |
The fleet consoleDirectiveGateConsoleViewCustomizer
A read-only peer that serves the whole room: a single-file web UI at /, a HAL+JSON API under /api/v1 walkable from the root by any hypermedia client, and an SSE activity stream at /api/v1/events, all derived from the replicated coordination state, with zero instrumentation in any agent. It binds to loopback by default, as the A2A gateway does. Two properties turn it on (the starter); two more add command-and-control: dispatch tasks, cancel what the console dispatched, and pause, resume, or drain workers, every command executed under the console peer's signed identity, attributed to the operator the credential names (the token holder's fixed name, or the JWT subject), and recorded in an audit space as a signed entry leased for 24 hours by default.
Operators authenticate one of two ways. A static command.token gates the HTTP command routes and always takes precedence when set; leave it blank with security.oidc.* configured and each request's bearer JWT is validated against your identity provider instead, required to carry aspace:console:operate, and audited under the token's own subject rather than a name the request declares. Directives travel through the fleet-control space and the audit trail lands in c2-audit; both names are properties, and both spaces belong in the group's spaces list.
Extend the read side two ways: a ConsoleViewCustomizer bean tells the default generic view what the application's entries mean (worker attribution above all), and every ConsolePanel bean becomes one more section on the page and one more document under /api/v1/panels. One panel ships with the module and is registered for every fleet: TrafficPanel, the wire as the console peer sees it, with frames and bytes by kind as rates over the UI's polling window, the signed, session and bare split, the frames dropped as stale, replayed or unverifiable, and each group's view, suspects, anti-entropy rounds and gossip-tree figures, so the traffic numbers of the performance notes and the robustness counters are readable on a live fleet without an audit harness. Extend the command side with ConsoleCommand beans, whose typed fields render as forms. Workers honor directives through a DirectiveGate (under the starter, gates.attach(space, worker) off the DirectiveGates bean), consulting gate.paused() and gate.draining() in the work loop. A gate obeys whoever the group's authorizer permits DIRECTIVE_ISSUER: under the membership profiles that is every admitted member unless security.grants.directive-issuer names the console peer, so set that grant on every worker (the hand-wired DirectiveGate.attach(space, worker, consolePeerId) pins one peer instead). Directives stamped more than five minutes ahead are ignored.
// Teach the generic view what your entries mean. Worker attribution above all: // without this the console can show that work happened but not who did it. @Bean ConsoleViewCustomizer researchConsole() { return (builder, spaces) -> builder .results("findings", Finding.class, f -> ((Finding) f).worker()); } // One panel bean = one section in the UI and one document at /api/v1/panels. @Bean ConsolePanel storyPanel(ConsoleView view) { return new ConsolePanel() { @Override public String id() { return "story"; } @Override public String title() { return "How this console works"; } @Override public Object data() { return Map.of("source", "replica state and leased advertisements only", "eventsObserved", view.lastSeq()); } }; }
// A worker that obeys the console. The gate is a subscription on the control // space; consult it at the top of the loop, and close it when the worker stops. try (DirectiveGate gate = gates.attach(controlSpace, "worker-1")) { while (!Thread.currentThread().isInterrupted()) { if (gate.draining()) { return; // commanded to drain: stop for good } if (gate.paused()) { Thread.sleep(200); // commanded to pause: hold off taking continue; } Optional<TakenEntry<TaskEntry>> taken = tasks.take(Template.of(TaskEntry.class), Lease.of(Duration.ofSeconds(30)), Duration.ofSeconds(2)); taken.ifPresent(t -> tasks.complete(t, work(t.entry()), RESULT_LEASE)); } }
Turn it on for any fleet somebody has to operate. It is a peer like any other, reading the same replicated state every agent reads, which is why it needs no instrumentation, no agent, no sidecar and no export: there is nothing to wire up because the coordination state is the telemetry.
See the fleet room for the operator's tour, and run examples/example-09-fleet-console to drive a live fleet: pause a worker mid-run and watch its queue hold.
ConsoleViewCustomizer, ConsolePanel and ConsoleCommand are @Beans collected by the console autoconfiguration; they need no @AgentSpec and get no card. They are plain beans on a peer where the console is off, so the same jar runs with and without it. The DirectiveGate is the opposite: it belongs inside the worker, because pausing is something a work loop does for itself.DIRECTIVE_ISSUER (grant it to the console peer on every worker under the membership profiles; the aspace:directive-issuer scope under OIDC) and ignores directives stamped more than five minutes ahead, so pausing a fleet is an authorized operation rather than a message anyone can write into the space. The gate is a subscription with a year-long lease that is never renewed: close it, or it lingers after the worker is gone. And @SpaceTake workers have no gate hook, so a fleet that must stop claiming work while paused should use a loop that checks the gate before taking, as above. Returning early from a take method completes the entry and discards its work; it is not a pause operation.Gateways and dataa2a packageconnect package
Two edges of the fleet. The A2A gateway lets an outside client drive agents through the standard protocol, and the connector SDK lets a data source join as a peer. The MCP edges live with the frameworks (Spring AI, Micronaut); the Python, TypeScript, and Clojure clients are chapter 19.
The A2A gateway
The gateway (agentspaces-a2a) serves one fleet card at the standard A2A discovery endpoint (/.well-known/agent-card.json, whose skills are the union of every agent's skills), lists every discovered AgentCard under /agents, serves each agent's own card at /agents/{name}, and speaks the task protocol as JSON-RPC over HTTP, with SSE streaming and push notifications: message/send becomes a leased task entry, tasks/get reads state from space semantics, message/stream and tasks/resubscribe stream over Server-Sent Events, and a lapsed lease honestly reports the task as submitted again. One gateway peer opens the door for the whole fleet, so individual agents never expose an inbound endpoint. It binds to loopback by default; discovery GETs stay open, the task endpoints take a constant-time static bearer token or an identity-provider JWT carrying aspace:a2a:client, and push-notification webhooks validate against a private-address denial with an explicit allowlist override. Task results carry the authenticated issuer alongside whatever the worker declares about itself.
// One gateway peer fronts the whole fleet: it serves one fleet card, lists // every discovered AgentCard as an A2A agent card under /agents, and binds // the task protocol to a space. try (A2aGateway gateway = new A2aGateway("research-fleet", "AgentSpaces demo", () -> discovery.find(AgentCard.class, card -> true)) .taskBinding(new A2aTaskBinding(tasks))) { int port = gateway.start(8080); // POST /agents/{name} {"method":"message/send", ...} -> a leased task entry // POST /agents/{name} {"method":"tasks/get", ...} -> state read from the space }
@Bean with a destroy method or a try-with-resources in a plain-Java peer. Individual agents know nothing about it and need no change to become reachable: the gateway serves whatever cards discovery is holding, so an agent becomes reachable over A2A by joining the fleet.The connector SDK and DataQueryClient
agentspaces-connect-core is how a data source becomes a peer. An AssetProvider describes what it holds and answers queries for it; ConnectorRuntime publishes leased AssetCards and serves aspace:cap/data-query through the space, with DataQueryClient giving pull-once semantics keyed on a recomputed canonical query hash. MaterializingAssetProvider goes the other way, pushing source changes into a space as leased entries, and DataSpaces wires the block exchange so bulk results travel content-addressed. The SPI and the protocol are Apache 2; the module is marked EXPERIMENTAL.
// examples/example-10-data-fleet -- DataFleet, trimmed. The provider peer publishes // an AssetCard and answers queries for it; keep the clients inside the try so the // connector is still serving when they fetch. try (ConnectorRuntime connector = new ConnectorRuntime(dataSpace, discovery, identity, "pg-connector", ordersTable, groupId, InstantSource.system(), Duration.ofMinutes(1))) { connector.start(); // Any analyst peer finds it by meaning (semantic discovery), then // fetches -- pull-once, so the second identical query anywhere in the fleet // is served from the space. AssetCard card = (AssetCard) semantic.remoteQuery("who holds customer order history?", 3, Duration.ofSeconds(2)).get(0).advertisement(); DataQueryClient client = new DataQueryClient(dataSpace, "analyst-a"); DataQueryClient.Fetched page = client.fetch(card.asset(), Map.of("region", "eu"), Duration.ofSeconds(10)).orElseThrow(); page.result().rows(); // page.fromCache() is true when nobody re-ran the source }
Asset operations, connector cells, and celldAssetInvocations
A query reads an asset; an operation changes it. The capability aspace:cap/asset-invoke (design: srad/agentspaces-js.md appendix F) adds that second verb without touching the AssetCard: the card's open access map lists read-ops and invoke-ops, an agent writes an AssetInvocation(v, asset, operation, params, invocationId) into the data space (v is the protocol version; AssetInvocation.of(asset, operation, params) fills it and mints the id), the connector takes it under the claim lattice (so exactly one connector runs it, and a crashed one lets it reappear), runs the operation, writes the AssetInvocationResult, and completes the take. The invocationId is the client's and stays constant across retries, so a connector that remembers results answers a retry without running the target again. An operation absent from the card's invoke-ops is answered with an error naming the rule and never run; under an authorizer the requester needs the scope aspace:connector-invoke:<asset>:<operation>, while aspace:connector-serve:<asset> belongs to the connector's own peer: the runtime advertises only the assets its peer holds that scope for, and a DataQueryClient built over an authorizer trusts only results from a peer so permitted. AssetInvocations.invoke(space, card, operation, params) is the Java client; RemoteActions.assetOperations() lists every foreign card's operations beside the agent actions, so a planner sees reserveStock on workers://inventory/do/Inventory as an action it may take.
The same protocol is what turns a celld or Cloudflare Workers deployment into a fleet member. celld is Deno's self-hosted runtime for Workers and Durable Objects: stateful JavaScript objects with SQLite storage, alarms, and a single live instance per name. The design treats each as an asset, not an agent: @agentspaces/workers runs a connector cell, a Durable Object that joins the group as a non-agent member (its identity and state in its own SQLite storage, its tick on the alarm, an outbound wss to the seeds), reads a manifest of what the deployment allows, and publishes one AssetCard per Durable Object class or named instance, KV namespace, D1 database, R2 bucket, Queue, Workflow, and route group, with the URI workers://<application>/<kind>/<name>. It runs nothing the manifest does not list: operations are allowlisted by name, parameters validated against declared names, D1 queries named and parameterised, and a refused request answered with an error naming the rule. Its once(invocationId, fn) records each invocation's result in SQLite so a retry returns the stored result, which is what makes an asset operation exactly-once end to end. One wrangler.json deploys the same bundle to celld, workerd, and Cloudflare. The connector runtime it builds on is @agentspaces/connect, the TypeScript twin of this module, byte-identical on the wire.
For a deployment you cannot change, agentspaces-connect-celld (a root project on this module) runs the same mapping in Java: a manifest of allowlisted routes with a bearer or mTLS credential, GET routes as read operations and POST routes as invoke operations carrying Aspace-Invocation-Id, the same cards and the same refusals.
Testing, performance, conformance
Every promise this guide makes is pinned by a test somewhere, and the same tools are yours: a test-support module with a clock you advance by hand and a network you partition on purpose, a ladder of harnesses from one binder on a local space to three peers over TCP, a performance project kept apart from the shipped artifacts, and a specification whose golden vectors every implementation reproduces byte for byte. The bill of materials manages the versions of all of it.
agentspaces-test-supportTestClockSimNetwork
agentspaces-test-support (stability: API, for tests) ships four things. TestClock is an InstantSource you advance by hand: TestClock.create() or startingAt(instant), then advance(duration), so leases, certificates, settle rules, and card TTLs are judged against an instant the test controls. Everything that takes a clock takes an InstantSource, the Micronaut library takes it as a bean, and the Python and TypeScript peers take an injectable clock the same way. SimNetwork is a deterministic in-JVM transport fabric: register(nodeId) returns a Transport a PeerNode listens and dials on with no socket, attest(nodeId, peerId) makes a channel count as attested for a peer, and partition(a, b) and heal() split the network and mend it, which is how the lease race, the ordered log, and anti-entropy are tested under a partition in milliseconds. TestCa is a miniature certificate authority for the enterprise-CA channel mode: TestCa.create() mints a self-signed CA, issue(identityKeyRaw) issues a leaf that carries an Ed25519 identity key in its subject, and crl(leaves...) issues a CRL that revokes chosen leaves. Fixtures holds shared records (TaskEntry, FindingEntry) and agent(name). Protocol ticks are also yours to count: group.capabilities().detachFromPeerTick().cadence(period) and protocolTick() make each tick an exact step (chapter 12). A tick loop under a test clock must advance the clock, or nothing it judges by time moves.
The harnesses
Four rungs, each catching what the one below cannot; the workflow guide's chapter 13 walks a nine-stage flow up all of them.
| Harness | What it exercises | Example | Use it for |
|---|---|---|---|
A LocalSpace and one binder | the binding's logic: filters, joins, folds, leases under a test clock | the binder's own tests, such as AgentBinderGatherTest | every stage, in milliseconds; no replication, so no lease race and no outrun part |
Three peers on a SimNetwork and a TestClock | replication, the lease race, the ordered log, the vote | AgentBinderJoinClusterTest, AgentBinderReduceClusterTest | the fleet-wide promises: once per key, exactly once, quorum |
| The same under CPU load, with many keys and the debug log captured per key | the timing a test clock hides: a ticket outrunning a part, a lease lapsing under a drain | AgentBinderJoinClusterStressTest with -Djoin.stress=true | any flake in a fleet-wide test; treat one failure in eight as a bug until its timeline says otherwise |
| Three peers over TCP, the real example | the whole flow as deployed | ClaimsFlowTest (example 18), every example's flow test | the acceptance run; ten green runs after any timing change |
Two more rungs span languages: a stub peer with no socket in Python or TypeScript, and a Java coordinator with Python and Node workers in their own processes; the cross-language council is that one. Above the core, integration-tests/spring-boot-it boots the quick start through the starter and integration-tests/embabel-it plans against the real Embabel platform; the Spring AI repository runs context tests for configuration and TCP flow tests for tools, crash recovery, streaming, routing, retrieval, usage, and MCP (Spring AI); the Micronaut library runs context tests that switch most of its features on and off with their properties (Micronaut). The checked-in examples use scripted models and run without provider credentials, so coordination is tested first and a live provider added as a separate check.
Performance: agentspaces-perf
agentspaces-perf is a standalone project on the published artifacts, kept apart so measurement code never leaks into the shipped jars. mvn package writes target/benchmarks.jar with two kinds of harness. The JMH microbenchmarks cover the layers whose per-operation cost multiplies through the whole fabric: CborCodecBenchmark (encode and decode, small records and 16 KiB blobs), CryptoBenchmark (SHA-256, Ed25519 sign and verify, AES-256-GCM seal and open at 256 B, 4 KiB, and 64 KiB), HlcBenchmark (the hybrid logical clock), TemplateBenchmark (matching, the inner loop of read, take, and notify routing), LocalSpaceBenchmark (the verbs at 100 to 10,000 entries), CrdtBenchmark (full-state, per-entry, and take-claim merges), and WireCodecBenchmark (a complete signed frame: encode plus sign, decode plus verify); run them from the jar, since each fork is a fresh JVM. The stress harnesses start a real fleet in one JVM, every peer with its own node, identity, TCP listener, gossip, and replica, and print latency percentiles: FleetThroughputStress (contended takes, write-to-take latency), GossipPropagationStress (time until a write is visible on every peer), and ChurnRecoveryStress (a peer dies, writes continue, a newcomer catches up), each shaped by system properties such as -Dpeers=3 -Dworkers=2 -Dtasks=200. Its mvn test runs each harness at toy scale to prove the fleets start and the reports carry data; it verifies that the harnesses run, and the numbers come from a full run.
Conformance: agentspaces-spec, the golden vectors, and the BOM
agentspaces-spec is the language-neutral half of AgentSpaces, what every implementation claims conformance to. SPEC.md is the normative specification (v0.1.21): identity, peering, discovery, the replicated space, capabilities, the programming model, security. TECH-SPEC.md is what the reference implementation puts on the wire, byte by byte, and which algorithm runs behind each operation; its §1.5 defines conformance and its §10 is the checklist a new implementation follows. golden.json holds the vectors: 197 named byte strings and values, 158 non-adversarial ones that a conforming implementation reproduces exactly (keys, ids, HLC stamps, frames, envelopes, signatures, records, claims, deltas, the founding document) and 39 hostile adv_* inputs it refuses (a transplanted claim, a NaN bid, an eternal lease, deep nesting, a frame for the wrong destination). Each implementation keeps a vendored copy and runs its gate offline: Java at tools/golden/golden.json through GoldenVectorsConformanceTest, AdversarialVectorsTest, SignedAgentVectorsClusterTest, and VoteVectorsConformanceTest (with -Dgolden.required=true); Python at tests/golden.json through test_golden.py and test_adversarial.py; TypeScript at test/golden.json through golden.test.ts. The spec's file is the source and the copies are derived: sync-golden.sh refreshes them, verify-all.sh at the workspace root refuses when any copy differs, and a vector is never edited by hand, since the generator reloads the fixed key material and changes only the vectors whose inputs changed. The model wire records have a gate of their own, wire-golden.properties inside agentspaces-model-wire.
The bill of materials, agentspaces-dependencies, manages the version of every core module (agentspaces-common, -api, -identity, -identity-tls, -auth-oidc, -space, -peering, -transport-quic, -transport-dtls, -transport-http, -transport-webrtc, -discovery, -capabilities, -agent, -a2a, -connect-core, -console, the Spring Boot autoconfigure and starter, embabel-agentspaces, and agentspaces-test-support) and of the third-party libraries the suite is tested against: the Jackson BOM, Caffeine, Nimbus JOSE+JWT, and Bouncy Castle. Import it once, as the quick start shows, and leave the version off every core dependency. The libraries that release from their own repositories (agentspaces-springai, agentspaces-micronaut, agentspaces-langchain4j, agentspaces-model-wire, agentspaces-clj, agentspaces-groovy) carry their version explicitly.
Examples and applications
Every snippet in this guide is cut from a checked-in source, and the sources come in three sizes: numbered examples that add one layer or one idea each, example applications that are complete fleets, and a fleet per framework library. All of them run on a laptop with no API keys, and each has a flow test that drives it end to end, over real TCP except examples 01 and 11, which run on a LocalSpace in one process.
The numbered examples
Sixteen graduated examples under agentspaces/examples, 01 to 14, 18, and 20; mvn -q -pl examples/<name> exec:java runs the live demo, each module's README covers what it shows and how to run it, and examples-guide.html beside them is the narrative walk-through.
| # | example | adds |
|---|---|---|
| 01 | hello-space | LocalSpace: write, read, take, complete, leases |
| 02 | research-fleet | ReplicatedSpace over TCP; closing and reopening a space by name; the cross-language council |
| 03 | discovery-cards | AgentCards and discovery |
| 04 | auction | the AUCTION strategy and @BidFunction, one bidder per space per peer |
| 05 | quorum | aggregate then vote: @Ballot members, an @OnDecision supervisor, a council of seats on one peer counted per granted agent |
| 06 | wan-rendezvous | the rendezvous role across a WAN |
| 07 | a2a-fleet | the A2A gateway over a fleet |
| 08 | intake-fleet | extraction as @SpaceNotify, a @Ballot voter, an @OnDecision auditor |
| 09 | fleet-console | the console and command-and-control |
| 10 | data-fleet | the connector SDK and semantic discovery |
| 11 | quickstart | the whole programming model in one file: the plain-Java form of the quick start |
| 12 | exactly-once-desk | ORDERED takes: OrderedTakes.over(...) and an @OrderedTake clerk |
| 13 | signed-agents | subordinate agent keys, attestation, two annotated agents on one peer |
| 14 | gossip-learning | three local models converge on the fleet mean on the peer tick alone |
| 18 | agentic-workflow | a nine-stage claims flow: fan-out, a duplicate-safe join, an auction, a vote gate, an exactly-once payment, fleet sensing, and entries that mirror an ontology; the workflow guide walks it |
| 20 | enterprise-bootstrap | three seeds behind one DNS name: a worker that knows only the name expands it to every address, dials them all, asks the bootstrap chain again when its view of the group is empty, and keeps the trace of what each locator answered |
Examples 15, 16, 17, and 19 live with the framework libraries, below.
The example apps
Four standalone applications under agentspaces-example-apps, each a complete fleet on the shipped libraries, a self-contained Maven project with its own pom.xml, a run-demo.sh, and a flow test over real TCP, so any of them can be lifted into its own repository as is. Read them in this order; each adds one coordination idea to the one before.
| app | teaches | core feature |
|---|---|---|
agentspaces-petclinic-fleet (start here) | service choreography: a nurse, a scheduler, and two vets coordinate purely by watching one shared space, and the scheduler's slot book is a @SpaceReduce accumulator | @SpaceNotify methods on plain objects |
agentspaces-code-fleet | cost-aware allocation and kill tolerance: a repository's issues become task entries, coding agents bid by expertise and price, review agents take the finished changes, and a worker that dies mid-migration lets its lease lapse | the AUCTION strategy: @BidFunction + @SpaceTake |
agentspaces-audit-fleet | collective decisions with a signed record: a release audited by discipline specialists, every finding adjudicated by a quorum vote of the whole panel | aspace:cap/vote (QUORUM) |
agentspaces-intake-fleet | evidence as ballots and a provenance chain a regulator can walk: content-addressed scans, parallel extractors, a quorum over the signed extractions, human review for the doubtful ones, a signed filing | content addressing + signed entries |
Every agent in them is a plain object with @SpaceNotify, @SpaceTake, @BidFunction, @Ballot, or @OnDecision methods, bound through the facade as the Spring starter binds beans; the intake and code fleets build the facade with an identity factory that calls identity.renewingSubordinate(...) for each agent, and the audit fleet writes its spaces as a renewingSubordinate agent, so each agent signs with a certified key of its own (in the intake and code fleets its @SpaceRef handles are per-agent views too). Larger applications sit beside them. agentspaces-partybus takes the Embabel travel planner and puts it on a bus: thirty-four agents using every coordination type at once, scouts draining a bag of research tasks, planners bidding for each day in an auction, a safety panel deciding advisories by signed quorum, travelers voting and reporting energy the fleet paces the trip from, booking clerks confirming exactly once through the ordered log, and an operator at a command turret; it has its own guide. examples/opscenter is the autonomous operations center flagship: an incident lands on a shared board, specialists claim the parts they understand, a worker dies mid-job and its task comes back, three judges disagree and a quorum decides, a person approves, the irreversible step runs exactly once, and the closed incident carries every piece of evidence signed by the agent that produced it; standalone on the 0.4.0 artifacts, Spring AI 2.0, and Embabel 1.5 under a profile, with ./run-demo.sh on scripted models and OPENAI_API_KEY for live ones. Three more applications live under examples/. superfriends has Grok, GPT, and Claude arrive on three different Java frameworks and join one fleet that shares a model service, a chat memory, and a usage ledger over the model wire, then run a research desk where the three models vote and the paper trade runs once on an ordered log. browser-council is a committee of senators voting on motions over a fleet whose members run in two Java peers, a web page, a resident browser tab, a Node process, and a Durable Object on workerd, with every decision minuted once through an ordered log that loses its leader mid-session. agentspaces-springai-jev-eg drafts a customer reply with Claude Haiku on a peer that holds no API key, through a model server that does, has TypeSafe Jev judge the draft on another peer, and sends a failed draft back as a new ticket.
The framework fleets
Each framework library ships its own fleet, in its own repository. agentspaces-springai/examples/springai-fleet (example 15) runs a summarizer, a translator, and an orchestrator whose model calls them as tools, as three Spring Boot applications; springai-patterns beside it holds the judges, the token-priced auction, and the extraction ensemble of the Spring AI chapter. agentspaces-micronaut/examples/micronaut-fleet (example 16) is an orders service that is both an agent and a data source, and an analyst that drives it through the task space, the data space, and @Client("orders") resolved through the fleet; micronaut-ai-fleet (example 17) adds a LangChain4j worker, a keyless orchestrator, and an MCP gateway on three peers. agentspaces-langchain4j/examples/langchain4j-fleet (example 19) is the same worker and orchestrator with no application framework at all, with a flow test that leaves a worker stuck mid-task and watches another finish it.
SPEC.md and TECH-SPEC.md are the deep end.Other languages
The wire protocol is CBOR plus Ed25519 (wire version 3, enforced; every map a signature covers is encoded with its entries in canonical order, shorter UTF-8 keys first and bytewise between equal lengths, so the signed bytes of a structure depend on its content alone in every language) with nothing Java-specific, and the project ships wire-compatible Python and TypeScript peers proven byte-identical against shared golden vectors that cover every wire structure, refusing the same hostile inputs as the Java implementation. Both join by seed or by GroupId with the founding document verified, write signed entries, take under the claim lattice, and read through anti-entropy.
Both also speak the agent-identity model of agent certificates: they verify agent certificates at signing time, apply the two-key rule to records, take-claim proofs, and state transitions (an attested agent's transitions must be its own), apply peer, agent, and agent-key revocations at merge, can write, take, and complete as a renewing subordinate agent themselves (renewing_subordinate / renewingSubordinate, with a keystore in Java's layout), carry agentPublicKey, declared actions, and the agent's certificate on the cards they build, and tally QUORUM ballots at either granularity (agentspaces.vote, vote.ts); their Peer takes an injectable clock so time-bound material is testable against a fixed instant, as Java's TestClock is. run-trilingual-demo.sh races workers in all three languages on one space, and the cross-language council is the test that the layers above the wire agree. Clojure and Groovy sit on the JVM and consume the Java artifacts directly, so each is a binding over the reference implementation and gets the wire for free.
Both clients carry the AgentSpace and the capability services (the annotations, the Space API, the capability services) in their own idiom rather than as a port of the Java annotations (the design and record are in L3L4-COVERAGE.md). An @entry class names its wire schema (a Java record's binary class name plus #v1 under the default registry, or the namespace a schema registry assigns); a Space holds write, read, read_all (readAll in TypeScript), entries (the metadata view: id, tags, issuer, lease, whether taken), take, complete, and watch; a Template carries the Java matchers (eq, ne, gt, gte, lt, lte, and contains; Python spells the rest in_, is_null, and not_null, TypeScript oneOf, isNull, and notNull; or any function) over fields and tags; a watch delivers WRITTEN, TAKEN, COMPLETED, EXPIRED (the write lease lapsed on an open entry), and REAPPEARED (a take claim lapsed and the entry is available again) once per entry and kind, in TypeScript as an EventEmitter or an async iterator; the in-flight take is a context variable (TakeContext.current(), over AsyncLocalStorage in Node).
The binder runs the loops AgentBinder runs, over decorated methods in each language's own form (below). The return conventions are the Java binder's: None or null writes nothing, a Tagged is written with its tags, a list or array is the fork, a Motion opens a vote once per proposal id; the take completes before a fork or a motion runs. The AgentCard, with its declared actions and the agent's certificate, is published on bind. The layer-4 pipes (PIPE_DATA frames addressed to a member) carry push-sum (Aggregate: avg, roster sum and count, min, max, Settle, await_settled, on_estimate), semantic discovery (Semantic, with the hashing embedder computing Java's vectors to the bit so the two sides rank alike, query, remote_query, and the answer to a peer's query), and remote actions (actions from the cards on the ads stream, invoke through the task space with the shared-fields correlation). A dial-only peer sends a capability frame only to a member it can reach, the seed it dialed, never to one it only heard of: the seed drops rather than relays an addressed frame. The ordered log reaches both clients as a Raft client; the voter runs on the JVM and, as @agentspaces/ordered, on a Node service. TypeScript binds it with @orderedTake, @spaceReduce, and the LEASED and ORDERED modes of @spaceJoin, and the residency of the host decides which of them it may carry (19.6). Python takes through the log with Space.take(template, through=OrderedLog(Pipes(peer))) and has no decorators for the ordered take, for @SpaceReduce, or for the fleet-wide join modes, so @space_join runs in LOCAL mode only. MAJORITY_GOSSIP is in both clients: cast_preference and leader in Python, castPreference and leader in TypeScript.
19.1 Python
agentspaces-python decorates plain methods: @agent on the class, then @space_take, @space_notify, @bid_function, @ballot, @on_decision, @on_estimate, @propose, and @space_join on methods, with the cue read from the parameter annotation and the attributes as keyword arguments in snake case (lease, where with the matchers, prefix, parts as a list of Part). An @entry class names its wire schema. Binder(peer, agent=identity.renewing_subordinate(name, ttl)) binds as a certified agent; Aggregate(Pipes(peer)), Semantic, and the remote actions sit on the capability pipes; the in-flight take is a context variable, TakeContext.current().
@entry("ai.badmonkey.agentspaces.examples.fleet.ResearchFleet$ResearchTask#v1")
@dataclass
class ResearchTask:
topic: str
priority: int
@agent("researcher", description="Researches topics", goals=["research"])
class Researcher:
@space_take("tasks", lease="30s", where={"priority": gte(1)}) # take, call, complete with the result
def research(self, task: ResearchTask) -> Finding: ...
@ballot("votes", prefix="council:") # one ballot per proposal, None abstains
def judge(self, proposal: Proposal) -> str | None: ...
@space_join("intake", key="claim_id", parts=[Part(Fraud), Part(Coverage)])
def assemble(self, j: Joined) -> Assessment: ... # once per key, LOCAL mode
bound = Binder(peer, agent=identity.renewing_subordinate("py-worker", "PT24H")).bind(Researcher())
aggregate = Aggregate(Pipes(peer)); aggregate.start("load", 12.5); aggregate.run()Finding the fleet. A client dials one seed and gossips from there, so the only question is which address. peer.connect_any(seeds) dials a list in order. peer.bootstrap(url, group_id=..., beacon_peer=...) reads a beacon document instead: it fetches the document (HTTPS unless allow_plain), verifies the signature, pins the signer when beacon_peer is given, checks the group's signed founding against group_id, and dials the document's tcp seeds in priority order, fetching the document again when it has to reconnect. It needs a founding, so it joins a group that has one. For a group started from a text, derive the id with group_id_from_founding and take the seeds from the document yourself with beacon.fetch_beacon_document, group_from_document, and seeds_of.
# a group with a signed founding: the id and the document's URL are all you need peer = Peer(identity, group="aspace://q9c2b...") url = "https://partner.example/.well-known/agentspaces/beacon.json" founding = peer.bootstrap(url, group_id="aspace://q9c2b...", beacon_peer="z6Mk...") # a group started from a text: the id from the text, the seeds from the document peer = Peer(identity, group=group_id_from_founding(b"research-fleet-v1")) url = "https://agentspaces.corp.example/.well-known/agentspaces/beacon.json" doc = beacon.fetch_beacon_document(url) group = beacon.group_from_document(doc, name="research-fleet") peer.connect_any(beacon.seeds_of(group))
agentspaces-python/agentspaces/space.py, agents.py, vote.py, and capabilities/; the README's "Layers 3 and 4" section; the socketless tests tests/test_layer34.py, test_layer4.py, and test_verbs.py; the golden gate tests/test_golden.py and test_adversarial.py; demo_council_worker.py for a councillor in the cross-language council.19.2 TypeScript
agentspaces-typescript uses stage-3 decorators with the cue type as the first argument, since TypeScript keeps no parameter types at runtime: @agent, @spaceTake(Type, space, options), @spaceNotify, @bidFunction, @ballot, @onDecision, @onEstimate, @propose(Type, space, voteSpace, options, quorum, more), and @spaceJoin, with produces named explicitly where the card cannot read a return type. A watch is an EventEmitter or an async iterator (space.events(template)), the in-flight take rides AsyncLocalStorage, and new Binder(peer, { agent: identity.renewingSubordinate(name, ttl), aggregate }) binds as a certified agent with the aggregate the @onEstimate methods watch.
@agent("researcher", { description: "Researches topics", goals: ["research"] })
class Researcher {
@spaceTake(ResearchTask, "tasks", { lease: "30s", where: { priority: gte(1) }, produces: Finding })
research(task: ResearchTask): Finding { ... }
@propose(Claim, "claims", "votes", ["approve", "deny"], 2, { prefix: "claim:", key: "claimId" })
ask(claim: Claim): string | null { ... } // one vote per key
@onEstimate({ epochs: "load", ticks: 2, tolerance: 0.01, resultSpace: "reports", produces: Report })
settled(estimate: Estimate): Report { ... }
}
const bound = new Binder(peer, { agent: identity.renewingSubordinate("ts-worker", "PT24H"), aggregate }).bind(new Researcher());
for await (const e of space.events(new Template(Finding, {}, { region: "eu" }))) { ... }Finding the fleet. peer.connectAny(seeds) dials a list in order, and peer.bootstrap(url, { groupId, beaconPeer }) reads a beacon document: it fetches the document with the runtime's fetch, verifies the signature, honors the pinned signer, checks the group's signed founding against groupId, and dials the document's seeds of every scheme the peer dials (a browser dials ws or wss, so a document whose seeds are all tls names nothing a browser can reach). It needs a founding. For a group started from a text, derive the id with groupIdFromFounding, a top-level export, and take the seeds from the document with beacon.fetchBeaconDocument, beacon.groupFromDocument, and beacon.seedsFor, since the package exports the beacon helpers only as the beacon namespace.
// import { beacon, groupIdFromFounding } from "@agentspaces/client"; // a group with a signed founding: the id and the document's URL are all you need const url = "https://partner.example/.well-known/agentspaces/beacon.json"; const group = await peer.bootstrap(url, { groupId: "aspace://q9c2b...", beaconPeer: "z6Mk..." }); // a group started from a text: groupIdFromFounding("research-fleet-v1") is the // id, and the document supplies the seeds const home = "https://agentspaces.corp.example/.well-known/agentspaces/beacon.json"; const doc = await beacon.fetchBeaconDocument(home); const entry = beacon.groupFromDocument(doc, { name: "research-fleet" }); await peer.connectAny(beacon.seedsFor(entry, ["wss"]));
agentspaces-typescript/src/space.ts, agents.ts, vote.ts, and capabilities/; the README's "Layers 3 and 4" section; the socketless tests test/layer34.test.ts, layer4.test.ts, and verbs.test.ts; the golden gate test/golden.test.ts; demo/council.ts for a councillor in the cross-language council.19.3 Clojure: defagent and the capability namespaces
agentspaces-clj (github.com/badmonkeyai/agentspaces-clj, on Clojars as ai.badmonkey.agentspaces/agentspaces-clj {:mvn/version "0.4.0"}) gives idiomatic bindings outside the Maven reactor, consuming the published artifacts by coordinates: maps in and out over record entry types, a keyword :where DSL, and fleet/start from a config map that mirrors the Spring starter's properties key for key.
agentspaces.agents/defagent defines an agent as one form: the spec map and one clause per verb (take, notify, bid, ballot, on-decision, ordered-take, on-estimate, propose, join, reduce), each with the parameter types, the annotation's attributes in kebab case (nested @Part maps under :parts, :mode as :local, :leased, or :ordered), and a return hint. The macro generates the annotated interface (the card and remote callers read the declared types there) and a constructor reifying it, so agents/bind! hands the object to the Java binder unchanged and every annotation binds. fleet/start takes a :capabilities block (:vote, :aggregate, :ordered with its members, :semantic, :learn), and the namespaces vote, aggregate, ordered, semantic, learn, and remote are the procedural forms over the same capabilities; core/template takes :tags and space/read-entries is the metadata view.
(defagent Researcher
{:description "Researches topics" :goals ["research"]}
(take research [^ResearchTask task] {:space "tasks" :lease "30s" :tags ["region=eu"]}
^Finding (Finding. (:topic task) (str "researched " (:topic task))))
(ballot judge [^VoteCapability$Proposal p]
{:space "votes" :prefix "council:"} ^String (identity "yes"))
(join assemble [^Joined j] {:space "intake" :key "claimId" :mode :leased
:parts [{:value Fraud} {:value Coverage}]}
^Assessment (Assessment. (.key j) "pay")))
(def system (fleet/start {:bind "127.0.0.1:7461" :agents true
:capabilities {:vote {:space "votes"} :aggregate true}
:group {:name "claims" :founding "claims-v1" :spaces [{:name "tasks"} {:name "votes"}]}}))
(def bound (agents/bind! system (->Researcher)))
(vote/propose! system {:id "council:ship" :question "ship?" :options ["yes" "no"] :quorum 2})
(aggregate/await-settled system "load" {:ticks 3 :tolerance 0.01})19.4 The cross-language council
CouncilFleet (example 02) is the proof that the layers above the wire agree across languages: a Java coordinator on TCP with the task and vote spaces, discovery, and push-sum, votes, and semantic discovery on one set of pipes; demo_council_worker.py and demo/council.ts bind a decorated councillor in each client. The coordinator writes tasks, proposes council:ship at a quorum of every member, starts the load epoch, and waits: both workers take tasks, the vote decides at three ballots, the epoch settles at the mean of the three contributions, a semantic query over the pipe ranks both workers' cards, and a remote action a worker's card declares is served. CouncilCrossLanguageTest runs it with the two workers in their own processes (a step of verify-all.sh), and run-trilingual-demo.sh plays it as its second act after the research race.
19.5 Groovy: one-file peers, the two DSLs, and await
agentspaces-groovy (a standalone project on the published 0.4.0 artifacts; Apache Groovy 6.0.0, Java 21 bytecode) sits where Clojure does: on the JVM, over the Java binder, with nothing reimplemented. A Groovy class carrying the Java annotations, dynamic or @CompileStatic, already binds through FleetRuntime.bind, the Spring Boot starter, or the Micronaut library; the project tests that floor and builds on it. Groovy records are Java records, so a Java peer names and builds the same entry type.
The one-file peer. FleetScript is a script base class: fleet { } starts the peer, agent(name) { } describes a worker as one closure per verb (take, notify, bid, ballot, onDecision, orderedTake, onEstimate, propose, join, reduce), bind hands it to the Java binder, and serve() blocks until SIGINT or stop(). The agent DSL writes a Java interface at run time, one annotated method per clause, compiles it once per shape, and binds a proxy over it; the binder reads the annotations through the interface exactly as it reads a Clojure reify. The fleet DSL's keywords are the starter's property names, camel-cased, emitted as the kebab-case tree the runtime's loader binds, with two aliases and no other sugar (tick '250ms' for tick-millis; the singular group and space add one list element). @Grab is a development convenience: pin the version, and ship a jar in production.
@Grab('ai.badmonkey.agentspaces:agentspaces-groovy:0.4.0')
import ai.badmonkey.agentspaces.examples.fleet.ResearchFleet.ResearchTask
import ai.badmonkey.agentspaces.examples.fleet.ResearchFleet.Finding
@groovy.transform.BaseScript ai.badmonkey.agentspaces.groovy.FleetScript _
fleet {
bind '127.0.0.1:7591' // agentspaces.bind
security { profile 'dev-local' } // agentspaces.security.profile
group('research-fleet') { // agentspaces.groups[0]
founding 'research-fleet-demo-v1'
seeds '127.0.0.1:7590'
space 'tasks'
}
}
bind agent('summarizer', description: 'Summarizes research tasks') {
take(ResearchTask, space: 'tasks', lease: '30s', returns: Finding) { ResearchTask t ->
new Finding(t.topic(), "summary of ${t.topic()}", 'groovy')
}
}
serve() // until Ctrl-C or stop()The class form, checked at compile time. FleetChecker is a type-checking extension for @TypeChecked and @CompileStatic code: a where entry that names no component of the cue record, a lease that does not parse, a @Ballot that returns anything but String, a DSL closure whose parameter disagrees with the cue, or @Memoized and @ActiveMethod beside a verb fail to compile with the binder's own words. It also lets the fleet DSL compile statically. Prefer the class form for anything that ships in a jar or runs under a host; the DSL for scripts, tests, and agents assembled at run time. Both publish the same card.
@CompileStatic(extensions = 'ai.badmonkey.agentspaces.groovy.FleetChecker')
@AgentSpec(name = 'pricer', description = 'Prices an order', goals = ['price orders'])
class Pricer {
@SpaceTake(space = 'tasks', lease = '10m', where = ['priority=1'])
Quote price(PriceRequest r) { new Quote(r.item(), r.quantity(), 4.25d * r.quantity()) }
@Ballot(space = 'votes')
String judge(VoteCapability.Proposal p) { p.question().contains('ship') ? 'approve' : null }
}
def runtime = Fleet.start(Path.of('agentspaces.yaml'))
runtime.bind(new Pricer())The Space API, in Groovy. An extension module adds the verbs with named arguments and the where: map DSL (a plain value is eq; anyOf stands for the Java in, a Groovy keyword); durations and leases coerce from strings ('10m' as Lease); records come from maps and go back (Task.from(map), record.toMap()). The filter closure of read, all, entries, and notify runs after the template matched, on this replica only; a take claim is judged by the fabric's template, so take takes where: and tags: and no filter closure. Every blocking verb and client wait has an Async twin returning a CompletableFuture on a virtual thread, which Groovy 6's await takes unchanged (await binds looser than member access: write (await x).size()). The incubating groovy.concurrent toolkit lives in a second artifact, agentspaces-groovy-concurrent: a channel fed by one subscription for for await, broadcast, select recipes, dataflow twins; closing a channel unsubscribes, and a channel closes itself when its lease would lapse. FleetShell opens groovysh over a running peer with fleet, spaces, space(name), and agent bound; it refuses a configuration that serves the console or the A2A gateway.
import static ai.badmonkey.agentspaces.groovy.Where.*
space << entry // the space's default lease (10m; space.defaultLease = '2h')
space.write(entry, lease: '10m', tags: [region: 'eu'])
space.read(Task, where: [priority: gte(2), topic: anyOf('a', 'b')], tags: [region: 'eu'])
space.read(Task) { priority > 2 } // a filter over the decoded entry, on this replica
space.all(Task, limit: 50) // an unmodifiable List: GINQ-ready; toSorted, never sort in place
space.take(Task, lease: '10m') { Task t -> new Quote(...) } // take, run, complete; null completes without a result; a throw lets the lease lapse
space.notify(Task, lease: '1h', on: 'written') { SpaceEvent e -> ... }
votes.propose(id: 'm-1', question: 'ship?', options: ['yes', 'no'], quorum: 2)
Optional<Task> first = await space.readAsync(Task, where: [topic: 'x']) // every blocking verb has an Async twin
def decision = await votes.decisionAsync('m-1')
for await (e in space.channel(Finding, lease: '1h')) { println e.entry().summary() } // agentspaces-groovy-concurrentExample 21 (agentspaces-groovy/examples/groovy-fleet) runs a script worker and a CSV-to-report intake script on example 02's research fleet beside its Java peers, with a statically compiled planner, and a Java RemoteActions caller invoking the script's card. The Groovy constructs that meet the binder are tested one per row: a map coerced to an annotated interface binds, a trait's annotated method binds through the implementing class, @Delegate to an annotated interface exposes the verb, a closure property is not a verb, @Memoized binds but caches per equal cue, and a Java map renders Groovy-style inside a GString.
19.6 JavaScript everywhere: residency, the browser, Node, and the stores
From 0.4.0 the TypeScript client is a portable core (srad/agentspaces-js.md): its main entry imports no Node module, crypto runs on the noble libraries, bytes are Uint8Array, and a Platform port supplies the clock, timers, key store, and residency, so the same Peer loads in a page, a Web Worker, a Cloudflare Worker, Deno, Bun, and Node 22. It dials ws, wss, http, and https with the runtime's own WebSocket and fetch, and tcp on Node (import "@agentspaces/client/node"). The fleet verbs are all there: leased push subscriptions, fold-driven takes, the filter grammar (priority>=3, region in eu,us, identical in Java, Python, and TypeScript), MAJORITY_GOSSIP, the Raft client, @orderedTake, the LEASED and ORDERED @spaceJoin modes, and @spaceReduce. What a host may carry is its residency:
| Residency | Host | Carries | Package |
|---|---|---|---|
visitor | a page, a short-lived script | watches, takes, @spaceNotify, @orderedTake (as a Raft client), LOCAL joins, gossip shares, the store adapters | @agentspaces/client |
resident | a SharedWorker or Worker holding a Web Lock | the above plus the keeper duties: LEASED and ORDERED joins, @spaceReduce | @agentspaces/browser |
cell | a Durable Object on celld, workerd, or Cloudflare | keeper and connector duties | @agentspaces/workers |
service | a Node process | everything: listeners, relay, the Raft voter, the ordered-log coordinator | @agentspaces/node, @agentspaces/ordered |
Binding a verb the residency cannot host fails at bind time naming the verb, the residency, and the package that can; an unknown class ranks as a visitor; a host reports its class as the hint js.residency, which peers show but never trust. A host whose tick drifts beyond twice its period three times running drops to visitor duties (voters suspend, keepers close, held claims lapse) and takes them back after sixty seconds of cadence.
A browser peer. A page connects to the resident host; the host runs the peer once per identity per origin (the Web Lock agentspaces:<group>:<peerId>), keeps keys and a state snapshot in IndexedDB, stops renewing and closes its socket when the last page hides or freezes, and resumes with the same identity when one returns. Two tabs are one member with one connection.
// the page: @agentspaces/browser const host = await connect({ group, seeds: ["wss://fleet.example:7443"], worker: "/agentspaces-worker.js" }); const tasks = host.space("tasks"); for await (const e of tasks.events(new Template(Finding, {}, { region: "eu" }))) { ... } const taken = await tasks.take(new Template(ResearchTask, {}, {}, ["priority>=3"]), "30s");
A Node service. agentspaces-node run fleet.yaml reads the agentspaces: subtree of the same fleet file a Java member reads, listens on tcp and ws, dials its seeds, serves anti-entropy and relays rumors for the browsers behind it in Java's own digest formats, ticks once a second, and drains on SIGTERM. Give a group an ordered-logs block and it hosts a Raft voter (@agentspaces/ordered) byte-identical to the Java one, with its term, vote, and entries in a file store: a desk of two Java voters and one Node voter completes every job exactly once when the leader dies, in either language.
# fleet.yaml: Java and agentspaces-node read the same file; only Node binds ordered-logs agentspaces: keystore: keys bind: 0.0.0.0:7500 transport: ws: { bind: 0.0.0.0:7501, allowed-origins: [ "https://app.example" ] } groups: - name: council founding: council-v1 seeds: 10.0.0.5:7460 spaces: [ tasks, votes ] ordered-logs: - { space: tasks, members: [ zJavaVoterA, zJavaVoterB ], log-dir: log/tasks }
The store adapters. An application on automerge-repo or Yjs changes one constructor argument: new Repo({ network: [new AgentSpacesNetworkAdapter(pipes)] }) or new AgentSpacesProvider(pipes, "room", doc). Members of the group become the library's peers under their PeerIDs, sync messages travel as signed PIPE_DATA frames (aspace:cap/automerge, aspace:cap/yjs), and the document formats are untouched; AgentSpaces adds signing, membership, and relay. Both run at the visitor residency and need no ordered log.
Python. The Python client has most of these verbs (leased notify, fold-driven takes, filters=, MAJORITY_GOSSIP, the Raft client through Space.take(..., through=log)) and the connector clients, on the same golden vectors. It has no @orderedTake, @spaceReduce, or LEASED and ORDERED join decorators, which TypeScript has, and it stays a tcp dialer that skips the other schemes.
agentspaces-typescript/: the core in src/, the hosts and adapters as workspace packages under packages/ (node, ordered, browser, connect, workers, automerge, yjs, webrtc), each with a README whose requirement-to-test map names the tests; the Java side in agentspaces-transport-http and agentspaces-connect-core. The conformance subset runs in Chromium, Firefox, WebKit, Deno, Bun, and workerd; a suite skips an engine that is not installed unless AGENTSPACES_RUNTIMES_REQUIRED=1 (Deno, Bun, workerd) or AGENTSPACES_BROWSERS_REQUIRED=1 (the browsers) is set.