A peer-to-peer coordination fabric for agents, services, and the people who work with them.
AgentSpaces is a P2P framework to quickly discover and join P2P networks, where agents, software services, and people share assets, queries, results, and coordinate. The framework helps you build fleets of agents, tie in other software, and coordinate across them with a replicated tuple space, quorum building, and more. Agents post work, claim tasks, publish results, vote on decisions, and recover from each other's failures. There is no broker, no registry, no scheduler, and no server to keep alive. Coordination is peer-to-peer, secured end to end, and replicated across the fleet.
An agent is a method. Its parameter is the work it takes, and its return value is the next entry in the space. In Groovy the agent is one closure per verb, and a single script is a whole peer: fleet { } configures it, agent(...) describes the worker, bind starts it, and serve() keeps it running.
1. Install Groovy 6. Groovy 6.0.0 on JDK 21 or newer. @Grab fetches agentspaces-groovy from Maven Central at run time, so the script needs no build file.
2. Write the agent. The two records live in the script under a package, so they carry the wire names shop.Order#v1 and shop.Shipment#v1. The fleet { } block uses the same keys as the Spring starter's YAML. The default mtls security profile secures every link, so the block has no security entry.
package shop
@Grab('ai.badmonkey.agentspaces:agentspaces-groovy:0.4.0')
import ai.badmonkey.agentspaces.groovy.FleetScript
@groovy.transform.BaseScript FleetScript _
record Order(String orderId, String item) { }
record Shipment(String orderId, String item, String by) { }
int port = args ? args[0] as int : 7701
String seed = args.size() > 1 ? args[1] : ''
def runtime = fleet {
bind "127.0.0.1:$port"
group('shop') {
founding 'shop-v1'
if (seed) { seeds seed }
space('orders') { }
}
}
bind agent('fulfiller', description: 'Ships orders') {
take(Order, space: 'orders', lease: '30s', returns: Shipment) { Order order ->
new Shipment(order.orderId(), order.item(), 'fulfiller')
}
}
if (args.size() > 2) { // groovy fulfiller.groovy 7702 127.0.0.1:7701 kite
def orders = runtime.context().group().space('orders')
orders << new Order('o-1', args[2])
println orders.read(Shipment, timeout: '30s').get()
} else {
serve()
}3. Run two instances. The first founds the group on port 7701. The second takes its own port, the first instance as its seed, and an item to order.
groovy fulfiller.groovy
groovy fulfiller.groovy 7702 127.0.0.1:7701 kite
The second instance prints the shipment. Either instance can take the order, because both run the same agent.
4. Kill one. Add println "shipping ${order.orderId()}" and Thread.sleep(10000) at the top of the closure. Start both instances, write an order from the second, and run kill -9 on whichever instance prints shipping. If you killed the first instance, the second prints the shipment. If you killed the second, the first prints shipping o-1 again when the 30 second lease lapses.
The sections below add the rest: the one-file peer in detail, the class form with compile-time checking, the Space API in Groovy, async verbs for Groovy 6's await, and a shell over a running peer.
AgentSpaces for Apache Groovy 6: the shared replicated tuple space and its peer-to-peer fabric in Groovy's own idiom. A Groovy class carrying the Java annotations already binds as a worker; this project builds on that floor. Let's use Groovy to quickly build, leverage, and interact with our P2P agent collaboration space!
| Artifact | What it is |
|---|---|
agentspaces-groovy |
GDK-style extension methods on the Space API and the capability clients; coercions ('10m' as Lease, Task.from(map), record.toMap()); the where: template DSL; Fleet.start { }, the fleet configuration DSL whose keywords are the starter's properties; agent(name) { }, the agent DSL bound by the Java binder through a generated interface; FleetScript, so one .groovy file is a peer; await-ready twins of every blocking verb; FleetChecker, a type-checking extension that turns bind-time refusals into compile errors; FleetShell, a groovysh session over a running peer. Depends on stable Groovy 6 features only. |
agentspaces-groovy-concurrent |
Channels over space subscriptions (for await), broadcast, select recipes, and dataflow twins over Groovy 6's groovy.concurrent toolkit, which Groovy marks incubating. Every use of that package lives here. |
<dependency>
<groupId>ai.badmonkey.agentspaces</groupId>
<artifactId>agentspaces-groovy</artifactId>
<version>0.4.0</version>
</dependency>// build.gradle
dependencies {
implementation 'ai.badmonkey.agentspaces:agentspaces-groovy:0.4.0'
implementation 'org.apache.groovy:groovy:6.0.0'
}Groovy 6.0.0 (general availability 2026-09-19) on JDK 21 or newer; the artifacts are Java 21 bytecode.
At the end of this section you will have run three fleets in one JVM: an
agent written as closures on one peer that completes work a second peer
writes; a worker that dies in the middle of a take, whose task comes back on
its own and is finished by another worker; and two peers that vote on a
proposal while the proposer awaits the decision with Groovy 6's await. Each
snippet below is the body of one test method in
groovy/src/test/groovy/ai/badmonkey/agentspaces/groovy/ReadmeExamplesTest.groovy,
verbatim with the imports elided. The peers run on free loopback ports with no
network and no model key, and freePort() and until(timeout) { } are the
test's two helpers. Run all three with
cd agentspaces-groovy && mvn -q test -pl groovy -Dtest=ReadmeExamplesTest.
agent(name) { } describes a worker as one closure per verb; Fleet.start { }
starts a peer from the same keywords as the starter's YAML. The second peer
seeds from the first, writes a task, and reads the finding the agent's return
value became. (ReadmeExamplesTest.anAgentOnOnePeerTakesWhatAnotherPeerWrites)
int workerPort = freePort()
worker = Fleet.start { // peer one
bind "127.0.0.1:$workerPort"
tick '200ms'
group('it-fleet') { founding 'groovy-it-fleet-v1'; space('tasks') { settleWindow '100ms' } }
}
worker.bind agent('summarizer', description: 'Summarizes tasks') { // the agent: one closure per verb
take(ItTask, space: 'tasks', lease: '30s', pollTimeout: '200ms', returns: ItFinding) { ItTask t ->
new ItFinding(t.topic(), "summary of ${t.topic()}")
}
}
client = Fleet.start { // peer two, seeded from peer one
bind "127.0.0.1:${freePort()}"
tick '200ms'
group('it-fleet') { founding 'groovy-it-fleet-v1'; seeds "127.0.0.1:$workerPort"; space('tasks') { settleWindow '100ms' } }
}
def tasks = client.context().group().space('tasks')
tasks << new ItTask('tuple spaces', 1) // peer two writes a task
Optional<ItFinding> finding = tasks.read(ItFinding, timeout: '30s') // peer one took it; its return is the next entry
assert finding.get().summary() == 'summary of tuple spaces'The space is the only thing the two peers share: no queue, no broker, and no URL for the agent. The take is a signed claim under a lease, so no other worker in the fleet sees the task while this one holds it.
A worker that takes a task and never finishes dies mid-take when its peer
closes. Its lease lapses, the task reappears in the space, and a worker bound
afterwards finishes it. No retry logic exists anywhere in this code.
(ReadmeExamplesTest.aCrashedWorkersTakeReappearsAndASecondWorkerFinishesIt)
int slowPort = freePort()
slow = Fleet.start {
bind "127.0.0.1:$slowPort"; tick '200ms'
group('it-fleet') { founding 'groovy-it-fleet-v1'; space('tasks') { settleWindow '100ms' } }
}
slow.bind agent('slow-worker') {
take(ItTask, space: 'tasks', lease: '3s', pollTimeout: '200ms', returns: ItFinding) { ItTask t ->
Thread.sleep(60_000) // a worker that never finishes
new ItFinding(t.topic(), 'never')
}
}
observer = Fleet.start {
bind "127.0.0.1:${freePort()}"; tick '200ms'
group('it-fleet') { founding 'groovy-it-fleet-v1'; seeds "127.0.0.1:$slowPort"; space('tasks') { settleWindow '100ms' } }
}
def tasks = observer.context().group().space('tasks')
tasks << new ItTask('resilient', 1)
assert until('20s') { tasks.count(ItTask) == 0 } // taken: no other worker can see it
slow.close() // the worker dies mid-take
assert until('30s') { tasks.count(ItTask) == 1 } // its lease lapsed: the task is back
observer.bind agent('second-worker') {
take(ItTask, space: 'tasks', lease: '30s', pollTimeout: '200ms', returns: ItFinding) { ItTask t ->
new ItFinding(t.topic(), 'finished by the second worker')
}
}
assert tasks.read(ItFinding, timeout: '30s').get().summary() == 'finished by the second worker'Each peer binds a reviewer whose ballot clause judges every proposal under
the ship: prefix. The proposer asks for the decision first, as a
CompletableFuture from the decisionAsync twin, proposes with a quorum of
two, and awaits the future; the tally shows one ballot from each peer.
(ReadmeExamplesTest.twoPeersVoteAndTheProposerAwaitsTheDecision)
int firstPort = freePort()
first = Fleet.start {
bind "127.0.0.1:$firstPort"; tick '200ms'
group('it-fleet') { founding 'groovy-it-fleet-v1'; space 'tasks'; space 'votes' }
}
second = Fleet.start {
bind "127.0.0.1:${freePort()}"; tick '200ms'
group('it-fleet') { founding 'groovy-it-fleet-v1'; seeds "127.0.0.1:$firstPort"; space 'tasks'; space 'votes' }
}
[first, second].each { peer -> // one reviewer per peer
peer.bind agent('reviewer') {
ballot(space: 'votes', prefix: 'ship:') { VoteCapability.Proposal p ->
p.question().contains('tests') ? 'yes' : 'no'
}
}
}
VoteClient votes = second.spaces().group('it-fleet').capability(VoteClient)
def decision = votes.decisionAsync('ship:release-1') // a CompletableFuture, before any proposal
votes.propose(id: 'ship:release-1', question: 'Ship once the tests pass?', options: ['yes', 'no'], quorum: 2)
VoteCapability.Decision decided = await decision // Groovy 6's await on the twin
assert decided.winner() == 'yes'
assert votes.tally('ship:release-1').yes == 2 // one ballot from each peerEvery blocking verb and client wait has an Async twin like decisionAsync,
so a bound body can fan out under AsyncScope and await several at once (see
"Async" below). The one-file @Grab form of the first example is the next
section.
@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()groovy worker.groovy is a peer. fleet { } starts it, agent { } describes
a worker as closures, bind hands it to the Java binder, and serve() blocks
until SIGINT or stop(); when the script body ends, the agents are unbound and
the peer closes (an in-flight take then lapses at its lease, the binder's crash
idiom). @Grab is a development convenience: pin the version, and ship a built
jar or a Maven or Gradle project in production, because a script runs whatever
a repository serves.
@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 Java annotations on a Groovy class, dynamic or @CompileStatic, bind
through FleetRuntime.bind, AgentSpaces.bind, the Spring Boot starter, or
the Micronaut library unchanged. With FleetChecker enabled, a where entry
that names no component of the cue record, a lease that does not parse, a
@Ballot that returns anything but String, or @Memoized beside a verb fail
at compile time with the binder's own words.
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.
import static ai.badmonkey.agentspaces.groovy.Where.*
space << entry // write under the space's default lease (10m; space.defaultLease = '2h')
space.write(entry, '10m') // or 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, judged on this replica
space.all(Task, limit: 50) // an unmodifiable List: GINQ-ready; toSorted, never sort in place
space.entries(Task, tags: [shard: present()]) // with id, issuer, tags, lease, stamp
space.take(Task, lease: '10m', timeout: '2s') // Optional<TakenEntry<Task>>
space.take(Task, lease: '10m') { Task t -> new Quote(...) } // take, run, complete with the result; null completes without one; a throw lets the lease lapse
space.use(Task, lease: '10m') { TakenEntry<Task> taken -> ... } // take, run, complete without a result
space.notify(Task, lease: '1h', on: 'written') { SpaceEvent e -> ... } // Subscription
votes.propose(id: 'm-1', question: 'ship?', options: ['yes', 'no'], quorum: 2); votes.cast('m-1', 'yes')
aggregate.settled('epoch', ticks: 20, tolerance: 0.01)where: takes a plain value (eq), a matcher function (eq, ne, gt,
gte, lt, lte, anyOf for the Java in, contains, isNull, notNull,
present on tags), or a closure over the field. 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 a 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()
or assign first.
Optional<Task> first = await space.readAsync(Task, where: [topic: 'x'])
def (quote, risk) = await(quotes.takeAsync(Quote, lease: '1m'), risk.readAsync(RiskScore, timeout: '5s'))
def decision = await votes.decisionAsync('m-1')
def estimate = await aggregate.settledAsync('epoch', ticks: 20, tolerance: 0.01)
AsyncScope.withScope { scope -> // inside a bound method: the binder runs it on a virtual thread
def q = scope.async { await quotes.takeAsync(Quote, lease: '1m', timeout: '5s') }
def r = scope.async { await risk.readAsync(RiskScore, timeout: '5s') }
new Order((await q).get(), (await r).get())
}A cancelled twin's blocking call still runs to its own timeout (the binder renews the take lease meanwhile), so keep fan-out timeouts short.
With agentspaces-groovy-concurrent:
for await (e in space.channel(Finding, lease: '1h', buffer: 64, on: 'written')) { println e.entry().summary() }
def fan = space.broadcast(Task); def a = fan.subscribe(); def b = fan.subscribe()
def first = Selects.firstOf('2s', left.channel(Task), right.channel(Task)).join() // first.index, first.value, first.timeout
def decided = votes.decisionVar('m-1') // a DataflowVariable bound when the decision landsA channel is fed by one subscription; closing either side closes both, and a
channel closes itself when its lease would lapse unless renewed. A full
buffer blocks the subscription's listener: a slow consumer slows this peer's
event delivery and leaves the fabric alone.
Every keyword is the camel-case name of a starter property under
agentspaces.*; a block is a nested group; a repeated keyword appends to a
list; a boolean keyword with no argument means true. Two aliases, and no other
sugar: a millis property takes a duration string under its name without the
suffix (tick '250ms' for tickMillis), and the singular of a list property
adds one element (group('research') { }, space 'tasks'). The DSL emits the
kebab-case tree the runtime's loader binds, so an unknown keyword fails naming
the level, the nearest keywords, and the known ones, and the DSL cannot drift
from the starter (a reflective test walks every property). Script variables
and methods resolve inside a block; a local named like a keyword shadows it,
as Groovy scoping has it. Fleet.start(Path), Fleet.start(String yaml), and
Fleet.start(Map) take the other forms.
java -cp ... ai.badmonkey.agentspaces.groovy.FleetShell --yaml agentspaces.yaml
java -cp ... ai.badmonkey.agentspaces.groovy.FleetShell -e "bind '127.0.0.1:7591'; security { profile 'dev-local' }; group('g') { seeds '127.0.0.1:7590'; space 'tasks' }"
fleet> space('tasks').all(ResearchTask).size()
fleet> space('tasks') << new ResearchTask('agentic memory', 3)
fleet, spaces, space(name), group(name), agent(name) { }, and
bind(agent) are bound; the shell runs as the configured identity and refuses
a configuration that enables the console or the A2A gateway, so it never
serves; closing it closes the peer. Needs org.apache.groovy:groovy-groovysh
(an optional dependency).
| Construct | What happens |
|---|---|
@CompileStatic or dynamic class with the Java annotations |
binds alike |
Groovy record |
a Java record: the entry type a Java peer names the same way (recommended) |
@Immutable class |
round-trips through the codec via the generated no-argument constructor; records stay the recommendation |
a map coerced to an annotated interface ([price: { }] as Pricer) |
binds: the binder reads annotations through interfaces; the agent DSL stands on this |
| a closure property | not a verb; a class that is only closures binds nothing |
| a trait with an annotated method | binds through the implementing class |
@Delegate to an annotated interface |
the class implements the interface, so the verb binds |
@Memoized on a verb |
binds, and answers a repeated equal cue from its cache; the checker refuses it |
@ActiveMethod on a verb |
returns before the work runs, breaking "the return is the next entry"; the checker refuses it |
| a Java map in a GString or string concatenation | renders Groovy-style ([k:v]); String.valueOf(map) gives Java's form |
'10m' as Duration, '10m' as Lease |
the extension module overrides Groovy's own asType(String, Class) for these two targets |
mvn -B -ntp verify # the library and the bridge: 63 and 7 tests
mvn -B -ntp verify -Pexamples # plus example 21 (examples/groovy-fleet), on example 02's fleet
mvn -B -ntp verify -Phosts # plus the Spring Boot and Micronaut passthroughs
ci/grab-check.sh # the @Grab script against an installed Groovy 6 and the local repositoryMaven with gmavenplus-plugin 5.1.0 compiles the Groovy sources; JUnit
Jupiter runs the tests (no Spock build exists for Groovy 6). The enforcer keeps
Spring, Micronaut, LangChain4j, and groovy-concurrent-java off the library's
classpath. The incubating Groovy features this project touches: the
groovy.concurrent toolkit (the bridge artifact), groovy-csv and
groovy-markdown (example 21 only), and Grape's dual engine (the @Grab
recipe); a Groovy minor that changes them touches those places and nothing else.