VYEBE Get unstuck

AI Front Desk

An automated front desk does not fail by throwing. It fails by saying something confident and wrong to a real customer.

Skill nameai-front-desk
Version1.0.0
LicenseMIT
SourceGitHub

Install

npx skills add halltony85-source/clickflame-agent-skills@ai-front-desk -g -y

Goes into the user level .claude/skills directory, which is the path Claude Code reads and Cursor also reads. Restart your agent afterward, because skills load at startup. Or copy the raw file into that folder yourself, which is all the installer does.

When your agent loads it

Build an automated front desk that answers texts, calls and chat without embarrassing the business it speaks for. Load this skill when wiring up a missed call textback, an SMS intake agent, a web chat that qualifies leads, an after hours responder, or any bot that talks to customers in a thread a human can also join. It covers recognizing the caller before choosing the words, why copy stored in more than one place silently reverts, deriving a day and night mode instead of scheduling it, knowing whether a colleague is already replying, handing off to a person without abandoning the customer, alarming on the outcome rather than on whether the job ran, and keeping the bot from quoting a price nobody approved. Also load it when an automated responder said something wrong to a real customer, when an alert never fired for the thing it was built to watch, or when generated copy does not sound like the person it claims to be.

What it catches

  • An after hours responder went out 32 times in one day saying the owner was busy. 23 of the 49 people who got it were already customers with a car on file, one of them ninety seconds after confirming his own booking. Look the caller up before choosing the words, and make the lookup fail open.
  • Message copy ends up in four places at once, so editing one is correct until the next mode swap puts the old words back.
  • A day and night mode should be derived every 15 minutes, not scheduled. Cron is UTC, so a fixed hour drifts with daylight saving, and a missed morning tick leaves the business quoting the overnight rate all day.
  • hourCycle: 'h23' is load bearing. hour12: false renders midnight as 24 on some builds, and 24 is neither past 22 nor before 6, so the one hour that most needs the overnight rule is the one that misses it.
  • Outbound openers recorded with an empty body made 39 threads in one week look human owned when nobody had touched them, which disabled the alarm on exactly the threads it existed for.
  • Alarm on the outcome, not on whether the job ran. One outage ran perfectly every two minutes and threw every time. Gate the all clear on positive proof, or a quiet night ages its own victims out of the list and reports fine.
  • Never let the bot quote a price nobody approved. Draft it, attach it, and leave it pending a human.

Where it came from

Running one in production for a business that deliberately stopped answering its phone, so the automation was not a convenience. It was the front door.

The complete skill, as published

This is the whole of SKILL.md, the same bytes the installer pulls. A skill is instructions injected straight into your agent's context, so the file is the payload and reading it is the only check that finally counts. Ours is below rather than summarized for that reason.

An automated front desk does not fail by throwing. It fails by saying something confident and wrong to a real customer, at a moment when nobody is watching.

The failures below all come from running one for a business that deliberately stopped picking up the phone, so the automation was not a convenience. It was the front door. That raises the cost of every mistake in here, which is why they were worth writing down.

Recognize the caller before you choose the words

The first message is the one that does the damage, because it goes out before anyone can see it.

An after hours missed call responder went out 32 times in one day saying the owner was under a car and would get back to them. 23 of the 49 people who got it were already customers with a vehicle on file. One received it ninety seconds after confirming his own booking. Another got it while sitting on an estimate he was waiting to approve.

Nothing errored. The copy was good copy. It was simply written for a stranger and sent to everyone.

Look the number up before picking the message. Three outcomes, three different openings:

  • Known caller with live work. Name the vehicle and give them status. They

are not asking what a call-out costs, they are asking where their car is.

  • Past customer, nothing open. Recognize them, then reopen the question.
  • Stranger. The plain ask.

Recognition must fail open. Match on an exact ten digit number, exactly one customer row, and a vehicle on file. Any ambiguity returns null and you fall back to the stranger copy. A lookup can never be the reason a reply does not send, because a slightly generic message is survivable and silence is not.

Be careful what counts as open work. A job marked paid can still have an unapproved estimate attached to it, and that customer is very much still in a conversation with you.

Copy stored in more than one place will revert

This is the quiet one, and it catches everybody.

Message copy tends to end up in four places at once: the live settings row, the per-mode defaults that a scheduled swap restores, the in-code default in the function that does the swapping, and the in-code default in the function that sends. Edit one, and the wording is correct until the next mode change puts the old words back.

Decide which copy is canonical and make every other location derive from it, or accept that a wording change is a four file edit and write that down where somebody will see it. The second option is worse but at least it is honest.

The related habit: seed the database row byte-identical to the code defaults when you introduce it. Then a copy change is an UPDATE rather than a deploy, and the two cannot drift without somebody noticing.

Derive the mode, never schedule it

A front desk that behaves differently at night needs a day mode and a night mode. The obvious implementation is two scheduled jobs, one at each boundary. It is wrong for three reasons.

Cron is UTC. A fixed hour drifts when the local zone changes offset, so twice a year the shop switches an hour late, and the morning one is the expensive direction: customers get quoted the overnight rate, which can be double, in broad daylight until somebody notices.

A missed tick is permanent. If the morning job fails once, the business stays in night mode all day.

Two jobs means two owners. Add a manual override script and now three things write the same row, and nobody can answer "why did the mode change at 4am".

Do this instead. Run every 15 minutes. Work out what mode the business should be in from the local hour, compare, and correct only on disagreement. That is DST-correct with no table of dates, self-healing within one tick, and writes nothing when nothing needs changing. Log an audit event on every actual change.

Two traps in reading the local hour.

Do not re-parse a rendered locale string. new Date(now.toLocaleString('en-US', {timeZone})) gives the right answer only because the render and the read cancel out in the server's own zone, and the format it produces is not in the specification. An engine that renders it differently yields Invalid Date, then NaN, then a silently false night check.

const hour = Number(new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/Los_Angeles', hour: 'numeric', hourCycle: 'h23',
}).format(new Date()))

hourCycle: 'h23' is load bearing. hour12: false renders midnight as "24" on some ICU builds, and 24 is neither >= 22 nor < 6, so the single hour that most needs the overnight behavior is the one that misses it.

Scope what the swap owns. List the keys it may write and never let it touch anything else. A mode swap that also resets the contact window will close your overnight line every morning, and the symptom is not an error, it is an absence.

One rule, one file, shared. If the price changes at 22:00 and the alarm sleeps from 23:00, the business has two different nights. Put the boundary in one module and import it everywhere.

Make the unknown case explicit. When the local hour cannot be read, return null rather than a boolean, because callers want opposite things from that case. A copy swap should leave the price alone. An alarm should ring anyway. A boolean silently picks one of those for both.

Related: Intl.format() throws RangeError on an invalid date rather than returning something your number check would reject. Unwrapped, that exception escapes through the hour helper and takes the caller down with it, which in an alarm means no alarm. Wrap it.

Quiet hours do not apply when they contacted you first

Worth stating plainly because it gets implemented backwards.

If somebody calls or texts the business, replying to them is not marketing. The contact window for a response to an inbound message is the whole day, in every mode. Quiet hours belong on outbound campaigns, not on answering the door.

Know whether a human is already in the thread

The moment a colleague can type into the same conversation, the bot needs to answer a question it has no natural way to answer: did we send that, or did a person?

The practical method is to match outbound messages against the bodies you have recorded sending. Anything with no record is assumed hand typed. Three things usually ride on that answer: whether the model stands down, whether the thread counts as picked up, and whether the alarm excludes it.

So getting it wrong disables all three at once. In one week, outbound openers were being recorded with an empty body, so none of them matched, and every one came back as hand typed. 66 of 108 messages, marking 39 threads as owned by a person nobody had assigned. The alarm excludes human owned threads and reads a six hour window, which is the entire life of a fresh lead, so the alarm could not fire on the threads it existed for. Nothing looked wrong for the whole week.

Recording the body is necessary and not sufficient. SMS providers commonly append opt-out text to the first message sent to a new contact, so the words delivered are not the words you stored. Match on a prefix in either direction, which also survives truncation in whatever normalizer you run.

Keep a minimum length floor, around 40 characters. Without one, a short send like "Got it" is a prefix of plenty of things a colleague might type, and claiming their message as yours hides a real person from the system. That is the expensive direction of this mistake, so bias the floor high.

Record before you stand down, not after

A subtle ordering bug with an ugly consequence.

If the code that records both sides of the conversation runs after the guard that decides to stay quiet, then on every thread where the bot chose silence, neither side is written to the customer file. The thread reads as though nothing happened.

In one case a colleague answered a customer by hand twice in three minutes and the system showed six hours of silence. It was not silence. She had waited just under two hours, and the record that would have proved it was never written.

Both recording paths run before the guard. And when you build any "who is waiting" report, know that data from before such a fix is not merely incomplete, it is biased: hand typed replies on quiet threads are simply absent, so every waiting time you compute from it is overstated.

Hand-off is a grace period, not a terminal state

If a business has stopped answering the phone, then "a human will take this" cannot be allowed to mean silence.

Marking a thread handed off and having the agent skip it forever is the obvious implementation and it abandons customers. The rule that works:

After a grace period, default around 15 minutes, the agent resumes, bounded by four things.

  1. A person who actually speaks keeps the thread for good. The grace period

ends the moment a real reply lands.

  1. The customer must still be waiting. If they got an answer, there is nothing

to resume.

  1. The per-thread question cap still applies, or a maxed out thread wakes up and

pesters somebody.

  1. The decision function must be pure and tested, because it runs unattended.

Alarm on the outcome, not on whether the job ran

The most important paragraph in this file.

An outage once ran perfectly on schedule every two minutes and threw every time. Any monitor watching "did the function run" would have reported green throughout.

Watch the thing customers experience. Is anyone sitting on an answered message with nothing coming back? That question is true or false regardless of which component broke.

Then be careful about the exclusions, because each one is a way for the alarm to go quiet:

  • Count only messages the agent really sent as evidence it is alive. A

different component's send is not proof this one works.

  • Exclude human owned threads, and see the matching section above for how

that exclusion silently swallowed everything.

  • Cap the age. Older than a couple of hours is backlog, not a live failure.

The trap in that last rule is severe. A real outage on a quiet night ages its own victims past the cap, empties the waiting list, and produces a false all clear. So gate the all clear on positive proof the agent spoke recently, never on an empty waiting list. Write a test for exactly this case.

Make the decision function pure. assess(events, now) with no network in it means the choice to wake somebody at 2am is testable, and you will want to change the thresholds more than once.

Expect the first nights after fixing a blind alarm to be loud. That is the alarm working, not a regression. Re-judge the exclusions only against threads that carry a real human message.

If the alarm sleeps overnight, hold the state, do not drop it. A break at 11pm should still report at 6am with the real waiting count, because an alarm that wakes you for something you cannot fix until morning is one you learn to sleep through.

Never let it quote a price nobody approved

A chat window or a text thread that ends in a real number is a very short step from a chat window that has promised something the business will not honor.

Run the automated conversation through the same intake and the same quote builder the staff use. A second path is a second set of bugs and a second price for the same job.

Then stop one step short. Draft the quote, attach it, and leave it pending an advisor. The customer is never shown a total by the bot. Showing it is a small code change and a large change in what the business has promised, and that is an owner's decision, not an implementation detail.

Measure the voice against a real person

Generated copy drifts toward sounding like generated copy, and nothing tells you.

Sample a day of real conversations, split the messages a human typed from the ones the system generated, and measure both against the same baseline. The gap shows up immediately in three numbers:

  • Length. One measured owner averaged 10 words. The bot averaged 24.
  • Questions per message. He asked one. The bot asked two or three.
  • Concreteness. He quoted a real number constantly. In 64 generated

messages the bot quoted one zero times.

Have the job write proposals, not changes. It must not edit the live copy, and it must not commit. A person approves wording before it reaches a customer. And because copy lives in several places, every proposal has to list every file a wording change would touch.

The verification moves, collected

  • Send the first message to yourself as a known customer, then as a

stranger, and read both. Most recognition bugs are visible in one minute and invisible in code review.

  • Grep for the message text. If it appears in more than one file or row, you

have a revert waiting to happen.

  • Set the clock to 23:59, 00:00 and 06:00 local and assert the mode. The

midnight case is the one that breaks.

  • Force the hour lookup to fail and confirm the copy swap holds the price

while the alarm still rings.

  • Have a colleague type one short message into a live thread, then check the

system knows a human is there.

  • Take the sender offline and confirm the alarm fires. Then take it offline

on a quiet night and confirm it does not send an all clear.

  • Read yesterday's generated messages end to end. Not the logs, the words.

It is the only way to notice the front desk has started sounding like a robot.

Every one of these asks what the customer actually experienced. The dashboard, the schedule and the deploy log will all tell you things were fine.


Written from running an automated front desk in production for a business that stopped answering its phone, by Clickflame. Companion skills: supabase-rls-audit, postgrest-silent-failures and netlify-deploy-traps, for the same class of quiet failure further down the stack.

The companion skill