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.
ai.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 hostsIntroduction
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
mtlssecurity 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 FleetScriptmakes the file a peer. It addsfleet,agent,bind,space, andserveto the script.- The
takeclause 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.ofattaches the tagkind=forkto 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.
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 theAGENTSPACES_CONFIGvariable, thenagentspaces.yaml.agent(name) { }builds an agent andbindbinds it.bindalso takes any object with the Java annotations.space(name)andcontent()return the joined group's space and content swarm.serve()blocks until SIGINT orstop().
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'setstick-millis. - The singular of a list property adds one element:
group('research') { }andspace '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:
| Mistake | The compile error says |
|---|---|
A where entry naming no component of the cue record | ItTask 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 cue | the 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 }anda2a { 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 withOrderedTakes.over(...)andgroup.ordered(name, ordered). BeaconLocatorbeans andPeerNodeCustomizers: 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_HOMEnames 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.0jar in~/.m2/repositorytakes precedence over Maven Central. Rebuild it withmvn install, or delete it. - **Pin the
@Grabversion**, and keep everyai.badmonkey.agentspacesartifact in one script at the same version. - **
example-02-research-fleetis 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. - **
awaitis a keyword** in Groovy 6 and binds looser than member access. Write(await x).size(), or assign first. Never name a helper methodawait. - **
all()returns an unmodifiable list.** UsetoSorted, and never sort it in place. - A filter closure runs locally.
read(Task) { priority > 2 }filters on this replica after the template matched.takeacceptswhere:andtags:only. - **
dev-local** signs frames over plain loopback TCP. Use it on a laptop, never on a shared network; production fleets usemtls.
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 theFleetScriptcall 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:7590The 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.