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.

Arrival window

Day
Arrival windows on Friday, October 9, Europe/Amsterdam (GMT+2) time. The technician arrives within the window.

Problem

Is water still leaking?

Address

Keizersgracht 221

Estimate€80–€140
Paid on site

Pick a window

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.summary with 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 through onFiles. 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. On reschedule, fetch new windows and show the component again. On dismiss, do nothing.
  • Lifecycle: arrival windows are live, so set fetchedAt and staleAfterMs (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, set expiresAt to 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, set fetchedAt with a short staleAfterMs while an ETA is shown.

Interaction

Select mode is one short form, with the decision first and the optional extras folded away:

  1. 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.
  2. 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.
  3. 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 prefilled address.accessNotes). Inside: apartment or unit, floor, a gate code (masked like a password, with Show; only the provider sees it), access notes, photos when photos.allowed, a Call / Text choice when contactPreference is set, and notes for the technician.
  4. 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

app/api/chat/route.ts
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,
  }),
}
components/message.tsx
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 click

answers 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

PropTypeNotes
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
timeZonestringIANA 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
contactPreferenceboolean?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

FieldTypeNotes
idstringKey in the result's answers
labelstringe.g. "Is water still leaking?"
type"select" | "text" | "boolean"
optionsstring[]?Select only, up to 8
requiredboolean?

Window

FieldTypeNotes
idstringData-bound. Returned as windowId
start, endstringData-bound. HH:mm, local time
feenumber?Data-bound. Extra charge, in pricing.currency
urgentboolean?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.

Source

On this page