aspace://guide/partybus

Party Bus

How to build the Embabel travel planner as a fleet: thirty-four agents on twenty-one peers, every coordination type AgentSpaces offers on the job it fits, a command-and-control turret, and a simulator that drives the bus through a week in Lisbon that goes wrong on purpose. Every code window is sliced from the project’s sources.

agentspaces-partybus 0.4.0AgentSpaces 0.4.0SPEC v0.1.21Java 2121 peers · 34 agents · 9 spacesApache 2.0
The weekBuild the bagThe plannerRun it
aspace://guide/partybus/the-idea

The idea: Tripper, on a bus

Tripper is the Embabel travel planner: one agent in one JVM that finds points of interest for a brief, researches them in parallel, writes a plan with a good model, and finds places to sleep. It is a lovely piece of software and it does everything itself. Party Bus keeps Tripper’s domain model and its planner and hands the work to a fleet. Every specialist Tripper would have been, the fleet has as an agent with a seat on the bus: a flight scout, a hotel scout, a restaurant scout, a photo curator, a review reader, a diet advisor for the friend with the allergy, an accessibility advisor for the friend who cannot do hills, a hospital locator for the day one of them needs it, three watchers reading the news, the video feeds, and the forums, and a sentiment analyst scoring what they find.

The agents coordinate through shared spaces and nothing else. There is no broker, no scheduler, and no one in charge, and that is the whole lesson of the project: each kind of coordination AgentSpaces offers appears once, on the job that fits it. Scouts drain a bag of typed requests under leases, so a scout that dies mid-search hands its request to the next. One join gathers their results into a plan. Activity planners bid for every day of the trip in an auction and the cheapest fit wins the day. A safety panel decides travel advisories by signed quorum vote, so a lurid video clip alone cannot cancel a holiday. The travelers themselves vote on where to go next and gossip how tired they are, and the fleet paces the week from the gossip. Booking clerks confirm reservations exactly once through an ordered log, because reservations are money. An operator watches and steers from a command-and-control turret, and a vacation simulator drives the fleet through a week that goes wrong on purpose.

This guide builds all of it, coordination type by coordination type, with the code from the project at 0.4.0 on AgentSpaces 0.4.0. Read it beside the developer guide, which explains each primitive, and the workflow guide, which names each pattern; this one shows what happens when you use all of them at once on a domain everybody understands.

what Tripper does what Party Bus does with it AgentSpaces feature
@Action findPointsOfInteresta SightseeingRequest in the research bag; the sightseeing scout takes it@SpaceTake, LEASE_RACE
parallelMap over points of interesttwelve specialists draining one bag, on three peersreplicated workers
@Action proposeTravelPlanthe plan assembler fires once per trip when the brief and five results are in@SpaceJoin keyed by trip id
@Action findPlacesToSleepthe stay finder reacts to the plan and orders the rooms@SpaceNotify choreography
Airbnb MCP toolthe hotel scout with Brave search and the model; booking clerks over the ordered log@OrderedTake over aspace:cap/ordered-log
the GOAP plannerthe same planner, with every scout’s AgentCard as a typed actionremote-actions bridge (SPEC §10.6)
nothingthe safety panel, the auction, the party’s votes and energy, the turret, the simulator@Propose, Motion, AUCTION, push-sum, console
aspace://guide/partybus/the-week

The week that goes wrong

Three friends fly Boston to Lisbon for a week on two hundred dollars a day. Marco eats for a living and has a shellfish allergy he ignores. Priya is an architecture historian with a camera. Dee wants a pool, a rooftop, and no steep hills. The bus researches, plans, and books the trip in about half a minute, and then the week happens. This is one recorded run of the 0.4.0 demo, verbatim; a live run with the API keys swaps the recording for the real world and keeps every line of this story.

partybus: the recorded week
2026-10-03  pace EASY  energy 71
   -> PHOTO_WALK by photo-walk-planner: Golden-hour walk with a 4-shot list
2026-10-04  pace FULL  energy 49
   !! WEATHER: Atlantic squall parks over the city until mid-afternoon.
   -> DINING by dining-planner: Market morning, long lunch, dinner at Taberna da Rua das Flores
2026-10-05  pace EASY  energy 33
   !! ILLNESS: Marco ordered the clams. Marco regrets the clams.
   -> SIGHTS by sights-planner: Sights day: Sintra and Pena Palace and the streets around it
2026-10-06  pace REST  energy 29
   !! PROTEST: Transport unions march on the Assembleia; Baixa is sealed off.
   -> REST by rest-planner: Rest day: pool, siesta, one gentle dinner
2026-10-07  pace REST  energy 23
   !! STRIKE: Metro and ferry strike; the tram to Belém is a rumor.
   -> REST by rest-planner: Rest day: pool, siesta, one gentle dinner
2026-10-08  pace REST  energy 25
   -> REST by rest-planner: Rest day: pool, siesta, one gentle dinner
2026-10-09  pace REST  energy 15
   !! LOST_PASSPORT: Priya's passport is in a taxi. Somewhere.
   -> REST by rest-planner: Rest day: pool, siesta, one gentle dinner

== the week, as the space remembers it ==
advisory [baseline] GO  GO=3 CAUTION=0 AVOID=0
advisory [lisbon-2026-d3-protest] CAUTION  GO=0 CAUTION=2 AVOID=1
booked lisbon-2026-stay-2 PB-643B6983 by clerk-b at log index 3
booked lisbon-2026-stay-1 PB-FC5A3CA3 by clerk-a at log index 1
next time: Porto and the Douro  [Porto and the Douro=2, Seville=1, The Azores=0]
plan: "Seven Hills, Three Friends, One Pool"  advisory GO  2 stays  https://www.google.com/maps/dir/Lisbon%2C%2BPortugal/Sintra%2C%2BPortugal
✓ 21 peers · 34 agents · 9 spaces · one group over TLS 1.3

Read the week against the mechanisms. The arrival day is EASY, and the photo walk prices an easy day lowest, so it takes it. The storm on the second day re-auctioned the day and the dining planner took it, because a walking tour in a squall prices itself out. The clams knocked the party’s average energy under forty, so the simulator called a rest day and the rest planner’s bid collapsed to almost nothing; the party never recovered its energy, so every day after that was a rest day too. The protest sent the three watchers back to their sources under a new wave; the video seat voted AVOID after the livestreams, the news and forum seats voted CAUTION, and the advisory moved to CAUTION with the ballots on record for anyone to recount. Two stays were booked through the log, each by exactly one clerk, and the travelers voted Porto for next time, two ballots to one. Nobody dispatched any of it.

What changes between runsTiming, never the counts. Which clerk confirms each stay and at which log index changes every run, because three clerks race and the log orders them. The auction awards a day among the bids that have arrived, so on a busy machine a close day can go to a planner before every bidder has spoken; in another 0.4.0 run the strike day went to the dining planner. Every run has two advisories with the same tallies, exactly one confirmation per stay, and the same vote.
aspace://guide/partybus/the-map

Who is on the bus

Twenty-one peers, one group, nine replicated spaces on every peer. A desk peer hosts several agents; the panelists, the travelers, the activity planners and the booking clerks each get a peer of their own, and the reason for each is a lesson in itself (chapters 5 to 8).

ONE GROUP, partybus: 21 PEERS, 34 AGENTS, TLS 1.3 WITH CHANNEL-ATTESTED FRAMESturretconsole, simulatorconciergefan-out, join, leadsscouts-a4 scoutsscouts-b4 scoutsadvisors4 advisorswatchers3 watchers, analystplannerEmbabel's seatnewspanelistvideopanelistsocialpanelistdiningAUCTION biddersightsAUCTION bidderphoto-walkAUCTION bidderrestAUCTION biddermarcotravelerpriyatravelerdeetravelerthe-busparty leadclerk-aRaft memberclerk-bRaft memberclerk-cRaft memberone peer per panelist, per traveler, per bidder, per clerk: a ballot, a bid function and a Raft seat each belong to a peerNINE REPLICATED SPACES, A FULL COPY ON EVERY PEERtripLEASE_RACEresearchLEASE_RACEsignalsLEASE_RACEadvisories+ votedecisions+ voteslotsAUCTIONbookings+ ordered logfleet-controldirectivesc2-auditaudit trail
Who is on the bus. Twenty-one peers join one group, and every peer holds a replica of all nine spaces. The rows group the peers by job: the desks that host several agents, the seats that must be peers of their own, and the party and the booking desk. Nothing sits in the middle; the turret is one more peer that reads.
seat agents coordination type
conciergedispatcher, plan-assembler, stay-finder, safety-lead@SpaceNotify fan-out, @SpaceJoin, @Propose
scouts-a, scouts-bflight, hotel, car, sightseeing, restaurant, photo, review, next-destination@SpaceTake on the research bag (LEASE_RACE)
advisorsdiet, foodie, accessibility, hospital-locatorthe same bag
watchersnews, video, and social watchers; sentiment analyst@SpaceTake sweeps, returning @SpaceNotify scoring
panelist-news, panelist-video, panelist-socialone panelist each@Ballot on aspace:cap/vote, QUORUM 3
dining-, sights-, photo-walk-, rest-plannerone activity planner each@BidFunction and @SpaceTake on slots (AUCTION)
traveler-marco, traveler-priya, traveler-deeone traveler eachaspace:cap/aggregate push-sum, @Ballot
the-busthe party leadMotion, @OnDecision
clerk-a, clerk-b, clerk-cbooking clerks@OrderedTake over aspace:cap/ordered-log: exactly once over Raft
plannerthe Embabel planner’s seatremote-actions bridge: every card is a typed action
turretthe console and the simulatorHAL+JSON, SSE, C2 directives with a signed audit trail
space strategy carries
tripLEASE_RACEbriefs, travelers, days, events, plans, advisories, decisions
researchLEASE_RACEtyped requests in, typed results back: the bag
signalsLEASE_RACEwatcher reports and the analyst’s assessments
advisoriesLEASE_RACE + votethe safety panel’s proposals and signed ballots
decisionsLEASE_RACE + votethe travelers’ proposals and signed ballots
slotsAUCTIONday slots and the activities that won them
bookingsLEASE_RACE + ordered logorders and confirmations
fleet-control, c2-auditLEASE_RACEthe turret’s directives and audit trail
aspace://guide/partybus/domain · chapter 1

Start from Tripper’s domain model

Tripper’s design lesson is to center the agent on a domain model, and the fleet keeps it. Every Tripper type becomes a Java record that can live in a space: a TravelBrief is its JourneyTravelBrief, Travelers is its Travelers, ProposedTravelPlan and TravelPlan are its plan types. Two things change. Every record carries a tripId, the field the whole fleet correlates on, and dates travel as ISO-8601 strings so templates match them with plain equality. Party Bus adds needs to a traveler, the dietary and mobility requirements the advisors consume, and that one field is what makes the diet advisor and the accessibility advisor possible.

TravelBrieftriptripIdfrom, todepartureDate, returnDatedailyBudget, brieftransportPreferenceFlightRequestresearchtripIdtopicfrom, todepartureDatereturnDate, preferenceFlightOptionsresearchtripIdtopicoptions: List<FlightOption>noteauthordispatcherflight scoutsame tripId and topic: the shared-field correlation the planner bridge pairs ona template of FlightRequest isone scout's whole work queueone (request, result) type pairis one action the planner can chaindates travel as ISO-8601 strings, so templates match them with plain equality;records with lists, nested records and enums go through the space's CBOR codec as they are
The shape of data. Every record carries the tripId the fleet correlates on. A request and its result also share a topic, and the result adds the author that wrote it, so the console can attribute every answer.
domain/Trip.java
public record TravelBrief(String tripId, String from, String to, String departureDate,
                          String returnDate, double dailyBudget, String brief,
                          String transportPreference) {

    /** The trip's days as ISO-8601 strings, departure to return inclusive. */
    public List<String> days() {
        LocalDate start = LocalDate.parse(departureDate);
        LocalDate end = LocalDate.parse(returnDate);
        return start.datesUntil(end.plusDays(1)).map(LocalDate::toString).toList();
    }

    /** The brief as prompt text, the way Tripper's {@code contribution()} renders it. */
    public String contribution() {
        return "Journey from " + from + " to " + to + "\nDates: " + departureDate + " to "
                + returnDate + "\nBrief: " + brief + "\nTransport preference: "
                + transportPreference + "\nDaily budget (USD): " + dailyBudget;
    }
}

The research bag needs a second family of types: one request and one result per specialist. Resist the temptation to make them one generic Finding. The planner conditions on types, so each (request, result) pair is one planner action; a template of the request type is one scout’s whole work queue; and the pair sharing tripId and topic is exactly the shared-field correlation the remote-actions bridge uses to pair a planner’s request with the answer some scout wrote back.

domain/Research.java
public record FlightRequest(String tripId, String topic, String from, String to,
                            String departureDate, String returnDate, String preference) {
}
The shape of dataRecords with Lists, nested records, and enums serialize through the space’s CBOR codec as they are. A result record repeats the request’s tripId and topic and adds author, so the console can attribute every answer to the scout that wrote it.
aspace://guide/partybus/peer · chapter 2

Board a peer

PartyBusFleet.startPeer is one peer: a fresh Ed25519 identity, a node listening on TLS, the partybus group joined from a seed, a replica of each of the nine spaces, an AgentBinder that knows every space by name, and the capabilities the peer will need (push-sum aggregation over the capability pipes, and vote capabilities over the two ballot spaces, registered with the binder so @Ballot, @Propose and Motion find them). The first peer has no seed and becomes everyone else’s. There is no server anywhere in this picture.

ONE PEER: PartyBusFleet.startPeeridentitya fresh Ed25519 key; the PeerId is its hashnodeTLS 1.3, ChannelAuth.ATTESTED: the handshake proves the sendergroup partybusjoined from the first peer as seed; SWIM membership, gossip, anti-entropytripresearchsignalsadvisoriesdecisionsslotsbookingsfleet-ctlc2-auditnine replicas, settle window 200 msAgentBinderevery space by name; bind(agent)capabilitiespush-sum; votes on two spacesWHAT THE TICK DRIVESmembershipSWIM probe, peer adsspace replicasrumour and anti-entropyaggregatepush-sum exchangevotestally and decisionclerks onlyRaft at a 300 ms tick
Board a peer. A peer is a process, an identity, a node, a group, nine replicas, a binder and its capabilities. The first peer has no seed and every later peer seeds from it. One clock, the peer tick, drives membership, the replicas and every capability, so registering a capability is all it takes to run it.
fleet/PartyBusFleet.java
/**
 * Starts one peer: joins the group over TCP, builds every space, and wires
 * the capabilities.
 *
 * @param name     the peer's name (its agents' host)
 * @param port     the TCP port to listen on
 * @param seedPort an existing member's port, or 0 for the first member
 * @return the running peer
 * @throws Exception if the port cannot be bound
 */
public static Peer startPeer(String name, int port, int seedPort) throws Exception {
    Objects.requireNonNull(name, "name");
    PeerIdentity identity = PeerIdentity.generate();
    // Channel authentication (SPEC §5.6): TLS 1.3 with identity-endorsed
    // certificates, so the channel vouches for the sender and frames travel
    // unsigned. Fifteen peers times nine spaces is thousands of gossip frames
    // a second; signing each one would spend the machine on Ed25519.
    PeerNode node = PeerNode.builder(identity)
            .channelAuth(PeerNode.ChannelAuth.ATTESTED).build();
    node.listen(new TlsTcpTransport(identity), HOST + ":" + port);
    List<PeerAdvertisement.Endpoint> seeds = seedPort == 0 ? List.of()
            : List.of(new PeerAdvertisement.Endpoint("tls", HOST + ":" + seedPort, 0));
    GroupRuntime runtime = node.joinGroup(group(),
            new GroupMembership.Config(Duration.ofSeconds(30), Duration.ofSeconds(2), 2),
            seeds);
    CborCodec codec = CborCodec.defaultCodec();
    InstantSource clock = InstantSource.system();
    DiscoveryService discovery = new DiscoveryService(runtime, new AdCache(codec, clock),
            codec, identity.peerId());

    Map<String, ReplicatedSpace> spaces = new LinkedHashMap<>();
    for (String spaceName : SPACES) {
        ReplicatedSpace.Builder builder = ReplicatedSpace.builder(runtime, spaceName,
                identity, name).settleWindow(Duration.ofMillis(200));
        if (spaceName.equals(SLOTS)) {
            // The bid comes from the planner's @BidFunction at bind time; a bid
            // function is single-assignment per space per peer (QA4 A4-8), so a
            // placeholder here would be the first bidder and the planner refused.
            builder.strategy(ConflictStrategyType.AUCTION);
        }
        spaces.put(spaceName, builder.build());
    }

    AgentBinder binder = new AgentBinder(identity, runtime.id(), discovery, clock);
    spaces.forEach(binder::space);

    CapabilityPipes pipes = new CapabilityPipes(runtime, codec);
    PushSumAggregate aggregate = new PushSumAggregate(pipes, runtime.sampler(),
            identity.peerId(), codec, clock);
    VoteCapability advisoryVote = new VoteCapability(spaces.get(ADVISORIES),
            identity.agent(name), identity.peerId(), clock);
    VoteCapability partyVote = new VoteCapability(spaces.get(DECISIONS),
            identity.agent(name), identity.peerId(), clock);
    // What the annotations cast and watch with: @Ballot/@OnDecision on the two
    // vote spaces, and a Contribution return on the aggregate.
    binder.vote(ADVISORIES, advisoryVote).vote(DECISIONS, partyVote).aggregate(aggregate);

    // One clock: the runtime rides the peer tick, so registering is all it
    // takes for the aggregate to gossip and for every capability to be found.
    CapabilityRuntime capabilities = new CapabilityRuntime(runtime, discovery, identity);
    capabilities.register(aggregate);
    capabilities.register(advisoryVote);
    capabilities.register(partyVote);
    node.startTicking(CLERK_TICK);
    return new Peer(name, identity, node, runtime, discovery, spaces, binder, codec, pipes,
            capabilities, aggregate, advisoryVote, partyVote, port);
}
✓ TLS 1.3 · channel-attested frames · 9 replicas · binder · aggregate · two vote capabilities

The one non-obvious line is the channel authentication. Twenty-one peers times nine spaces is thousands of gossip frames a second, and the first cut of Party Bus ran on plain TCP with every frame Ed25519-signed and verified. On a two-core machine that spent the whole machine on cryptography, membership flapped, and the dispatcher’s reaction to the brief arrived tens of seconds late or never. SPEC §5.6 exists for exactly this: with ChannelAuth.ATTESTED and the TlsTcpTransport, the TLS handshake proves the sender through an identity-endorsed certificate and frames travel unsigned. The evidence layer (record signatures, state signatures, ballots, cards) stays signed and verified end to end. The fleet went from unusable to a ninety-second test on the same laptop with that one change.

Sizing the busThirty-four agents on twenty-one peers, rather than one peer each, is a deliberate choice for a laptop. Anything bound with @SpaceTake, @SpaceNotify or @SpaceJoin shares a peer happily. Three things do not: an AUCTION bid function is per space per peer, so each bidder needs its own peer (chapter 6); a ballot counts one voter per peer, so each panelist and each traveler sits on its own (chapters 5 and 7); and a Raft member is a peer (chapter 8).
aspace://guide/partybus/bag · chapter 3

The research bag: scouts and advisors

A scout is one class with one @SpaceTake method on the research space. The binder turns the parameter type into the scout’s work queue, takes each request under a lease, invokes the method, and writes whatever it returns back into the space as the result, completing the take atomically. A scout that throws, or dies, never completes: the lease lapses and the request reappears for the next scout of that kind. Tripper researches its points of interest in a parallelMap inside one process; here the parallelism is the fleet, and the failure handling is the absence of a renewal.

dispatcher@SpaceNotifyresearch (LEASE_RACE)FlightRequestHotelRequestCarRequestSightseeingRequestRestaurantRequestPhotoRequestReviewRequestNextDestinationRequestDietRequestFoodieRequestAccessibilityRequestHospitalRequest12scouts-aflight, hotel, car, sightstake, lease 2mthe result, written backscouts-brestaurant, photo, review, nexttake, lease 2mthe result, written backadvisorsdiet, foodie, access, hospitaltake, lease 2mthe result, written backONE REQUEST, TWO ENDINGStaken under a leasethe method runsthe return value is writtenthe take completes, atomicallythe scout dies mid-searchno renewal arrivesthe lease lapses: REAPPEAREDthe next scout takes it
The research bag. Twelve request types land in one space, and each scout's parameter type is its queue. A scout takes a request under a lease and its return value completes the take. A scout that dies simply stops renewing, the request reappears, and the next scout of that kind takes it: the crash path and the happy path are the same code.
agents/Scouts.java
public static final class FlightScout {
    private final Sources sources;

    public FlightScout(Sources sources) {
        this.sources = Objects.requireNonNull(sources, "sources");
    }

    @SpaceTake(space = SPACE, lease = "2m", pollTimeout = "500ms")
    public FlightOptions scout(FlightRequest request) {
        List<Link> web = sources.search("flights " + request.from() + " to " + request.to()
                + " " + request.departureDate() + " return " + request.returnDate(), 8);
        FlightOptions found = sources.structured("flights", RESEARCHER + "\n"
                + "Propose up to three itineraries from " + request.from() + " to "
                + request.to() + " departing " + request.departureDate() + " returning "
                + request.returnDate() + ". Preference: " + request.preference() + ".\n"
                + digest("Search results", web), FlightOptions.class);
        return new FlightOptions(request.tripId(), request.topic(), found.options(),
                found.note(), "flight-scout");
    }
}
✓ @AgentSpec publishes an AgentCard: consumes FlightRequest, produces FlightOptions

Every scout follows the same three lines: build a query, ask the sources, bind the model’s structured answer to the result record. The hospital locator is the exception that proves the rule. It goes to the map (OpenStreetMap through Nominatim and Overpass) rather than to the model, because a language model guessing at the address of an emergency room is the wrong tool when someone has eaten the wrong clams. Twelve of these specialists share three peers, four to a peer, and running two flight scouts drains the flight queue twice as fast.

The world behind one interface

Tripper reaches the world through MCP tool groups and a model, and it insists on its API keys. Party Bus keeps the same posture with a smaller footprint: one Sources provider interface, a LiveSources that asks its model through a Spring AI ChatClient (over Spring AI’s OpenAiChatModel and gpt-4.1-mini by default, any provider by constructor) and speaks REST/JSON to Brave Search, the YouTube Data API, and OpenStreetMap with java.net.http, and a RecordedSources that replays a recorded trip for the simulator and the tests. The scouts never learn which one they hold, so the coordination is orthogonal to the data provider, and the flow test runs the whole fleet with no network.

sources/
public interface Sources {

    /** A place from the map provider. */
    record Place(String name, String address, double lat, double lon, String phone,
                 boolean emergency) {
    }

    /** A short label for logs and the console: {@code live} or {@code recorded}. */
    String name();

    /** Web search results. */
    List<Link> search(String query, int limit);

    /** News results, freshest first. */
    List<Link> news(String query, int limit);

    /** Image results; each link is the image itself. */
    List<Link> images(String query, int limit);

    /** Video results, YouTube first when a key is present. */
    List<Link> videos(String query, int limit);

    /**
     * Places of one kind near a location, from the map provider.
     *
     * @param location a place name to geocode
     * @param amenity  an OpenStreetMap amenity value, e.g. {@code hospital}
     * @param limit    the maximum results
     * @return the places, nearest first
     */
    List<Place> places(String location, String amenity, int limit);

    /**
     * Asks the language model for a structured answer and binds it to a record
     * type. The prompt describes the task; the implementation adds the shape of
     * the record and parses the JSON that comes back.
     *
     * @param tag    a stable name for the kind of answer (the recorded key)
     * @param prompt the task
     * @param type   the record type to bind
     * @param <T>    the record type
     * @return the bound answer
     */
    <T> T structured(String tag, String prompt, Class<T> type);

    /**
     * The sources the environment selects: {@link LiveSources} when the API keys
     * are present, and otherwise a fail-fast error naming what is missing, the
     * way Tripper insists on its keys. {@code PARTYBUS_SOURCES=recorded} selects
     * the recorded trip explicitly.
     *
     * @return the configured sources
     */
    static Sources fromEnvironment() {
        String mode = System.getenv().getOrDefault("PARTYBUS_SOURCES", "live");
        if ("recorded".equalsIgnoreCase(mode)) {
            return RecordedSources.lisbon();
        }
        return LiveSources.fromEnvironment();
    }
}
Keys, like TripperA live run wants OPENAI_API_KEY and BRAVE_API_KEY, and takes YOUTUBE_API_KEY when offered; PARTYBUS_OPENAI_MODEL picks another model. A missing key fails at startup with the variable named, never silently at the first search. PARTYBUS_SOURCES=recorded replays the bundled Lisbon week instead, which is what run-demo.sh does when it finds no keys.
aspace://guide/partybus/choreography · chapter 4

The fan-out and the join

Tripper’s planner expresses the trip as a chain of @Actions. With a fleet in the picture the chain becomes a fan-out into the bag and a join when the bundle is complete, and both are ordinary agents. The dispatcher reacts to a TravelBrief with @SpaceNotify and writes every specialist’s request, the three safety sweeps, and one DaySlot per day into the auction. Its @SpaceRef fields are the spaces it writes to; it never answers anything, which keeps the fan-out in one place and the specialists ignorant of each other.

TravelBrieftriptripIdfrom, to, datesdispatcher@SpaceNotify12 requestsresearch3 safety sweepsresearch1 DaySlot per dayslots@SpaceJoin key = tripIdTravelBrieftripSightseeingOptionsresearchHotelOptionsresearchRestaurantOptionsresearchFoodieOptionsresearchFlightOptionsresearchTravelers (optional)tripthe scouts' results come backfires once per trip when every required part is inProposedTravelPlantriptitle, plan, daysassembledBy plan-assemblerstay finder@SpaceNotifyTravelPlantripstays, advisory, mapUrlBookingOrderbookingsone per stay, amountUsdwaits up to 45 s for the panel's advisory,then ships PENDING rather than block
The fan-out and the join. One reaction fans the brief out into the bag, the safety sweeps and the auction. One @SpaceJoin gathers six records from two spaces by trip id and drafts the plan once. The stay finder reacts to the plan, attaches the hotels and the advisory, and writes one booking order per stay. No agent here knows any other.
agents/Dispatcher.java
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);
    research.write(new CarRequest(id, "car " + brief.to(), brief.to(), brief.departureDate(),
            brief.returnDate(), party.travelers().size(), needs), REQUEST_LEASE);
    research.write(new SightseeingRequest(id, "sights " + brief.to(), brief.to(),
            brief.brief(), brief.departureDate(), brief.returnDate(), who), REQUEST_LEASE);
    research.write(new RestaurantRequest(id, "restaurants " + brief.to(), brief.to(),
            brief.dailyBudget() / 4.0, needs), REQUEST_LEASE);
    research.write(new DietRequest(id, "diet " + brief.to(), brief.to(), needs),
            REQUEST_LEASE);
    research.write(new FoodieRequest(id, "foodie " + brief.to(), brief.to(), brief.brief()),
            REQUEST_LEASE);
    research.write(new PhotoRequest(id, "photos " + brief.to(), "landmarks", brief.to()),
            REQUEST_LEASE);
    research.write(new ReviewRequest(id, "reviews " + brief.to(), "visiting", brief.to()),
            REQUEST_LEASE);
    research.write(new AccessibilityRequest(id, "accessibility " + brief.to(), brief.to(),
            needs), REQUEST_LEASE);
    research.write(new HospitalRequest(id, "hospitals " + brief.to(), brief.to(),
            "pre-trip: know the nearest emergency room"), REQUEST_LEASE);
    research.write(new NextDestinationRequest(id, "next after " + brief.to(), brief.to(),
            brief.brief(), who), REQUEST_LEASE);
    sweep(id, brief.to(), "travel safety", Safety.BASELINE);

    List<String> interests = List.of(brief.brief().split("[,;.]\\s*"));
    int day = 0;
    for (String date : brief.days()) {
        slots.write(new DaySlot(id, date, brief.to(), interests, day == 0 ? "EASY" : "FULL", 0),
                REQUEST_LEASE);
        day++;
    }
}
✓ two @SpaceNotify reactions; three @SpaceRef spaces; writes requests, never answers them

The second reaction is the whole event model. A TripEvent from the simulator, or from the turret, gets exactly the requests it deserves: an illness sends the hospital locator to the map and the diet advisor to a gentle menu; a protest opens a fresh sweep under the event’s id; a strike or a storm re-auctions the day and re-scouts ground transport; a lost passport asks for the consulate route. The dispatcher returns an EventResponse, which the binder writes back so the turret can show what the fleet did about each event.

The join is one annotation. @SpaceJoin names its parts, the brief in the trip space and five research results in the research space, with the party roster as an optional sixth, and a key, tripId. The binder holds a ticket per trip id, fires the method once when every required part for that trip is present, and writes the returned ProposedTravelPlan under a twelve-hour lease. Before 0.3.0 this was a virtual thread looping over every brief with an assembled set, five reads per brief, and a sleep; the annotation replaced all of it. The stay finder then does what Tripper’s findPlacesToSleep does: groups the days into stays, attaches the hotel scout’s options and the safety panel’s advisory, computes the map link in code because models get map links wrong, and writes one BookingOrder per stay for chapter 8.

agents/Assemblers.java
public static final class PlanAssembler {
    private final Sources sources;

    public PlanAssembler(Sources sources) {
        this.sources = Objects.requireNonNull(sources, "sources");
    }

    @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()));
        ProposedTravelPlan drafted = sources.structured("plan", PLANNER + "\n"
                + "Write the plan for this brief as HTML starting at <h4>, in 500 "
                + "words or fewer, with a catchy title, one Day entry per date in "
                + "'City,+Country' form, and the countries visited.\n<brief>"
                + brief.contribution() + "</brief>\n" + party.contribution()
                + "\nPoints of interest: " + j.get(SightseeingOptions.class).pointsOfInterest()
                + "\nHotels: " + j.get(HotelOptions.class).options()
                + "\nRestaurants: " + j.get(RestaurantOptions.class).restaurants()
                + "\nMust eat: " + j.get(FoodieOptions.class).dishes()
                + "\nFlights: " + j.get(FlightOptions.class).options(), ProposedTravelPlan.class);
        List<Day> days = drafted.days().isEmpty() ? defaultDays(brief) : drafted.days();
        return new ProposedTravelPlan(j.key(), drafted.title(), drafted.plan(), days,
                drafted.countriesVisited(), "plan-assembler");
    }
}
✓ LOCAL join of seven parts across two spaces, tickets on trip, one plan per trip id
Joins wait, brieflyThe stay finder gives the safety panel’s baseline vote forty-five seconds to land before it ships the plan with PENDING. The first version read the advisory once and shipped PENDING whenever the plan happened to beat the vote by a second. A bounded read(template, timeout) is the idiom for a reaction that usually has what it needs already; a @SpaceJoin is the idiom when it usually does not.
aspace://guide/partybus/safety · chapter 5

Watching the world, voting on it

The safety pipeline is three coordination types in a row. Three watchers take sweep requests from the bag and write SignalReports into the signals space, one per channel: the news watcher reads headlines, the video watcher reads YouTube and video search (which is where the front-line footage, the protest livestreams, and the “is it safe” vlogs live), and the social watcher reads the forums. The sentiment analyst reacts to every report with a returning @SpaceNotify and writes a DangerAssessment, a score from 0 to 100 with the themes behind it.

sweepsresearchnews watcherheadlinesvideo watcherYouTube, video searchsocial watcherforumsSignalReportsignalschannel, waveanalystreturning @SpaceNotifyDangerAssessmentsignalsdanger 0..100, themeschannel, waveTHE PANEL DECIDES: aspace:cap/vote, QUORUM 3safety lead@Propose, key tripId:location:waveonce per waveProposaladvisoriesGO | CAUTION | AVOIDnews seatown 1.0, others 0.6video seatown 1.0, others 0.6social seatown 1.0, others 0.6three signed @Ballots, one voter per peersafety lead@OnDecisionTravelAdvisorytripverdict, tallythe ballots stay in advisories, signed, for anyone to recount
Watching the world, voting on it. Watchers report, the analyst scores, and nothing decides until the panel votes. @Propose opens one vote per wave, each panelist weighs its own channel at full trust and the others at 0.6, and the lead's @OnDecision writes the advisory with its tally.
agents/Watchers.java
public static final class NewsWatcher {
    private final Sources sources;

    public NewsWatcher(Sources sources) {
        this.sources = Objects.requireNonNull(sources, "sources");
    }

    @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");
    }
}
✓ @SpaceTake with resultSpace = signals · returning @SpaceNotify writes the next entry in the flow

Nothing so far decides anything, and that restraint is the design. The verdict belongs to the panel: three panelists, one seated per channel, deciding one advisory per place per sweep through aspace:cap/vote in QUORUM mode. Each panelist trusts the other channels a little less than its own, so a vivid video score alone cannot carry an AVOID that the news and the forums do not support.

The lead opens each vote with one annotation. @Propose reacts to every DangerAssessment, derives the proposal id from its prefix and the record’s tripId, location and wave, and opens the vote with three options and a quorum of three. Three channels assess the same wave, so three assessments map to one proposal id, and the binder invokes the method once per wave and skips the rest; the method only returns the question’s text. The panelists answer with @Ballot, and the lead’s @OnDecision records the TravelAdvisory when the vote closes. Every ballot stays in the advisories space signed by its panelist, so the advisory is an auditable decision anyone can recount rather than a number that fell out of a model.

agents/SafetyPanel.java
public static double trust(String seat, String channel) {
    if (seat.equals(channel)) {
        return 1.0;
    }
    return switch (channel) {
        case "news" -> 0.9;
        case "social" -> 0.7;
        default -> 0.6; // video
    };
}

public static String verdictFor(double weightedDanger) {
    if (weightedDanger >= AVOID_AT) {
        return "AVOID";
    }
    return weightedDanger >= CAUTION_AT ? "CAUTION" : "GO";
}
✓ @Propose opens one vote per wave · three @Ballots, one per peer · @OnDecision publishes the advisory

The panel votes again for every new sweep. The protest in the simulator triggers a sweep named by the event id; the analyst scores the fresh reports under that wave; and the panel decides that wave’s advisory. In the recorded week the video seat weighs its own 78 at full trust and votes AVOID, while the news seat weighs the video’s 78 at 0.6 and its own 55 at 1.0 and votes CAUTION, as does the social seat. Two to one, CAUTION, and the turret shows the fleet’s opinion moving with the news. SafetyQuorumTest walks this chain one stage at a time, report, assessment, proposal, ballots, advisory, so a stall names its own stage.

Who votesThe tally counts one voter per peer, so each panelist sits on a peer of its own: three seats sharing a peer would be one voter, and the three-seat quorum could never close. When seats must share a peer, give each a certified key of its own and grant the vote per agent, which counts per agent instead (core example 05). The same rule gives each traveler its own peer in chapter 7.
aspace://guide/partybus/auction · chapter 6

The itinerary auction

The slots space runs the AUCTION strategy. The dispatcher writes one DaySlot per day of the trip, carrying the party’s interests, the pace, and a round number. Four activity planners each carry a @BidFunction that prices the day from their own point of view (how well the day suits what they do, how the interests match, how the pace and the weather sit with it) and a @SpaceTake that fires when they win. Bids converge over gossip rounds with no auctioneer, the lowest bid takes the day, and the winner’s method returns the ScheduledActivity the space writes back.

DaySlotslotsdate, interestspace, round@BidFunction ON EACH PLANNER'S PEER: THE LOWEST BID TAKES THE DAYdiningplannersightsplannerphoto-walkplannerrestplannerFULL, round 0554550140EASY, round 0554530140REST, round 1801701605FULL, round 1, storm6090100140a storm or a strike re-writes the day with round + 1; outdoor bids climbScheduledActivityslotskind, title, costUsdplannedBy, roundthe winner's @SpaceTake returns it
The itinerary auction. Each planner prices the day from its own point of view, the space converges on the lowest bid over gossip rounds with no auctioneer, and the winner's method returns the activity. The bids shown are the ones the code computes and the unit test pins. In a live run a day is awarded among the bids that have arrived, so a busy fleet can award a day before every bidder has spoken.
domain and agents/Planners.java
public record DaySlot(String tripId, String date, String location, List<String> interests,
                      String pace, int round) {
}
✓ one @BidFunction per peer per space · the winner’s @SpaceTake returns the ScheduledActivity

Nobody schedules. The dining planner wins the foodie’s days, the sights planner wins the historian’s, the photo walk takes the arrival day when the pace is EASY, and when the travelers’ energy turns a day to REST the rest planner’s bid collapses from 140 to 5 and it wins the day with a pool and a long lunch. A storm or a strike re-writes the day with round + 1, the outdoor planners’ bids climb by their round penalty, and the day changes hands to whoever minds the weather least. Each planner also holds a @SpaceRef to the research space so the day it schedules names a real table or sight the scouts found.

The prices are deterministic, and PartyBusUnitTest pins the winner of each kind of day. The award is a live protocol: a day goes to the lowest bid among the bids that have reached the deciding peer, so a laptop running twenty-one peers can, now and then, award a close day before a slower bidder has spoken. That is the honest cost of an auction with no auctioneer, and the reason a trust-sensitive decision goes to a vote instead.

Why one bidder per peerA replicated space holds one bid function per peer: the function the space calls when this peer takes. Four planners therefore live on four peers. Everything else about them is ordinary: the same annotations, the same binder, the same cards.
aspace://guide/partybus/party · chapter 7

The party votes and gossips

Tripper models travelers as data the planner writes for. Party Bus gives each traveler a seat. A TravelerAgent reacts to every TripDay with an EnergyReport and feeds the same number into the aspace:cap/aggregate push-sum epoch for that day, so the fleet learns the party’s average energy in a handful of gossip rounds with nobody collecting anything. The simulator reads the estimate and sets the next day’s pace, and the pace is what flips the auction toward the rest planner. Each traveler also casts one signed @Ballot when the party lead puts the next-destination advisor’s ideas to a quorum vote, preferring the idea that sounds most like them.

THE PACE: push-sum, NOTHING WRITTENmarco85 staminapriya70 staminadee55 staminaaggregate.start(energy epoch for the day, value)gossip averaging over the capability pipes, O(log N) roundssimulator reads the estimatebelow 40: REST; the next DaySlot goes out with that pacere-auctioned day: the rest planner bids 5THE DESTINATION: A VOTE ON THE RECORDNextDestinationIdeasresearchideasparty leadreturns a MotionProposaldecisionsnext:<tripId>, 3 optionsmarcopriyadeeone @Ballot each, from its own peerparty lead@OnDecisionPartyDecisiontripwinner, tally
The party votes and gossips. Two collective decisions, two mechanisms. The pace is an approximate average, cheap every day, so it is a push-sum epoch that writes nothing. The destination must be exact and auditable, so the lead returns a Motion, each traveler casts a signed ballot from its own peer, and the decision lands in the trip space.
agents/Party.java
public static final class TravelerAgent {
    private final Traveler traveler;
    private final int stamina;
    private final PushSumAggregate aggregate;

    @SpaceRef(TRIP)
    Space trip;

    /**
     * @param traveler  who
     * @param stamina   baseline energy, 0 to 100
     * @param aggregate the push-sum capability this traveler reports into
     * @param vote      the vote capability over the decisions space
     */
    public TravelerAgent(Traveler traveler, int stamina, PushSumAggregate aggregate) {
        this.traveler = Objects.requireNonNull(traveler, "traveler");
        this.stamina = stamina;
        this.aggregate = Objects.requireNonNull(aggregate, "aggregate");
    }

    /** The traveler's name. */
    public String name() {
        return traveler.name();
    }

    /**
     * How this traveler feels on a day: stamina minus wear, minus a lot when
     * the day's events hit them, and the same number goes into the aggregate.
     */
    @SpaceNotify(space = TRIP, lease = "12h")
    public EnergyReport feel(TripDay day) {
        int energy = energyOn(day, trip.readAll(Template.of(TripEvent.class)
                .where("tripId", eq(day.tripId())).where("date", eq(day.date())), 20));
        aggregate.start(Itinerary.energyEpoch(day.tripId(), day.date()), energy);
        double spent = 40 + (traveler.about().toLowerCase().contains("food") ? 45 : 20)
                + day.dayIndex() * 3;
        aggregate.start(Itinerary.spendEpoch(day.tripId(), day.date()), spent);
        return new EnergyReport(day.tripId(), day.date(), traveler.name(), energy, spent);
    }

    /** Pure and deterministic, so the simulator's story replays the same way. */
    int energyOn(TripDay day, List<TripEvent> events) {
        int energy = stamina - day.dayIndex() * 9;
        for (TripEvent event : events) {
            if (event.kind() == EventKind.ILLNESS
                    && event.detail().toLowerCase().contains(traveler.name().toLowerCase())) {
                energy -= 60;
            } else if (event.kind() == EventKind.WEATHER || event.kind() == EventKind.STRIKE) {
                energy -= 10;
            } else if (event.kind() == EventKind.PROTEST) {
                energy -= 15;
            }
        }
        return Math.max(0, Math.min(100, energy));
    }

    /**
     * One signed ballot per proposal, for the option that sounds most like them.
     * The cue is the vote's own proposal, so the ballot is never refused as
     * unknown and no wait is needed.
     */
    @Ballot(space = DECISIONS, prefix = "next:", lease = "12h")
    public String decide(VoteCapability.Proposal proposal) {
        return preference(proposal.options());
    }

    String preference(List<String> options) {
        String about = traveler.about().toLowerCase();
        for (String option : options) {
            for (String word : option.toLowerCase().split("[^a-z]+")) {
                if (word.length() > 3 && about.contains(word)) {
                    return option;
                }
            }
        }
        // Fall back on a stable, name-derived choice so ties break differently per traveler.
        return options.get(Math.abs(traveler.name().hashCode()) % options.size());
    }
}
✓ push-sum epoch per day · the lead returns a Motion · one signed ballot per traveler · the decision recorded in the trip space

The lead’s options come from the cue, the advisor’s list of ideas, so the vote cannot be declared in an annotation the way the safety panel’s is. This is the dynamic form: the reaction returns a Motion, the vote to open with its proposal id, options, quorum and lease, and the binder opens it once per proposal id. The human-readable announcement is a second entry the lead writes through its @SpaceRef, because one return carries one value. Before 0.3.0 the lead held the vote capability itself, kept a set of proposals it had opened, and called propose; the Motion moved all of that into the binder.

Two collective decisions, two mechanisms, chosen by what each needs. The pace is quorum sensing: an approximate average is plenty, it must be cheap every day, and push-sum converges in O(log N) rounds over the capability pipes with no entries written at all. The destination is a ballot: it must be exact and auditable, it happens once, and the tally lives in the space. Each traveler sits on a peer of its own, so each contributes its own value to the push-sum epoch and carries its own ballot, and the party quorum is a quorum of members.

When a share has not arrivedPush-sum is probabilistic and a fresh epoch may not have reached the simulator’s peer by the end of a short simulated day. The simulator falls back to averaging the EnergyReports in the space when the estimate is empty, which is the right posture for a fleet: the space is the record, the aggregate is the fast path.
aspace://guide/partybus/bookings · chapter 8

Exactly once: the bookings

A reservation is paid for, so it must be confirmed once. LEASE_RACE promises at most one completion per entry and tolerates duplicate work during a partition; for money, Party Bus uses the ordered log. Three clerk peers form a Raft group over the capability pipes. formBookingQuorum builds each clerk’s OrderedTakes coordinator over its bookings replica, registers the log so the peer tick drives it, and hands the coordinator to the binder; the clerk itself is one @OrderedTake method and the binder owns the take loop. Three clerks race for every order, the log commits their claims in one order, and only the clerk whose claim committed first completes the take and writes the BookingConfirmation, with the committed log index on it.

BookingOrderbookingsorderId, whatamountUsd@OrderedTake ON THREE CLERK PEERSclerk-aRaft leaderclaimclerk-bRaft memberclaimclerk-cRaft memberclaimTHE LOG1 claim by clerk-b2 claim by clerk-a3 claim by clerk-cfirst committedclaim adopts the takeBookingConfirmationbookingsconfirmation, amountUsdclerk, logIndexthe other two claims lose:no second confirmation,no second chargea majority must commit, so a clerk cut off from the other two cannot take
Exactly once: the bookings. Three clerks race for every order. The ordered log commits their claims in one order and only the first committed claim completes, so each order is confirmed once, with the committed log index on the confirmation. The log index and the winning clerk vary from run to run; the count of confirmations does not.
fleet and agents
/**
 * Forms the booking quorum: a Raft group among the clerk peers, an
 * {@link OrderedTakes} coordinator per clerk over its {@code bookings}
✓ Raft among three peers · election timer at 300 ms ticks · one confirmation per order, a simulated week later

Two operational details are worth their lines. Raft’s election timer counts ticks, so the clerks tick their log on a slower cadence than the aggregate (300 ms rather than 100 ms) to keep elections calm under load, and the fleet test checks that the leader lease the log advertises matches that real cadence. And the flow test checks the bookings twice: once when the confirmations land, and once more after the simulated week, well past the thirty-second take lease, to prove no order ever reappeared.

That second check exists because the first Party Bus run showed each order confirmed three times, thirty seconds apart, by a different clerk each time. The ordered-log coordinator applies every committed claim on every member under that member’s own key, a placeholder; the holder’s own proof arrives later with its completion; and SignedClaim.merge kept the placeholder on a tie, so no member but the holder could authenticate the completion (SPEC §11a.4), the entry stayed live on the other replicas, and the next clerk took it when the lease lapsed. The merge now prefers the holder-attested proof for the same claim, SignedClaimMergeTest in agentspaces-space pins it, and a flagship earned its keep by finding a bug the unit tests had not.

aspace://guide/partybus/planner · chapter 9

The planner sees the fleet

This is the chapter Tripper’s author would read first. Tripper’s GOAP planner chains @Actions by their input and output types. Party Bus lets it keep doing that while the actions live on other peers. RemoteActions watches the group’s ad-cache and exposes one remote action per foreign AgentCard and (consumed, produced) type pair whose types resolve locally, 17 of them in the recorded run; the routes say which space carries which request type and where the result is awaited. Invoking an action writes the request entry into the bag and awaits the correlated result, so it is choreography over the space rather than RPC: whichever scout takes the request answers it, and a scout that dies hands the request to the next.

ALONE: THE PLANNER'S OWN ACTIONSTravelBriefaskForSightsSightseeingRequestReviewDigest ?no pathWITH THE FLEET: EVERY AGENTCARD IS A TYPED ACTION (17 IN THIS RUN)askForSightslocalsightseeing-scout_remote: scouts-aaskForReviewsOf...localreview-reader_remote: scouts-bDigestresearch: write the request, await the result correlated by tripId and topicchoreography, not RPC: whichever scout takes the request answers it, and a dead scout hands it on
The planner sees the fleet. RemoteActions turns each foreign card's (consumed, produced) type pair into an action the planner can chain. Alone, the planner's two local actions cannot reach a ReviewDigest; with the fleet's cards the four-step plan exists and runs as two leased round-trips through two different peers.
fleet/PartyBusPlanner.java
/**
 * The registry of remote actions over a peer's discovery and spaces, with
 * every request type routed to the research bag and every result awaited
 * there (sweeps excepted: watchers write their reports into {@code signals}).

EmbabelRemoteActions then generates a real @Agent class with one @Action method per remote action, because the planner conditions on method signatures, and deployTo(platform) hands it to Embabel’s AgentPlatform. Without Embabel on the classpath the same registry drives a small breadth-first planner, which is enough to show the property that matters. The planner’s local repertoire (turn a brief into a sightseeing request, turn the top sight into a review request) provably cannot reach a ReviewDigest. Add the fleet’s cards and the four-step plan exists, and executes, as two leased round-trips through two different peers.

the planner, alone and with the fleet
planner alone reaches ReviewDigest: false
no Embabel on the classpath (-Pembabel adds it): planning over 17 remote actions directly
planner with the fleet: [askForSights, sightseeing-scout_SightseeingRequest,
                         askForReviewsOfTheTopSight, review-reader_ReviewRequest]
  -> reviews of the top sight: 4.6/5, praise: [the light on the river at 6pm,
     the food, and the price of the food, how kind people are when you attempt Portuguese]
✓ plan(TravelBrief → ReviewDigest): empty alone; four steps with the fleet; executed over live peers

Under the embabel Maven profile, src/embabel/java compiles against the same Embabel starter Tripper uses and boards the bus inside a Spring Boot application. The local agent supplies the two actions the fleet cannot, the bridge deploys the generated agent, and the real GOAP planner plans across the fleet. The profile runs on Embabel 1.5, Spring AI 2.0, and Spring Boot 4.1, and the workspace gate (verify-all.sh) builds and tests it, beside the core repository’s integration test that plans over fleet actions with the real Embabel planner.

src/embabel/java (compiles with -Pembabel)
package ai.badmonkey.agentspaces.partybus.embabel;

import ai.badmonkey.agentspaces.agent.remote.RemoteActions;
import ai.badmonkey.agentspaces.embabel.EmbabelRemoteActions;
import ai.badmonkey.agentspaces.partybus.PartyBus;
import ai.badmonkey.agentspaces.partybus.domain.Research.ReviewDigest;
import ai.badmonkey.agentspaces.partybus.domain.Research.ReviewRequest;
import ai.badmonkey.agentspaces.partybus.domain.Research.SightseeingOptions;
import ai.badmonkey.agentspaces.partybus.domain.Research.SightseeingRequest;
import ai.badmonkey.agentspaces.partybus.domain.Trip.PointOfInterest;
import ai.badmonkey.agentspaces.partybus.domain.Trip.TravelBrief;
import ai.badmonkey.agentspaces.partybus.fleet.PartyBusPlanner;
import ai.badmonkey.agentspaces.partybus.sources.Sources;
import com.embabel.agent.api.annotation.AchievesGoal;
import com.embabel.agent.api.annotation.Action;
import com.embabel.agent.api.annotation.Agent;
import com.embabel.agent.config.annotation.EnableAgents;
import com.embabel.agent.core.AgentPlatform;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

import java.time.Duration;

/**
 * Party Bus as a Tripper-style Embabel application. This source set compiles
 * only under the {@code embabel} Maven profile, against the same Embabel
 * starter Tripper uses, and it is the part of the project that cannot be
 * verified in a network-restricted build; treat it as the shape of the
 * integration and run it from a laptop with Maven Central and the API keys.
 *
 * <p>Two things happen here. First, the bus boards inside the Spring Boot
 * application, exactly as the standalone demo boards it. Second, the fleet's
 * AgentCards come back in as planner actions: {@link PartyBusPlanner#bridge}
 * generates an {@code @Agent} with one {@code @Action} per remote card and
 * {@code deployTo(platform)} hands it to Embabel's {@code AgentPlatform}, so
 * the GOAP planner sees the sightseeing scout, the review reader, and every
 * other specialist as actions it can chain. The local {@link PartyBusPlannerAgent}
 * supplies the two actions the fleet cannot: turning a brief into the first
 * request, and turning the top sight into a review request. The goal, a
 * {@code ReviewDigest} from a {@code TravelBrief}, is reachable only through
 * the fleet, which is the whole point.
 */
@SpringBootApplication
@EnableAgents
public class PartyBusEmbabelApplication {

    /**
     * Runs the application.
     *
     * @param args passed to Spring Boot
     */
    public static void main(String[] args) {
        SpringApplication.run(PartyBusEmbabelApplication.class, args);
    }

    /** Boards the bus and bridges its cards into the platform once Spring is up. */
    @Bean
    CommandLineRunner boardTheBus(AgentPlatform platform) {
        return args -> {
            Sources sources = Sources.fromEnvironment();
            PartyBus.Seating bus = PartyBus.board(sources, 7700, 7590);
            RemoteActions remote = PartyBusPlanner.remoteActions(bus.plannerPeer());
            PartyBusPlanner.awaitActions(remote, 12, Duration.ofSeconds(30));
            EmbabelRemoteActions bridge = PartyBusPlanner.bridge(remote);
            if (!bridge.deployTo(platform)) {
                throw new IllegalStateException("the fleet agent did not deploy; are the cards in?");
            }
        };
    }

    /**
     * The planner's own two actions. Everything between them is a remote scout
     * the bridge generated from an AgentCard.
     */
    @Agent(description = "Plans a trip with the Party Bus fleet's specialists")
    public static class PartyBusPlannerAgent {

        @Action
        public SightseeingRequest askForSights(TravelBrief brief) {
            return new SightseeingRequest(brief.tripId(), "planner sights " + brief.to(),
                    brief.to(), brief.brief(), brief.departureDate(), brief.returnDate(),
                    PartyBus.party(brief.tripId()).contribution());
        }

        @Action
        public ReviewRequest askForReviewsOfTheTopSight(SightseeingOptions sights) {
            PointOfInterest top = sights.pointsOfInterest().isEmpty()
                    ? new PointOfInterest("the old town", "", "", "", "")
                    : sights.pointsOfInterest().get(0);
            return new ReviewRequest(sights.tripId(), "planner reviews " + top.name(),
                    top.name(), top.location());
        }

        @AchievesGoal(description = "Know what travelers say about the trip's top sight")
        @Action
        public ReviewDigest done(ReviewDigest digest) {
            return digest;
        }
    }
}
aspace://guide/partybus/turret · chapter 10

The command-and-control turret

The turret is the fleet console with Party Bus panels and commands, running as one more read-mostly peer. The panels are pure reads of the spaces: the itinerary (the winning activity per day, latest round first), the advisories with their ballot splits, the bookings with the clerk and the committed log index, the party’s energy per day beside the push-sum estimate, the events with the fleet’s responses, and the finished plan. The commands are writes: start a trip, throw an event at the fleet exactly as the simulator would, reroute the party to a new destination under a new trip id. Every command lands in c2-audit signed by the operator’s peer, so the trip’s audit trail includes the human, and the built-in PAUSE, RESUME, and DRAIN directives reach every worker through fleet-control.

browserweb UI at /curl, HATEOASHAL+JSON /api/v1listenersSSE /api/v1/eventsturret PEER: FleetConsoleServerpanels: pure reads of the spacesitinerarysafetybookingsenergyresponsesplancommands: writes, signed by the operator's peerstart-tripthrow-eventreroutedirectiveoperator token partybus-operatortrip, slotsevents, briefs, reroutesfleet-controlPAUSE, RESUME, DRAINc2-auditevery command, signedthe panels read the same replicas every peer holds: the console is one more peer, not a privileged service
The command-and-control turret. Three open surfaces over one peer: a web UI, HAL+JSON, and Server-Sent Events. Panels read the spaces; commands write to them, and every command lands in c2-audit signed by the operator's peer, so the trip's audit trail includes the human.
fleet/Turret.java
public static Turret over(PartyBusFleet.Peer peer) {
    Objects.requireNonNull(peer, "peer");
    ConsoleView.Builder view = ConsoleView.builder()
            .space(PartyBusFleet.RESEARCH, peer.space(PartyBusFleet.RESEARCH),
                    Research.FlightRequest.class, Research.HotelRequest.class,
                    Research.CarRequest.class, Research.SightseeingRequest.class,
                    Research.RestaurantRequest.class, Research.DietRequest.class,
                    Research.FoodieRequest.class, Research.PhotoRequest.class,
                    Research.ReviewRequest.class, Research.AccessibilityRequest.class,
                    Research.HospitalRequest.class, Research.NextDestinationRequest.class,
                    Safety.NewsSweep.class, Safety.VideoSweep.class, Safety.SocialSweep.class)
            .space(PartyBusFleet.SLOTS, peer.space(PartyBusFleet.SLOTS), DaySlot.class)
            .results(PartyBusFleet.SLOTS, ScheduledActivity.class,
                    a -> ((ScheduledActivity) a).plannedBy())
            .space(PartyBusFleet.BOOKINGS, peer.space(PartyBusFleet.BOOKINGS),
                    BookingOrder.class)
            .results(PartyBusFleet.BOOKINGS, BookingConfirmation.class,
                    c -> ((BookingConfirmation) c).clerk())
            .members(() -> peer.runtime().membership().allMembers())
            .discovery(peer.discovery());
    for (Class<?> result : RESULT_TYPES) {
        view.results(PartyBusFleet.RESEARCH, result, Turret::authorOf);
    }
    ConsoleView built = view.build();

    FleetCommander commander = FleetCommander.builder(peer.spaces()::get)
            .controlSpace(PartyBusFleet.CONTROL)
            .auditSpace(PartyBusFleet.AUDIT)
            .view(built)
            .command(startTrip())
            .command(throwEvent())
            .command(reroute(peer.space(PartyBusFleet.TRIP)))
            .build();

    FleetConsoleServer server = FleetConsoleServer.builder(built)
            .fleetName("partybus")
            .commandAndControl(commander, OPERATOR_TOKEN)
            .panel(itineraryPanel(peer))
            .panel(safetyPanel(peer))
            .panel(bookingsPanel(peer))
            .panel(energyPanel(peer))
            .panel(responsesPanel(peer))
            .panel(planPanel(peer))
            .build();
    return new Turret(peer, built, server);
}
✓ HAL+JSON under /api/v1 · SSE at /api/v1/events · web UI at / · operator token partybus-operator

The console’s three interfaces are open standards, so the turret works from a browser, from curl, or from a Spring HATEOAS client, and a panel is an id, a title, and a live JSON document.

curl -s localhost:7590/api/v1/panels/safety/data | jq .
curl -s localhost:7590/api/v1/panels/itinerary/data | jq '.[] | {date, kind, plannedBy, round}'
curl -N localhost:7590/api/v1/events          # the activity stream, as Server-Sent Events
curl -s -X POST localhost:7590/api/v1/commands/throw-event \
     -H 'Authorization: Bearer partybus-operator' -H 'Content-Type: application/json' \
     -d '{"operator":"al","args":{"tripId":"lisbon-2026","date":"2026-10-05","kind":"STRIKE",
          "location":"Lisbon, Portugal","detail":"taxi strike","severity":"3"}}'
curl -s -X POST localhost:7590/api/v1/commands/directive \
     -H 'Authorization: Bearer partybus-operator' -H 'Content-Type: application/json' \
     -d '{"operator":"al","action":"PAUSE","target":"*"}'   # every worker pauses; RESUME lifts it
aspace://guide/partybus/simulator · chapter 11

The vacation simulator

The simulator is a clock and a script. It writes one TripDay per day of the brief, throws the script’s TripEvents at the fleet, and between days reads the party’s energy from the aggregate to set the next day’s pace; when the pace turns to REST it re-auctions the day. It never calls an agent. Everything it does is a write into the trip or slots space, and everything the fleet does about it is a reaction, so the same fleet handles the same events when the turret throws them by hand, or when the real world does. That is what makes the simulation a test of the fleet rather than of the simulator.

THE SIMULATOR WRITES; THE FLEET REACTS. IT NEVER CALLS AN AGENT.TripEvent: before the dayTripDay: the travelers wakeestimate: energy, push-sumDaySlot: re-auction on REST10-03EASYenergy 71calmphoto-walkwon the day10-04FULLenergy 49WEATHERdiningwon the day10-05EASYenergy 33ILLNESSsightswon the day10-06RESTenergy 29PROTESTrestwon the day10-07RESTenergy 23STRIKErestwon the day10-08RESTenergy 25calmrestwon the day10-09RESTenergy 15LOST_PASSPORTrestwon the day
The week, as the 0.4.0 run lived it. Seven simulated days. Each day's event is written before the day, the travelers report their energy, the push-sum estimate sets the next day's pace, and a REST pace re-auctions the day. The bars are the party's average energy; the bottom line is the planner that won the day.
fleet/VacationSimulator.java
public static List<Scene> lisbonWeek() {
    return List.of(
            new Scene(0, EventKind.CALM, "Wheels down. Pastel de nata count: 3 before the hotel.", 1),
            new Scene(1, EventKind.WEATHER, "Atlantic squall parks over the city until mid-afternoon.", 2),
            new Scene(2, EventKind.ILLNESS, "Marco ordered the clams. Marco regrets the clams.", 3),
            new Scene(3, EventKind.PROTEST, "Transport unions march on the Assembleia; Baixa is sealed off.", 3),
            new Scene(4, EventKind.STRIKE, "Metro and ferry strike; the tram to Belém is a rumor.", 3),
            new Scene(5, EventKind.CALM, "Sun, a rooftop, and nothing on fire.", 1),
            new Scene(6, EventKind.LOST_PASSPORT, "Priya's passport is in a taxi. Somewhere.", 4));
}
✓ a day is a few seconds of wall clock; events land before the day so the travelers wake up to them
Order mattersThe event for a day is written before the TripDay, so a traveler’s feel reaction reads the illness that already happened. Written the other way round, Marco reported a fine morning and then ate the clams, and the rest day never came.
aspace://guide/partybus/running · chapter 12

Running, testing, going live

PartyBus.board is the seating plan: every peer started, every agent bound, the quorum formed, the turret up. The demo then writes the travelers and the brief, shows the planner alone and with the fleet, lives the week, and prints what the space remembers. It keeps the turret up afterwards, so the panels stay live until you stop it.

PartyBus.java
/**
 * Boards the bus: starts every peer, binds every agent, forms the booking
 * quorum, and starts the turret.
 *
 * @param sources     the specialists' sources
 * @param basePort    the first TCP port; peers take consecutive ports
 * @param consolePort the turret's HTTP port (0 for any)
 * @return the seating
 * @throws Exception on startup failure
 */
public static Seating board(Sources sources, int basePort, int consolePort) throws Exception {
    List<Peer> peers = new ArrayList<>();
    int[] port = {basePort};
    Function<String, Peer> seat = name -> {
        try {
            Peer peer = PartyBusFleet.startPeer(name, port[0]++,
                    peers.isEmpty() ? 0 : basePort);
            peers.add(peer);
            return peer;
        } catch (Exception e) {
            throw new IllegalStateException("cannot seat " + name, e);
        }
    };

    Peer turretPeer = seat.apply("turret");

    // The concierge desk: the agents that watch the whole trip rather than one task.
    Peer concierge = seat.apply("concierge");
    concierge.bind(new Dispatcher());
    concierge.bind(new Assemblers.StayFinder());
    concierge.bind(new Assemblers.PlanAssembler(sources));
    concierge.bind(new SafetyPanel.Lead("safety-lead"));

    // The scouts and advisors: the research bag's workers, four to a peer.
    Peer scoutsA = seat.apply("scouts-a");
    scoutsA.bind(new Scouts.FlightScout(sources));
    scoutsA.bind(new Scouts.HotelScout(sources));
    scoutsA.bind(new Scouts.CarScout(sources));
    scoutsA.bind(new Scouts.SightseeingScout(sources));
    Peer scoutsB = seat.apply("scouts-b");
    scoutsB.bind(new Scouts.RestaurantScout(sources));
    scoutsB.bind(new Scouts.PhotoCurator(sources));
    scoutsB.bind(new Scouts.ReviewReader(sources));
    scoutsB.bind(new Scouts.NextDestinationAdvisor(sources));
    Peer advisors = seat.apply("advisors");
    advisors.bind(new Advisors.DietAdvisor(sources));
    advisors.bind(new Advisors.FoodieAdvisor(sources));
    advisors.bind(new Advisors.AccessibilityAdvisor(sources));
    advisors.bind(new Advisors.HospitalLocator(sources));

    // The safety pipeline: watchers, the analyst, and the three-seat panel.
    Peer watchers = seat.apply("watchers");
    watchers.bind(new Watchers.NewsWatcher(sources));
    watchers.bind(new Watchers.VideoWatcher(sources));
    watchers.bind(new Watchers.SocialWatcher(sources));
    watchers.bind(new Watchers.SentimentAnalyst(sources));
    // One peer per panelist: a ballot's voter is the peer's own agent, so
    // three seats sharing a peer would be one voter and the three-seat quorum
    // could never close (QA4 A4-4).
    for (String seatName : SafetyPanel.SEATS) {
        Peer panelist = seat.apply("panelist-" + seatName);
        panelist.bind(new SafetyPanel.Panelist(seatName));
    }

    // The activity auction: one bid function per peer, so one planner per peer.
    seat.apply("dining-planner").bind(new Planners.DiningPlanner());
    seat.apply("sights-planner").bind(new Planners.SightsPlanner());
    seat.apply("photo-walk-planner").bind(new Planners.PhotoWalkPlanner());
    seat.apply("rest-planner").bind(new Planners.RestPlanner());

    // The party itself: one peer per traveler, so each carries its own ballot
    // and the party quorum is a quorum of members (QA4 A4-4).
    Travelers party = party("lisbon-2026");
    int[] stamina = {85, 70, 55};
    for (int i = 0; i < party.travelers().size(); i++) {
        Traveler traveler = party.travelers().get(i);
        Peer seatOf = seat.apply("traveler-" + traveler.name().toLowerCase());
        seatOf.bind(new Party.TravelerAgent(traveler, stamina[i], seatOf.aggregate()));
    }
    Peer bus = seat.apply("the-bus");
    bus.bind(new Party.Lead(party.travelers().size()));

    // The booking desk: three clerks, one Raft quorum.
    List<Peer> clerks = List.of(seat.apply("clerk-a"), seat.apply("clerk-b"),
            seat.apply("clerk-c"));
    PartyBusFleet.formBookingQuorum(clerks);

    // The planner is where Tripper's own agent would run; the simulator rides with the turret.
    Peer plannerPeer = seat.apply("planner");
    Peer simulatorPeer = turretPeer;

    Turret turret = Turret.over(turretPeer);
    turretPeer.run(turret);
    int bound = turret.start(consolePort);
    System.out.println("turret: http://localhost:" + bound + "/  (operator token: "
            + Turret.OPERATOR_TOKEN + ")");
    return new Seating(peers, turretPeer, plannerPeer, simulatorPeer);
}
# in agentspaces-partybus; the AgentSpaces 0.4.0 libraries resolve from Maven Central
mvn test                       # 10 tests in under a minute: 6 unit, the chat client, the safety quorum, 2 fleet tests over TLS
./run-demo.sh                  # the recorded week; open http://localhost:7590/

# live, exactly as Tripper asks
export OPENAI_API_KEY=...
export BRAVE_API_KEY=...
export YOUTUBE_API_KEY=...     # optional
./run-demo.sh
AGENTSPACES_LIVE_MODELS=1 mvn test   # adds the one test that calls a real model

# with the real Embabel planner
mvn -Pembabel spring-boot:run
Stale classesAn IDE that compiles into target/ can leave test classes that fail with “Unresolved compilation problems” before a single test runs. mvn clean test rebuilds them with the real compiler.

The flow test is the project’s proof. It boards the whole bus on free ports with the recorded sources and asserts, in order: every one of the twelve result types answered its request; the baseline advisory is GO with three ballots; every day was auctioned and more than one planner won; the join fired and the plan carries its stays and the advisory; each booking was confirmed by exactly one clerk and the orders drained; the planner reaches a ReviewDigest only with the fleet and executes the four-step plan; and after the simulated week the illness produced a hospital list, the protest produced a CAUTION advisory with one AVOID ballot, every event has a response on record, the storm day went to an indoor planner, a rest day exists, the travelers voted, and the bookings are still exactly once. Its sibling seats the three clerks alone and checks that every capability is discovered and that the leader lease matches the real tick.

Where to go next

Clone agentspaces-partybus; it builds on the published libraries alone. Swap RecordedSources for a recording of your own trip by pointing RecordedSources.of at any document with the same layout. Add a scout by writing one class with one @SpaceTake method and one request/result pair, and watch the planner grow an action. Seat a second flight scout and kill one mid-search. Then open the developer guide for the primitives you just used, the workflow guide for the patterns they make, and SPEC.md for the guarantees behind each one.