Inspiration
A service advisor's hardest job is not answering the phone. It is everything after: calling the day before, calling again when nobody picks up, moving the appointment when the customer finally calls back, and writing the paperwork when the car is done. That work is spread across days, it is nobody's favourite part of the shift, and it is the part that quietly gets dropped.
Almost every agent demo we had seen ends when the conversation ends. We wanted one whose unit of work is not a turn but an appointment: an agent you could leave alone for a week and come back to find it had done the follow-up.
What it does
ServisAjan answers an inbound service request in Turkish, by text or by voice, and then keeps working on that appointment for days without being asked again.
- Finds the customer by phone number and the car by plate, offers open slots and books one. It separates two intents in one sentence, a booking and a complaint, and records them separately.
- Recovers rather than failing: if the requested day is full it offers the three nearest open days; if the number is unknown it opens a new customer record; if it cannot get a plate after three turns it writes a draft appointment and notes that a human has to take it.
- Runs the lifecycle itself. Booking queues Cloud Tasks triggers days into the future. The day before, a trigger wakes it in a brand new Cloud Run request to make the reminder call. Nobody answers, so it retries twice, then gives up on the phone and falls back to SMS, recording each step.
- Reschedules when the customer calls back, frees the old slot, takes a new one, and queues a fresh set of triggers against the new date.
- Issues a signable work order when the job is done, HTML to print and PDF to archive, then follows up two days after the service.
- Writes every action it takes to the appointment's event trail. The board renders that trail on a day axis, so duration is distance: three days of waiting look different from three seconds of work.
A real run on the live service produced 21 events across 5 states in one appointment, and nobody drove the middle of it.
How we built it
One agent, one process. No swarm: this is Taskmaster, and a second agent would have been a costume rather than a capability.
- Google ADK with Gemini 3.5 Flash on Vertex AI, and ten Turkish tools. No tool ever raises; each returns
{"ok": false, "hata": "..."}so the agent sees the failure and explains it to the customer instead of a stack trace becoming a dropped call. - Gemini Live API native audio for the voice channel, both directions, push-to-talk in the browser. No third-party speech-to-text anywhere.
- Firestore holds all durable state. Nothing is kept in memory between triggers: an outbound call happens days later in a separate request, and the process that took the booking is dead by then.
- Cloud Tasks carries the future. Cloud Run runs the whole thing. Cloud Storage archives the work orders.
- The appointment is an explicit state machine, validated in exactly one place, so a trigger that arrives twice, late, or out of order cannot corrupt a lifecycle by going around the rules. They really do arrive twice.
- The interface is one page of hand-written HTML, CSS and vanilla JavaScript. No framework, no CSS library, no icon pack, no CDN request at all.
Challenges we ran into
The agent promised something it had not done. In a real microphone test it told a customer "I'm creating your appointment for tomorrow, we'll see you in the morning", and only then called the tools, which came back saying the car was not in the system. The instruction already forbade this, but only in the past tense; the violation was in the present. Restating the rule harder did not fix it, and the live model broke it again on the very next run. What fixed it was writing the same scenario into the instruction twice, once wrong and once right. For this class of error, an example beat a rule by a wide margin.
We verified a broken feature and reported it working. The voice channel's first end-to-end test was driven by a script that spoke the protocol correctly. The browser did not: it never sent the turn boundaries, so the model listened forever and never replied. Fifteen passing server tests ran straight through the bug, because the client's own send path was never executed.
A hole in the state machine that no unit test could see. On the last day it turned out that nothing in the system ever wrote the ONAYLANDI state. Every transition was legal, every test passed, and the final state was simply unreachable, so a work order could never be issued in a real flow. It only surfaced when the whole lifecycle was driven end to end against the live service.
Turkish is not a formatting detail. "Müşteri".upper() returns MÜŞTERI in Python. The default Google Fonts latin subset has no ş, ğ, İ or ı. ReportLab's built-in faces silently drop the same letters. Three separate layers, each of which fails quietly and looks perfectly fine to someone who does not read Turkish.
Accomplishments we're proud of
- The lifecycle genuinely runs unattended on the deployed service. The reminder, the two retries, the SMS fallback and the follow-up all fired from Cloud Tasks, into fresh processes, with nobody watching.
- A board where duration is distance. The empty day columns are the visible proof that the agent waited, which a timestamp list can never show.
- Native two-way Turkish voice, on a stack with no speech-to-text in it.
- 151 tests, most of them calling the real model rather than a mock, including tests that assert on what the agent said and not only on what it wrote.
- Accessibility measured against real computed styles in the browser: seven contrast failures found and fixed, final count zero.
What we learned
- Proving one end of a contract is not proving the contract.
- Testing the transitions is not testing that the path is walkable.
- A layout verified on one dataset is verified for exactly one dataset. The board was audited with five day-columns and broke the first time real data produced fourteen.
- For instruction-following failures, a worked example outperforms a stronger rule.
- An automated sweep measures one state; a screenshot shows all of them.
What's next
Real telephony, so the outbound call is a call. A second and third call type: parts enquiries and appraisal bookings. Barge-in, so a customer can interrupt the agent mid-sentence. A real DMS integration in place of the synthetic CRM.
Disclosures
- The main agent runs on
gemini-3.5-flash; the voice layer runs ongemini-live-2.5-flashbecause the Live API does not serve 3.5 Flash. A platform constraint, not a preference: same agent, same instruction, same tools, different transport. - Outbound calls are simulated. There is no phone line, no number, no SIP trunk and no carrier. A "call" is the sentence the agent wrote when the trigger fired; the board can read it aloud. The SMS fallback is likewise recorded as an event, not sent.
- All data is synthetic, generated by
scripts/seed_data.py. No real customer data, no CRM or DMS integration. - No audio is stored. No authentication: the service is public by design so the jury can open it.
- Everything was written during the hackathon, 26 to 31 August 2026. No template, no starter project, no pre-existing codebase.
Built With
- cloud-run
- cloud-storage
- cloud-tasks
- css
- fastapi
- firestore
- gemini
- gemini-live-api
- google-adk
- html
- javascript
- pytest
- python
- reportlab
- vertex-ai
- websockets
Log in or sign up for Devpost to join the conversation.