A person filling out a travel request often already has the information in a message:
Prepare a round trip from SFO to JFK, departing November 12, 2026 and returning November 15, 2026, for one traveler in economy. Purpose: customer workshop.
An AI assistant can turn that brief into form fields. The more interesting problem starts when its response arrives. The person might have changed the dates, corrected the purpose, or opened another request.
I built and maintain Trip Workbench, a React and TypeScript demo with synthetic travel data, to explore that interaction. Its assistance flow lets a person request suggestions, inspect proposed fields and clarification questions, and explicitly apply a change.
The useful unit of work is a proposal tied to a particular draft state. That gives the application something it can validate, display, reject when outdated, and save through its existing API.
The feature uses two application operations:
| Operation | Responsibility |
|---|---|
POST /api/trips/:id/proposal |
Read the authorized draft, ask Gemini for suggested fields, and validate the response |
PATCH /api/trips/:id |
Apply a user-accepted patch after checking authorization, revision, and domain rules |
Generating a proposal leaves the saved trip unchanged. Clicking Apply proposed changes sends a separate mutation. In this demo, applying saves the draft; reviewing and submitting the trip request happens afterward.
That distinction should be visible in the interface. A generic “Accept” button is less clear than a label that explains whether the action fills a local form or saves to the server.
The Gemini call runs in the Node backend using @google/genai. The React client sends its brief and draft context to the application API. Google’s API-key guidance recommends keeping production keys out of browser code.
This article follows that embedded assistance flow. Trip Workbench’s separate WebMCP tools provide another entry point for external agents; they are not involved in the Suggest button.
A proposal contains two things: a patch and clarification questions.
For the opening brief, this is an illustrative response:
{
"patch": {
"origin": "SFO",
"destination": "JFK",
"departureDate": "2026-11-12",
"returnDate": "2026-11-15",
"tripType": "round_trip",
"passengerCount": 1,
"cabin": "economy",
"purpose": "customer workshop"
},
"clarifications": []
}
The patch contains editable trip fields. It has no place for an owner ID, tenant ID, approval decision, or submitted status.
Partial patches are useful. If the brief says “change the destination to JFK,” the assistant should preserve unrelated fields. If the destination is ambiguous, it can return an empty patch and a question.
For example, “Springfield next Friday” needs more context. This demo supports a small airport list. The assistant should explain that limit and ask for a supported destination instead of silently substituting one.
Here is a stricter proposal schema I would use when extending the adapter. It constrains airports at the schema boundary and rejects unknown keys throughout:
import { z } from "zod";
import { AIRPORTS } from "../../domain/types.js";
const Field = z.enum([
"origin", "destination", "departureDate", "returnDate",
"tripType", "passengerCount", "cabin", "purpose",
]);
const ProposedTripSchema = z.object({
patch: z.object({
origin: z.enum(AIRPORTS).nullable().optional(),
destination: z.enum(AIRPORTS).nullable().optional(),
departureDate: z.string().nullable().optional(),
returnDate: z.string().nullable().optional(),
tripType: z.enum(["one_way", "round_trip"]).optional(),
passengerCount: z.number().int().min(1).max(9).optional(),
cabin: z.enum(["economy", "premium_economy", "business"]).optional(),
purpose: z.string().max(500).optional(),
}).strict(),
clarifications: z.array(z.object({
field: Field.nullable(),
kind: z.enum(["missing", "ambiguous", "unsupported"]),
question: z.string().min(1).max(240),
}).strict()).max(8),
}).strict();
The date strings still need calendar validation. A string-shaped value does not establish that February 30 exists.
Define patch semantics explicitly, too: omitting a field preserves its value; null clears a nullable field. The model should omit uncertain information and ask a question. It should clear a field only when the requested change calls for that.
The server loads the current saved draft after checking access. It also supplies a reference date, application timezone, and allowed values.
These details affect interpretation. “Next Friday” depends on a reference date and may still require clarification. An airport mentioned in ordinary language might be outside the application’s supported set.
The prompt should describe those limits and ask for only the requested changes. Treat the brief as input data; instructions embedded in it do not grant additional application capabilities.
At commit 4b3f370, the repository pins @google/genai 1.30.0. Its adapter requests JSON using responseMimeType, then parses and validates the result locally. It does not yet pass an output schema to Gemini.
A focused improvement is to supply the schema during generation as well:
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: config.geminiApiKey! });
// config and prompt are constructed by the server adapter.
// ProposedTripSchema is the schema defined above.
const response = await ai.models.generateContent({
model: config.geminiModel,
contents: prompt,
config: {
responseMimeType: "application/json",
responseJsonSchema: z.toJSONSchema(ProposedTripSchema),
},
});
if (!response.text) {
throw new Error("AI_INVALID_OUTPUT");
}
const proposal = ProposedTripSchema.parse(
JSON.parse(response.text),
);
This excerpt belongs inside the existing adapter, after its API-key check. Its route must translate parsing and validation failures into an application error.
The SDK reference documents responseJsonSchema for this API. It supports a subset of JSON Schema, so keep runtime checks such as string limits and calendar rules even when the schema is supplied to the model. Current Google examples also show the newer responseFormat shape; do not copy fields between SDK generations without checking the version you actually ship.
These examples follow the repository’s models.generateContent path. Google now documents that path under Generate Content API Legacy; its newer Interactions API uses a different request shape. Keep the SDK version and API surface consistent when adapting the code.
The next check is application-specific. Merge the proposed patch with the saved fields and run the existing domain validator. A return date can be a real date and still precede departure. A valid cabin can still require policy review.
Google’s structured-output documentation also calls for application validation. Constraining the response shape does not establish that its values match the person’s intent.
Consider this sequence:
Checking only the server revision misses the problem. The person’s newer text may still be unsaved.
Trip Workbench tracks three versions for this reason:
| Version | What it tracks |
|---|---|
| Saved revision | Changes persisted by the backend |
| Client edit version | Local form edits |
| Brief version | Changes to the text being interpreted |
The server echoes those bindings outside the model-generated proposal. They are application metadata; Gemini does not choose them.
The coordinator’s freshness check includes:
return (
bound.boundRevision !== state.savedDraft.revision ||
bound.boundClientEditVersion !== state.editVersion ||
bound.boundBriefVersion !== state.briefVersion
);
The surrounding implementation also checks the draft identity, session, and editor generation. That prevents a response for one editor context from being treated as a suggestion for another.
Check freshness when the response arrives and again when the person clicks Apply. The second check matters because a proposal can become outdated while someone is reading it.
The simplest policy is to mark the proposal stale and ask for a new suggestion. For eight fields, that is easier to explain than automatically merging an old proposal with new intent. A larger editor might justify more granular conflict handling.
There is another practical detail: this endpoint uses saved fields as model context. Unsaved form edits are not sent to Gemini. The coordinator blocks an agent-style patch while local edits remain unsaved. A clearer UI would ask the person to save or discard those edits before requesting a suggestion.
Version checks protect this particular handoff. They do not, by themselves, prove that every refresh or recovery path in a shared editor preserves local changes.
The current React panel lists proposed values and clarification questions. It disables Apply when the proposal is stale or its patch is empty.
For an existing populated form, I would add a current-value column:
| Field | Current | Proposed |
|---|---|---|
| Destination | BOS | JFK |
| Departure | November 10, 2026 | November 12, 2026 |
| Purpose | Team meeting | Customer workshop |
That table is a suggested review improvement, not a screenshot of the current demo. It makes the consequences of applying easier to inspect.
Keep clarification questions visible beside the fields. A partial proposal can be useful without being ready for submission. In the current design, the person can refine the brief and request another suggestion.
The Apply handler checks freshness, then delegates to the existing coordinator:
if (coordinator.isProposalStale(proposal)) {
// Keep the form unchanged and ask for another suggestion.
return;
}
const result = await coordinator.applyAgentPatch(
proposal.patch,
proposal.boundRevision,
newCommandId(),
);
The backend checks the user’s authority, validates the patch against the current draft, and enforces the expected revision. If another writer changed the draft, the application should require a fresh review before applying a different change.
This second validation is necessary even though the proposal was already checked. The draft can change between generation and application. Client-side review is also not an authorization boundary.
A business-cabin request illustrates the distinction: the patch can save successfully while the later validation step returns a review requirement. Saving the draft does not approve a policy exception or purchase a ticket.
The manual form should remain usable when assistance fails.
A rejected model response should leave the fields unchanged and explain that no suggestion could be prepared. A stale proposal should explain that the brief or draft changed. A revision conflict should lead to reviewing current data before trying again.
For a production feature, also release the “Suggesting” state in a finally block and distinguish provider failures from invalid output. The current adapter maps otherwise unclassified provider exceptions to AI_TIMEOUT; that label alone does not establish that a timeout occurred.
Generation failures and save failures need different treatment. A lost proposal response has not saved a trip. A lost PATCH response may have followed a successful save. The latter requires reconciling the original command’s outcome before creating another mutation.
Trip Workbench has disabled, fixture, and live modes. Fixture mode returns canned examples so the interface can be exercised repeatedly. Its SFO–JFK example uses fixed dates, so it does not measure Gemini’s extraction ability.
Use deterministic checks for the application behavior:
| Check | Expected behavior |
|---|---|
| Unknown patch key or invalid calendar date | Reject the proposal without changing saved fields |
| Form edit while generation is pending | Mark the returned proposal stale |
| Brief change after generation | Prevent applying the old suggestion |
| Navigation to another draft | Discard the old context’s response |
| Server revision changes before Apply | Reject the stale mutation |
| Network failure during generation | Restore usable controls and preserve the form |
Then evaluate live Gemini with a complete brief, an ambiguous brief, and a partial update to an existing draft. Run each several times and record the model, prompt, proposed fields, clarification questions, human corrections, and final saved state.
For the partial update, explicitly check what stayed unchanged. A correct destination is not enough if the assistant also overwrote a purpose that the user never asked to change.
The repository’s results document records fixture evidence and leaves live Gemini unverified. These exercises define the next evaluation; they are not a reported success rate.
Finally, compare the complete task with manual entry. Include the time spent writing the brief, waiting, reviewing, and correcting. For a short form, manual entry may be faster. Assistance becomes more interesting when the user already has a longer brief containing the required information.
The question I want feedback on is concrete: can a person understand and correct the proposed change with less effort than entering it themselves?
That is the outcome this design lets us evaluate. The model provides a candidate change, the interface makes it reviewable, and the application decides whether it can be saved.