Language guide

AgentSpaces for Groovy

Write AgentSpaces peers in Apache Groovy 6, from a one-file script with @Grab to a statically compiled class in a Spring Boot or Micronaut app. Groovy runs on the Java binder, so a Groovy peer joins the same fleet as Java peers and has every verb and transport they have.

Groovy 6.0.0 on JDK 21 or newerai.badmonkey.agentspaces:agentspaces-groovy:0.5.0one-file peers with @Grab, no build fileruns on the Java binder: every verb and transportSpring Boot and Micronaut hosts
aspace://guide/groovy/intro

Introduction

This part builds a working Groovy agent in one script, runs two copies of it, and adds a second verb. A few words first, because the rest of the guide uses them:

  • A peer is one running process in the fleet. Each Groovy script you start is a peer.
  • A group is the set of peers that share data. Peers join a group by its founding text and a seed address.
  • A space is a named, replicated collection inside a group. Every peer in the group sees the same space.
  • An entry is one record in a space. In Groovy an entry is a record.
  • A lease is how long an entry or a claim lives. When it lapses, the fabric drops it or gives it back.
  • A take removes one entry from a space under a lease, so only one worker gets it.
  • An agent is an object whose methods react to entries. Its return value becomes the next entry.
  • A capability is a fleet service built on spaces, such as voting or aggregation.

Install

You need Groovy 6.0.0 on JDK 21 or newer. The library is ai.badmonkey.agentspaces:agentspaces-groovy:0.5.0 on Maven Central. A script fetches it at run time with @Grab, so it needs no build file. SDKMAN installs Groovy 6 beside any older line you have:

sdk install groovy 6.0.0
export GROOVY_HOME="$HOME/.sdkman/candidates/groovy/6.0.0"
"$GROOVY_HOME/bin/groovy" --version

The groovy launcher checks GROOVY_HOME against its own location. If SDKMAN's current Groovy is 5.x, point GROOVY_HOME at 6.0.0 and run that binary, or the script fails before it starts.

For a Maven or Gradle project, depend on ai.badmonkey.agentspaces:agentspaces-groovy:0.5.0 and org.apache.groovy:groovy:6.0.0. The artifacts are Java 21 bytecode.

A peer, a group, and a space

The fleet { } block starts a peer. Its keywords are the same as the Spring Boot starter's YAML properties. This block listens on a port, founds or joins the group shop, and opens the space orders:

// fulfiller.groovy
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') { }
    }
}
  • The first peer has no seed, so it founds the group from the text shop-v1.
  • A later peer names any running peer as its seed and joins the same group, because it uses the same founding text.
  • The default mtls security profile secures every link, so the block has no security entry.

Entries

Declare entries as Groovy records. Groovy records are Java records, so a Java peer names and builds the same type. Put them under a package: the package gives them stable wire names, here shop.Order#v1 and shop.Shipment#v1.

// fulfiller.groovy
package shop
// ...
record Order(String orderId, String item) { }
record Shipment(String orderId, String item, String by) { }
// ...
    def orders = runtime.context().group().space('orders')
    orders << new Order('o-1', args[2])
    println orders.read(Shipment, timeout: '30s').get()

<< writes an entry under the space's default lease. read(Type, timeout: ...) waits for a matching entry and returns an Optional. The Space API takes named arguments such as where:, lease:, and tags:; the Space API chapter lists every verb.

Your first agent

In Groovy an agent is one closure per verb. agent(name) { } describes it, bind hands it to the Java binder, and serve() keeps the peer running until Ctrl-C. Here is the whole peer in one file:

// fulfiller.groovy
package shop

@Grab('ai.badmonkey.agentspaces:agentspaces-groovy:0.5.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()
}
  • @BaseScript FleetScript makes the file a peer. It adds fleet, agent, bind, space, and serve to the script.
  • The take clause names the cue type, the space, a 30 second lease, and the return type.
  • The closure's return value is written back to the space as the next entry.

Run two instances

Save the file as fulfiller.groovy and start two copies. The first founds the group on port 7701. The second takes port 7702, the first as its seed, and an item to order:

groovy fulfiller.groovy
groovy fulfiller.groovy 7702 127.0.0.1:7701 kite

The first run downloads the library through Grape, which can take a minute. The second instance prints the shipment:

Shipment[orderId=o-1, item=kite, by=fulfiller]

Either instance can take the order, because both run the same agent. To see a crash recovered, add println "shipping ${order.orderId()}" and Thread.sleep(10000) at the top of the closure, start both, and run kill -9 on whichever prints shipping. The survivor finishes the order after the 30 second lease lapses. You wrote no retry code.

A second verb: notify

A take consumes its cue. A notify clause sees every matching entry and leaves it in the space. One agent can hold both, each with its own where: filter. Outside a script, import agent from Agents:

// AgentDslTest.groovy
import static ai.badmonkey.agentspaces.groovy.Agents.agent
// ...
def worker = agent('clause-worker', description: 'Takes and forks', goals: ['work']) {
    take(ItTask, name: 'summarize', space: 'tasks', pollTimeout: 'PT0.2S', where: ['priority=1'], returns: ItFinding) { ItTask t ->
        new ItFinding(t.topic(), 'from a clause')
    }
    notify(ItTask, space: 'tasks', where: ['priority=2'], produces: [ItFinding, ItTally]) { ItTask t ->
        [Tagged.of(new ItFinding(t.topic(), 'forked'), 'kind', 'fork'), new ItTally(t.topic(), t.priority())]
    }
}
// ...
List<AgentBinder.Bound> bound = runtime.bind(worker)
  • The take handles tasks of priority 1 and consumes them.
  • The notify handles tasks of priority 2. It returns a list, so it writes two entries, and Tagged.of attaches the tag kind=fork to one of them.
  • produces: declares the types the clause writes. The agent's card, the description other peers read, lists them.

The other clauses follow the same shape: bid, ballot, onDecision, orderedTake, onEstimate, propose, join, and reduce. The vote example uses ballot.

Where to read next

  • The Key features part covers the two DSLs, the class form, await, and the hosts.
  • The developer guide's Groovy section summarizes this guide for Java readers, and its appendix explains how the DSLs reach the Java binder.
  • Each verb's rules are in the developer guide: take, notify, bid, and ballot.
aspace://guide/groovy/features

Key features

One-file peers with @Grab

A script whose base class is FleetScript is a whole peer. Grape resolves the @Grab line, so the file runs with groovy file.groovy and no build. The base class gives the script these calls:

  • fleet { } starts the peer, once per script. It also takes a YAML path, YAML text, or a map. With no argument it reads --yaml <path>, then the AGENTSPACES_CONFIG variable, then agentspaces.yaml.
  • agent(name) { } builds an agent and bind binds it. bind also takes any object with the Java annotations.
  • space(name) and content() return the joined group's space and content swarm.
  • serve() blocks until SIGINT or stop().

When the script body ends, FleetScript unbinds the agents and closes the peer. An in-flight take then lapses at its lease, which is how the fleet recovers a crashed worker. Use @Grab for development, and pin the version. For production, ship a built jar or a Maven or Gradle project, because a script runs whatever a repository serves.

The fleet DSL

Every fleet DSL keyword is a starter property under agentspaces.* in camel case. A block is a nested object, and a repeated keyword appends to a list. There are two aliases and no other sugar:

  • A millisecond property takes a duration string under its short name: tick '250ms' sets tick-millis.
  • The singular of a list property adds one element: group('research') { } and space 'tasks'.

The DSL emits the same tree as the YAML. This block and this YAML give equal trees in the project's tests:

// FleetDslTest.groovy
Map<String, Object> fromDsl = Fleet.tree {
    bind '127.0.0.1:7591'
    tick '250ms'
    // ...
    transport { tls { trustStore './ca.p12'; requireAttestation() } }
    // ...
    group('research') {
        seeds 'tls://10.0.0.5:7590', 'tls://10.0.0.6:7590'
        founding 'research-fleet-v1'
        joinTimeout '10s'
        space('tasks') { strategy 'LEASE_RACE'; admission 'credential'; settleWindow '100ms' }
        space 'findings'
    }
}
# agentspaces.yaml
agentspaces:
  bind: 127.0.0.1:7591
  tick-millis: 250
  transport:
    tls:
      trust-store: ./ca.p12
      require-attestation: true
  groups:
    - name: research
      seeds: [tls://10.0.0.5:7590, tls://10.0.0.6:7590]
      founding: research-fleet-v1
      join-timeout-millis: 10000
      spaces:
        - name: tasks
          strategy: LEASE_RACE
          admission: credential
          settle-window-millis: 100
        - name: findings

An unknown keyword fails and names the level, the nearest keywords, and the known ones. Fleet.start { } starts a peer from the DSL outside a script, and Fleet.start(Path), Fleet.start(String), and Fleet.start(Map) take the other forms. The starter properties are the full keyword list.

The agent DSL and the class form

You can write an agent in two ways, and both publish the same card.

  • The agent DSL: agent(name) { } with one clause per verb, as in the first agent. Each clause names the cue type, the verb's attributes as named arguments, and a closure body. Use it for scripts, tests, and agents assembled at run time.
  • The class form: a Groovy class carrying the Java annotations, dynamic or @CompileStatic. Use it for anything that ships in a jar or runs under a host.

This class is example 21's planner. Every Finding written to tasks by another worker becomes a Plan:

// Planner.groovy
@CompileStatic(extensions = 'ai.badmonkey.agentspaces.groovy.FleetChecker')
@AgentSpec(name = 'planner', description = 'Turns each finding into the next step', goals = ['plan'])
class Planner {

    @SpaceNotify(space = 'tasks', where = ['worker!=planner'], produces = [Plan])
    Plan plan(Finding finding) {
        new Plan(finding.topic(), "draft a brief on ${finding.topic()} from: ${finding.summary()}".toString(), 'planner')
    }
}
// ...
FleetRuntime runtime = Fleet.start(Path.of(args ? args[0] : 'agentspaces.yaml'))
runtime.bind(new Planner())

Closures become agents only through an annotated method. A map of closures coerced to an annotated interface binds, because the binder reads annotations through interfaces, and the agent DSL is built on exactly that. A class whose only members are closure properties binds nothing.

// CompatibilityTest.groovy
@AgentSpec(name = 'coerced', description = 'An interface a map is coerced to')
interface Coerced {
    @SpaceTake(space = 'tasks', pollTimeout = 'PT0.2S', where = ['topic=coerced'])
    ItFinding summarize(ItTask task)
}
// ...
Coerced agent = [summarize: { ItTask t -> new ItFinding(t.topic, 'from a coerced map') }] as Coerced
AgentBinder.Bound bound = runtime.bind(agent).first()

Traits and @Delegate to an annotated interface bind as well.

Compile-time checks with FleetChecker

FleetChecker is a type-checking extension for @CompileStatic and @TypeChecked code. Turn it on with @CompileStatic(extensions = 'ai.badmonkey.agentspaces.groovy.FleetChecker'), as the planner above does. It turns mistakes the binder would refuse at bind time into compile errors, in the binder's own words:

MistakeThe compile error says
A where entry naming no component of the cue recordItTask has no component 'priorty'
A lease that does not parse, such as '10 minutes'not a duration
A @Ballot method returning anything but String@Ballot method returns the chosen option as a String
@Memoized on a verb method@Memoized cannot sit on a verb method
@ActiveMethod on a verb method@ActiveMethod cannot sit on a verb method
A DSL closure whose parameter disagrees with the cuethe body's first parameter is ItFinding but the cue is ItTask

The checker also lets the fleet DSL compile statically, because its keywords resolve at run time. The checker's tests hold one row per refusal.

await and the concurrent bridge

Every blocking verb and client wait has an Async twin that returns a CompletableFuture on a virtual thread. Groovy 6's await takes it unchanged, and await(a, b) waits for several at once:

// AsyncTest.groovy
CompletableFuture<Optional<ItTask>> read = tasks.readAsync(ItTask, where: [topic: 'async-a'])
Optional<ItTask> first = await read
// ...
def (Optional<ItTask> a, Optional<ItTask> b) = await(tasks.readAsync(ItTask, where: [topic: 'async-a']), tasks.readAsync(ItTask, where: [topic: 'async-b']))
// ...
ItFinding result = await tasks.takeAsync(ItTask, lease: '30s', where: [topic: 'async-b']) { ItTask t -> new ItFinding(t.topic(), 'async body') }

Inside a bound clause, AsyncScope.withScope fans out and awaits the children. If one child fails, the scope cancels the other, the body throws, and the take's lease lapses:

// AsyncTest.groovy
take(ItTask, space: 'tasks', lease: '3s', pollTimeout: 'PT0.2S', where: ['priority=9'], resultSpace: 'findings', returns: ItFinding) { ItTask t ->
    AsyncScope.withScope { scope ->
        def tally = scope.async { await data.readAsync(ItTally, timeout: '10s', where: [topic: t.topic()]) }
        def mark = scope.async { await data.readAsync(ItFinding, timeout: '10s', where: [topic: t.topic()]) }
        Optional<ItTally> points = await tally
        Optional<ItFinding> marked = await mark
        new ItFinding(t.topic(), "planned ${points.get().points()} ${marked.get().summary()}")
    }
}

A cancelled twin's blocking call still runs to its own timeout, so keep fan-out timeouts short.

The second artifact, agentspaces-groovy-concurrent, builds on Groovy 6's groovy.concurrent toolkit, which Groovy marks incubating. It adds a channel fed by one space subscription for for await, a broadcast to several subscribers, Selects.firstOf over channels, and dataflow variables such as votes.decisionVar(id). Closing a channel unsubscribes, and a channel closes itself when its lease would lapse unless you renew it.

// BridgeTest.groovy
def channel = s.channel(ItTask, lease: '1h', buffer: 8, on: 'written', filter: { priority > 1 })
// ...
for await (SpaceEvent<ItTask> e in channel) {
    seen << e.entry().topic()
    if (seen.size() == 2) {
        break
    }
}

Every verb and transport, from the Java binder

The Groovy library reimplements nothing. It adds Groovy idioms over the Java runtime and binder, so a Groovy peer has every verb, capability, security profile, and transport a Java peer has. A bid, for example, is the Java @BidFunction on a Groovy class. The bids space uses the AUCTION strategy (strategy 'AUCTION' in its space block), so the take claims an entry only after the bid function priced it:

// Bidder.groovy
@CompileStatic
@AgentSpec(name = 'bidder', description = "Bids a task's priority and takes what it wins")
class Bidder {

    private volatile double lastBid = -1

    @BidFunction(space = 'bids')
    double bid(ItTask task) {
        lastBid = task.priority() * 1.5d
        lastBid
    }

    @SpaceTake(space = 'bids', lease = 'PT10M', pollTimeout = 'PT0.2S', resultSpace = 'findings')
    ItFinding win(ItTask task) {
        new ItFinding(task.topic(), "won at bid $lastBid".toString())
    }
}

Transports and security come from the same keywords as the starter, such as transport { tls { } } and security { profile 'mtls' }. See Security and Peering for the choices.

A peer started by Fleet.start or fleet { } has no Spring context, so a few starter features need another route:

  • The fleet console and the A2A gateway: console { enabled } and a2a { enabled } fail and name the starter. Run the agents under the Spring Boot host to serve them.
  • The ordered log: capabilities { orderedLog() } is refused, because the ordered log runs over a fixed member set. Wire it by hand with OrderedTakes.over(...) and group.ordered(name, ordered).
  • BeaconLocator beans and PeerNodeCustomizers: the runtime joins with each group's seeds, multicast when enabled, and the default bootstrap chain.

Spring Boot and Micronaut hosts

A Groovy class with the annotations is an ordinary bean to both hosts. The Spring Boot starter enrolls it with @SpaceAgent from ai.badmonkey.agentspaces.spring, dynamic or @CompileStatic, and the Groovy extension methods work on the injected spaces:

// GroovyFleetApp.groovy
@Component
@SpaceAgent(name = 'groovy-researcher', description = 'Researches topics from a Groovy bean', goals = ['research'])
static class Researcher {
    final List<String> worked = new CopyOnWriteArrayList<>()

    @SpaceRef('findings')
    Space findings

    @SpaceTake(space = 'tasks', lease = 'PT10M', pollTimeout = 'PT0.2S', resultSpace = 'findings')
    HostFinding research(HostTask task) {
        worked << task.topic
        findings << new HostNote(task.topic, 'written through the Groovy extension module')
        new HostFinding(task.topic, "done: ${task.topic}")
    }
}

The Micronaut library enrolls a @Singleton with @SpaceAgent from ai.badmonkey.agentspaces.micronaut. Add micronaut-inject-groovy so Micronaut processes the Groovy beans:

// GroovyWorker.groovy
@Singleton
@SpaceAgent(name = 'mn-groovy-worker', description = 'Works tasks from a Groovy Micronaut bean', goals = ['work'])
class GroovyWorker {
// ...
    @SpaceTake(space = 'tasks', lease = 'PT10M', pollTimeout = 'PT0.2S', resultSpace = 'findings')
    MnFinding work(MnTask task) {
        worked << task.topic()
        new MnFinding(task.topic(), "done: ${task.topic()}".toString())
    }
}

Both hosts configure the peer from their own agentspaces.* properties. The hosts directory holds both apps and their tests. mvn -B -ntp verify -Phosts builds them; the Micronaut host needs JDK 25. The Spring Boot and Micronaut chapters cover each host.

Groovy 6 gotchas

These are the problems a newcomer hits first:

  • GROOVY_HOME. The launcher refuses to start when GROOVY_HOME names another Groovy line. Point it at 6.0.0 as in Install.
  • Grape reads your local Maven repository first. A stale or half-built 0.5.0 jar in ~/.m2/repository takes precedence over Maven Central. Rebuild it with mvn install, or delete it.
  • **Pin the @Grab version**, and keep every ai.badmonkey.agentspaces artifact in one script at the same version.
  • **example-02-research-fleet is not on Maven Central.** Install it from a core checkout (mvn -q install -DskipTests), or declare your own records in the script.
  • **Records belong under a package** so their wire names are stable and other peers can match them.
  • **await is a keyword** in Groovy 6 and binds looser than member access. Write (await x).size(), or assign first. Never name a helper method await.
  • **all() returns an unmodifiable list.** Use toSorted, and never sort it in place.
  • A filter closure runs locally. read(Task) { priority > 2 } filters on this replica after the template matched. take accepts where: and tags: only.
  • **dev-local** signs frames over plain loopback TCP. Use it on a laptop, never on a shared network; production fleets use mtls.
aspace://guide/groovy/examples

Examples

A Groovy worker beside Java peers

Example 21 puts Groovy peers on example 02's research fleet, whose Java peers write ResearchTask entries and collect Finding entries. The script worker below takes tasks; the planner turns each finding into a plan. Example 02's peers speak plain TCP, so the script uses the dev-local profile: use it on a laptop, never on a shared network.

// worker.groovy
@Grab('ai.badmonkey.agentspaces:agentspaces-groovy:0.5.0')
@Grab('ai.badmonkey.agentspaces:example-02-research-fleet:0.5.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 _

int port = args ? (args[0] as int) : 7591
String seed = args.size() > 1 ? args[1] : '127.0.0.1:7590'

fleet {
    bind "127.0.0.1:$port"
    tick '250ms'
    // Example 02's peers speak plain TCP unless started with --tls; a tls:// seed selects the
    // starter's default mtls profile (TLS 1.3, channel certificates, ATTESTED channel mode).
    security { profile(seed.startsWith('tls://') ? 'mtls' : 'dev-local') }
    group('research-fleet') {
        founding 'research-fleet-demo-v1'      // the same bytes example 02 founds from, so the GroupId agrees
        seeds seed
        space('tasks') { settleWindow '150ms' }
    }
}

bind agent('groovy-summarizer', description: 'Summarizes research tasks from a one-file Groovy peer', goals: ['research']) {
    take(ResearchTask, space: 'tasks', lease: '30s', pollTimeout: '500ms', returns: Finding) { ResearchTask task ->
        new Finding(task.topic(), "researched: ${task.topic()} (groovy)", 'groovy')
    }
}

println "groovy worker on 127.0.0.1:$port, seeded from $seed; Ctrl-C to stop"
serve()

The founding text matches example 02's, so both derive the same group. To run it by hand, install the core from a checkout (mvn -q install -DskipTests), start example 02's coordinator there, and start the worker from examples/groovy-fleet:

mvn -q -pl examples/example-02-research-fleet exec:java -Dexec.args="coordinator 7590"
groovy scripts/worker.groovy 7591 127.0.0.1:7590

The coordinator writes six tasks and prints each finding with the worker that produced it. The fleet test runs the Java coordinator, this worker, the planner, and a Java caller that invokes the script's card in one build: mvn -B -ntp verify -Pexamples from the project root. The source is in examples/groovy-fleet.

CSV in, a Markdown report out

The intake script turns CSV rows into tasks, collects the findings over a channel with for await, and writes a Markdown report. It uses groovy-csv and groovy-markdown, two modules Groovy 6.0 marks incubating, and the concurrent bridge.

// intake.groovy
@Grab('ai.badmonkey.agentspaces:agentspaces-groovy-concurrent:0.5.0')
// ...
def tasks = space('tasks')

// 1. CSV rows (header: topic,priority) become tasks. Subscribe for findings first, so none is missed.
List<Map<String, String>> rows = new CsvSlurper().setUseHeader(true).parse(csv)
Set<String> pending = rows*.topic as Set
def findings = tasks.channel(Finding, on: 'written', lease: '10m')
rows.each { Map<String, String> row -> tasks << new ResearchTask(row.topic, row.priority as int) }
println "wrote ${rows.size()} tasks from ${csv.name}; waiting for findings"

// 2. Findings arrive on the channel; stop when every topic has one (or after two minutes).
def deadline = System.nanoTime() + 120_000_000_000L
for await (e in findings) {
    pending.remove(e.entry().topic())
    println "  ${e.entry().worker()} researched '${e.entry().topic()}'"
    if (pending.isEmpty() || System.nanoTime() > deadline) {
        break
    }
}
findings.close()

// 3. The report, written as Markdown and read back through the Markdown parser's table extraction.
def done = tasks.all(Finding).toSorted { it.topic() }     // all() is unmodifiable: toSorted, never sort in place
  • The script opens the channel before it writes, so it misses no finding.
  • space('tasks') is the FleetScript call for the joined group's space.
  • The loop ends when every topic has a finding, then closes the channel, which unsubscribes.

With the coordinator and the worker from the previous example running, start the intake on its own port from examples/groovy-fleet, where topics.csv lives:

groovy scripts/intake.groovy topics.csv report.md 7592 127.0.0.1:7590

The full script is intake.groovy.

A fleet vote, awaited

Two peers each bind a reviewer whose ballot clause judges every proposal under the ship: prefix. The proposer asks for the decision first, as a future, proposes with a quorum of two, and awaits the result. Voting is a capability: VoteClient proposes and tallies over the group's votes space.

// ReadmeExamplesTest.groovy
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 peer
  • Fleet.start { } starts each peer in the same JVM on a free loopback port. freePort() is a test helper.
  • The second peer seeds from the first, so both join it-fleet.
  • The tally shows one ballot from each peer.

The same test class also runs the take-and-reply flow and the crashed-worker recovery. It needs no network and no model key:

cd agentspaces-groovy && mvn -q test -pl groovy -Dtest=ReadmeExamplesTest

The source is ReadmeExamplesTest.groovy. The Clojure guide shows the other JVM language over the same binder.