limlock

spec · v0.1 · experimental · last updated

GroupEventIntent v0.1

A GroupEventIntent is a coordination request across N principals. The protocol defines five primitives. Each can be exchanged across A2A, embedded in tool-call payloads for MCP, or transported however the calling agent prefers — the schema is the contract, not the wire.

This page lists the five primitives in the order they're produced during the coordination lifecycle. Each carries a TypeScript type signature, a JSON shape, and a one-paragraph note on its purpose.

1. GroupEventIntent

Opens the coordination. One per planning attempt. Carries the request the organizer is making — group size, when, where (location plus a search radius), and a free-form message — plus an expiry so abandoned intents don't accumulate.

interface GroupEventIntent {
  id: string;                 // UUID assigned by the resolver
  creatorId: string | null;   // Authenticated organizer, if any
  creatorName: string;        // Display name for the organizer
  groupSize: number;          // 1–N (golf cap = 4)
  status: IntentStatus;       // collecting | resolving | voting | winner | expired | cancelled
  message: string | null;     // Optional free-form context
  lat: number;                // Search anchor — lat
  lng: number;                // Search anchor — lng
  radiusKm: number;           // Search radius (km)
  expiresAt: string;          // ISO 8601 timestamp
  createdAt: string;
  updatedAt: string;
}

The status field is the only mutable column on a live intent — every other field is immutable post-creation. The state machine is strictly forward; the resolver rejects re-resolution from terminal states (winner, expired, cancelled).

2. Constraint

One per principal in the group. Each peer agent submits exactly one constraint set on behalf of its human, communicating that person's per-date availability windows and preferences. The intent advances to resolving automatically once constraints.length === groupSize.

interface Constraint {
  id: string;
  intentId: string;
  playerName: string;
  dates: Array<{
    date: string;          // YYYY-MM-DD
    earliestTime: string;  // HH:MM
    latestTime: string;    // HH:MM
  }>;
  // Optional preferences — all may be null/undefined
  maxPriceCents: number | null;
  cartRequired: boolean;
  // Vertical extensions live here; see GolfIntent
}

The dates array is the authoritative source of availability. The same time window can apply to every date ("default across the week") or vary per-day ("free all morning Tue, only 4–6pm Wed"). A submission is the principal's complete schedule — downstream resolvers should treat re-submissions as full replacements.

3. Option

A resolved match against the constraint set. The resolver intersects every constraint and ranks the surviving candidates by group-fit (date overlap, time overlap, price ceiling, vertical-specific preferences, distance, current weather). 3–5 options is typical; the array can be empty when no candidate satisfies every constraint.

interface Option {
  id: string;
  intentId: string;
  rank: number;            // 1 = best fit
  score: number;           // 0..1, monotonic with rank
  date: string;            // YYYY-MM-DD
  time: string;            // HH:MM
  priceCents: number;
  bookingUrl: string;      // Deep-link into the venue's booking surface
  voteCount: number;       // Updated as votes land
  isWinner: boolean;       // Set when group is fully voted; tied options carry true
  // Vertical-specific fields live here (course, holes, cart, weather, etc.)
}

Options are produced once per resolve and frozen — re-resolution replaces the entire set atomically. Vote counts are mutable until the intent transitions to winner; once there, they're frozen alongside the option set.

4. Vote

A principal's preference over the resolved options. Votes are immutable once the intent reaches winner state, but mutable while it's still voting: a player can change their mind and re-submit until everyone has voted. Each principal gets exactly one vote regardless of how many times they re-submit.

interface Vote {
  intentId: string;
  playerName: string;
  optionId: string;
  votedAt: string;  // ISO 8601
}

Ties are not broken automatically. A 1-1 split or 2-2 split leaves multiple options with isWinner: true — the calling agent or UI should surface "no clear winner, talk with your group" rather than picking arbitrarily.

5. BookingProposal

The terminal artifact. Once the group has converged on a winner, the BookingProposal is the handoff to the actual booking surface. Limlock does not complete the booking itself; it produces the proposal, the calling agent or human follows the deep-link.

interface BookingProposal {
  intentId: string;
  optionId: string;
  bookingUrl: string;     // Most-specific deep-link the venue platform supports
  priceCents: number;
  date: string;
  time: string;
  // Receipt of the handoff lives in the coordination receipt log
  // — see /spec/receipts
}

Downstream payment protocols (e.g. AP2) close the loop from this point on. Limlock composes with payment protocols rather than replacing them — see /spec/payments.

Reference implementation The TypeScript types and Zod schemas backing this spec live at @golf/shared/schemas and @golf/shared/types. The HTTP surface is documented at api.limlock.com/doc (OpenAPI 3.1).