Coordination without a coordinator.
AgentSpaces is a peer-to-peer fabric. It gives you a replicated tuple space and a framework for coordination services on top of it. Peers join groups, advertise what they can do, find each other by capability, and trade work, information, and decisions through shared, leased entries. No broker sits in the middle.
You write a plain class. One annotation turns a method into a fleet worker: its parameter type says what it takes, and its return value is the next entry. The same annotations work in plain Java, in Spring Boot, and alongside Spring AI and Embabel.
- Takes under a lease. One worker wins each order fleet-wide. If that worker crashes, the lease lapses and the order comes back for another.
- Reacts without consuming. Every auditor sees every shipment once, even when the network delivers it twice.
- Signs and replicates. Every entry is signed by its writer and copied to every peer in the group.
- Publishes a card. The agent's name and the types it consumes and produces go out as a leased, signed AgentCard.
// examples/example-11-quickstart -- Quickstart.Fulfiller
@AgentSpec(name = "fulfiller",
description = "Ships orders from the shared space",
goals = {"fulfill orders"})
public static class Fulfiller {
@SpaceRef
private Space space; // the group's sole space, injected
@SpaceTake(lease = "10m", pollTimeout = "300ms")
public Shipment ship(Order order) {
space.write(new Progress(order.orderId(),
"picking " + order.item()),
Lease.of(Duration.ofMinutes(5)));
return new Shipment(order.orderId(), order.item(),
"fulfiller");
}
}
// examples/example-11-quickstart -- Quickstart.Auditor
@AgentSpec(name = "auditor",
description = "Writes a receipt for every shipment",
goals = {"account for shipments"})
public static class Auditor {
@SpaceNotify // react to X, produce Y
public Receipt account(Shipment shipment) {
return new Receipt(shipment.orderId(),
shipment.item() + " shipped by " + shipment.by());
}
}
// examples/example-11-quickstart -- the entries are plain records
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) {}
// examples/example-11-quickstart -- Quickstart.main: two peers, one space
workerPeer.binder().bind(new Fulfiller());
auditorPeer.binder().bind(new Auditor());
workerPeer.space().write(new Order("ord-1", "kite"),
Lease.of(Duration.ofMinutes(10)));
workerPeer.space().write(new Order("ord-2", "compass"),
Lease.of(Duration.ofMinutes(10)));
// The auditor peer's receipts replicate back to the worker's peer.
receipts = workerPeer.space().readAll(Template.of(Receipt.class), 10);
the formal versionAgentSpaces provides a decentralized, cryptographically verifiable coordination fabric in which autonomous peers discover capabilities and coordinate information, work, and decisions through leased, signed, replicated tuple spaces, while permitting each workload to select the minimum consistency and coordination strength required by its mission.
Thirty years of protocol evolution, in one fabric.
Brought to you by Bad Monkey, whose cofounders have spent the past 30 years leading advancements in telecom, event processing systems, ontology implementations, and lightweight enterprise software development using Java, Spring, Clojure, Python, and more.
As Bell Labs alumni, we reviewed key advancements in protocol and signaling technology, from early P2P frameworks like Jini and JXTA to modern distributed systems.
AgentSpaces resolves their historical limitations by combining the 30 years of protocol evolution we have worked through into a modern, developer-friendly architecture.
Flexible security and networking
Pluggable discovery, relay, and rendezvous, with traditional TLS alongside end-to-end encryption (similar to Signal).
Every message is signed CBOR. Payload encryption uses the primitives Signal uses: Ed25519, X25519, HKDF, and AES-256-GCM. Peers behind NAT connect through relay and rendezvous peers.
Simple coordination
Easy-to-use replicated tuple spaces backed by robust coordination APIs.
Write, read, take, and watch typed entries. Every entry carries a lease, so anything nobody renews goes away on its own. Take is exclusive: if the taker dies, the entry reappears.
Extensible P2P fabric
Powered by an optimized gossip algorithm, with modular capabilities for Raft consensus, quorum building, voting, and more.
The first set of capabilities: bidding on tasks, voting in the group, reacting to decisions, exactly-once work through a Raft log, fleet-wide aggregates, gossip learning, semantic discovery, and key distribution.
Pick the weakest guarantee that does the job.
Each workload chooses how strongly it coordinates. Stronger guarantees cost more latency and less availability, so the cost is listed with each step. Use several spaces the way you would use several queues.
@SpaceNotify / @SpaceTake
Plain choreography and the LEASE_RACE take: the first claim wins after one settle window.
@BidFunction
An AUCTION space: every take carries a bid, and the cheapest agent wins the entry.
@Ballot / @OnDecision
QUORUM voting: distinct, signed voters, one ballot each, and one reaction when the vote closes.
@OrderedTake
Takes committed through a Raft log. Each entry completes exactly once, in one total order.
Five layers, numbered the way the spec numbers them.
Each layer depends only on the ones beneath it. Annotations appear from Layer 2 up; below that, you configure the node once and leave it alone. Select a layer to jump to its section.
Capabilities
Coordination services: auction, vote, ordered log, aggregate, learning, semantic discovery, key wrap.
Space
The replicated tuple space: a delta-CRDT replica, leases, templates, and conflict strategies. This is the layer you meet first.
Discovery
Signed, TTL-leased cards for peers, groups, spaces, agents, assets, and capabilities, in a cache that gossip keeps fresh.
Peering
SWIM membership, rumor and anti-entropy gossip, rendezvous and relay roles, and TCP, TLS 1.3, QUIC, and loopback transports.
Identity
Ed25519 keys, self-certifying PeerIds and GroupIds, per-agent certificates, and payload encryption.
Identity: every record says who wrote it.
A peer is an Ed25519 keypair, and its PeerId is the hash of the public key. The identity stays the same across every address and transport change, and the peer signs every frame, card, and entry it sends.
- Self-certifying groups. A GroupId is the hash of a founder-signed founding document. A newcomer who knows only the id can check any seed's answer against it, so a hostile seed can delay a join but can't substitute a different group.
- Per-agent keys, when you need them. By default the peer signs for its agents (
PEER_ASSERTED). For an audit trail that must hold this agent to a finding, the peer certifies a key of the agent's own (AGENT_ATTESTED). - Encryption from well-known primitives. Entry payloads are sealed with AES-256-GCM under a group content key. New members receive that key sealed to their own X25519 key (X25519 + HKDF + AES-256-GCM), so it never crosses the wire in the clear. These are the same building blocks Signal uses.
- Annotations stay unchanged. Switch on per-agent keys and
@SpaceReffields arrive as that agent's signed view. The agent code itself doesn't change.
// examples/example-13-signed-agents -- SignedAgents
@AgentSpec(name = "auditor", description = "Audits releases", goals = {"audit"})
public static final class Auditor {
@SpaceRef(FINDINGS)
Space findings; // arrives as this agent's own signed view
public void conclude(String subject, String verdict) {
findings.write(new Finding(subject, verdict), FINDING_LEASE);
}
}
// startAnnotatedPeer(...): the starter does exactly this from
// agentspaces.identity.agent-keys=subordinate.
AgentSpaces spaces = new AgentSpaces(identity, InstantSource.system(), identity::subordinate);
AgentSpaces.GroupContext group = spaces.register("signed-agents", joined.runtime().id(),
joined.runtime(), null);
group.space(FINDINGS, findings);
AgentBinder.Bound auditorBound = group.bind(auditor);
AgentBinder.Bound clerkBound = group.bind(clerk);
// ledger(...) and describe(...): who said it, and how strongly
reader.findings().readAllIssued(Template.of(Finding.class), 100);
issued.issuer().encoded() + " (" + issued.attestation() + ")";
Peering: join a group, stay a member by answering.
Membership is SWIM-style probing with a lease: a member that goes silent drops out of the view on its own. State travels on two gossip channels, rumor for speed and anti-entropy for completeness, in signed CBOR envelopes.
- Rendezvous. Any member can volunteer as a
RENDEZVOUSpeer, which holds a bigger discovery cache and is the one address newcomers need to know. Example 06 runs two sites that know only the rendezvous address. - Relay. A
RELAYpeer forwards signed frames unchanged for peers behind NAT. It can't alter them, because the signature covers the bytes. Correctness never depends on either role. - Admission. Groups admit members by policy:
OPEN,INVITE(with a founder-signed credential), orPOLICY(your own validator). - Transports. In-JVM loopback for tests, TCP, TLS 1.3, and QUIC. A peer advertises its endpoints in priority order and dialers try them in turn. On a LAN, an opt-in multicast beacon replaces seed addresses entirely.
// examples/example-06-wan-rendezvous -- WanFleet.startPeer(...)
PeerIdentity identity = PeerIdentity.generate();
PeerNode node = PeerNode.builder(identity)
.roles(rendezvous ? Set.of(PeerAdvertisement.PeerRole.RENDEZVOUS) : Set.of())
.build();
node.listen(new TcpTransport(), HOST + ":" + port);
List<PeerAdvertisement.Endpoint> seeds = seedPort == 0 ? List.of()
: List.of(new PeerAdvertisement.Endpoint("tcp", HOST + ":" + seedPort, 0));
GroupRuntime runtime = node.joinGroup(group(),
new GroupMembership.Config(Duration.ofSeconds(30), Duration.ofSeconds(2), 2),
seeds);
CborCodec codec = CborCodec.defaultCodec();
DiscoveryService discovery = new DiscoveryService(runtime,
new AdCache(codec, InstantSource.system()), codec, identity.peerId());
ReplicatedSpace tasks = ReplicatedSpace.builder(runtime, "site-tasks", identity, name)
.settleWindow(Duration.ofMillis(150))
.build();
node.startTicking(Duration.ofMillis(250));
Discovery: who can do what, as signed cards.
Everything you can discover is a typed, signed card in a per-group cache that gossip keeps fresh. When a card's issuer stops republishing it, the card ages out. Agents publish their cards just by being bound.
@AgentSpecnames the agent and describes it for humans and for semantic search. The binder reads the consumed and produced types from the method signatures and puts them on the card too.@SpaceAgentis the Spring version: a@Componentthat is also an@AgentSpec. Component scanning finds it, and the starter enrolls it in the fleet. You can compose your own stereotypes the same way.findscans the local cache in memory, so call it as often as you like.remoteFindasks rendezvous peers first and then uses scoped gossip. It blocks, so keep it off hot paths.- Most fleets never look anything up. Work reaches its worker because the worker is taking that entry type. Discovery is for knowing who is out there: consoles, planners, and dispatchers that should fail loudly when nobody is listening.
Guide: Discovery and capabilities · @SpaceAgent
// examples/example-03-discovery-cards -- CardsFleet
// Each card carries the name, description, and goals from @AgentSpec, and the
// consumed and produced types from the method signature.
@AgentSpec(name = "summarizer", description = "Summarizes documents", goals = {"summarize"})
public static class Summarizer {
@SpaceTake(space = "work", pollTimeout = "PT0.3S")
public Summary summarize(SummaryTask task) {
return new Summary(task.document(), "summary of " + task.document());
}
}
@AgentSpec(name = "translator", description = "Translates text", goals = {"translate"})
public static class Translator {
@SpaceTake(space = "work", pollTimeout = "PT0.3S")
public Translation translate(TranslateTask task) {
return new Translation(task.text(), task.language(),
"[" + task.language() + "] " + task.text());
}
}
// examples/example-03-discovery-cards -- CardsFleet.main
summarizerPeer.binder().bind(new Summarizer());
translatorPeer.binder().bind(new Translator());
Thread.sleep(1500); // cards gossip through the group
String summarySchema = Summary.class.getName() + "#v1";
List<AgentCard> summarizers = dispatcher.discovery().find(AgentCard.class,
card -> card.produces().contains(summarySchema));
// "no static wiring: the cards were the routing table."
Space: shared memory that forgets on purpose.
A replicated tuple space built as a delta-CRDT. Reads run against the local replica and never wait on the network. Every entry carries a lease. A take is exclusive under the space's conflict strategy, and the taker must complete before its take lease lapses, or the entry comes back.
@SpaceTakefor work that should happen exactly once per entry. It retries until the work succeeds: a crash, a kill, or an exception lets the lease lapse, and the entry comes back.@SpaceNotifyfor reactions every interested agent should see. It never retries: a reaction that throws is logged and dropped. Put work that must happen behind a take.@SpaceRefwhen one trigger produces several entries, or entries in other spaces. Combine it with Spring's@Scheduledfor a periodic agent.- No Spring needed. Create an
AgentBinder, register spaces, and callbind. The starter automates the same three lines.
Strategies are chosen per space: LEASE_RACE (the default) or AUCTION. Exactly-once uses the ordered-log capability in Layer 4.
Guide: The space API · Annotation conventions
// examples/example-11-quickstart -- Quickstart.startPeer(...): the same
// annotations, without Spring.
ReplicatedSpace space = ReplicatedSpace.builder(runtime, "work", identity, "host")
.settleWindow(Duration.ofMillis(150))
.build();
AgentBinder binder = new AgentBinder(identity, runtime.id(), null,
InstantSource.system());
binder.space("work", space);
node.startTicking(Duration.ofMillis(250));
// Quickstart.main
workerPeer.binder().bind(new Fulfiller());
auditorPeer.binder().bind(new Auditor());
// examples/example-08-intake-fleet -- IntakeFleet.Extractor
// Every extractor sees every scan; each returns its own reading as the next entry.
@AgentSpec(name = "extractor", description = "Reads a scan", goals = {"extract"})
public static final class Extractor {
private final String label;
private final double skill;
public Extractor(String label, double skill) {
this.label = label;
this.skill = skill;
}
@SpaceNotify(space = "intake", lease = "1h", resultLease = "10m")
public ExtractionCandidate extract(ScanEntry scan) {
String taxpayerId = skill > 0.8 ? "TIN-88-1234567" : "TIN-88-1284567";
String amount = skill > 0.8 ? "12,400.00" : "12,4O0.OO";
return new ExtractionCandidate(scan.scanId(), label, taxpayerId, amount, skill);
}
}
# agentspaces-partybus -- src/main/resources/application.yml
agentspaces:
keystore: ./partybus-keys
bind: 0.0.0.0:7700
transport:
channel-auth: attested # SPEC §5.6: TLS vouches for the sender, frames travel bare
tls:
enabled: true
groups:
- name: partybus
founding: partybus-v1
spaces:
- { name: trip }
- { name: research }
- { name: advisories }
- { name: decisions }
- { name: slots, strategy: AUCTION }
- { name: bookings }
Capabilities: fleet protocols as one method each.
Capabilities are services built on the same fabric. Each is advertised with a signed card, discovered like any other, and reached through a typed client. The first set covers bidding, voting, reacting to decisions, exactly-once work, and fleet-wide aggregates. Each annotation handles the part that every hand-written version got wrong at least once.
@BidFunctionprices work on anAUCTIONspace, and the lowest bid wins. Bids are ordinary code, so they can be denominated in dollars, tokens, or queue depth, and even learned.@Ballotcasts one ballot per proposal, or abstains by returningnull. The proposal itself triggers the method, so a ballot can never arrive for a proposal the voter hasn't seen.@OnDecisionfires once per proposal when the tally reaches quorum, and its return value becomes the next entry.@OrderedTakeis@SpaceTakewith the take routed through a Raft log, so each entry completes exactly once across the fleet.@CapabilityRefinjects a typed client (VoteClient,AggregateClient,SemanticClient).@ProvidesCapabilityadvertises your ownCapabilityProvider.- Aggregates without a tick. Return a
Contributionfrom any bound method to feed a push-sum epoch, and read the fleet's estimate withawaitEstimate.
Guide: Capabilities inside agents · Taking through the ordered log
// agentspaces-partybus -- Planners.DiningPlanner
@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)
public double bid(DaySlot slot) {
double bid = 90;
if (interested(slot, "food", "wine", "eat", "cook")) {
bid -= 35;
}
if (slot.pace().equals("REST")) {
bid += 20;
}
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");
// ...builds the day's ScheduledActivity around that table
}
}
// examples/example-05-quorum -- QuorumFleet.Member
@AgentSpec(name = "member", description = "Votes on scaling by its own backlog", goals = {"vote"})
public static final class Member {
private final double backlog;
public Member(double backlog) {
this.backlog = backlog;
}
// The proposal is the cue, so there's no waiting and no "voted" set.
@Ballot(space = "votes", prefix = "scale", lease = "10m")
public String vote(VoteCapability.Proposal proposal) {
return backlog > BACKLOG_THRESHOLD / 2 ? "approve" : "reject";
}
}
// QuorumFleet.main: the supervisor opens the vote
supervisor.vote().propose("scale-up-1",
"Average backlog " + String.format("%.1f", sensed)
+ " exceeds threshold; add two workers?",
List.of("approve", "reject"), 4, Lease.of(Duration.ofMinutes(10)));
// examples/example-05-quorum -- QuorumFleet.Supervisor
public record ScalingDecision(String proposalId, String winner, String tally, String countedPer) {
}
@AgentSpec(name = "supervisor", description = "Records scaling decisions", goals = {"record"})
public static final class Supervisor {
// Once per proposal, the first time this replica's tally meets quorum.
@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());
}
}
// QuorumFleet.startPeer(...): providing the vote is what makes these bindable
group.space("votes", votes);
group.provide(aggregate);
group.provide(vote);
// 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); // now @OrderedTake methods can bind
// 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());
}
Spring Boot, Spring AI, and Embabel.
The annotations go on the classes you already write. The Spring Boot starter enrolls them from component scanning. Spring AI workers call their models inside an annotated method. Embabel agents publish cards to the fleet, and the fleet's cards come back to Embabel's planner as actions.
Spring AI
agentspaces-springai- A worker is a
@SpaceAgentbean. Inject aChatClient.Builderand call the model inside a@SpaceTakemethod. A crash mid-call lets the lease lapse, and another worker picks the task up. - Fleet tools.
FleetToolsturns the fleet's AgentCards into Spring AI tool callbacks, so a model's tool loop can call agents running on other machines. - Patterns built from the same annotations. A panel of model judges is a set of
@Ballotagents. A token-priced auction is a@BidFunctionover each model's price. - Spring AI's own extension points. The project also provides fleet-backed chat memory, a
ChatModelserved by the fleet, embeddings for semantic discovery, fleet-wide token usage, and MCP export. No Embabel dependency.
// agentspaces-springai/examples/springai-fleet -- SpringAiFleet.Summarizer
// A Spring AI worker on the fleet: a @SpaceAgent bean with an injected
// ChatClient.Builder and one @SpaceTake method.
@SpaceAgent(name = "summarizer", description = "Summarizes a topic into a short briefing",
goals = {"summarize topics"})
public static class Summarizer {
private final ChatClient chat;
Summarizer(ChatClient.Builder builder) {
this.chat = builder.build();
}
@SpaceTake(space = "work", lease = "2m")
public Summary summarize(Brief brief) {
return new Summary(brief.topic(),
chat.prompt().user("Summarize: " + brief.topic()).call().content());
}
}
// agentspaces-springai/examples/springai-fleet -- SpringAiFleet.Orchestrator
// The fleet's AgentCards become Spring AI tool callbacks: the model's tool loop
// can call the summarizer and translator, wherever they run.
public static class Orchestrator {
private final ChatClient chat;
Orchestrator(ChatClient.Builder builder, FleetTools fleet) {
this.chat = builder.defaultToolCallbacks(fleet.toolCallbacks()).build();
}
public String ask(String question) {
return chat.prompt().user(question).call().content();
}
}
@SpringBootConfiguration
@EnableAutoConfiguration
@Import(DemoModel.class)
public static class OrchestratorApp {
@Bean
Orchestrator orchestrator(ChatClient.Builder builder, FleetTools fleet) {
return new Orchestrator(builder, fleet);
}
}
// agentspaces-springai/examples/springai-patterns -- JudgePanel
// Each judge is an ordinary @Ballot agent whose vote is a Spring AI Evaluator.
@AgentSpec(name = "judge", description = "Fact-checks claims against evidence",
goals = {"judge claims"})
public static final class Judge {
private final Evaluator evaluator;
public Judge(Evaluator evaluator) {
this.evaluator = Objects.requireNonNull(evaluator, "evaluator");
}
@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";
}
}
@AgentSpec(name = "lead", description = "Records the panel's verdicts",
goals = {"record verdicts"})
public static final class Lead {
@OnDecision(space = "votes", prefix = "claim:", resultLease = "1h")
public Verdict record(VoteCapability.Decision decision) {
return new Verdict(decision.proposalId(), decision.winner(), decision.tally().toString());
}
}
// agentspaces-springai/examples/springai-patterns -- TokenPricedAuction.ModelWorker
// The cheapest model that can handle the task wins it.
@AgentSpec(name = "model-worker", description = "Runs tasks on one model, priced by tokens",
goals = {"answer prompts"})
public static final class ModelWorker {
private final String name;
private final ChatClient chat;
private final double pricePerThousandTokens;
private final int maxDifficulty;
@BidFunction(space = "model-tasks")
public double bid(ModelTask task) {
if (task.difficulty() > maxDifficulty) {
return Double.POSITIVE_INFINITY;
}
return pricePerThousandTokens * estimatedTokens(task.prompt()) / 1000.0;
}
@SpaceTake(space = "model-tasks", pollTimeout = "PT0.3S")
public ModelResult run(ModelTask task) {
return new ModelResult(task.prompt(), chat.prompt().user(task.prompt()).call().content(),
name, bid(task));
}
}
Embabel
embabel-agentspaces- Outbound: your
@Agentbeans become cards. The description, the@AchievesGoalstatements, and each@Action's input and output types are published as an AgentCard. Add@SpaceTaketo an action and it becomes a fleet worker. - Inbound: the fleet becomes planner actions.
EmbabelRemoteActionsgenerates an@Agentwith one typed@Actionper remote card, so the GOAP planner can chain specialists it can't reach on its own. - Choreography, not RPC. Invoking a remote action writes an entry and waits for the matching result. If the worker crashes, the lease lapses, and whoever finishes delivers the result.
- Kept current. Cards are leased, so the planner's action set follows the live fleet.
// agentspaces-partybus/src/embabel -- PartyBusEmbabelApplication
// The planner's own two actions. Everything between them is a remote fleet
// specialist that the bridge generated from an AgentCard.
@Agent(description = "Plans a trip with the Party Bus fleet's specialists")
public static class PartyBusPlannerAgent {
@Action
public SightseeingRequest askForSights(TravelBrief brief) {
return new SightseeingRequest(brief.tripId(), "planner sights " + brief.to(),
brief.to(), brief.brief(), brief.departureDate(), brief.returnDate(),
PartyBus.party(brief.tripId()).contribution());
}
@Action
public ReviewRequest askForReviewsOfTheTopSight(SightseeingOptions sights) {
PointOfInterest top = sights.pointsOfInterest().isEmpty()
? new PointOfInterest("the old town", "", "", "", "")
: sights.pointsOfInterest().get(0);
return new ReviewRequest(sights.tripId(), "planner reviews " + top.name(),
top.name(), top.location());
}
@AchievesGoal(description = "Know what travelers say about the trip's top sight")
@Action
public ReviewDigest done(ReviewDigest digest) {
return digest;
}
}
// agentspaces-partybus/src/embabel -- PartyBusEmbabelApplication.boardTheBus
// The fleet's AgentCards come back in as planner actions for Embabel's GOAP planner.
@Bean
CommandLineRunner boardTheBus(AgentPlatform platform) {
return args -> {
Sources sources = Sources.fromEnvironment();
PartyBus.Seating bus = PartyBus.board(sources, 7700, 7590);
RemoteActions remote = PartyBusPlanner.remoteActions(bus.plannerPeer());
PartyBusPlanner.awaitActions(remote, 12, Duration.ofSeconds(30));
EmbabelRemoteActions bridge = PartyBusPlanner.bridge(remote);
if (!bridge.deployTo(platform)) {
throw new IllegalStateException("the fleet agent did not deploy; are the cards in?");
}
};
}
// agentspaces-partybus -- PartyBusPlanner.bridge(...)
return new EmbabelRemoteActions(remote, INVOKE_TIMEOUT, "partyBusFleet",
"The Party Bus fleet's advertised skills: scouts, advisors, watchers");
// agentspaces-partybus -- Scouts.SightseeingScout
// The remote action the planner's SightseeingRequest reaches: an ordinary
// @SpaceTake worker, possibly on another machine.
@AgentSpec(name = "sightseeing-scout",
description = "Proposes points of interest suited to the travelers and the brief",
goals = {"propose points of interest"})
public static final class SightseeingScout {
private final Sources sources;
@SpaceTake(space = SPACE, lease = "3m", pollTimeout = "500ms")
public SightseeingOptions scout(SightseeingRequest request) {
List<Link> web = sources.search("things to do " + request.destination() + " "
+ request.brief(), 10);
SightseeingOptions found = sources.structured("sights", RESEARCHER
+ "\nPropose six to eight points of interest in and around "
+ request.destination() /* ...the rest of the prompt... */,
SightseeingOptions.class);
return new SightseeingOptions(request.tripId(), request.topic(),
found.pointsOfInterest(), "sightseeing-scout");
}
}
// embabel-agentspaces -- EmbabelBinderTest.SpaceBoundEmbabelWorker
// The outbound direction: an Embabel action that is also a space worker.
// Its AgentCard is built from the @Agent metadata and the action's types.
@Agent(name = "spaceWorker", description = "Takes tasks from the work space")
public static class SpaceBoundEmbabelWorker {
@Action
@ai.badmonkey.agentspaces.agent.annotation.SpaceTake(space = "work")
public FindingEntry research(TaskEntry task) {
return new FindingEntry(task.topic(), "done");
}
}
All eleven annotations, by layer.
Every annotation goes on an ordinary object: no base class, no interface, no container. They all accept an optional group, an empty space resolves to the group's only space, and durations can be written as "10m" or "PT10M".
| Annotation | On | What the binder does | Key attributes (defaults) |
|---|---|---|---|
| Layer 2 · Discovery | |||
@AgentSpec | class | Names and describes the agent. This becomes its signed AgentCard. | name, description, goals[] |
@SpaceAgent | class | Spring stereotype: @Component + @AgentSpec. Enrolled by the starter. | name, description, goals[] |
| Layer 3 · Space | |||
@SpaceTake | method | Takes under a lease, then completes or lets the lease lapse. The return value is the result entry. | space, lease (10m), pollTimeout (1s), resultSpace, resultLease (1h) |
@SpaceNotify | method | Reacts to every match without consuming it, filtering duplicate deliveries, on its own virtual thread. The return value is the next entry. | space, lease (1h), resultSpace, resultLease (1h) |
@SpaceRef | field | Injects a Space handle, the agent's own signed view when it has its own key. | value (space name) |
| Layer 4 · Capabilities | |||
@BidFunction | method | Prices an entry on an AUCTION space. The lowest bid wins. | space |
@Ballot | method | Casts one ballot per proposal, as this agent. null abstains. | space, prefix, lease (1h) |
@OnDecision | method | Fires once per closed vote. The return value is the next entry. | space, prefix, lease, resultSpace, resultLease (1h) |
@OrderedTake | method | A take through the Raft log: exactly once, fleet-wide. | space, lease (30s), pollTimeout (2s), resultSpace, resultLease |
@CapabilityRef | field | Injects a typed capability client. Binding fails immediately if none is available. | group |
@ProvidesCapability | class | Registers a CapabilityProvider and keeps its advertisement fresh. | value (URI), group |
Guide: Chapter 1, Annotations, which covers each annotation with where it belongs and what to keep in mind.
Four languages, one wire.
Nothing on the wire is Java-specific: CBOR framing (RFC 8949), Ed25519 signatures (RFC 8032), and URI names. The Python and TypeScript clients speak the protocol directly, and Clojure binds the JVM library. The Java, Python, and TypeScript codecs are checked against the same golden byte vectors, signatures included. The trilingual demo has a Java coordinator publish tasks while Python and TypeScript workers race for them: three runtimes, one space, no broker.
Java
The full stack and all eleven annotations. Spring Boot starter and Embabel adapter.
agentspaces-*Clojure
Idiomatic bindings over the JVM library: maps in, maps out, keywords and duration strings.
agentspaces-cljTypeScript
A wire-compatible peer written against Node's standard library alone. Joins, takes, writes, and reads back.
@agentspaces/clientPython
A wire-compatible peer that joins a running Java fleet over TCP and takes work under the same claim rules.
agentspaces-client// examples/example-04-auction -- AuctionFleet.MiniModelWorker
// Java agents are annotated POJOs: the binder owns the loop, the lease, and the bid.
@AgentSpec(name = "mini-model", description = "Cheap small-model worker")
public static class MiniModelWorker {
@BidFunction(space = "model-tasks")
public double bid(ModelTask task) {
return 1.0 + task.difficulty() * 2.0; // 3 .. 21
}
@SpaceTake(space = "model-tasks", pollTimeout = "PT0.3S")
public ModelResult run(ModelTask task) {
return new ModelResult(task.prompt(), task.difficulty(), "mini-model", bid(task));
}
}
; agentspaces-clj -- test/agentspaces/fleet_test.clj
; The config map mirrors the Spring starter's properties.
{:bind (str "127.0.0.1:" port)
:tick "200ms"
:group {:name group-name
:founding (str group-name "-v1")
:seeds (if seed-port [(str "127.0.0.1:" seed-port)] [])
:membership {:ttl "30s" :probe "2s" :indirect 2}
:spaces [{:name "work" :settle "150ms"}]}}
; a-two-peer-clojure-fleet-round-trips-work-over-tcp
(space/write! (fleet/space a "work") Fixtures$TaskEntry
{:topic "replicate me" :priority 5} {:lease "10m"})
(let [task (space/take! (fleet/space b "work") Fixtures$TaskEntry
{:lease "5m" :timeout "20s"})]
(space/complete! (fleet/space b "work") task Fixtures$FindingEntry
{:topic (:topic task) :summary "done by b"}
{:lease "1h"}))
// agentspaces-typescript -- demo/worker.ts
import { loads } from "../src/cbor.js";
import { Identity, groupIdFromFounding } from "../src/identity.js";
import { Peer } from "../src/peer.js";
const identity = Identity.generate();
const group = groupIdFromFounding("research-fleet-demo-v1");
const peer = new Peer(identity, group);
await peer.connect(host, port);
const entryId = await peer.takeEntry("tasks", TASK_TYPE, "ts-worker",
60_000, 600, 8_000);
const record = peer.states.get(entryId)!["record"] as Dict;
const task = loads(Buffer.from(record["payload"] as Uint8Array)) as Dict;
peer.completeEntry("tasks", entryId);
peer.writeEntry("tasks", FINDING_TYPE, {
topic: task["topic"],
summary: `researched in typescript: ${task["topic"]}`,
worker: "ts-worker",
}, "ts-worker", 3_600_000);
# agentspaces-python -- demo_python_worker.py
from agentspaces import cbor
from agentspaces.identity import Identity, group_id_from_founding
from agentspaces.peer import Peer
identity = Identity.generate()
group = group_id_from_founding(b"research-fleet-demo-v1")
peer = Peer(identity, group)
peer.connect(host, port)
entry_id = peer.take_entry("tasks", TASK_TYPE, agent_name="py-worker",
timeout_seconds=8.0 if race else 15.0)
record = peer.states[entry_id]["record"]
task = cbor.loads(record["payload"])
peer.complete_entry("tasks", entry_id)
peer.write_entry("tasks", FINDING_TYPE,
{"topic": task["topic"],
"summary": "researched in python: " + task["topic"],
"worker": "py-worker"},
agent_name="py-worker", lease_millis=3_600_000)
The annotations are a JVM feature, available from Java and from any JVM language through the binder. The Python and TypeScript clients work at the wire level: join, take, complete, write, and read back.
Go deeper in the developer guide.
find, and each capability worked through.
Peering and identityGroups, transports, relay and rendezvous, agent keys.