Building AgentSpaces workflows
This guide walks one business workflow, an insurance claims flow, from the first claim to the last ledger line, and shows how AgentSpaces expresses each step a workflow author already has a word for: sequence, map, fan-out, fork, publish and subscribe, join, gather, reduce, compose, filter, route, branch, decide, exactly once, aggregate, deadline, loop, compensation, the human step, the model step, and the planner. The developer guide explains each piece of AgentSpaces on its own; this guide shows the pieces working together on one example, and at every step it names the options you have and what each one costs.
Every snippet comes from code that the repository checks in and runs under the build gate, and the first line of every snippet names the file and the method it comes from. The main example is example 18, the claims flow, in the AgentSpaces repository under examples/example-18-agentic-workflow. Where the claims flow does not use an option, the guide shows that option from another checked-in example or test, and the snippet names that source instead.
The guide uses two vocabularies, and keeps them apart. The first is the claims flow's own: its records, which are plain Java records such as Claim and PricedClaim, and its agents, which are plain Java classes such as Registrar and Payer. The second is the AgentSpaces API: spaces, entries, leases, annotations, and the runtime that runs them. Chapter 0 defines both before any snippet uses them.
The quick start, read as a flow
The developer guide's quick start has two classes, and they already form a two-stage workflow. The Fulfiller class takes an Order record and returns a Shipment record. The Auditor class reacts to every Shipment and returns a Receipt. Neither class names the other. The method signatures are the workflow: the parameter type says which record the method waits for, the return type says which record the method produces, and the space carries one record to the next method.
// agentspaces/examples/example-11-quickstart -- Quickstart.Fulfiller and Quickstart.Auditor @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"); } @SpaceNotify public Receipt account(Shipment shipment) { return new Receipt(shipment.orderId(), shipment.item() + " shipped by " + shipment.by()); }
Every pattern in this guide ends the same way: a method returns a record, and the AgentSpaces runtime writes that record into a space as an entry. A stage never calls the next stage. It writes what it knows, and whichever stage consumes that record type picks the entry up, on whichever peer hosts that stage. That one rule is what lets the patterns compose without a workflow engine.
0. Two vocabularies: the claims flow and the AgentSpaces API
The claims flow
Example 18 is a claims department. A claimant files a claim against a policy. The department registers the claim, screens it for fraud, checks the policy is in force, estimates the damage, assembles those findings into one assessment, prices the assessment, asks three repair shops for quotes, puts the priced claim to a panel, pays the claims the panel approves, keeps a ledger of every outcome, and watches the fleet's average reserve. Three peers run it, and five spaces carry it. The first figure shows the flow as the records move between the spaces; the second shows the records themselves with their fields; the table after them lists every record the flow writes, and each chapter repeats the rows it uses.
Claims.java. The bold field is the key the flow threads through every stage.| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
Claim | a claimant's claim against a policy: claim id, policy id, claimant, description, amount | the application that files claims | the Registrar | intake |
RegisteredClaim | a claim the department accepted; the cue for every specialist | the Registrar | the FraudScreen, CoverageCheck, DamageEstimator, and the Assembler | intake |
Rejected | a claim the department refused at the door, with the reason | the Registrar | the Ledger | intake |
FraudFinding | a fraud score for one claim | the FraudScreen | the Assembler | intake |
CoverageFinding | whether the policy covers the claim, and up to what limit | the CoverageCheck | the Assembler | intake |
Instance (a DamageFinding) | the damage estimate and severity, carried as an ontology individual rather than a record of its own (chapter 12) | the DamageEstimator | the Assembler | intake |
ClaimAssessment | the three findings and the claim, composed into one record | the Assembler | the SeniorAdjuster, JuniorAdjuster, QuoteDesk, and SeniorReviewer | assessment |
PricedClaim | the reserve and a recommendation to approve or deny | an adjuster | the Underwriter, the Panelists, the Approver, the ReserveSensor | assessment |
QuoteRequest, QuoteTask | a request for repair quotes saying how many to expect, and one task per shop | the QuoteDesk | the shops and the QuoteGatherer | intake |
RepairQuote, RepairEstimate | one shop's quote; the lowest of the quotes gathered | a shop; the QuoteGatherer | the QuoteGatherer; the ledger's readers | intake; ledger |
SeniorReview | a senior reviewer's note on a severe claim | the SeniorReviewer | the ledger's readers | ledger |
Reminder | the panel's deadline for one claim, as a leased entry | the Underwriter | the Escalator, when it lapses | decisions |
PaymentOrder, ClaimDenied | the panel's approval, as an order to pay; or its denial | the Approver | the Payer; the Ledger | payments; ledger |
Payment | a payment made, with the log index that admitted it | the Payer | the Ledger and the Exposure stage | payments |
ClaimantExposure | a claimant's running total of payments | the Exposure stage | anyone | payments |
ClaimClosed | the terminal record of one case: paid, denied, or rejected | the Ledger | anyone, long after the flow | ledger |
ReserveReport | the fleet's settled average reserve | the Supervisor | anyone | ledger |
The AgentSpaces API
These are the AgentSpaces terms the chapters use. The figure shows what they look like in example 18, and the table defines each one. Each chapter repeats a definition the first time it needs the term, so you can read the chapters out of order.
| AgentSpaces term | What it is |
|---|---|
| peer | one process that runs the AgentSpaces library and joins a group. Example 18 runs three peers, named peer-a, peer-b, and peer-c. |
| group | the set of peers that share spaces. Every peer in example 18 joins the one group claims-fleet. |
| space | a named, replicated bag of entries. A space has one conflict strategy and one retention policy, decided when the peer builds it. Example 18 has five: intake, assessment, decisions, payments, and ledger. |
| replica | one peer's copy of a space. Every peer in the group holds a replica of every space, and the peers keep the replicas converging by gossip. |
| entry | one record written into a space, wrapped with its issuer, its lease, its tags, and a signature. When the guide says "the Registrar writes a RegisteredClaim", it means the runtime writes an entry whose payload is that record. |
| lease | how long something lasts. A write lease is how long an entry stays in the space. A take lease is how long a worker may hold an entry it took before the space assumes the worker died and offers the entry again. |
| tag | a string label on an entry, beside the record. A replica can match tags before it decodes the record. |
| template | a query: a record type plus conditions on its fields and its tags. Every read, take, and subscription matches a template against the replica's entries. |
| take | claiming one entry exclusively under a take lease, doing work, and completing it. Completing the entry removes it; completing it with a result writes the result in the same commit. |
| subscription, reaction | asking a replica to deliver every new entry that matches a template to a method. The guide calls the delivered call a reaction. A reaction holds no lease and claims nothing. |
| annotation | how you tell the runtime what a method is: @SpaceTake makes the method a take, @SpaceNotify makes it a reaction, @BidFunction prices an auction, @Ballot and @OnDecision vote and react to the decision, @Propose opens a vote, @OnEstimate reacts to a settled estimate, @OrderedTake takes through the ordered log, @SpaceJoin a join, @SpaceReduce a fold. The method's parameter type is the record the method waits for; its return type is the record the method produces. On the class, @AgentSpec (or the Spring and Micronaut @SpaceAgent stereotype) names the agent and its card; on a field, @SpaceRef injects a space and @CapabilityRef a typed client of a capability (VoteClient, AggregateClient); on a bean, @ProvidesCapability serves one. The developer guide has every attribute. |
| the binder | the runtime object, AgentBinder, that you give your annotated object to with bind(agent). The binder reads the annotations and then runs everything the annotations imply: the take loops, the subscriptions and their renewals, the joins, the folds, the votes. When the guide says "the binder writes the result", it means this object. |
| agent | an annotated Java object the binder has bound. Example 18's agents are the classes in the records table: Registrar, FraudScreen, Assembler, and the rest. |
| card | the advertisement the binder publishes for each bound agent, named by its @AgentSpec: which record types its methods consume and produce, and where, with one declared action per bound method. Planners and other peers read cards to find stages. |
| capability | a service a peer offers the group beyond the core: the vote, the aggregate, the ordered log. Example 18 registers all three on every peer. |
| coordinator, the ordered log | the capability that elects a quorum among the peers and serializes takes through a log, so that a take happens at most once across the whole group. |
| ticket | a small entry, JoinTicket, that the binder writes for one key of a join or a fold and then takes, so that only the taker acts on that key. |
| key | the value that identifies one case across many entries. In example 18 the key is almost always the claimId field. |
1. Entries are the protocol
Where we are in the claims flow
Before any stage runs, example 18 decides what its records look like and which space each one lives in. This chapter is that decision. A workflow engine would hold a process definition and a history; the claims flow holds neither. The records that travel between its stages are the whole of its protocol, so the shape of those records is the design.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
Claim | what a claimant files: the claim id, the policy id, the claimant's name, a description, and the amount claimed | the application that files claims | the Registrar | intake |
RegisteredClaim | the same claim after the department accepted it, plus who registered it | the Registrar | three specialists and the Assembler | intake |
ClaimAssessment | the claim and its three findings in one record: the damage estimate, the severity, the fraud score, whether the policy covers it, and the coverage limit | the Assembler | the adjusters, the quote desk, the senior reviewer | assessment |
// agentspaces/examples/example-18-agentic-workflow -- Claims: three of the records /** What arrives: a claim against a policy. */ public record Claim(String claimId, String policyId, String claimant, String description, double claimedAmount) { } /** Stage 1: the registrar's output, the cue for every specialist. */ public record RegisteredClaim(String claimId, String policyId, String claimant, String description, double claimedAmount, String registeredBy) implements Registration { } /** Stage 3: the join of the three findings, priced at auction in stage 4. */ public record ClaimAssessment(String claimId, String claimant, double claimedAmount, double damageEstimate, String severity, int fraudScore, boolean covered, double coverageLimit, String assembledBy) { }
What the design must do
Three rules shape every record in the flow, and every later chapter leans on them.
Every record carries the correlation id. The field claimId appears in every record above. A join keys on it to collect the findings of one claim, a fold groups by it, a compensating stage finds what to undo by it, and a test reads a case's outcome by it. A record without the case id is a record no later stage can connect to anything.
A reference is an id, never a nested object. Claim.policyId names the policy; it does not embed it. A stage that needs the policy reads the policy's own entry with a template on that id. The policy then lives in its own space, under its own lease, and a change to it does not invalidate every claim that mentions it.
One space per lane. The peer builds each space once, choosing its conflict strategy and its retention, and every stage inherits those choices by naming the space. In the snippet below, the assessment space uses the AUCTION strategy because the adjusters in chapter 6 bid for assessments; the other four use the default lease race.
// agentspaces/examples/example-18-agentic-workflow -- ClaimsFlow.startPeer: one space per lane // One space per lane: the strategy, the retention and the admission are // decided here, once, and every stage inherits them by naming the space. ReplicatedSpace intake = space(runtime, identity, name, Intake.SPACE); ReplicatedSpace assessment = ReplicatedSpace.builder(runtime, Pricing.SPACE, identity, name) .settleWindow(Duration.ofMillis(500)) // two ticks: a rival's reaction time plus delivery .strategy(ConflictStrategyType.AUCTION) // the bound @BidFunction prices takes .build(); ReplicatedSpace decisions = space(runtime, identity, name, Decisions.SPACE); ReplicatedSpace payments = space(runtime, identity, name, Settlement.PAYMENTS); ReplicatedSpace ledger = space(runtime, identity, name, Settlement.LEDGER);
The two failure models
Every stage in the flow runs under one of two contracts, and choosing between them is the first decision in every later chapter. The Registrar is a take: it claims a Claim entry under a lease, does its work, and completes the entry. If the registrar's method throws, or its peer dies, the lease lapses, the space offers the Claim entry again, and another registrar retries it. The FraudScreen is a reaction: every peer that hosts a FraudScreen delivers each RegisteredClaim entry to its copy of the method. If that method throws, the binder logs the exception and drops that delivery, and nothing retries it.
A take (@SpaceTake, @OrderedTake) | A reaction (@SpaceNotify, and the annotations built on it) | |
|---|---|---|
| Who runs the method | one worker: whichever agent wins the claim on the entry | every peer that hosts the agent |
| What happens on a crash | the take lease lapses, the entry reappears, another worker retries | the binder logs and drops the delivery; the entry stays; nothing retries |
| Atomicity | the completion of the input and a result written to the same space are one commit | the binder writes the result as a plain write after the method returns |
| Cost per entry | one claim, which waits one settle window on a replicated space | delivery only |
| Example 18 uses it for | the Registrar, the adjusters, the shops, the Payer | the three specialists, the SeniorReviewer, the Ledger, the ReserveSensor |
| Choose it when | the work must happen once and may fail | the work is an observation, a fan-out, or anything you can safely repeat |
FraudScreen on three peers, three fraud screens score every claim. Example 18 binds each specialist on one peer. When a reaction's effect must happen exactly once across the group, use a take instead, or a join in a fleet-wide mode (chapter 4), or a vote (chapter 7), or write a record that carries the case id so the next stage can discard duplicates.2. Sequence and recovery
Where we are in the claims flow
Stage 1. A Claim entry sits in the intake space. The department must register it, exactly once, and the registration must survive the registrar crashing halfway through. Example 18 runs two registrars, on peer-a and peer-b, and its test makes the first registration any registrar attempts throw, on purpose.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
Claim | the filed claim | the filing application | the Registrar | intake |
RegisteredClaim | the accepted claim | the Registrar | the specialists | intake |
Rejected | a claim refused at the door: an amount of zero, or no policy | the Registrar | the Ledger | intake |
Registration | a sealed interface that RegisteredClaim and Rejected implement: the registrar's two possible outcomes | nobody; it is a type | the card (chapter 6) |
What the stage must do
Take one Claim, check the vocabulary's constraints, and write either a RegisteredClaim or a Rejected. Consume the Claim and write the outcome together, so a crash cannot leave a claim consumed with no outcome, or an outcome written with the claim still waiting.
How AgentSpaces does it
// agentspaces/examples/example-18-agentic-workflow -- Intake.Registrar.register @SpaceTake(space = SPACE, lease = "3s", pollTimeout = "300ms", resultLease = "1h") public Registration register(Claim claim) { hook.beforeRegistering(name, claim); // The vocabulary's constraints, checked where the data enters: a claim // that fails them is quarantined as a Rejected entry, which the ledger // closes, rather than thrown away. if (claim.claimedAmount() <= 0) { return new Rejected(claim.claimId(), "claimedAmount must be positive"); } if (claim.policyId() == null || claim.policyId().isBlank()) { return new Rejected(claim.claimId(), "a claim names its policy"); } return new RegisteredClaim(claim.claimId(), claim.policyId(), claim.claimant(), claim.description(), claim.claimedAmount(), name); }
The annotation @SpaceTake tells the binder that register is a take. The binder reads the parameter type, Claim, and runs a loop on a virtual thread: it asks the intake space for one Claim entry nobody else holds, claims it under a three-second take lease, calls register with the record, and when the method returns, completes the Claim entry and writes the returned record into the same space under a one-hour write lease, in one commit. The hook is the example's own test device, a callback the test uses to throw once; it is not part of AgentSpaces. When the hook throws, register throws, the binder does not complete the entry, the three-second lease lapses, and the other registrar's loop takes the same Claim and registers it. The test counts one crash and five registrations.
The options
Option 1: write the result into the take space. Leave resultSpace empty, as the Registrar does. The binder then completes the Claim and writes the Registration in one commit, and the next stage takes or reacts from the same space. This is the default, and the guide prefers it wherever the next stage can live in the same lane.
Option 2: write the result into another space. Name a resultSpace. The binder completes the input bare and then writes the result as a second step. Choose it when the result belongs to a space with a different strategy or retention, and accept that a crash between the two steps loses the result; carry the input's id in the result so the next stage can spot the gap. The Party Bus watcher below does this because its reports live in a signals space with their own retention.
Option 3: renew the lease from inside the method. Ask for a short lease and call TakeContext.current().renew() while the work runs. Choose it when one call can take a hundred times longer than another, such as a model call, so that an honest slow call keeps its entry while a dead worker's entry still returns quickly. The test recorder below shows the call.
Option 4: make the effect safe to repeat and keep the lease short. This is your code, not an attribute. The registrar registers the same claim twice harmlessly because the Assembler keys on the claim id, so a three-second lease is safe. AgentSpaces cannot supply this discipline; the method must.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| Write the result into the take space | resultSpace left empty on @SpaceTake | the Registrar: the Claim and the RegisteredClaim both live in intake | the next stage can take or react from the same space; the completion and the result then commit together | the registrar above |
| Write the result into another space | resultSpace = "..." on @SpaceTake | the shops in chapter 6 take a QuoteTask from intake and write the RepairQuote to intake too; the Party Bus watchers write elsewhere | the result belongs to a space with a different strategy or retention; the binder then completes the input bare and writes the result as a second step, and a crash between the two loses the result | Party Bus NewsWatcher |
| Renew the take lease during long work | TakeContext.current() inside the method, then renew() | not used; every take in example 18 finishes in milliseconds | one call can run far longer than another; ask for a short lease and renew it from inside the method rather than guessing a long one | TakeContextTest.Recorder |
| Make the effect idempotent and keep the lease short | your own code | the registrar: registering the same claim twice writes the same record twice, and the join in chapter 4 keys on the claim id | a retry after a crash must not double an external effect; the fabric cannot supply this discipline, your method must | the registrar above |
// agentspaces-partybus -- Watchers.NewsWatcher.sweep: a take whose result goes to another space @SpaceTake(space = Scouts.SPACE, resultSpace = SIGNALS, lease = "1m", pollTimeout = "500ms") public SignalReport sweep(NewsSweep sweep) { List<Link> news = sources.news(sweep.location() + " " + sweep.topic(), 10); return new SignalReport(sweep.tripId(), sweep.topic(), sweep.location(), "news", sweep.wave(), news.stream().map(Link::summary).toList(), news, "news-watcher"); }
In the Party Bus, a NewsWatcher takes a NewsSweep request from the research space and writes its SignalReport into the signals space, where a different retention applies. The binder completes the request first and then writes the report.
// agentspaces-agent tests -- TakeContextTest.Recorder.work: renewing the lease from inside the method @SpaceTake(space = "tasks", lease = "2m", pollTimeout = "PT0.1S") public FindingEntry work(TaskEntry task) { TakeContext context = TakeContext.current().orElseThrow(); context.renew(); seen.set(context); return new FindingEntry(task.topic(), "done"); }
TakeContext.current() returns the take the current method runs under. Calling renew() extends its lease by the annotation's lease again. If the renewal throws, the lease already lapsed and another worker may hold the entry, so the method should stop.
renew for the rare long call, and make the effect safe to repeat. Example 18 uses three seconds for registering, thirty for quoting and paying, and the Spring AI summarizer of chapter 11 uses two minutes.3. Map, fork, fan-out, and publish-subscribe
Where we are in the claims flow
Stage 2 and stage 3a. A RegisteredClaim entry exists. Three specialists must each examine it and write their own finding: the FraudScreen a FraudFinding, the CoverageCheck a CoverageFinding, the DamageEstimator a damage finding. Later, once an assessment exists, the QuoteDesk must ask three repair shops for quotes, which means writing one request and three tasks from one assessment.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
RegisteredClaim | the accepted claim; the cue every specialist waits for | the Registrar | the three specialists | intake |
FraudFinding | a fraud score from 0 to 100 and a rationale | the FraudScreen | the Assembler | intake |
CoverageFinding | whether the policy is in force, and its limit | the CoverageCheck | the Assembler | intake |
ClaimAssessment | the assembled findings | the Assembler | the QuoteDesk, among others | assessment |
QuoteRequest | a request for quotes on one claim, stating how many quotes to expect | the QuoteDesk | the QuoteGatherer | intake |
QuoteTask | one quote task for one shop, naming the shop and the damage estimate | the QuoteDesk | the shop the task names | intake |
Map: one record in, one record out
In a stream library, map applies one function to every element. Here the elements are the entries of one record type in one space, and the function is a method you annotate. The CoverageCheck maps every RegisteredClaim to a CoverageFinding:
// agentspaces/examples/example-18-agentic-workflow -- Intake.CoverageCheck.check @SpaceNotify(space = SPACE, resultLease = "1h") public CoverageFinding check(RegisteredClaim claim) { boolean covered = !claim.policyId().endsWith("-LAPSED"); return new CoverageFinding(claim.claimId(), covered, COVERAGE_LIMIT, "coverage-check"); }
@SpaceNotify tells the binder that check is a reaction. The binder subscribes to the intake space for RegisteredClaim entries, and each time the peer's replica receives one, the binder calls check with the record on a virtual thread and writes the returned CoverageFinding into the same space under a one-hour lease. The binder also renews the subscription's lease for as long as the agent stays bound, and it delivers each entry to this agent once, even when gossip delivers the same entry twice.
Fan-out and publish-subscribe: several maps on one cue
Fan-out is the map above written three times for three record types. The FraudScreen, the CoverageCheck, and the DamageEstimator each declare RegisteredClaim as their parameter, so each of them reacts to every registered claim, and each returns its own finding. None of the three knows the other two exist, and the Registrar that writes the RegisteredClaim knows none of them. Example 18 binds the FraudScreen on peer-a, the CoverageCheck on peer-b, and the DamageEstimator on peer-c, and the replicas carry every registered claim to all three.
That is also publish and subscribe. The Registrar is the publisher, the RegisteredClaim record type is the topic, each specialist is a subscriber, and the space is the broker that is not there. The table at the end of this chapter maps every broker term onto this example.
Fork: one record in, several records out
The QuoteDesk reacts to one ClaimAssessment and must write four records: a QuoteRequest that tells the gatherer to expect three quotes, and three QuoteTasks, one per shop. A method has one return value, so it returns an Entries, which is the AgentSpaces class for "several entries from one return":
// agentspaces/examples/example-18-agentic-workflow -- Quotes.QuoteDesk.ask @SpaceNotify(space = Pricing.SPACE, resultSpace = Intake.SPACE, resultLease = "1h", produces = {QuoteRequest.class, QuoteTask.class}) public Entries ask(ClaimAssessment assessment) { return Entries.of(new QuoteRequest(assessment.claimId(), SHOPS.size()), new QuoteTask(assessment.claimId(), "north", assessment.damageEstimate()), new QuoteTask(assessment.claimId(), "east", assessment.damageEstimate()), new QuoteTask(assessment.claimId(), "south", assessment.damageEstimate())); }
Four things happen when ask returns, and each one deserves its own sentence.
The binder writes each element on its own. The binder walks the four elements in order and writes each one into the intake space as a separate entry under the one-hour result lease, exactly as it would have written a single returned record. The three shop classes in chapter 6 then take the three QuoteTask entries one at a time, and the QuoteGatherer in chapter 4 reads the QuoteRequest.
An element may be any return kind the binder knows. Because the binder dispatches each element as if the method had returned it alone, an element may be a plain record, a Tagged record (a record plus the tags to write on its entry, chapter 12), a Motion (an instruction to open a vote, chapter 7), or a Contribution (a value for a fleet-wide aggregate, chapter 9). The binder refuses an Entries inside an Entries.
From a take, the binder completes the input before it writes the first element. If the forking method were a @SpaceTake, the binder would complete the taken entry once with no result, then write the elements. A take's one-commit guarantee covers one result, and a fork has several. The QuoteDesk is a reaction, so the binder completes nothing and only writes.
A fork is not atomic, so the flow tolerates a partial one. A peer can die after writing the second element and before the third, and no engine rolls that back. Two habits make a partial or repeated fork harmless. First, every forked record carries the case id: each QuoteTask and the QuoteRequest carry claimId. Second, the stage downstream discards duplicates by that id: the QuoteGatherer fires once per claimId, so a repeated fork yields one estimate.
The annotation tells the card what the fork produces. The binder publishes a card for every agent, listing the record types each method consumes and produces, by reading the method signature. A signature that says Entries names no record type, so the method lists its element types in produces. That changes no behaviour; it keeps the card honest.
Map to nothing: a null return
A reaction that decides it has nothing to write returns null, and the binder writes nothing. The Escalator in chapter 10 does this when the panel has already decided. Use a null return when the decision needs a lookup; when the decision is a field value, use the where filter of chapter 6 instead, because the filter keeps the method from running at all.
The options
Option 1: map with a take. Annotate the method @SpaceTake and return the next record. One worker handles each entry, and a crash retries it. The Registrar in chapter 2 is this shape. Choose it when the output must exist once or the work can fail.
Option 2: map with a reaction. Annotate the method @SpaceNotify and return the next record, as the CoverageCheck does. Every peer that hosts the agent runs it. Choose it when you bind the agent once, or when a repeated output is harmless.
Option 3: fork through the return value. Return Entries.of(...) and list the element types in produces, as the QuoteDesk does. Choose it when one record starts several tasks and you know the elements when the method returns; the card then lists them.
Option 4: fork through explicit writes. Declare a @SpaceRef field, which asks the binder to inject a Space handle at bind time, and call space.write(record, lease) as many times as you need. The Party Bus Dispatcher below does this. Choose it when the elements go to several spaces or the method must read between writes; the card cannot list what you write by hand.
Option 5: map to nothing. Return null, and the binder writes nothing. The PetClinic Vet below and the Escalator of chapter 10 do this. Choose it when the decision needs a lookup; when the decision is a field value, prefer the where filter of chapter 6, which keeps the method from running.
Option 6: map over a backlog. A subscription only delivers entries that arrive after it starts, so a reaction bound late misses the backlog. A take sees every entry still in the space, so prefer a take for a backlog; when you must sweep from a reaction, call space.readAll(template, limit) through a @SpaceRef field.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| Map with a take: once per entry, with retry | @SpaceTake returning the next record | the Registrar (chapter 2) | the output must exist once, or the work may fail and must retry | chapter 2 |
| Map with a reaction: on every hosting peer | @SpaceNotify returning the next record | the CoverageCheck above | you bind the agent once, or the output is harmless to repeat | above |
| Fork through the return value | Entries.of(...) with produces on the annotation | the QuoteDesk above | one record starts several tasks and the elements are known when the method returns | above |
| Fork through explicit writes | a @SpaceRef field holding a Space, and space.write(...) in the method | not used in example 18; the Party Bus Dispatcher writes nine requests this way | the elements go to several spaces, or the method must read something between writes; the card cannot list what you write by hand | Party Bus Dispatcher |
| Map to nothing | return null | the Escalator (chapter 10), the PetClinic Vet | the decision needs a lookup the template cannot express | PetClinic Vet |
| Map over entries already in the space | space.readAll(template, limit) and a loop | not used | a subscription only delivers entries written after it starts; to process a backlog, prefer a take, which sees every entry still in the space |
// agentspaces-partybus -- Dispatcher.dispatch: a fork by explicit writes through @SpaceRef fields @SpaceRef(Scouts.SPACE) Space research; @SpaceRef(Party.TRIP) Space trip; /** Fan a brief out into the bag, the sweeps, and the auction. */ @SpaceNotify(space = Party.TRIP, lease = "12h") public void dispatch(TravelBrief brief) { String id = brief.tripId(); Travelers party = trip.read(Template.of(Travelers.class).where("tripId", eq(id)), Duration.ofSeconds(10)).orElseThrow(() -> new IllegalStateException( "no travelers written for trip " + id)); List<String> needs = party.allNeeds(); String who = party.contribution(); double nightly = brief.dailyBudget() / 2.0; // Tripper's half-the-budget rule research.write(new FlightRequest(id, "flights " + brief.from() + " to " + brief.to(), brief.from(), brief.to(), brief.departureDate(), brief.returnDate(), brief.transportPreference()), REQUEST_LEASE); research.write(new HotelRequest(id, "hotels " + brief.to(), brief.to(), brief.departureDate(), brief.returnDate(), nightly, needs), REQUEST_LEASE);
The Party Bus Dispatcher reacts to a TravelBrief, reads the Travelers record for the trip first, and then writes nine research requests by hand through @SpaceRef fields. @SpaceRef asks the binder to inject a Space handle into the field at bind time. This shape lets the method read between writes and write to several spaces; it costs the card, which cannot see the nine types.
// agentspaces-example-apps/agentspaces-petclinic-fleet -- PetClinicFleet.Vet.see: a reaction that returns null @SpaceNotify(space = CLINIC, lease = "4h", resultLease = "4h") public VisitRecord see(Appointment appointment) { if (!appointment.vet().equals(vetName)) { return null; // not mine } String[] outcome = diagnose(appointment.petName()); return new VisitRecord(appointment.requestId(), appointment.petName(), vetName, outcome[0], outcome[1]); }
In the PetClinic, both vets react to every Appointment, and each returns null for an appointment that names the other vet. A where = "vet=dr-song" filter would do the same without calling the method, but it would need one class per vet, since an annotation value is a constant.
Publish and subscribe, term by term
| Broker term | What it is in the claims flow |
|---|---|
| Topic | a record type in a space. RegisteredClaim in intake is the topic the three specialists subscribe to. Tags on an entry are sub-topics. |
| Publish | writing an entry. The Registrar publishes by returning a RegisteredClaim; any code, in any language, publishes with Space.write(record, lease). |
| Subscribe | a @SpaceNotify method whose parameter is the topic's record type, narrowed by tags and where if needed. In the API, Space.notify(template, listener, lease). |
| Delivery to every subscriber | every replica delivers every matching entry to every subscription it holds. Three specialists on three peers all receive every RegisteredClaim. |
| Consumer group, one of N | a @SpaceTake. The two registrars form a consumer group over Claim: each claim goes to one of them. |
| Retained message | the entry itself, for its write lease. A specialist that binds after the registrar wrote a claim can still read that claim with readAll, because a subscription only delivers the entries that arrive after it starts. |
| Durable subscription, replay | AgentSpaces does not provide one. The subscription holds a lease and starts now; the entry is the durable thing; an agent that restarts reads the space. |
| At least once, deduplicated | a replica may deliver an entry twice; the binder delivers each entry id to each agent once. |
| Ordering | first in, first out among entries of one type on one peer; no order across peers or types. The ordered log of chapter 8 gives order when order matters. |
| Wildcard, hierarchy | tags. tags = "region" subscribes to every entry with that tag; tags = "region=eu" to one value. A template names one concrete record type and never an interface. |
| Lifecycle events | @SpaceNotify(on = EXPIRED) and the other kinds: the subscriber hears an entry lapse, reappear, or complete (chapter 10). |
Four rules for a subscriber
A subscriber, in this guide, is an agent with a @SpaceNotify method. That method is the reaction; the type of its parameter is the cue type, the record it listens for; and the record it returns goes into a space under the result lease named on the annotation. Each peer holds its own replica of every space, and the peer that hosts the subscriber delivers matching entries to it from that replica. A broker would enforce the four rules below for you. Here they are yours, and each one follows from how the replica delivers.
1. A reaction runs on every peer that hosts the subscriber. If you bind the FraudScreen on three peers, each peer delivers each RegisteredClaim to its own copy, so three fraud findings appear per claim. That is what makes fan-out cheap, and it is a problem only when the reaction's effect must happen once. Three remedies, in order of preference: bind the subscriber on one peer, as example 18 binds the FraudScreen on peer-a alone; return a Motion, which opens a vote once per proposal id however many peers return it; or write a record that carries the case id and let the next stage discard duplicates by it, as the Assembler does. A reaction whose repeated effect is harmless needs none of these.
2. The binder drops a reaction that throws, and nothing retries it. A take holds a lease, so when its method throws the lease lapses and the entry comes back for another worker. A reaction holds nothing: the replica delivered the entry, nobody claimed it. When the method throws, the binder logs the exception and never delivers that entry to this subscriber again. If the work can fail and must still happen, write the stage as a @SpaceTake; if it may fail harmlessly, catch what you can and let the rest go.
3. A reaction must not write its own cue type. The subscriber sees every entry of its cue type in the replica, including the entries this same process wrote a moment ago. A reaction that listens for X and returns an X delivers to itself forever. Return a different type, as every reaction in example 18 does, or when the type cannot change, put a field on the record that the reaction never produces and filter on it with where (chapter 6).
4. The result lease is how long the publication stays. The CoverageFinding stays in the intake space for one hour and then every replica drops it. A subscriber that starts late can still read it with readAll while the hour lasts; after that nothing replays it. When a record must outlive the flow, write it into a space of its own under a long lease: the Ledger writes every ClaimClosed into the ledger space under a twenty-four-hour lease, and the short-lived lanes stay short-lived.
4. Join, gather, and reduce
Where we are in the claims flow
Stage 3, stage 3a, and stage 7a. The three findings of one claim land in the intake space on three different peers, in any order, and the Assembler must wait for all three before it can write the ClaimAssessment. Later, three shops write three RepairQuotes per claim and the QuoteGatherer must wait for exactly three before choosing the lowest. Later still, payments land one at a time and the Exposure stage must keep each claimant's running total current after every payment.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
RegisteredClaim, FraudFinding, CoverageFinding, the damage Instance | the four parts of one claim, each written by a different agent on a different peer | the Registrar and the three specialists | the Assembler | intake |
ClaimAssessment | the four parts composed into one record | the Assembler | the adjusters and the quote desk | assessment |
QuoteRequest | how many quotes to expect for one claim | the QuoteDesk | the QuoteGatherer | intake |
RepairQuote | one shop's quote for one claim | a shop | the QuoteGatherer | intake |
RepairEstimate | the lowest quote, and how many were considered | the QuoteGatherer | the ledger's readers | ledger |
Payment | one payment to one claimant | the Payer | the Exposure stage and the Ledger | payments |
ClaimantExposure | a claimant's running total of payments, and their count | the Exposure stage | anyone | payments |
Join: all the parts of one case
What the stage must do
Fire once per claim when the RegisteredClaim, the FraudFinding, the CoverageFinding, and the damage finding for that claim are all present, whatever order they arrived in and whichever peer wrote them, and write one ClaimAssessment. In relational terms this is an equi-join of four record types on the one key claimId.
How AgentSpaces does it
// agentspaces/examples/example-18-agentic-workflow -- Intake.Assembler.assemble @SpaceJoin(space = SPACE, key = "claimId", resultSpace = Pricing.SPACE, resultLease = "1h", parts = {@Part(RegisteredClaim.class), @Part(FraudFinding.class), @Part(CoverageFinding.class), @Part(value = Instance.class, keyTag = "claimId", tags = "rdf:type=" + Claims.NAMESPACE + Instance.DAMAGE_FINDING)}) public ClaimAssessment assemble(Joined j) { RegisteredClaim claim = j.get(RegisteredClaim.class); Instance damage = j.get(Instance.class); return new ClaimAssessment(j.key(), claim.claimant(), claim.claimedAmount(), damage.number("estimate"), damage.string("severity"), j.get(FraudFinding.class).fraudScore(), j.get(CoverageFinding.class).covered(), j.get(CoverageFinding.class).coverageLimit(), name); }
@SpaceJoin tells the binder that assemble is a join. The annotation lists the parts: four @Part declarations, each naming a record type the join waits for. key = "claimId" names the field every part carries, except the damage finding, which is an Instance record with no claimId field; for that part keyTag = "claimId" tells the binder to read the key from a tag on the entry, and tags = "rdf:type=..." tells it to consider only instances of the damage-finding class. The binder subscribes to all four record types in the intake space, keeps a table of which parts it has seen per claimId, and when a claim has all four, reads the four entries again, wraps them in a Joined bag, and calls assemble on a virtual thread. Joined.get(type) returns the newest part of a type; Joined.key() returns the claim id. The binder writes the returned ClaimAssessment into the assessment space.
The binder also seeds its table from the space when the agent binds, so parts that landed before the Assembler started still count, and it forgets a claim whose parts stop arriving after within, one hour by default.
The options: how many times one claim fires
Example 18 binds one Assembler on peer-a. If you bind an Assembler on every peer, each one sees every claim complete, and the question becomes how many times one claim may fire across the group. That is the join's mode, and it is the join's one real trade-off.
LOCAL, the default. The binder remembers the keys it fired in memory and writes nothing before the result. Each bound joiner fires each claim once, so bind one Assembler, as example 18 does, or let the next stage discard a duplicate by the claim id. A restarted joiner re-reads the space and may fire a claim again.
LEASED. When a claim completes, the binder writes a JoinTicket entry for it into the join's space, withdraws the younger of two tickets written in the same instant, and fires from a take of that ticket. Several joiners on several peers then share one ticket per claim, and the take's lease gives the firing a retry. Under a network partition two tickets may survive, which is the contract of the lease race; the test joiner below shows two joiners and a crash.
ORDERED. The binder takes the ticket through the ordered-log coordinator registered for the join's space, and the first ticket the log commits for a claim is the only one that fires, on whichever peer took it. This is once across the group, at the price of the log: a peer hosts one ordered log per group, so the OrderedAssembler below names the payments space, where example 18's log runs, as the join's space, and intake on each part.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
LOCAL: once per bound joiner | mode = SpaceJoin.Mode.LOCAL, the default; the binder keeps the fired keys in memory and writes nothing before the result | the Assembler above, bound on one peer | you bind one joiner, or the next stage discards duplicates by the key; a restarted joiner re-reads the space and may fire a claim again | above |
LEASED: once per lease race, per ticket | mode = LEASED; when a claim completes, the binder writes a JoinTicket entry for it, withdraws the younger of two written in the same instant, and fires from a take of that ticket | not used in example 18 | several joiners on several peers, and a duplicate under a network partition is acceptable; this is the contract of the lease race | AgentBinderJoinTest.LeasedJoiner |
ORDERED: once across the group | mode = ORDERED; the binder takes the ticket through the space's ordered-log coordinator, and the first ticket the log commits for a claim is the only one that fires | the OrderedAssembler, which the second test binds on peer-a and peer-b | the assessment must exist exactly once; costs the log, and a peer hosts one log per group, so the join lives on the space that has the coordinator while its parts stay where they are | below |
// agentspaces/examples/example-18-agentic-workflow -- Intake.OrderedAssembler: the same join, once fleet-wide, on the payments log @SpaceJoin(space = Settlement.PAYMENTS, key = "claimId", mode = SpaceJoin.Mode.ORDERED, takeLease = "30s", pollTimeout = "500ms", resultSpace = Pricing.SPACE, resultLease = "1h", parts = {@Part(value = RegisteredClaim.class, space = SPACE), @Part(value = FraudFinding.class, space = SPACE), @Part(value = CoverageFinding.class, space = SPACE), @Part(value = Instance.class, space = SPACE, keyTag = "claimId")})
The OrderedAssembler names the payments space as the join's own space, because example 18's ordered log runs there, and names intake on every part, because the findings live there. The join's space is where its tickets go; its parts may live anywhere.
// agentspaces-agent tests -- AgentBinderJoinTest.LeasedJoiner.assemble: two joiners sharing one ticket under a lease @SpaceJoin(space = "intake", key = "claimId", mode = SpaceJoin.Mode.LEASED, takeLease = "300ms", pollTimeout = "100ms", parts = {@Part(Fraud.class), @Part(Coverage.class)}) public Assessment assemble(Joined j) { invocations.incrementAndGet(); if (crashesLeft.getAndDecrement() > 0) { throw new IllegalStateException("simulated crash"); } return new Assessment(j.key(), j.get(Fraud.class).score(), j.get(Coverage.class).covered(), 0, ""); }
The test binds two of these joiners over one space. Both see each key complete, one ticket stands per key, and the joiner that takes it fires. When the firing throws, the ticket's take lease lapses and the other joiner takes it, which is the take retry applied to a join.
Assembler was hand-written, and it guarded against firing twice by reading for its own ClaimAssessment. The adjuster's auction take consumed that assessment within milliseconds, and a consumed entry is invisible to a read, so the assembler fired again and the adjusters priced one claim twice. The second story is the outrun part. In the LEASED and ORDERED modes, the peer that saw a claim complete writes the ticket, and the peer that wins the ticket may not hold every part of that claim yet. A stress run of the ordered join under CPU load lost two claims in sixty that way, before the binder learned to wait for the parts and, if they never arrive, to release the ticket by its lease instead of completing it. Both are now the binder's job; the guide tells them so you recognise their shape in your own flows.LOCAL on one peer, or on several, is enough. If a duplicate is costly but a rare one under a partition is survivable, LEASED gives you several joiners with one ticket per claim and no quorum. If the assessment starts an irreversible step, ORDERED gives you once across the group, and you accept that the join needs the quorum to be live and must sit on the space that hosts the coordinator.Gather: several of one kind
What the stage must do
Wait for the three RepairQuotes of one claim and then choose the lowest. The QuoteDesk in chapter 3 wrote a QuoteRequest saying how many quotes to expect, so the gatherer can wait for exactly that many.
How AgentSpaces does it
// agentspaces/examples/example-18-agentic-workflow -- Quotes.QuoteGatherer.pick @SpaceJoin(space = Intake.SPACE, key = "claimId", resultSpace = Settlement.LEDGER, resultLease = "24h", parts = {@Part(QuoteRequest.class), @Part(value = RepairQuote.class, countedBy = "QuoteRequest.expected")}) public RepairEstimate pick(Joined j) { List<RepairQuote> quotes = j.all(RepairQuote.class); RepairQuote lowest = quotes.stream().min(Comparator.comparingDouble(RepairQuote::amount)).orElseThrow(); return new RepairEstimate(j.key(), lowest.shop(), lowest.amount(), quotes.size()); }
A gather is a join whose part may appear several times for one key. countedBy = "QuoteRequest.expected" tells the binder to read the number of RepairQuotes to wait for from the expected field of the QuoteRequest part of the same claim, whenever that request lands, before or after the quotes. When the quotes reach that count, the binder calls pick, and Joined.all(RepairQuote.class) returns every quote for the claim. The method body is the fold: it keeps the lowest. The binder writes the RepairEstimate into the ledger space.
The options: when is "enough"
Option 1: a count another record declares. countedBy = "QuoteRequest.expected", as the QuoteGatherer uses. Choose it when the stage that forked the work knows how many pieces it made, and have that stage write the count; the binder reads it whenever the counting record lands.
Option 2: a fixed count. atLeast = n on the part, as the test gatherer below uses. Choose it when the design fixes the number. A fourth quote after the join fired does not fire it again.
Option 3: quiet for a while. settle = "300ms" on the join with atLeast = 1 on the part, as the second test gatherer below uses. The binder fires once no new quote has arrived for the settle time. Choose it when nobody knows the count; a slow bidder extends the window, and a settle time shorter than the gap between two honest bids splits one gather into two.
Option 4: give up. within on the join, one hour by default. The binder forgets a claim whose parts stop arriving, and a later part starts the claim again. Set it on every gather as the deadline for a claim that will never complete.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| A count another record declares | @Part(countedBy = "Type.field") | the QuoteGatherer above: the QuoteRequest declares three | the stage that forked the work knows how many pieces it made | above |
| A fixed count | @Part(atLeast = n) | not used | the design fixes the number: always two signatures, always three bids | AgentBinderGatherTest.AtLeastTwo |
| Quiet for a while | settle = "2s" on @SpaceJoin, usually with atLeast = 1 | not used | nobody knows the count: collect bids until no new bid has arrived for the settle time; a slow bidder extends the window | AgentBinderGatherTest.Settled |
| Give up | within = "1h" on @SpaceJoin | every join in example 18, at the default | always, as the deadline for a claim whose parts never all arrive; a part that arrives later starts the claim again | above |
// agentspaces-agent tests -- AgentBinderGatherTest.AtLeastTwo.pick: a fixed count @SpaceJoin(space = "quotes", key = "claimId", parts = {@Part(value = Quote.class, atLeast = 2)}) public Best pick(Joined j) { fired.add(j); Quote best = j.all(Quote.class).stream().min((a, b) -> Double.compare(a.amount(), b.amount())).orElseThrow(); return new Best(j.key(), best.shop(), best.amount(), j.all(Quote.class).size()); }
// agentspaces-agent tests -- AgentBinderGatherTest.Settled.pick: whatever arrived before the key went quiet @SpaceJoin(space = "quotes", key = "claimId", settle = "300ms", parts = {@Part(value = Quote.class, atLeast = 1)}) public Best pick(Joined j) { fired.add(j); Quote best = j.all(Quote.class).stream().min((a, b) -> Double.compare(a.amount(), b.amount())).orElseThrow(); return new Best(j.key(), best.shop(), best.amount(), j.all(Quote.class).size()); }
Reduce: three forms, and which one you mean
@SpaceReduce: the accumulator is an entry that every peer can read. If it means one number per peer, or a numeric field over whatever each peer holds, and an approximation that settles is acceptable, you want the aggregate: no peer collects anything, and the figure converges by gossip."Reduce" names three different things in a fleet, and AgentSpaces has a separate expression for each. The QuoteGatherer above is the first: a bounded set of records per key, folded once in the method body. The Exposure stage below is the second: an unbounded stream of records per key, folded one at a time into an accumulator that stays current. The ReserveSensor and Supervisor in chapter 9 are the third: one number per peer, averaged across the group.
The gather (@SpaceJoin) | The persistent accumulator (@SpaceReduce) | The aggregate (Contribution, @OnEstimate) | |
|---|---|---|---|
| The elements | a bounded set of records per key | an unbounded stream of records per key | one value per peer, or the matches of a template on each peer |
| The operator | any Java code in the method | any Java code in the method | sum, average, count, min, max, quantile |
| Where the state lives | in the element entries, until the join fires | in an accumulator entry beside the elements | inside the aggregate capability on each peer |
| When the value is current | once, when the join fires | after every element | an estimate that converges and then settles |
| Exact | yes | yes | converging |
| Survives a peer restart | yes, in the entries | yes, in the accumulator entry | no; the epoch forms again |
| Who can read it | anyone can read the elements | anyone can read the accumulator, in any language | a Java peer calls estimate |
| Example 18 | the QuoteGatherer | the Exposure stage | the ReserveSensor and Supervisor |
What the Exposure stage must do
Keep one ClaimantExposure record per claimant, holding the total paid and the number of payments, and advance it each time a Payment lands, without ever counting a payment twice and without two peers losing each other's update.
// agentspaces/examples/example-18-agentic-workflow -- Settlement.Exposure.expose @SpaceReduce(space = PAYMENTS, key = "claimant", name = "exposure", lease = "30s", pollTimeout = "300ms", accumulatorLease = "24h") public ClaimantExposure expose(ClaimantExposure exposure, Payment payment) { long paid = (exposure == null ? 0 : exposure.paidCents()) + payment.cents(); int count = (exposure == null ? 0 : exposure.payments()) + 1; return new ClaimantExposure(payment.claimant(), paid, count); }
@SpaceReduce tells the binder that expose is a fold. The first parameter type, ClaimantExposure, is the accumulator; the second, Payment, is the element; key = "claimant" names the element field that groups payments by claimant. For each payment the binder takes the Payment entry under a thirty-second lease, reads the claimant's current ClaimantExposure entry (null for the first payment), calls expose, and completes the Payment entry with the returned ClaimantExposure as the result, in one commit. That one commit is why the stage never counts a payment twice: either the commit consumes the payment and writes the new total, or it does neither. The binder then retires the previous total. The twenty-four-hour accumulatorLease is the write lease of each new total, so a claimant with no payment for a day lapses from the space, and the lapse is an event chapter 10 can react to.
Two reducers on two peers could each take a different payment of the same claimant at the same moment, read the same old total, and each write a new total that ignores the other's payment. The binder prevents that with a JoinTicket per claimant: a reducer takes the claimant's ticket before it folds, renews the ticket's lease before every payment, and stops if a renewal fails. The hand-written version of this stage, take the payment, read the total, write a new one, loses updates in exactly that race, and no test on one peer shows it.
Reductions.current(space, ClaimantExposure.class, "exposure", claimant) is how any stage reads a claimant's newest total, and it is what the test uses on every peer. The developer guide's annotation chapter lists every attribute of @SpaceReduce, and the PetClinic scheduler keeps its appointment slot book the same way, as the other consumer.
5. Compose
Where we are in the claims flow
Stage 3, inside the Assembler. The four parts of a claim are in hand, and the assembler must write one ClaimAssessment that the adjusters can price without reading the four parts again.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
ClaimAssessment | the claim id, the claimant, the amount claimed, the damage estimate and severity from the damage finding, the fraud score, whether the policy covers the claim, the coverage limit, and who assembled it | the Assembler | the adjusters, the quote desk, the senior reviewer | assessment |
What the stage must do
Copy into the assessment exactly what the next stages need, and nothing else, so that the adjusters can price it, the quote desk can fork from it, and the senior reviewer can filter on its severity, each with one read of one record.
// agentspaces/examples/example-18-agentic-workflow -- Intake.Assembler.assemble: the composition RegisteredClaim claim = j.get(RegisteredClaim.class); Instance damage = j.get(Instance.class); return new ClaimAssessment(j.key(), claim.claimant(), claim.claimedAmount(), damage.number("estimate"), damage.string("severity"), j.get(FraudFinding.class).fraudScore(), j.get(CoverageFinding.class).covered(), j.get(CoverageFinding.class).coverageLimit(), name);
The Joined bag that the binder hands to assemble answers get(type) for the newest part of one type, find(type) for a part marked optional, all(type) for a gathered part, and entry(type) for the entry's metadata: who wrote it, its tags, its lease, and when. The assembler copies values out of the parts into the new record. damage.number("estimate") and damage.string("severity") read properties of the ontology instance, which chapter 12 explains.
The options: how to carry each value
Option 1: copy the value into the composed record. The assembler copies the severity, the fraud score, and the coverage limit into the ClaimAssessment, because the senior reviewer filters on the severity, the adjusters bid on it, and the pricing reads the limit. Choose it for any value a later stage matches on, keys on, or branches on; the writer owns the duplication, and the value freezes at composition time.
Option 2: carry the id of another entry. Every claim record carries policyId as a string rather than a policy. A stage that needs the policy reads it with a template on the id, as the Panelist in chapter 7 reads the PricedClaim by claimId. Choose it when the referent is large, has its own lease, or changes on its own; the reading stage pays one read and must handle a referent that lapsed.
Option 3: write a label as a tag. Return Tagged.of(record, key, value, ...), as the DamageEstimator does in chapter 12. Choose it when a stage must select on the value before decoding the record, or when the value is a label such as a class IRI rather than data. Tags stay in plain text under content encryption.
Option 4: compose parts from several spaces. Name a space on each @Part that lives elsewhere, as the OrderedAssembler in chapter 4 and the Party Bus plan assembler below do. Choose it when the parts live in different lanes; the join's own space is where its tickets go.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| Copy the value into the composed record | a record component | severity, fraud score, coverage limit: every later stage filters or decides on them | a later stage will match on the value with where, key a join on it, or branch on it | above |
| Carry the id of another entry | a String id component, read later with Template.of(T.class).where("id", eq(...)) | policyId on every claim record; the Panelist reads the PricedClaim by claimId (chapter 7) | the referent is large, has its own lease, or changes on its own | chapter 7, the Panelist |
| Write a label as a tag | return Tagged.of(record, key, value, ...) | the DamageEstimator tags its instance with the claim id and the class IRI (chapter 12) | a stage must select on the value before decoding, or the value is a label rather than data | chapter 12 |
| Compose parts from several spaces | @Part(value = T.class, space = "...") | the OrderedAssembler (chapter 4) | the parts live in different lanes | Party Bus PlanAssembler |
// agentspaces-partybus -- Assemblers.PlanAssembler.assemble: parts from two spaces, one of them optional @SpaceJoin(space = Party.TRIP, key = "tripId", resultLease = "12h", parts = {@Part(TravelBrief.class), @Part(value = SightseeingOptions.class, space = Scouts.SPACE), @Part(value = HotelOptions.class, space = Scouts.SPACE), @Part(value = RestaurantOptions.class, space = Scouts.SPACE), @Part(value = FoodieOptions.class, space = Scouts.SPACE), @Part(value = FlightOptions.class, space = Scouts.SPACE), @Part(value = Travelers.class, optional = true)}) public ProposedTravelPlan assemble(Joined j) { TravelBrief brief = j.get(TravelBrief.class); Travelers party = j.find(Travelers.class).orElse(new Travelers(j.key(), List.of()));
The Party Bus plan assembler composes a ProposedTravelPlan from a TravelBrief in the trip space and five research results in the scouts space, and treats the Travelers record as optional: j.find(Travelers.class) returns an Optional, and the method supplies an empty party when nobody wrote one.
6. Filter, route, and branch
Where we are in the claims flow
Stages 3a, 3b, 4, and 1 again. The QuoteDesk wrote three QuoteTasks, and each of the three shops must take only the task that names it. The SeniorReviewer must see only the assessments whose severity is HIGH. The two adjusters must split the assessments so that the senior adjuster prices the severe ones and the junior the routine ones. And the Registrar of chapter 2 must send a claim down one of two paths, registered or rejected.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
QuoteTask | a quote task that names one shop in its shop field: north, east, or south | the QuoteDesk | the shop it names | intake |
RepairQuote | a shop's quote | the shop | the QuoteGatherer | intake |
ClaimAssessment | the assessment, with a severity field of LOW, MEDIUM, or HIGH | the Assembler | the adjusters and the SeniorReviewer | assessment |
SeniorReview | a note on a severe claim | the SeniorReviewer | the ledger's readers | ledger |
PricedClaim | the reserve and the recommendation, and which adjuster priced it | an adjuster | the underwriter, the panel, the approver | assessment |
Registration, RegisteredClaim, Rejected | the registrar's two outcomes behind one sealed interface | the Registrar | the specialists; the Ledger | intake |
Filter: a take or a reaction that sees only some entries
What the stage must do
The NorthShop must take QuoteTask entries whose shop field is north and never touch the others. The SeniorReviewer must react to ClaimAssessment entries whose severity field is HIGH and ignore the rest.
How AgentSpaces does it
Every take and every reaction matches entries against a template. A template is a query: one record type, plus conditions on the record's fields and on the entry's tags. The binder builds the template for a method from the method's parameter type, and the annotation's where and tags attributes add conditions to it. The replica applies the template before it hands the entry to anyone, so a condition on the annotation keeps the method from ever seeing an entry that fails it.
// agentspaces/examples/example-18-agentic-workflow -- Quotes.NorthShop.quote: a take that sees only its own tasks @SpaceTake(space = Intake.SPACE, where = "shop=north", lease = "30s", pollTimeout = "300ms", resultLease = "1h") public RepairQuote quote(QuoteTask task) { return Quotes.quote(task, "north", 1.1); }
where = "shop=north" adds the condition "the shop field equals north" to the NorthShop's template. The replica judges that condition before the take claims anything, so the north shop never leases a task meant for the east shop, and that task stays in the space for the east shop's take. Example 18 has one class per shop, NorthShop, EastShop, and SouthShop, because a Java annotation value must be a constant, and it binds one shop per peer.
// agentspaces/examples/example-18-agentic-workflow -- Quotes.SeniorReviewer.review: a reaction that sees only severe assessments @SpaceNotify(space = Pricing.SPACE, where = "severity=HIGH", resultSpace = Settlement.LEDGER, resultLease = "24h") public SeniorReview review(ClaimAssessment assessment) { return new SeniorReview(assessment.claimId(), "severe: damage " + assessment.damageEstimate() + " against a limit of " + assessment.coverageLimit()); }
The same attribute on a reaction keeps the replica from delivering a LOW or MEDIUM assessment to review at all. The test asserts that exactly one SeniorReview exists, for the one severe claim.
The order in which a replica judges a template matters. It compares the record type first, then the tags, then whether the entry is already claimed, then it decodes the record and judges the field conditions. Tags therefore cost nothing to check and a relay that cannot decrypt the record can still route on them (chapter 12); field conditions need the decoded record, so only a peer that holds the content key can judge them.
The options
Option 1: filter on a field, on the annotation. where = "shop=north" or where = "severity!=LOW". The binder adds the condition to the method's template at bind time and refuses a field the record lacks. Choose it when the routing key is a field with a fixed set of values; the filter compares the field's string form for equality or inequality, and a range stays in the method.
Option 2: filter on a tag, on the annotation. tags = "rdf:type=..." or tags = "region". The replica judges a tag before it decodes the record, so a relay without the content key can route on it, and a shard can hold only entries with it. Choose it when the producer labels the entry, as the DamageEstimator labels its instance in chapter 12.
Option 3: filter in the API, with any matcher. Build the template yourself: Template.of(PricedClaim.class).where("claimId", eq(id)), or gte, lt, in, contains. Choose it when the stage reads rather than subscribes, as the Panelist does in chapter 7, or when the condition is a comparison the annotation grammar cannot express.
Option 4: decide inside the method. Return null, as the PetClinic Vet does in chapter 3. Choose it when the decision needs a lookup or a computation. On a take, the binder has already claimed the entry by then and completes it when the method returns null, so the entry is gone; on a reaction the binder consumes nothing.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| Filter on a field, on the annotation | where = {"field=value", "field!=value"} on @SpaceTake, @SpaceNotify, @OrderedTake, @Part, and @Propose | the shops and the SeniorReviewer above | the routing key is a field with a fixed set of values; equality and inequality on the field's string form | AgentBinderVerbsTest.NotLow |
| Filter on a tag, on the annotation | tags = {"key=value", "key"} on the same annotations | the Assembler's damage part: tags = "rdf:type=..." (chapter 4) | the producer labels the entry, and a relay or a shard may need to route on the label without decoding | chapter 4 |
| Filter in the API, with any matcher | Template.of(T.class).where("field", gte(3)) and the other Matchers | the Panelist reads a PricedClaim by claimId (chapter 7) | the condition is a comparison, a set, or a substring; or the stage reads rather than subscribes | chapter 7 |
| Decide inside the method | return null | the Escalator (chapter 10); the PetClinic Vet (chapter 3) | the decision needs a lookup or a computation; a take has already claimed the entry by then, and completes it | chapter 3 |
// agentspaces-agent tests -- AgentBinderVerbsTest.NotLow.see: an inequality filter @SpaceNotify(space = "tasks", where = "severity!=LOW") public void see(Brief brief) { seen.add(brief.id()); }
where when the value is part of the data and only a peer with the content key should read it: the shop a task names, the severity of an assessment. Use tags when the value is a label the producer attaches so that others can route without reading the data: the class of an ontology instance, the region a shard holds, a sensitivity level the console shows. A replica judges tags before decoding and fields after, so tags are cheaper and more visible, and that visibility is the trade-off: a tag is plain text to every relay.Branch: one method, two possible outcomes
What the stage must do
The Registrar must send each claim down one of two paths. A registered claim goes to the specialists; a rejected claim goes to the ledger. A Java method has one return type.
// agentspaces/examples/example-18-agentic-workflow -- Claims.Registration: the two outcomes behind one type /** Stage 1: what the registrar returns, one of two branches; the card declares both. */ public sealed interface Registration permits RegisteredClaim, Rejected { }
The Registrar.register method of chapter 2 declares Registration as its return type and returns either a RegisteredClaim or a Rejected. The binder writes whichever concrete record the method returns, and because you sealed the interface, the binder lists both permitted records on the agent's card, so a planner reading the card knows the registrar may produce either. The specialists consume RegisteredClaim; the Ledger consumes Rejected; each branch has its own consumer, and the flow never loses a claim it refused.
// agentspaces-agent tests -- AgentBinderVerbsTest.Gate.decide: a sealed return from a take public sealed interface Next permits Accepted, Declined { } @SpaceTake(space = "tasks", pollTimeout = "50ms") public Next decide(Brief brief) { return brief.severity().equals("HIGH") ? new Declined(brief.id(), "too risky") : new Accepted(brief.id()); }
Route by cost: let the space choose the worker
What the stage must do
Two adjusters price assessments. The SeniorAdjuster should price the severe claims and the JuniorAdjuster the routine ones, but neither should sit idle while the other has a backlog. The right routing depends on who is cheapest for each assessment at that moment, which no filter on the annotation can express.
How AgentSpaces does it
Chapter 1 built the assessment space with the AUCTION conflict strategy. Under that strategy every take carries a bid, the space collects the bids across the group for one gossip round, and the lowest bid wins the entry. An agent supplies its bid with a @BidFunction method, which the binder calls for each candidate entry before the take.
// agentspaces/examples/example-18-agentic-workflow -- Pricing.SeniorAdjuster: the bid and the take @BidFunction(space = SPACE) public double bid(ClaimAssessment a) { return "HIGH".equals(a.severity()) ? 10 : 80; } @SpaceTake(space = SPACE, lease = "30s", pollTimeout = "300ms", resultLease = "1h") public PricedClaim price(ClaimAssessment a) { return Pricing.price(a, "senior-adjuster"); }
The SeniorAdjuster bids 10 for a HIGH assessment and 80 otherwise; the JuniorAdjuster bids 90 and 20. The test asserts that the senior adjuster priced the fire claim and the junior the collision. The pricing itself, Pricing.price, is the same code in both; only the bid differs. One agent per space per peer may bid, and every bid in a space should use one comparable cost unit.
The options, side by side
The three routing shapes answer three different questions. A filter asks which of the entries of this type are mine, and the answer is in the entry. A branch asks which type this entry should become, and the answer is in the producer. The auction asks which of several willing workers should take this entry, and the answer depends on the workers and the moment. The table collects them with the fork of chapter 3, which is the fourth shape: all of them at once.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| Route by a field value | where | the shops, the senior reviewer | the routing key is in the record and its values are known when you write the class | above |
| Route by a label | tags | the assembler's damage part | the producer labels entries and consumers must not decode to route | chapter 4 |
| Route by cost | the AUCTION strategy on the space and a @BidFunction | the adjusters | workers differ by price, load, skill, or locality, and the cheapest should win | above |
| Branch at the producer | a sealed return type | the registrar | one stage chooses among several next records | above |
| Branch to several records at once | Entries.of(...) | the quote desk | the outcomes are not alternatives but companions | chapter 3 |
@SpaceNotify whose parameter is the Registration interface matches nothing on a replicated space, because a replica matches the entry's type name exactly and an entry is always a concrete record. Model a hierarchy as one record per leaf, as example 18 does, or as one record with a discriminator field you filter on.7. Decide
Where we are in the claims flow
Stages 5 and 6. An adjuster has written a PricedClaim. The department must put every priced claim to a panel of three, one panelist per peer, and act on the panel's decision: a PaymentOrder when the panel approves, a ClaimDenied when it denies.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
PricedClaim | the reserve and the adjuster's recommendation | an adjuster | the Underwriter, the Panelists, the Approver | assessment |
Proposal, Ballot, Decision | the vote capability's own records: the question with its options and quorum; one voter's answer; the closed result with its tally | the binder, on the agents' behalf | the binder | decisions |
Reminder | the panel's deadline for one claim (chapter 10) | the Underwriter | the Escalator | decisions |
PaymentOrder | an approved claim, as an order to pay | the Approver | the Payer | payments |
ClaimDenied | a denied claim and the tally | the Approver | the Ledger | ledger |
What the stages must do
The Underwriter opens one vote per priced claim, with the options approve and deny and a quorum of three. Each Panelist casts one ballot per vote, judging the priced claim against a ceiling of its own. The Approver acts exactly once when a vote closes.
How AgentSpaces does it
The vote capability keeps proposals, ballots, and decisions as ordinary entries in the decisions space, each signed by the agent that wrote it, so the space itself replicates the vote, authenticates every ballot, and keeps the audit trail. Three annotations describe one vote across three classes.
// agentspaces/examples/example-18-agentic-workflow -- Decisions.Underwriter.open: opening the vote @Propose(space = Pricing.SPACE, vote = SPACE, prefix = PREFIX, key = "claimId", options = {Pricing.APPROVE, Pricing.DENY}, quorum = 3, lease = "10m") public String open(PricedClaim priced) { // The deadline is a leased entry: when it lapses, the escalator below // reacts; when the panel decides first, the escalator finds the // decision and does nothing. decisions.write(new Reminder(priced.claimId()), reminderLease); return "Settle claim " + priced.claimId() + "? The adjuster recommends " + priced.recommendation() + " with a reserve of " + priced.reserve(); }
@Propose tells the binder that open opens a vote. The binder reacts to every PricedClaim in the assessment space, builds the proposal id from prefix plus the claim's claimId field, calls open for the question text, and writes a Proposal entry into the decisions space with the options, the quorum, and the ten-minute lease from the annotation. The binder opens each proposal id once per peer, and the method may return null to open nothing. The decisions.write line writes the Reminder that chapter 10 uses as the deadline, through a @SpaceRef field.
// agentspaces/examples/example-18-agentic-workflow -- Decisions.Panelist.judge: one ballot per proposal @Ballot(space = SPACE, prefix = PREFIX, lease = "10m") public String judge(VoteCapability.Proposal proposal) { Optional<PricedClaim> priced = assessments.read(Template.of(PricedClaim.class) .where("claimId", eq(claimIdOf(proposal.proposalId()))), Duration.ofSeconds(5)); if (priced.isEmpty()) { return null; // abstain } boolean approve = Pricing.APPROVE.equals(priced.get().recommendation()) && priced.get().reserve() <= ceiling; return approve ? Pricing.APPROVE : Pricing.DENY; }
@Ballot tells the binder that judge casts ballots. The binder reacts to every Proposal whose id starts with claim:, calls judge, and writes a Ballot entry with the returned option, signed by the panelist's agent identity. The panelist reads the PricedClaim it is judging through a @SpaceRef field with a five-second wait, because the priced claim may not have replicated to this peer yet, and returns null to abstain if it has not. Example 18 gives the three panelists ceilings of 50,000, 50,000, and 15,000, so the panel splits two to one on the 25,000 fire claim and still approves it.
// agentspaces/examples/example-18-agentic-workflow -- Decisions.Approver.settle: acting once on the decision @OnDecision(space = SPACE, prefix = PREFIX, resultSpace = Settlement.PAYMENTS, resultLease = "1h") public PaymentOrder settle(VoteCapability.Decision decision) { String claimId = claimIdOf(decision.proposalId()); PricedClaim priced = assessments.read(Template.of(PricedClaim.class) .where("claimId", eq(claimId)), Duration.ofSeconds(5)).orElseThrow(); if (!Pricing.APPROVE.equals(decision.winner())) { ledger.write(new ClaimDenied(claimId, "the panel voted " + decision.tally() + ", counted per " + decision.granularity()), Lease.of(Duration.ofHours(24))); return null; } return new PaymentOrder(claimId, priced.claimant(), Math.round(priced.reserve() * 100)); }
@OnDecision tells the binder that settle reacts to closed votes. When this peer's tally of ballots for a proposal reaches the quorum, the binder calls settle once with the Decision, which carries the winner, the tally, and the granularity the voters were counted at. The approver writes a PaymentOrder into the payments space by returning it, or a ClaimDenied into the ledger through a @SpaceRef field, since one return carries one record. Example 18 binds one Approver, on peer-a.
The options
Option 1: open with a fixed shape. @Propose holds the options, the quorum, the lease, and the rule that builds the proposal id, and the method returns only the question text. The Underwriter uses it because every claim vote looks the same. The Party Bus safety lead below uses it with a composite key, three fields joined into one id, so that several assessments of one wave open one vote.
Option 2: open with a shape built per call. Return a Motion from any bound method, built with Motion.of(id, question, options, quorum, lease). The Escalator of chapter 10 does this, and the Party Bus party lead below does it because its options are the destinations a scout proposed, which differ per trip. The binder opens a Motion's proposal id once, so a reaction that runs on every peer may return one safely.
Option 3: abstain. Return null from the @Ballot method. The Panelist abstains when the PricedClaim has not replicated within five seconds. An abstention is final for that voter on that proposal, so read with a timeout before abstaining.
Option 4: choose the quorum. The quorum is the number of distinct voters that closes the decision: three for the panel, one for the escalation. Too low and one peer decides; too high and a lost peer stalls the vote until the deadline of chapter 10 escalates it.
Option 5: choose who may vote. By default every authenticated agent in the group may cast a ballot. The vote capability can count only the holders of fresh cards, or only the agents an authorizer grants; the developer guide's security chapter covers both. A stage that needs the tally or the decision as data rather than as a cue injects a VoteClient with @CapabilityRef and asks it; a peer that serves the vote to the group registers it, through the facade or with @ProvidesCapability on the bean that builds it.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| Open with a fixed shape | @Propose: options, quorum, lease, and the id rule on the annotation; the method returns the question | the Underwriter | every vote of this kind has the same options and quorum, and the cue decides only the question | Party Bus SafetyPanel, with a composite key |
| Open with a shape built per call | return a Motion from any bound method: Motion.of(id, question, options, quorum, lease) | the Escalator (chapter 10) | the options or the quorum come from the cue, or the stage is not a reaction on the cue type | Party Bus Party.open |
| Abstain | return null from a @Ballot method | the Panelist when the priced claim has not replicated | a judge lacks the evidence; read with a timeout first, because an abstention is final for that voter | above |
| Choose the quorum | quorum on @Propose or the Motion | 3 for the panel; 1 for the escalation | as many distinct voters as the decision needs; too low and one peer decides, too high and a lost peer stalls the vote until a deadline escalates it | chapter 10 |
| Choose who may vote | the vote capability's electorate: every authenticated agent, the holders of fresh cards, or an authorizer's grants | the default: every authenticated agent in the group | the panel must be a named set; see the developer guide's security chapter |
@Propose: the shape is visible on the class, the binder derives the id, and the method stays a one-line question. Reach for a Motion when the options come from the cue, when the id must follow a rule the annotation cannot express, or when the stage that opens the vote is a deadline reaction or a settled-estimate reaction rather than a reaction on the cue type. Both open once per proposal id.// agentspaces-partybus -- SafetyPanel.open: a composite key, one vote per trip, location, and wave @Propose(space = Watchers.SIGNALS, vote = ADVISORIES, prefix = PROPOSAL_PREFIX, key = {"tripId", "location", "wave"}, options = {"GO", "CAUTION", "AVOID"}, quorum = 3, lease = "12h", subscriptionLease = "12h") public String open(DangerAssessment assessment) { waves.putIfAbsent(Safety.proposalId(assessment.tripId(), assessment.location(), assessment.wave()), assessment); return "Travel advisory for " + assessment.location() + " (" + assessment.wave() + ")"; }
The Party Bus safety lead reacts to every DangerAssessment, and several assessments, one per watcher channel, describe the same trip, location, and wave. The composite key joins the three fields into one proposal id, so the binder opens one vote per wave however many assessments arrive.
// agentspaces-partybus -- Party.PartyLead.open: a Motion whose options come from the cue @SpaceNotify(space = Scouts.SPACE, lease = "12h") public Motion open(NextDestinationIdeas ideas) { String proposalId = "next:" + ideas.tripId(); List<String> options = new ArrayList<>(); for (Destination idea : ideas.ideas()) { options.add(idea.name()); }
The party lead's options are the destinations a scout proposed, which differ per trip, so no annotation can hold them. The method builds a Motion with the options from the cue and returns it, and the binder opens the vote once per proposal id.
8. Exactly once
Where we are in the claims flow
Stage 7. The Approver wrote a PaymentOrder. A Payer on each of the three peers stands ready to pay it. The department must pay each order exactly once, and a network partition must not turn one order into two payments.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
PaymentOrder | an approved claim, as an order to pay the claimant a number of cents | the Approver | one Payer | payments |
Payment | the payment made: who was paid, how much, by which peer, and the log index that admitted it | the Payer that won the order | the Ledger, the Exposure stage | payments |
What the stage must do
Take each PaymentOrder at most once across all three peers, pay it, and write the Payment in the same commit as the completion, so that neither a crash nor a partition produces two payments or zero.
How AgentSpaces does it
The lease race that the other spaces use promises at most one completed take per entry, but it allows two peers to both believe they hold an entry during a partition, and for a payment that is one belief too many. The ordered-log capability elects a Raft quorum among the three peers and serializes takes through a log: a take becomes a claim the log commits, the log admits one claim per entry generation, and every peer applies the same committed order, so every peer agrees on who holds each order.
// agentspaces/examples/example-18-agentic-workflow -- Settlement.Payer.pay @OrderedTake(space = PAYMENTS, lease = "30s", pollTimeout = "2s", resultLease = "1h") public Payment pay(PaymentOrder order) { return new Payment(order.claimId(), order.claimant(), order.cents(), name, ordered.raft().commitIndex()); }
@OrderedTake is @SpaceTake with the claim routed through the coordinator. The binder runs the same loop as chapter 2, but each claim goes through the log, and the binder resubmits a claim that a leader election lost. When the log admits the claim, the binder calls pay, then completes the PaymentOrder and writes the Payment in one commit, in the payments space. The ordered field is the coordinator the peer registered, which the payer uses only to record the commit index in the payment.
// agentspaces/examples/example-18-agentic-workflow -- ClaimsFlow.startPeer: registering the three capabilities group.provide(vote); // backs @Ballot and @OnDecision on "decisions" group.provide(aggregate); // the target of Contribution returns group.ordered(Settlement.PAYMENTS, ordered); // backs @OrderedTake on "payments"
group.ordered(space, coordinator) tells the binder which coordinator backs @OrderedTake methods on the payments space. A peer hosts one ordered log per group, which is why the OrderedAssembler of chapter 4 also lives on the payments space. The log does not transact with the world outside the space: the method may still call a payment gateway once per retry, so the method makes the irreversible call last and keys it by the order's id.
The options, as a ladder
Rung 1: an idempotent write. If the step is a record and a second copy costs nothing, give the record a deterministic id and let a duplicate replace the first. The Ledger could write a ClaimClosed twice without harm.
Rung 2: a take under the lease race. @SpaceTake on an ordinary space promises at most one completed take per entry and tolerates transient duplicate work under a partition. The Registrar and the shops stand here, because registering or quoting twice is harmless.
Rung 3: a take through the ordered log. @OrderedTake promises exactly one completed take across the group, and costs a quorum round trip per claim and a live quorum. The Payer stands here, and example 12's Clerk below is the same stage on its own.
Rung 4: a join through the ordered log. @SpaceJoin(mode = ORDERED) gives the join's firing the same promise, for a join whose output starts an irreversible step, as the OrderedAssembler of chapter 4 does.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| An idempotent write | a record whose id is deterministic, so a second copy replaces the first | the Ledger: a second ClaimClosed for one claim would be harmless | the step is a record and a duplicate costs nothing | chapter 10, the Ledger |
| A take under the lease race | @SpaceTake on a LEASE_RACE space | the Registrar, the shops | the work is idempotent, or a duplicate under a partition is tolerable | chapter 2 |
| A take through the ordered log | @OrderedTake, with group.ordered(...) registered | the Payer | the step is irreversible and the group can keep a quorum | example 12's Clerk |
| A join through the ordered log | @SpaceJoin(mode = ORDERED) | the OrderedAssembler | the join's output starts an irreversible step | chapter 4 |
// agentspaces/examples/example-12-exactly-once-desk -- ExactlyOnceDesk.Clerk.confirm: the same shape, as a standalone example @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()); }
Example 12 is this one stage on its own: three clerks, one log, orders written from several peers, and a test that kills a clerk mid-order and still counts one receipt per order.
9. Aggregate, reduce, and sense
Where we are in the claims flow
Stage 8. Each adjuster has priced a claim and set a reserve. The department wants to know the average reserve across every claim the whole fleet has priced, without any peer collecting every claim, and it wants one report when that figure has settled.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
PricedClaim | a priced claim with its reserve | an adjuster | the ReserveSensor on every peer | assessment |
Contribution | not a record of the flow: the AgentSpaces return value that adds one number to a named aggregate epoch | the ReserveSensor returns it | the aggregate capability | |
Estimate | the aggregate capability's record of the current fleet-wide figure for one epoch | the capability | the Supervisor | |
ReserveReport | the settled average reserve, on the record | the Supervisor | anyone | ledger |
What the stage must do
Every peer contributes the reserve of every claim it sees to one shared figure, the fleet converges on the average with no collector, and one agent writes one ReserveReport when the average stops moving.
How AgentSpaces does it
The aggregate capability runs push-sum gossip: every peer holds a value and a weight, each gossip round it halves them and sends half to one random peer that advertises the aggregate, and every peer's ratio converges to the fleet-wide average in a number of rounds logarithmic in the fleet size. An agent adds to it by returning a Contribution, and reads it by reacting to the settled estimate with @OnEstimate, or procedurally through an AggregateClient injected with @CapabilityRef (awaitSettled). A settle rule counts steps, and a step is a gossip round the peer took part in once it has heard the epoch at all, or the epoch heard and since gone quiet, which completes the streak: a peer nobody pushes to never settles on its own value, and a plateau before a late contribution does not count as settled either.
// agentspaces/examples/example-18-agentic-workflow -- Settlement.ReserveSensor.sense and Settlement.Supervisor.report @SpaceNotify(space = Pricing.SPACE) public Contribution sense(PricedClaim priced) { return Contribution.to(RESERVE_EPOCH, priced.reserve()); } @OnEstimate(prefix = "reserve:", settleTicks = 40, tolerance = 0.01, resultSpace = LEDGER, resultLease = "24h") public ReserveReport report(PushSumAggregate.Estimate reserve) { return new ReserveReport(reserve.epochId(), reserve.value(), name); }
The ReserveSensor reacts to every PricedClaim and returns Contribution.to(epoch, value); the binder hands the value to the aggregate capability on this peer under the epoch name reserve:2026-10. Example 18 binds one ReserveSensor on each of the three peers, and every peer's replica delivers every PricedClaim, so each reserve enters the aggregate three times. The AVG operator does not mind: the average of three copies of each reserve equals the average of the reserves, which is why this chapter's options steer a fully replicated space away from SUM and COUNT. @OnEstimate tells the binder that report reacts to a settled estimate: the capability evaluates the settle rule at the end of each of its own ticks, and when the estimate for an epoch whose name starts with reserve: has stayed within one percent for twenty consecutive ticks, the binder calls report once and writes the ReserveReport to the ledger. Nothing polls.
The settle window is the number to tune. Forty steps is about ten quiet seconds at example 18's 250-millisecond peer tick, longer than the gap between the first and the last priced claim, so the one report carries the average of all four; twenty was not, once the paid claim's reserve followed the ordered log and could land twenty seconds after the denials' zero reserves, and a twenty-step settle reported that plateau. A flow that prices claims over a longer window wants a longer settle, or one epoch per claim.
The options
Option 1: contribute a value per entry. Return Contribution.to(epoch, value) from any bound method, as the ReserveSensor does for every PricedClaim. Choose it when the figure is a property of the entries a stage already sees.
Option 2: contribute a template's matches. Call PushSumAggregate.start(epochId, mode, space, template, field) on each peer. The capability reads every local entry the template matches, applies the field extractor to each, and contributes the fold under the mode. Choose it when the figure is over what each peer already holds; the template filters, the field maps, the mode reduces.
Option 3: choose the operator. AVG is the default and the one to reach for over a fully replicated space, because every peer holds every entry and an average of copies is the same average. SUM and COUNT multiply by the number of peers over a replicated space, so they belong over sharded or per-peer spaces. MIN, MAX, and a Quantile do not mind copies.
Option 4: react once when it settles. @OnEstimate(prefix, settleTicks, tolerance), as the Supervisor uses. Choose it when one agent must act on the figure once.
Option 5: read the live figure. AggregateClient.estimate(epoch) returns the current estimate, and awaitSettled(epoch, settle, timeout) blocks until it settles. The test reads the figure on peer-c this way. Choose it for a dashboard or for procedural code.
Option 6: decide on the figure. Return a Motion from the @OnEstimate method. The settled figure then opens a vote: sense, then decide.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| Contribute a value per entry | return Contribution.to(epoch, value) from any bound method | the ReserveSensor | the figure is a property of entries the stage sees | above |
| Contribute a template's matches | PushSumAggregate.start(epochId, mode, space, template, field), called on each peer | not used | the figure is over what each peer already holds; the template filters, the field maps, the mode folds, in one call | |
| Choose the operator | Mode.AVG, SUM, COUNT, MIN, MAX, or a Quantile | AVG, the default | over a fully replicated space, SUM and COUNT multiply by the number of peers; AVG, MIN, MAX, and QUANTILE do not | |
| React once when it settles | @OnEstimate(prefix, settleTicks, tolerance) | the Supervisor | one agent must act on the figure | above |
| Read the live figure | AggregateClient.estimate(epoch), or awaitSettled(epoch, settle, timeout) to block | the test, to check the figure on peer-c | a dashboard, or procedural code | example 18's test |
| Decide on the figure | return a Motion from the @OnEstimate method | not used | the settled figure should open a vote: sense, then decide | chapter 7 |
tolerance for settleTicks consecutive steps, a step being a gossip round this peer took part in once it has heard the epoch, or the epoch going quiet, which completes the streak. Make the window longer than the gap between the first and the last contribution you expect in one epoch, or the report carries a partial figure; example 18's first attempt at four ticks reported a three-claim average. Make it shorter than the time you can afford to wait after the last contribution. When contributions arrive over a long or unknown period, open one epoch per period, as the Spring AI usage sink does per model and time window, rather than widening the settle.10. Time, loops, and compensation
Where we are in the claims flow
Stage 6a, and the ledger. The Underwriter opened a vote and the panel may never answer. The department must escalate a claim the panel has not decided within the deadline, once, however many peers notice. And the Registrar rejected a claim with no amount; the department must close that case on the record rather than lose it.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
Reminder | the deadline for one claim's decision, as an entry whose write lease is the deadline | the Underwriter, when it opens the vote | the Escalator, when the lease lapses | decisions |
Motion | not a record of the flow: the AgentSpaces return value that opens a vote | the Escalator returns it | the vote capability | |
Rejected | a claim refused at registration | the Registrar | the Ledger | intake |
ClaimClosed | the terminal record: paid, denied, or rejected, with a summary | the Ledger | anyone, long after the flow | ledger |
Deadline: a lease as a timer
What the stage must do
When ten seconds pass after the underwriter opens a vote and no decision exists, open an escalation vote, once per claim. When the panel decides in time, do nothing.
How AgentSpaces does it
A space already runs timers: it sweeps leases and emits an EXPIRED event when an entry's write lease lapses. A deadline is therefore an entry written with the deadline as its lease, and a reaction that listens for that entry's expiry.
// agentspaces/examples/example-18-agentic-workflow -- Decisions.Underwriter.open: writing the reminder decisions.write(new Reminder(priced.claimId()), reminderLease);
// agentspaces/examples/example-18-agentic-workflow -- Decisions.Escalator.escalate: reacting to the lapse @SpaceNotify(space = SPACE, on = SpaceEvent.Kind.EXPIRED) public Motion escalate(Reminder reminder) { if (votes.decision(PREFIX + reminder.claimId()).isPresent()) { return null; // decided in time: nothing to escalate } return Motion.of("escalate:" + reminder.claimId(), "The panel has not decided claim " + reminder.claimId() + " in time; escalate to the chief underwriter?", List.of("escalate", "wait"), 1, Lease.of(Duration.ofMinutes(10))); }
The Underwriter writes a Reminder for the claim with a lease equal to the panel's deadline. on = SpaceEvent.Kind.EXPIRED tells the binder to call escalate when a Reminder's lease lapses rather than when the underwriter writes it. The escalator asks the vote capability, through its votes field, whether a decision for the claim exists; if so it returns null, and if not it returns a Motion, which the binder turns into a new proposal. Every peer sweeps its own replica, so every peer that hosts an Escalator sees the lapse; the binder opens a Motion's proposal id once, so three escalators open one escalation. The test staffs a fleet with no panel and a short deadline and counts exactly one escalation per claim.
The options
Option 1: a deadline. Write an entry whose lease is the deadline and react to its EXPIRED event, as the Underwriter and the Escalator do. Choose it whenever something must happen if nothing happens in time.
Option 2: cancel a deadline. Keep the EntryHandle that write returned and call cancel() when the outcome arrives, or do what the escalator does and let the reaction find the outcome and return null. The second costs one lookup per lapse and needs no bookkeeping.
Option 3: notice a dead worker. React to REAPPEARED on a task type. A worker took the task, its lease lapsed, and the space offered the task again; the reaction sees that happen. Choose it for a supervisor that records failures. The task is already back in the space, so the supervisor observes rather than retries.
Option 4: notice completion elsewhere. React to COMPLETED. Choose it when a stage wants to know a task finished without consuming its result. The test agent below listens to a write and its lapse with one method.
Option 5: make the escalation happen once. Every peer sweeps its own replica, so every escalator sees the lapse. Return a Motion, which the binder opens once per proposal id, or write a record carrying the claim id that the next stage dedups on.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| A deadline | a leased entry and @SpaceNotify(on = EXPIRED) on its type | the Reminder and the Escalator | if nothing happens within a time, something must | above |
| Cancel a deadline | EntryHandle.cancel() on the reminder, or let the reaction find the outcome and return null | the escalator finds the decision | the outcome arrived in time | above |
| Notice a dead worker | @SpaceNotify(on = REAPPEARED) on the task type | not used | a supervisor should record that a worker died holding an entry; the entry is already back in the space, so the supervisor observes rather than retries | AgentBinderVerbsTest.Both |
| Notice completion elsewhere | @SpaceNotify(on = COMPLETED) | not used | a stage wants to know a task was done without consuming its result | |
| Make the escalation happen once | return a Motion, or write a record the next stage dedups on | the Motion above | every replica sees the lapse | above |
// agentspaces-agent tests -- AgentBinderVerbsTest.Both.see: one method for the write and the lapse @SpaceNotify(space = "tasks", on = {SpaceEvent.Kind.WRITTEN, SpaceEvent.Kind.EXPIRED}) public void see(Reminder reminder) { kinds.add(reminder.id()); }
Loops
A loop is a stage that writes its own cue record again with a round counter, until a condition holds. The claims flow has no loop, because a loop would be contrived there; the shape is the Registrar's sealed return turned inward. A stage takes a Retry(claimId, round), does the work, and returns either the result record or a new Retry with the round advanced. Two guards keep it finite: the stage returns the result instead of a retry when the work succeeds, and a where = "round!=5" filter on the annotation keeps the stage from taking a retry that has exhausted its rounds, so the exhausted retry sits in the space for a deadline to find. The cue type and the output type must differ, or the stage must filter on a field it never produces, for the reason chapter 3 gives.
Compensation and the quarantine
What the stage must do
Close every case on the record, including the one the registrar refused, so that no claim disappears from the department's history.
// agentspaces/examples/example-18-agentic-workflow -- Settlement.Ledger.onRejection @SpaceNotify(space = Intake.SPACE, resultSpace = LEDGER, resultLease = "24h") public ClaimClosed onRejection(Rejected rejected) { return new ClaimClosed(rejected.claimId(), "REJECTED", rejected.reason()); }
The Ledger reacts to a Rejected in the intake space and writes a ClaimClosed into the ledger space under a twenty-four-hour lease. The same class has onPayment and onDenial for the other two outcomes, so every case closes through one agent whatever path it took. That is the smallest saga: a typed failure record, Rejected, and a compensating stage that reacts to it. A longer saga has one failure record per step that can fail and a compensating reaction per failure record, every record carrying the claimId. AgentSpaces supplies the records, the leases, and the correlation; the undo itself is yours.
The human step
A person is a stage. When a claim needs a manager's approval, the flow writes a request record for it and the manager writes an approval record through the console, the A2A gateway, or an application of your own. The flow waits for the approval the way it waits for anything: a join with the approval as a part, a Reminder written when the request went out, and an escalation when the reminder lapses. Nothing in the flow distinguishes a person from a worker. Lease the request for as long as a person takes, and put the deadline on the reminder, not on the request.
| Pattern | AgentSpaces expression | Example 18 | Watch for |
|---|---|---|---|
| Deadline | a leased entry and @SpaceNotify(on = EXPIRED) | the Reminder and the Escalator | every replica sees the lapse: answer with a Motion or a deduplicated write |
| Dead worker | @SpaceNotify(on = REAPPEARED) on the task type | none | the task is already back in the space |
| Loop | a self-cue record with a round counter and a where guard | none | a reaction that writes its own cue type loops without the guard |
| Compensation | one failure record per step and one reacting stage per failure record | the Rejected and the Ledger | the undo is your code; carry the case id on every record |
| Human step | a request record, an approval record a person writes, a reminder | none | people are slow: a long lease on the request, a deadline on the reminder |
11. Models and planners
Where we are
Example 18 has no model and no planner; every stage is plain Java. This chapter says where each would go, with the one checked-in model worker the Spring AI examples provide, so that you can place a model step or a planner in your own flow without changing the patterns of the other chapters.
A model as a stage
A model does a stage's work the way any worker does. The method is a @SpaceTake, the model call sits inside it, and the structured record the model returns is the method's return value. The take lease covers the model's latency, and a crash mid-call lets the lease lapse and another worker retry.
// agentspaces-springai/examples/springai-fleet -- SpringAiFleet.Summarizer.summarize: a ChatClient inside a take @SpaceTake(space = "work", lease = "2m") public Summary summarize(Brief brief) { return new Summary(brief.topic(), chat.prompt().user("Summarize: " + brief.topic()).call().content()); }
The Summarizer takes a Brief from the work space, asks a Spring AI ChatClient for a summary, and returns a Summary, which the binder writes as the completion result. In the claims flow, the DamageEstimator of chapter 3 is where a model would read the claim's description and produce the damage finding; it would become a take rather than a reaction, because a model call can fail and should retry, and its lease would match the model's latency. The developer guide's Spring AI chapter covers the TakeLeaseAdvisor, which renews the take lease during a long call, and the fleet tools a model may choose between.
A planner as a composer
A planner works the other way round: it reads the cards of the agents in the group, treats each card's consumed and produced record types as an action, and composes a route from a starting record to a goal record. The Embabel bridge in the developer guide turns the fleet's cards into such actions. Invoking one is still a write of the consumed record into the right space and a correlated read of the produced record, so a planner composes the stages this guide describes; it does not replace them.
| A model as a stage | A planner as a composer | |
|---|---|---|
| Where it lives | inside a @SpaceTake method on one agent | above the group, reading its cards |
| What it decides | the content of one record | which stages run, in what order |
| Retry | the take lease | the planner's own loop; a remote action that times out returns empty |
| Choose it when | one step needs judgement over text or data | the route to a goal depends on which skills are available right now |
| Read more | Spring AI workers | Embabel remote actions |
12. The shape of data
Where we are in the claims flow
Stage 2 again, the DamageEstimator. Example 18's records mirror a small claims vocabulary, an ontology with classes such as Claim and DamageFinding. Most of the flow carries that vocabulary as one Java record per class. The damage finding travels differently, as a generic instance of its class, to show the second way an ontology reaches a space.
The records in play
| Record (the claims flow's POJO) | What it means in the business | Who writes it | Who reads it | Space |
|---|---|---|---|---|
Instance | a generic ontology individual: its id, its class name, the class IRI, and the properties as a map; here a DamageFinding with an estimate, a severity, and the claim id among its properties | the DamageEstimator | the Assembler | intake |
Tagged | not a record of the flow: the AgentSpaces return value that pairs a record with the tags to write on its entry | the DamageEstimator returns it | the binder |
Route A: one record per class
Every record in chapter 1 is this route. A namespace becomes a package, a class becomes a record, a datatype property becomes a component, an object property becomes a component holding the referent's id, and a class hierarchy becomes one record per leaf class or one record with a discriminator field. Every property then becomes a component a template can filter on, which is the strongest matching the space offers, and you pay one record per class.
Route B: entries as ontology instances
// agentspaces/examples/example-18-agentic-workflow -- Instance public record Instance(String id, String type, String typeIri, Map<String, Object> properties) {
One record carries any individual of any class. A template can match the components id, type, and typeIri; it cannot look inside properties, because a field condition reads one accessor and the map is one value. So anything a stage must match on moves out of the map: into a component, or into a tag on the entry. The map itself, like every map a signature covers (the entry's tags, a card's bindings), is encoded in canonical order on the wire since wire version 3, shorter UTF-8 keys first and bytewise between equal lengths, so an instance written by a Java peer and read by a Python or TypeScript one verifies whatever order its properties were built in.
// agentspaces/examples/example-18-agentic-workflow -- Intake.DamageEstimator.estimate: tags carry what a template can see @SpaceNotify(space = SPACE, resultLease = "1h") public Tagged<Instance> estimate(RegisteredClaim claim) { double estimate = Math.round(claim.claimedAmount() * 0.9); Instance finding = Instance.damageFinding(claim.claimId(), estimate, severity(claim.claimedAmount()), "damage-estimator"); // The tags carry what a template can select on before decoding: the // correlation key the join needs, and the class IRI (SPEC §7.2, §11). return Tagged.of(finding, "claimId", claim.claimId(), "rdf:type", finding.typeIri()); }
The DamageEstimator builds a DamageFinding instance and returns it wrapped in Tagged.of(record, key, value, key, value). The binder writes the instance as an entry with two tags: claimId, which the Assembler in chapter 4 uses as the part's key through keyTag = "claimId", and rdf:type, which the assembler filters on through tags = "rdf:type=..." so that it joins damage findings and not some other class carried the same way. The assembler then reads the estimate and the severity out of the properties with damage.number("estimate") and damage.string("severity").
Route C: naming types by IRI
By default an entry's type name on the wire is the record's fully qualified class name with a version fragment. A NamespaceSchemaRegistry names the records of chosen packages by an ontology namespace instead, so that records, cards, remote actions, and the Python and TypeScript clients' constants all use the class IRI: NamespaceSchemaRegistry.of(Map.of("org.example.claims", "https://example.org/claims#")), given to the space builders and the binder, or a SchemaRegistry bean under the Spring Boot starter. The library's own reserved types keep their names.
The options
Route A: one record per class. Write a Java record for each class of the vocabulary. Every property is a component a template can filter on, every stage declares the concrete record it consumes, and the card tells the truth. Choose it when the vocabulary is stable and stages filter on many properties. Example 18 takes this route for every record but one.
Route B: generic instances. Carry each individual as an Instance with its class and a property map, and promote the properties stages match on into tags with Tagged.of. Choose it when the vocabulary changes faster than the code, or when one flow carries individuals of many classes. Example 18 takes this route for the damage finding, and chapter 4's assembler shows what it costs: a keyTag and a tags filter on the part instead of a field.
Route C: types named by IRI. Keep either route and add a NamespaceSchemaRegistry, so that the type name on every entry, card, and client constant is the class IRI. Choose it when systems outside the fleet, or clients in other languages, should recognise the ontology's names on the wire.
In every route: validate at the door. Check the vocabulary's constraints in the first take and return a Rejected record for incongruent data, as the Registrar does, so that the ledger closes the case instead of the flow losing it.
The options at a glance
| Option | AgentSpaces API | Example 18 | Choose it when | Alternative shown below |
|---|---|---|---|---|
| One record per class | plain Java records | every record but the damage finding | the vocabulary is stable and stages filter on many properties | chapter 1 |
| Generic instances | a record with a property map, and Tagged.of for the properties stages match on | the damage finding | the vocabulary changes faster than the code, or one flow carries individuals of many classes | above |
| Types named by IRI | NamespaceSchemaRegistry on the spaces and the binder | not used; the test suite runs the fleet under one to show the names on cards and records | cards, records, and clients should speak the ontology's names | |
| Validation at the door | your checks in the first take, with a Rejected record as the outcome | the Registrar | always; incongruent data becomes a record the ledger closes rather than an exception the flow loses | chapter 2 |
13. Running, observing, and testing
Where we are
The whole flow. Example 18 runs as three peers over TCP, with five spaces and three capabilities on each, and its test runs the same fleet with a crash arranged. This chapter shows how example 18 assembles a peer, seats the agents, and asserts the outcome, so that your own flow has the same three parts.
Seating the agents
// agentspaces/examples/example-18-agentic-workflow -- ClaimsFlow.staff: who sits on which peer bound.add(a.group().bind(new Intake.Registrar("registrar-a", hook))); bound.add(b.group().bind(new Intake.Registrar("registrar-b", hook))); bound.add(a.group().bind(new Intake.FraudScreen())); bound.add(b.group().bind(new Intake.CoverageCheck())); bound.add(c.group().bind(new Intake.DamageEstimator()));
group().bind(agent) hands an agent to the binder of that peer's group and returns a handle that unbinds it when closed. Roles that must be singletons in the flow, the Assembler, the Underwriter, the Approver, the Ledger, the Supervisor, sit on one peer. Roles that are one per peer, the Panelist, the Payer, the ReserveSensor, sit on all three. The two Registrars show the lease race, and the two adjusters show the auction.
Arranging the crash
// agentspaces/examples/example-18-agentic-workflow -- ClaimsFlowTest: the hook that throws once // Crash the first registration any registrar attempts, and count only the // attempt that actually crashed (two registrars race for the same flag). Intake.Hook counted = (worker, claim) -> { if (armed.compareAndSet(true, false)) { crashes.incrementAndGet(); throw new IllegalStateException("simulated crash in " + worker); } };
The Hook is the example's own interface, a callback the Registrar calls before registering, so that a test can throw inside the take. The test then asserts one property per stage, each by reading a space: every case closes once with its expected outcome; the crash happened once and every claim still registered; one assessment per claim however many joiners; the senior adjuster priced the severe claim; the panel split and still approved; exactly one payment per approved claim at every peer, folded once into its claimant's exposure; three quotes gathered and the lowest chosen; only the severe claim reviewed; no reminder escalated in the normal flow and one per claim when there is no panel; one reserve report at the four-claim average.
The harnesses
| Harness | What it exercises | Example | Use it for |
|---|---|---|---|
A LocalSpace and one binder | the binding's logic: filters, joins, folds, leases under a test clock | the binder's own tests, such as AgentBinderGatherTest | every stage, in milliseconds; no replication, so no lease race and no outrun part |
| Three peers on a simulated network and a test clock | replication, the lease race, the ordered log, the vote | AgentBinderJoinClusterTest, AgentBinderReduceClusterTest | the fleet-wide promises: once per key, exactly once, quorum |
| The same under CPU load, with many keys and the debug log captured per key | the timing a test clock hides: a ticket outrunning a part, a lease lapsing under a drain | AgentBinderJoinClusterStressTest with -Djoin.stress=true | any flake in a fleet-wide test; treat one failure in eight as a bug until its timeline says otherwise |
| Three peers over TCP, the real example | the whole flow as deployed | ClaimsFlowTest | the acceptance run; ten green runs after any timing change |
| A stub peer with no socket, in Python or TypeScript | a decorated agent's takes, notifies, ballots, decisions, joins, forks, and motions; the capability pipes on an in-memory network | tests/test_layer34.py, test_layer4.py, test_verbs.py; test/layer34.test.ts, layer4.test.ts, verbs.test.ts | a stage written in another language, in milliseconds |
| A Java coordinator with Python and Node workers in their own processes | the wire between languages: tasks, a council vote, a push-sum epoch, a semantic query over the pipe, a remote action | CouncilCrossLanguageTest (example 02), the second act of run-trilingual-demo.sh | any stage of a flow that spans languages; it taught that a dial-only peer sends capability frames only to members it can reach |
The fleet console shows the take ledger, which agent holds which entry under which lease, and the data map, which record types flow through which spaces with their tags. Both are the first place to look when a flow stalls; the wire-traffic panel, frames and bytes by kind as rates with the frames dropped as stale, replayed or unverifiable beside them, is where to look when a flow is slow rather than stuck. The checklist a flow leaves this guide with: every record carries the case id; every stage is a take or is safe to repeat; every effect that must happen once across the group is an ordered take, an ordered join, a vote, or a deduplicated write; every wait has a deadline; every failure is a record; and the test reads every property from a space.
Further reading
The developer guide is the reference for each piece this guide composes: every annotation and attribute, the Space API, the conflict strategies, the capabilities, the console, and the same verbs from Python, TypeScript, and Clojure. The architecture guide decides what a flow like this should be built on: how many peers, which spaces and groups, which rung of the consistency ladder each stage stands on, and when a decision is a vote. Example 18's README walks the flow stage by stage with the test's assertions.