Skip to content

widget contract

Kadyapam edited this page May 24, 2026 · 1 revision

Widget contract

The schema-driven contract between playbook outputs and SPA UI. A widget is a JSON schema plus a React component. The SPA dispatches incoming widget envelopes by type and renders the matching component.

Source-of-truth files:

The envelope

Every widget the SPA renders arrives wrapped in the envelope defined by _envelope.schema.json. Conceptually:

{
  "widget_id": "uuid",
  "type": "hotel_card | flight_list | place_list | order_confirmation | ...",
  "version": 1,
  "data": { /* widget-specific shape, validated by <type>.schema.json */ },
  "actions": [ { /* optional, user-actionable affordances */ } ],
  "rendered_at": "ISO8601"
}

The envelope is created inside a playbook step (typically render_widget_chat in itinerary-planner.yaml) and then persisted to Firestore as an append_widget_event record. The SPA receives it either:

  • as part of an executePlaybook GraphQL response (immediate results), or
  • through a subscription/event SSE frame (chat_threads/<thread_id>/events/* collection updates).

Widget catalog (today)

Widget type Component Used for
action_chooser ActionChooser.tsx Present a small set of next-actions to the user.
bot_text / user_text BotText.tsx / UserText.tsx Plain conversational text bubbles.
calendar_view CalendarView.tsx Live calendar of trip events, fed by subscribeToCalendarEvents.
clarify_question ClarifyQuestion.tsx Ask the user a structured follow-up.
date_range_picker DateRangePicker.tsx Date-range input for trips.
error_card ErrorCard.tsx Human-readable failure summary.
filter_panel FilterPanel.tsx Faceted filters over a result list.
flight_card / flight_list FlightCard.tsx / FlightList.tsx Flight offer cards (Duffel/Amadeus).
hotel_card / hotel_list HotelCard.tsx / HotelList.tsx Hotel cards (Amadeus).
hotel_compare HotelCompare.tsx Side-by-side comparison.
itinerary_summary ItinerarySummary.tsx Final trip-plan summary.
loading_card LoadingCard.tsx "Working on it" state.
map_view MapView.tsx Google Maps embed for places.
notification Notification.tsx Inline alert (info/warn/error).
order_confirmation OrderConfirmation.tsx Post-booking confirmation with PDF link.
party_picker PartyPicker.tsx Number of travelers / ages.
place_autocomplete_input PlaceAutocompleteInput.tsx Google Places autocomplete control.
place_card / place_list PlaceCard.tsx / PlaceList.tsx Point-of-interest cards.
property_block PropertyBlock.tsx Property detail block in chat.
typing_indicator TypingIndicator.tsx Animated typing state.

Each entry has a <type>.schema.json declaring its data shape. The schema's additionalProperties is constrained so adding a field to the component without updating the schema fails npm run type-check.

How rendering works

  1. SPA receives the envelope (GraphQL response or SSE frame).
  2. The dispatcher reads envelope.type and looks up the matching component in widgetUtils.tsx.
  3. The dispatcher passes envelope.data (and envelope.actions where relevant) into the component.
  4. The component renders Material-UI elements with the data. Actions (buttons, CTAs) fire callbacks that re-invoke playbooks via executePlaybook(...).

A widget action is not a side effect the SPA performs. The action is "invoke playbook X with workload Y." The SPA's only local action is "ask the gateway to run the next playbook."

Add a new widget

Five steps, in this order:

1. Write the schema

Add playbooks/widget-contract/<your_widget>.schema.json. Use the existing schemas as templates. The schema must:

  • declare a type constant matching the filename stem,
  • describe data exhaustively (every field, every enum, every nested shape),
  • set additionalProperties: false at every object level.

2. Regenerate the TS types

npm run contracts

This regenerates src/contracts/widgets.ts. Commit both the new schema and the regenerated types.

3. Implement the component

Add src/components/widgets/<YourWidget>.tsx. Import the generated type from src/contracts/widgets. The component must accept data (and actions if applicable) typed against the generated shape. Existing widgets are the style template: Material UI, Emotion for component-scoped styles.

4. Register in the dispatcher

Wire the new component into the dispatch table in src/components/widgets/widgetUtils.tsx keyed by the widget's type string.

5. Add a smoke example + run the harness

Add a minimal example envelope to playbooks/widget-contract/widget_envelope_examples.md (near a similar widget) so the smoke harness exercises it.

Run:

npm run smoke:widgets   # round-trips every example through the dispatcher
npm test                # vitest suite
npm run type-check
npm run lint

If the schema and the component agree, all four pass.

Then have a playbook emit it

The new widget is now renderable. To get it onto the page, a playbook step must produce an envelope of the new type. The typical pattern is a tool: python step that builds the envelope dict, then arcs into append_widget_event which persists it.

The render_widget_chat step in the orchestrator is the example to follow.

Why this contract is load-bearing

The widget contract is the boundary between "domain knowledge" and "rendering knowledge."

  • The playbook author knows what to say to the user. They emit envelopes.
  • The component author knows how to draw it. They render envelopes.

The two sides agree only on the schema. There is no shared domain code, no shared formatting helpers, no shared business logic. This is what lets the SPA shell stay domain-neutral and makes the fork-for-another-domain workflow tractable.

Related

Clone this wiki locally