Service Visit
Schedule a technician at home from a provider's real arrival windows, or show a booked visit.
Experience · Shares patterns with Slot Picker, Generated Form, File Upload
≈ 2,038 tokens per use (tool 1,321 · guidance 384 · typical payload 333)How token costs are estimated
Example: Service Visit
My dishwasher is leaking, get someone out this week
Fixwell Appliance Repair
Appliance repair: DishwasherRated 4.8 out of 5, from 312 reviews.Licensed. Insured.
When to use
- The user wants someone to come to their home: a plumber, an electrician, appliance repair, an internet installation, cleaning, pest control, moving help.
- The agent has one provider's real arrival windows for the service.
- The user asks about a visit that's already booked (
mode: "confirmation").
When not to use
- The user is still choosing a provider. Compare them first, e.g. with a Comparison Matrix.
- The agent hasn't fetched availability from the provider. Never invent windows or prices.
- It's an appointment the user goes to (a haircut, a doctor). Use the Slot Picker.
- It's a restaurant table. Use the Restaurant Reservation.
How the agent should use it
- Pass windows, fees, prices and triage questions exactly as the provider returned them. Include fully booked days with empty
slots; they show as Full. - Prefill
issue.summarywith what the user said, in their words. Ask at most 4 triage questions, only ones the provider needs. - Pass the address on file. The card only shows the street; the user adds the apartment, floor, gate code and access notes.
- On
request, send exactly the returned window, answers and access details to the provider's booking tool, with the photos the host received throughonFiles. Never treat it as booked until the provider confirms. - Never repeat a gate code in chat. Never ask for card details: the provider is paid on site.
- On
cancel-visit, cancel through the provider's tool and confirm in one line. Onreschedule, fetch new windows and show the component again. Ondismiss, do nothing. - Lifecycle: arrival windows are live, so set
fetchedAtandstaleAfterMs(e.g. 15 minutes) on the call, and the Renderer marks the card stale once that passes. When the provider holds a window for a while, setexpiresAtto the end of the hold so the card turns inert after it. When the agent fetches windows again, supersede the old call. In confirmation mode, setfetchedAtwith a shortstaleAfterMswhile an ETA is shown.
Interaction
Select mode is one short form, with the decision first and the optional extras folded away:
- Arrival window. Days are a radio group (arrow keys move between them); days with no windows are struck through and disabled ("Full" for screen readers). Windows are cards showing the range and any fee ("+€15 urgent"). Screen readers hear that the technician arrives within the window.
- Problem. An editable box prefilled from
issue.summary, then the triage questions: a select, a short text, or Yes/No. Required ones carry an asterisk and are checked when the user presses Request visit; focus moves to the first problem. - Address. The street from
address.line1, with one Add details disclosure (closed by default; it reads Edit details once something is filled in, including a prefilledaddress.accessNotes). Inside: apartment or unit, floor, a gate code (masked like a password, with Show; only the provider sees it), access notes, photos whenphotos.allowed, a Call / Text choice whencontactPreferenceis set, and notes for the technician. - Price. One number, big: the estimate ("€80–€140"), or the call-out fee, or the hourly rate, with "Paid on site" and the chosen window's fee. Price details opens the other lines (call-out fee, hourly rate, the provider's note). When there's no amount to lead with (e.g.
callOutFee: 0), the lines show as text. Nothing is charged now: Request visit never books or charges anything, it hands the request to the agent.
Photos: up to photos.max images (default 4, 10 MB each) with previews. Previews are local object URLs, released on remove and unmount. The files go to the host's onFiles on request; the result only carries photoCount, never image data.
The header shows the provider, the service and the rating; "licensed" and "insured" are read to screen readers only. The footer shows the chosen window ("Tue Oct 13 · 8:00 AM–12:00 PM").
Confirmation mode leads with when the technician arrives: the ETA ("Arriving around 10:20 AM", in the provider's zone), or the arrival window when there's no ETA. Below it: the state (Confirmed, Pending, Technician assigned, Cancelled), the technician, the street, the price (with details) and the booking reference (with copy). Actions depend on the state: pending visits can't be added to a calendar, and cancelled ones only offer Contact provider. The first action is the primary button, Cancel visit stays a text button, and Add to calendar and Contact provider are labelled icon buttons. Cancel visit asks for a second, explicit click before it returns cancel-visit.
Window times are wall-clock HH:mm in timeZone and are formatted with Intl in the viewer's locale. The zone is shown only when the viewer's clock is on a different offset (checked after mount); screen readers always hear it with the windows.
Installation
npx shadcn@latest add https://your-site.com/r/service-visit.json
Usage
import { tool } from "ai"
import { serviceVisitTool } from "@/components/genui/service-visit.schema"
const tools = {
// No execute(): the call is completed by the user's choice in the UI.
[serviceVisitTool.name]: tool({
description: serviceVisitTool.description,
inputSchema: serviceVisitTool.inputSchema,
}),
}import { ServiceVisit } from "@/components/genui/service-visit"
function ToolPart({ part, addToolResult }) {
if (part.type !== "tool-schedule_service_visit") return null
return (
<ServiceVisit
{...part.input}
status={part.state === "input-streaming" ? "streaming" : undefined}
result={part.state === "output-available" ? part.output : undefined}
// The photos stay in the browser until you upload them.
onFiles={(files) => uploadVisitPhotos(part.toolCallId, files)}
onResult={(result) =>
addToolResult({
tool: "schedule_service_visit",
toolCallId: part.toolCallId,
output: result,
})
}
/>
)
}The agent receives one of:
{
action: "request",
windowId: "fw-1013-am",
date: "2026-10-13",
summary: "Dishwasher leaks from the bottom during the wash cycle.",
answers: { brand: "Bosch", "still-leaking": true },
address: { unit: "2", floor: "1", gateCode: "4512", accessNotes: "Bike in the hallway" },
photoCount: 2,
contact: "text",
notes: "The shut-off valve is under the sink",
}
{ action: "dismiss" }
{ action: "reschedule" } // confirmation mode
{ action: "add-to-calendar" } // confirmation mode
{ action: "contact-provider" } // confirmation mode
{ action: "cancel-visit" } // confirmation mode, after the second clickanswers only holds answered questions. contact, notes and each address field are left out when the user didn't fill them in.
With LangGraph
Push it from a graph node with ui.push({ name: "schedule_service_visit", props: { toolCallId, ...args } }, { message }), and render the thread's UI messages with the Renderer.
Restoring from history
Pass the stored result back as result (or an array of results, for several confirmation actions). Select mode shows what was sent, with the gate code hidden and photos as a count ("Requested. Not booked until Fixwell Appliance Repair confirms.", or "No visit requested." after a dismiss); confirmation mode marks the used actions ("Cancellation requested"). It never calls onResult again.
<ServiceVisit
{...part.input}
result={part.state === "output-available" ? part.output : undefined}
/>Schema
| Prop | Type | Notes |
|---|---|---|
mode | "select" | "confirmation" | |
provider | { name, rating?, licensed?, insured? } | Data-bound. rating is { score, outOf, count } |
service | { type, description? } | Data-bound. e.g. "Appliance repair", "Dishwasher" |
issue | { summary?, questions? } | summary prefills the editable box. Up to 4 questions (data-bound) |
address | { line1, city, postcode?, accessNotes? } | Data-bound. Only the street is shown |
windows | { date, slots }[] | Data-bound. Up to 7 days. Empty slots shows the day as full. Confirmation mode: include the booked window |
timeZone | string | IANA zone of the address. Windows are local to it |
pricing | { callOutFee?, hourlyRate?, estimate?, currency, note? } | Data-bound. estimate is { low, high } and leads when present. callOutFee: 0 shows "No call-out fee" |
photos | { allowed, max? } | Select mode. max 1–10, default 4 |
contactPreference | boolean? | Select mode: ask for call or text |
confirmation | { number, bookingState, windowId, technician?, eta? } | Data-bound. Confirmation mode. bookingState: confirmed, pending, technician-assigned, cancelled. technician is { name, rating? } (the rating is read to screen readers); eta is ISO with an offset |
Triage question
| Field | Type | Notes |
|---|---|---|
id | string | Key in the result's answers |
label | string | e.g. "Is water still leaking?" |
type | "select" | "text" | "boolean" | |
options | string[]? | Select only, up to 8 |
required | boolean? |
Window
| Field | Type | Notes |
|---|---|---|
id | string | Data-bound. Returned as windowId |
start, end | string | Data-bound. HH:mm, local time |
fee | number? | Data-bound. Extra charge, in pricing.currency |
urgent | boolean? | Data-bound. An urgent or same-day window |
Host props: status (streaming, ready, submitting, requested, dismissed, cancel-requested, done), onResult, onFiles (receives the photos as File[] on request), result and className.