aspace://guide/partybus

Party Bus

How to build the Embabel travel planner as a fleet: thirty-four agents on fifteen 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.2.0SPEC v0.1.7Java 2115 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. 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. Read it beside the developer guide, which explains each primitive; 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 five peersreplicated workers
@Action proposeTravelPlanthe plan assembler joins the bundle when the last result landsthe join over the space
@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 logaspace: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 simulatorvote, AUCTION, aggregate, console, choreography
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 the recorded run, 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 70
   -> PHOTO_WALK by photo-walk-planner: Golden-hour walk with a 4-shot list
2026-10-04  pace FULL  energy 51
   !! 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 32
   !! 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 28
   !! 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 24
   !! STRIKE: Metro and ferry strike; the tram to Belém is a rumor.
   -> DINING by dining-planner: Market morning, long lunch, dinner at Taberna da Rua das Flores
2026-10-08  pace REST  energy 25
   -> REST by rest-planner: Rest day: pool, siesta, one gentle dinner
2026-10-09  pace REST  energy 16
   !! 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-1 PB-FC5A3CA5 by clerk-c at log index 1
booked lisbon-2026-stay-2 PB-643B6984 by clerk-c at log index 3
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
✓ 15 peers · 34 agents · 9 spaces · fleet flow test green in about 90 s

Read the week against the mechanisms. The storm on the second day re-auctioned the day and the dining planner took it from the sights planner, 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 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 by one clerk each through the log, and the travelers voted Porto for next time, two ballots to one. Nobody dispatched any of it.

aspace://guide/partybus/the-map

Who is on the bus

Fifteen peers, one group, nine replicated spaces on every peer. A peer hosts a few agents; the activity planners and the booking clerks are the exceptions, and the reason for each exception is a lesson in itself (chapters 6 and 8).

seat agents coordination type
conciergedispatcher, plan-assembler, stay-finder, safety-leadnotify choreography: the fan-out and the join
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, social watchers; sentiment analyst; three panelists@SpaceTake sweeps, @SpaceNotify scoring, aspace:cap/vote QUORUM
dining-, sights-, photo-walk-, rest-plannerone activity planner each@BidFunction and @SpaceTake on slots (AUCTION)
the-busMarco, Priya, Dee, and the party leadaspace:cap/aggregate push-sum, aspace:cap/vote
clerk-a, clerk-b, clerk-cbooking clerksaspace:cap/ordered-log: exactly-once takes 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.

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). The first peer has no seed and becomes everyone else’s. There is no server anywhere in this picture.

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);

    node.startTicking(Duration.ofMillis(500));
    return new Peer(name, identity, node, runtime, discovery, spaces, binder, codec, pipes,
            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. Fifteen 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 fifteen peers, rather than one peer each, is a deliberate choice for a laptop. Anything bound with @SpaceTake or @SpaceNotify shares a peer happily. Two things do not: an AUCTION bid function is per space per peer, so each bidder needs its own peer (chapter 6), 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.

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 five peers; the flagship pattern from code-fleet applies unchanged, 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 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. 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.

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 waits on five different entry types and fires once per trip when the last of them lands, so it is a loop over the space rather than a notify. It reads the sights, the hotels, the restaurants, the foodie list, and the flights, asks the model for Tripper’s ProposedTravelPlan, and writes 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 AutoCloseable runPlanAssembler(Space research, Space trip, Sources sources) {
    Thread thread = Thread.ofVirtual().name("plan-assembler").start(() -> {
        Set<String> assembled = new HashSet<>();
        while (!Thread.currentThread().isInterrupted()) {
            for (TravelBrief brief : trip.readAll(Template.of(TravelBrief.class), 20)) {
                String id = brief.tripId();
                if (assembled.contains(id)) {
                    continue;
                }
                Optional<SightseeingOptions> sights = first(research, SightseeingOptions.class, id);
                Optional<HotelOptions> hotels = first(research, HotelOptions.class, id);
                Optional<RestaurantOptions> tables = first(research, RestaurantOptions.class, id);
                Optional<FoodieOptions> foodie = first(research, FoodieOptions.class, id);
                Optional<FlightOptions> flights = first(research, FlightOptions.class, id);
                if (sights.isEmpty() || hotels.isEmpty() || tables.isEmpty()
                        || foodie.isEmpty() || flights.isEmpty()) {
                    continue;
                }
                Travelers party = trip.read(Template.of(Travelers.class)
                        .where("tripId", eq(id))).orElse(new Travelers(id, 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: " + sights.get().pointsOfInterest()
                        + "\nHotels: " + hotels.get().options()
                        + "\nRestaurants: " + tables.get().restaurants()
                        + "\nMust eat: " + foodie.get().dishes()
                        + "\nFlights: " + flights.get().options(), ProposedTravelPlan.class);
                List<Day> days = drafted.days().isEmpty() ? defaultDays(brief) : drafted.days();
                trip.write(new ProposedTravelPlan(id, drafted.title(), drafted.plan(), days,
                        drafted.countriesVisited(), "plan-assembler"),
                        Lease.of(Duration.ofHours(12)));
                assembled.add(id);
            }
            SafetyPanel.sleep(250);
        }
    });
    return thread::interrupt;
}
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 join that usually has what it needs already.
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.

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 the proposal when the first assessment of a sweep lands and records the TravelAdvisory when the vote reaches quorum. 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";
}

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.

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. The space runs the CBBA rounds among the bidders, awards the day to the lowest bid, and the winner’s method returns the ScheduledActivity the space writes back.

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.

day dining sights photo walk rest winner
FULL, round 0, all interests554550140sights
EASY (arrival), round 0554530140photo walk
REST, round 1801701605rest
FULL, round 1 (storm)6090100140dining
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.

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 · one signed ballot per traveler · the decision recorded in the trip space

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, each holds an OrderedTakes coordinator over its bookings replica, and each runs a clerk that takes BookingOrders through it. Three clerks race for every order; the log commits their claims in one order; and only the clerk whose claim committed first adopts the take and writes the BookingConfirmation, with the committed log index on it.

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 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; 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.

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 15 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.

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 surfaces 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.

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.

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.run(Assemblers.runPlanAssembler(concierge.space(PartyBusFleet.RESEARCH),
            concierge.space(PartyBusFleet.TRIP), sources));
    concierge.bind(new SafetyPanel.Lead(concierge.vote(PartyBusFleet.ADVISORIES), "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(bus.vote(PartyBusFleet.DECISIONS), 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);
}
# from a clone of the AgentSpaces repository, once
mvn -N install
mvn install -DskipTests -pl agentspaces-common,agentspaces-api,agentspaces-identity,\
agentspaces-peering,agentspaces-discovery,agentspaces-space,agentspaces-capabilities,\
agentspaces-agent,agentspaces-a2a,agentspaces-connect-core,agentspaces-console,\
agentspaces-spring-stubs,agentspaces-spring-boot-autoconfigure,\
agentspaces-spring-boot-starter,embabel-agentspaces

# in agentspaces-partybus
mvn test                       # 6 unit tests + the fleet flow test over TLS, about 90 s
./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

# with the real Embabel planner (needs Maven Central)
mvn -Pembabel spring-boot:run

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 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 the dining planner, a rest day exists, the travelers voted, and the bookings are still exactly once.

Where to go next

Lift agentspaces-partybus into its own repository as it is; 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, and SPEC.md for the guarantees behind each one.