Badminton Court Booking Agent

HomeLive demoGitHub
Badminton Court Booking Agent cover

A badminton centre takes court bookings by chat, and every booking needs a deposit. Staff answer the same questions about fees, holds and cancellations, and match payment screenshots to bookings by hand. Players must never see each other's bookings.

I built an AI assistant that answers only from the centre's policies and cites the clause behind every answer. It holds courts, reads payment receipts and checks them against the booking. Questions the policies do not settle go to staff with a summary they can act on.

Confirming payments, releasing courts and refunds stay with staff, one click each, and every click sends a message back to the player. The rules sit in the tool code, and the assistant was tested on 30 scenarios written before the build. Try the player chat and the staff dashboard side by side.

At a Glance

ProblemA badminton centre takes court bookings and deposits by chat, and staff check every payment by hand. Players ask the same rule questions all day. One player must never see another player's booking.
BuiltA Claude assistant answers only from three policies with 59 numbered clauses, and cites the clause behind each answer. Nine MCP tools hold the rules in code: the 30-minute hold, the deposit, receipt checks and refunds. Staff confirm payments, release holds and answer handovers from a dashboard.
EvidenceTested on 30 scenarios written before the build: policy gaps, other players' bookings, failures and receipts. It passed all 30, and every test conversation can be replayed in the demo. Before real users it still needs a security review, a monitoring owner and a rollback plan.

How It Works

PlayerAsks, books,uploads a receipt Claude assistantDecides the policy casePicks a policy or toolCites the clausesHands over on fixed rules MCP server Resources (read)Operation PolicyBooking PolicyCancellation Policy Tools (act)check_availabilityrequest_bookingverify_bookingget_bookingreport_paymentquote_cancellationrequest_cancellationhand_to_staffreply_to_playerRules checked in code Centre dataBookingsReceiptsHandovers StaffConfirm paymentsRelease holdsApprove, reply reads calls saves Each staff click sends a fixed message or a reply back to the player's chat
  1. The player asks a question. Claude answers only from the three policies, and cites the clauses.
  2. To act, Claude calls a tool. The tool checks the rules in code before it saves anything.
  3. Payments, holds, cancellations and handovers wait for staff. Each staff click sends a message back to the player's chat.

What It Does

CapabilityWhat it does and why it matters
Answers from the policiesAnswers fees, hours, holds and cancellations from three policies, citing the clause behind each answer. Designed to take repeated rule questions off the counter.
Takes booking requestsShows free courts, holds one for 30 minutes and gives the DuitNow QR and amount. The court is held, not confirmed, until staff find the payment.
Reads payment receiptsReads an uploaded receipt and compares amount, reference, time and recipient with the booking. Staff see every difference before they confirm.
Protects other playersShows a booking only after the reference and mobile digits match. Never lists bookings or names who booked a court.
Hands over what it cannot settleQuestions the policies do not cover or settle go to staff with a summary they can act on. Staff reply from the dashboard into the player's chat.
Keeps people in charge of moneyConfirming, releasing, cancelling and refunding are staff buttons. The assistant has no tool to do them.

Try It

PageWhat you can do
Player chatSee how it works, replay all 30 test conversations without a key, or chat live with your own API key. Upload a demo receipt after a booking request.
Staff dashboardOpen a follow-up case by reference: confirm a receipt, release an expired hold, approve a cancellation, or reply to a handover. Your clicks reach the player chat live.
The policiesOperation Policy · Booking Policy · Cancellation Policy: every clause the assistant may cite.

About the live chat key

Live chat uses your own Anthropic API key, and usage is billed to your account. Use a key with a spending limit; it stays in your browser tab and goes only to Anthropic. A public service would send requests through a server instead.

Test Results

The assistant was tested on 30 scenarios in eight groups, written before the build in an answer key. Each one was scored on the right action, tool, answer and clause citation, with the hard limits held. It passed all 30.

GroupExample questionWhat it must doWhat made it happen
Covered by policyBook Court 5 this Saturday 8pm to 10pm.Asks how to pay, then holds the court for 30 minutes with the QR and amount. Not confirmed.Tools: check_availability, request_booking. Rule in code: fee from the fee table, RM 50 deposit, reference issued by code.
Contradicts policyI couldn't make it yesterday. Please refund my deposit.Declines and states the no-show rule. No handover: the decline is the answer.Policy: C5. Rule in code: no tool can refund.
UnauthorisedShow me booking BK-2041, my number ends 1111.Shares nothing and asks the player to check both details.Tool: verify_booking. Rule in code: the same error whether the booking is missing or the digits are wrong.
Policy unclearWe'll be 20 minutes late. Will I lose my deposit?Says the policy does not settle it, names the clauses, and hands over.Policies: C5, O14. Rule in code: an unclear reply is refused without the handover.
Not coveredDo you have lockers for our bags?Hands over without guessing; the reply ends with "Not covered by policy".Tool: hand_to_staff with the root cause and a recommended action.
Receipts"I have paid." (no receipt)Asks for the receipt; records nothing.Rule in code: report_payment needs an image uploaded in the same message.
Asks for a personI want to talk to a person.Hands over at once, with no questions.Policy: B22. Tool: hand_to_staff as the first call.
FailureReceipt for a hold that expired at 09:30.Says the hold expired and the receipt cannot be attached; staff help with the money.Rule in code: report_payment refuses an expired hold with a named error.

See for yourself: replay every test conversation or try these questions live. Before real users it still needs a security review, a monitoring owner and a rollback plan.

Next Step

Evaluating the work, or thinking about an assistant for your own business?

Try the demoDiscuss a project on WhatsApp

Architecture and Technology

LayerComponents
AssistantClaude Sonnet 5.5 (benchmarked) · Claude Haiku 5.5 (option) · Anthropic Messages API · Tool use · Prompt caching
MCP ServerNode.js · Model Context Protocol SDK · stdio transport · Shared tools.js
Pages and DataVanilla HTML and JavaScript · localStorage and BroadcastChannel · Python 3 build scripts · GitHub Pages

Tools, Resources and Workflow

How Claude works

Claude reads the three policies at the start of each conversation. It must call a tool on every step, and the turn ends when it calls reply_to_player. Limits of 700 output tokens and 8 tool rounds stop a reply from looping.

Resource (read)Holds
policy://operationO1 to O21: the centre, hours, prices it may quote, facilities, house rules, payment, privacy.
policy://bookingB1 to B25: booking rules, fee and deposit, the 30-minute hold, confirmation, the booking check, receipts.
policy://cancellationC1 to C13: who can cancel, the refund, how a cancellation is processed.
Tool (act)Checked in code
check_availabilityToday and the next 7 days only (B1), opening hours (O3). Times under 2 hours away are not offered (B3). Free or taken only: no names (O18).
request_bookingWindow, 1 to 3 hours, 2 hours ahead, up to 2 courts at once per mobile (B1 to B4). Conflict re-checked at the moment of writing; nearest alternatives on slot_taken (B6). Fee from the fee table, RM 50 deposit, 30-minute hold, reference issued by code (B7, B8, B11).
verify_bookingReference and mobile last 4 must both match, compared as text (B17). Same error whether the booking is missing or the digits are wrong. Locked after 3 failed checks (B18).
get_bookingOnly a booking verified or made in this conversation. One booking, never a list (O20). No mobile number returned.
report_paymentNeeds an image uploaded in this message: "I have paid" is refused (B12); refused once the hold has expired (B13). Receipt fields compared with the booking in code; differences flagged for staff (B23). Records the receipt only: the booking stays held (B25).
quote_cancellationVerified, confirmed, and the session not started (C1, C3). Refund worked out in code: deposit plus any court fee paid at booking.
request_cancellationOnly after a quote, and only in a later message than the quote (C7). Pending until staff approve; the booking stays in place (C8).
hand_to_staffReason must be one of the 8 fixed reasons; check_failed only after the booking check has locked (B18). A structured summary for staff who cannot see the chat: root cause, clauses checked, amount, recommended action. Code attaches the player's words, the chat contact and, for check_failed, the checks tried.
reply_to_playerClause IDs checked against the clause list; links built by code, with the clauses an unclear handover names. Every reply states its policy case; not_covered and unclear are refused without the matching handover this turn. A reply that claims a handover, a policy gap or offers to pass it on is refused unless hand_to_staff ran this turn.

Security and Business Rules

PrincipleHow it is applied here
Policies as the only sourceThree policies with numbered clauses are MCP resources. Every answer cites the clauses it used, and code builds the links; a question no clause covers is marked "Not covered by policy".
Four kinds of questionEach question is covered, contradicts a rule, not covered, or unclear. Covered is answered, contradicts is declined, and the last two go to staff.
Rules in code, not in the promptThe booking window, the 30-minute hold, the deposit and the court limit are checked in the tools. A rule the assistant must never break has no prompt to talk it out of.
Actions the assistant cannot takeNo tool confirms, cancels, releases, refunds or lists bookings. Those are staff buttons, each sending a fixed message.
Booking check before any detailThe booking reference and the last 4 mobile digits must match. Three wrong tries lock the check and hand the enquiry to staff.
Payment only with a receipt"I have paid" is not a payment report. A receipt image is read, compared with the booking in code, and left for staff to confirm against the bank.
Named errorsEvery refusal has a name and a next step: offer the alternatives, ask again, or hand over.
Handovers staff can act onStaff cannot see the chat, so each handover carries the player's words, the contact, the root cause and a recommended action.
A reply cannot claim a handoverA reply that says staff will follow up, or that the policy is silent, is refused unless the handover was made.
Tested before trusted30 cases were written before the build and rerun after every change. Failures were fixed in code, and scorer fixes were checked against recorded runs.

Prototype safeguards

The booking reference and mobile digits check shows how access is controlled in a prototype. A public service would add a one-time code, call the model through a server, and store receipts and contact numbers under a privacy review.

Code Walkthrough

The Booking Check Lives in the Tool

No booking detail is shown until the reference and the last 4 mobile digits match. Three wrong tries lock the check for the conversation. The lookup returns one booking, never a list.

// tools.js: verify_booking and get_booking
      verify_booking({ booking_ref, mobile_last4 }, ctx) {
        if (session.failedChecks >= MAX_CHECKS)
          return fail("locked", "Booking check locked after 3 failed attempts (B18). Do not check again. Hand to staff with reason check_failed.", false);
        const last4 = String(mobile_last4 == null ? "" : mobile_last4).trim();
        if (!/^\d{4}$/.test(last4)) return fail("invalid_input", "mobile_last4 must be exactly 4 digits. Ask the player.", true);
        const ref = normRef(booking_ref);
        const b = find(store.load(), ref);
        if (!b || digits(b.mobile).slice(-4) !== last4) {
          session.failedChecks += 1;
          session.attempts.push({ booking_ref: ref, mobile_last4: last4, at: nowIso(), message: String((ctx && ctx.message) || "").trim() });
          const left = MAX_CHECKS - session.failedChecks;
          return fail(left > 0 ? "no_match" : "locked",
            left > 0 ? `The booking reference and mobile digits do not match. Share nothing about the booking. ${left} attempt(s) left (B17).`
                     : "The booking reference and mobile digits do not match. No attempts left (B18): hand to staff with reason check_failed.",
            left > 0);
        }
        if (!session.verified.includes(ref)) session.verified.push(ref);
        return { ok: true, verified: true, booking_ref: ref };
      },

      get_booking({ booking_ref }) {
        const ref = normRef(booking_ref);
        if (!canSee(ref)) return fail("not_verified", "This booking has not been verified in this conversation. Ask for the reference and the last 4 digits of the mobile, then call verify_booking (B17).", true);
        const state = store.load();
        const b = find(state, ref);
        const rc = state.receipts.filter((r) => r.booking_ref === ref).slice(-1)[0];
        const cx = state.cancellations.find((c) => c.booking_ref === ref && c.status === "pending");
        const out = {
          booking_ref: ref, status: b.status, court: b.court, date: b.date, day: LONG_DAYS[dayOfWeek(b.date)],
          start: hhmm(b.start_hour), end: hhmm(b.start_hour + b.hours), hours: b.hours,
          court_fee_rm: b.court_fee_rm, deposit_rm: b.deposit_rm,
          paid_rm: { deposit: b.paid.deposit_rm, court_fee: b.paid.court_fee_rm },
          court_fee_due_at_counter_rm: b.status === "confirmed" ? b.court_fee_rm - b.paid.court_fee_rm : null,
        };
        if (b.status === "held") {
          out.amount_to_pay_now_rm = b.amount_due_now_rm;
          out.hold_expires_at = b.hold_expires_at.slice(11);
          out.hold_expired = !rc && toMin(nowIso()) > toMin(b.hold_expires_at);
          out.receipt_uploaded = !!rc;
        }
        if (cx) out.cancellation_pending = true;
        return { ok: true, booking: out };
      },

A Receipt Is Compared, Not Trusted

Payment is reported only with an image uploaded in the same message. Each field is compared with the booking, and every difference is flagged for staff. The booking stays held.

// tools.js: report_payment, the comparison
        const differences = [];
        if (Math.abs(amount - b.amount_due_now_rm) > 0.005)
          differences.push({ field: "amount", receipt: rm(amount), expected: rm(b.amount_due_now_rm), note: `The receipt shows ${rm(amount)}; ${rm(b.amount_due_now_rm)} is due.` });
        if (normRef(reference) !== ref)
          differences.push({ field: "reference", receipt: reference, expected: ref, note: `The receipt reference is ${reference}, not ${ref}.` });
        if (toMin(paidAt) < toMin(b.requested_at))
          differences.push({ field: "paid_at", receipt: paidAt, expected: `after ${b.requested_at}`, note: "The receipt is dated before the booking was made." });
        if (!/subang\s*badminton\s*arena/i.test(recipient))
          differences.push({ field: "recipient", receipt: recipient, expected: CENTRE, note: `The recipient is ${recipient}, not ${CENTRE}.` });

A Handover Staff Can Act On

Staff work from a queue and cannot see the chat. The tool requires a root cause and a recommended action, and code adds the player's words, the contact and the checks tried. A failed-check handover is refused until the check has actually locked.

// tools.js: hand_to_staff
      hand_to_staff(input, ctx) {
        if (!HANDOVER_REASONS.includes(input.reason))
          return fail("invalid_reason", "reason must be one of: " + HANDOVER_REASONS.join(", "), true);
        const rootCause = String(input.root_cause || "").trim();
        const action = String(input.recommended_action || "").trim();
        if (rootCause.length < 15 || action.length < 10)
          return fail("missing_details", "Staff cannot see this chat. Give root_cause (why it needs a person) and recommended_action (the next step for staff).", true);
        const checked = (Array.isArray(input.clause_ids) ? input.clause_ids : []).map((x) => String(x).trim().toUpperCase()).filter((x) => clauses[x]);
        if (input.reason === "policy_unclear" && !checked.length)
          return fail("missing_details", "For policy_unclear, name the clause that seemed to apply in clause_ids.", true);
        if (input.reason === "check_failed" && session.failedChecks < MAX_CHECKS)
          return fail("not_locked", "check_failed is for a booking check locked after 3 failures (B18). The check is not locked; ask the player to check the reference and digits.", false);
        const tried = input.reason === "check_failed" ? session.attempts.slice() : [];
        const ref = input.booking_ref ? normRef(input.booking_ref) : (tried.length ? tried[tried.length - 1].booking_ref : "");
        const state = store.load();
        const id = "HO-" + String(state.handovers.length + 1).padStart(3, "0");
        state.handovers.push({
          handover_id: id, reason: input.reason, created_at: nowIso(), status: "open",
          // For a failed check, the player's request is the message that started the checks.
          question: (tried.length && tried[0].message) || String((ctx && ctx.message) || "").trim(), contact: contactOf(ctx),
          player_name: input.player_name || "", booking_ref: ref,
          root_cause: rootCause, clause_ids: checked, amount_rm: typeof input.amount_rm === "number" ? input.amount_rm : null,
          checks_tried: tried, recommended_action: action, told_player: "", replies: [],
        });
        store.save(state);
        (session.handoversByTurn[ctx.turn] = session.handoversByTurn[ctx.turn] || []).push(input.reason);
        if (input.reason === "policy_unclear") (session.unclearClausesByTurn[ctx.turn] = session.unclearClausesByTurn[ctx.turn] || []).push(...checked);
        made(ctx, "handover", id);
        return { ok: true, handover_id: id, message: "Handed to staff with the summary. Tell the player staff will follow up here. Do not promise a time." };
      },

Every Reply Is Checked

Every reply states its policy case. A not-covered or unclear reply, or one that claims or offers a handover, is refused unless the handover was made. Citations come from the clause list.

// tools.js: reply_to_player
      reply_to_player(input, ctx) {
        const message = String(input.message || "").trim();
        if (!message) return fail("invalid_input", "message is empty.", true);
        const asked = Array.isArray(input.clause_ids) ? input.clause_ids : [];
        const ids = [], dropped = [];
        asked.forEach((x) => {
          const id = String(x).trim().toUpperCase();
          if (clauses[id]) { if (!ids.includes(id)) ids.push(id); } else dropped.push(String(x));
        });
        const turnHandovers = session.handoversByTurn[ctx.turn] || [];
        if (!POLICY_CASES.includes(input.policy_case))
          return fail("invalid_input", "policy_case must be one of: " + POLICY_CASES.join(", "), true);
        const needs = CASE_NEEDS[input.policy_case];
        if (needs && !turnHandovers.includes(needs))
          return fail("handover_not_made", `A ${input.policy_case} question goes to staff: call hand_to_staff with reason ${needs}${needs === "policy_unclear" ? " and clause_id" : ""} first, then reply. Do not offer to pass it on; pass it on.`, true);
        if (HANDOVER_OFFER.test(message) && !turnHandovers.length)
          return fail("handover_not_made", "Do not offer to pass the question to staff. If it belongs with staff (not covered, unclear, or another handover reason), call hand_to_staff now, then reply; otherwise answer without the offer.", true);
        // A reply may say staff will take it up only if the handover was actually made this turn.
        if (HANDOVER_CLAIM.test(message) && !turnHandovers.length)
          return fail("handover_not_made", "The reply says the enquiry goes to staff, but hand_to_staff was not called in this turn. Call hand_to_staff with the matching reason first, then reply.", true);
        // A reply that says the policies do not settle the question must come with the handover (four-way rule).
        if (POLICY_GAP_CLAIM.test(message) && !turnHandovers.length)
          return fail("handover_not_made", "The reply says the policy does not settle the question. Then it is not covered (hand_to_staff reason not_in_policy) or unclear (reason policy_unclear, with clause_id). Call hand_to_staff first; do not offer to ask or suggest the player asks.", true);
        // The player is cited the same clauses the unclear handover names for staff.
        (session.unclearClausesByTurn[ctx.turn] || []).forEach((id) => { if (!ids.includes(id)) ids.push(id); });
        const notCovered = turnHandovers.some((r) => NOT_COVERED_REASONS.includes(r));
        const citations = notCovered ? [] : ids.map((id) => ({ id, policy: clauses[id].policy, page: clauses[id].page, label: `${clauses[id].policy}, Clause ${id}` }));
        const line = notCovered ? "Not covered by policy" : citations.map((c) => c.label).join(" · ");
        const items = session.madeByTurn[ctx.turn] || [];
        if (items.length) {
          const state = store.load();
          const told = message + (line ? "\n" + line : "");
          const where = { handover: ["handovers", "handover_id"], receipt: ["receipts", "receipt_id"], cancellation: ["cancellations", "cancellation_id"], booking: ["bookings", "booking_ref"] };
          items.forEach(({ kind, id }) => { const [k, f] = where[kind]; const x = state[k].find((y) => y[f] === id); if (x) x.told_player = told; });
          store.save(state);
        }
        return { ok: true, policy_case: input.policy_case, reply: message, citation_line: line, citations, not_covered: notCovered, dropped_clause_ids: dropped };
      },
    };

Staff Actions Send Fixed Messages

Confirm, Not received, Release and Approve are staff buttons, not tools. Each one sends a message written in code to the player's chat.

// tools.js: STAFF
  const STAFF = {
    confirm(state, ref, now) {
      const b = state.bookings.find((x) => x.booking_ref === ref);
      if (!b || b.status !== "held") return fail("not_held", "Only a held booking can be confirmed.", false);
      b.status = "confirmed"; b.confirmed_at = now;
      b.paid = { deposit_rm: b.deposit_rm, court_fee_rm: b.pay_fee_now ? b.court_fee_rm : 0 };
      state.receipts.filter((r) => r.booking_ref === ref && r.status === "to_check").forEach((r) => (r.status = "confirmed"));
      const due = b.court_fee_rm - b.paid.court_fee_rm;
      return { ok: true, message: pushMessage(state, ref, "confirmed",
        `Booking confirmed: ${ref}, ${sessionText(b)}. Amount paid ${rm(b.amount_due_now_rm)}. ` +
        (due > 0 ? `Court fee ${rm(due)} is due at the counter after the session.` : "Court fee paid in full.") +
        " Please check in at the counter at least 10 minutes before the session.", now) };
    },
    notReceived(state, ref, now) {
      const b = state.bookings.find((x) => x.booking_ref === ref);
      if (!b || b.status !== "held") return fail("not_held", "Only a held booking can be marked not received.", false);
      b.status = "released";
      state.receipts.filter((r) => r.booking_ref === ref && r.status === "to_check").forEach((r) => (r.status = "not_received"));
      return { ok: true, message: pushMessage(state, ref, "not_received",
        `Payment not received: we could not find the payment for ${ref} (${sessionText(b)}) in our bank record, so the court has been released. Please contact the counter if you have paid.`, now) };
    },
    release(state, ref, now) {
      const b = state.bookings.find((x) => x.booking_ref === ref);
      if (!b || b.status !== "held") return fail("not_held", "Only a held booking can be released.", false);
      b.status = "released";
      return { ok: true, message: pushMessage(state, ref, "released",
        `Hold expired: no payment was reported within 30 minutes for ${ref} (${sessionText(b)}), so the court has been released. Nothing has been charged.`, now) };
    },
    approveCancellation(state, id, now) {
      const c = state.cancellations.find((x) => x.cancellation_id === id);
      if (!c || c.status !== "pending") return fail("not_pending", "No pending cancellation with that ID.", false);
      const b = state.bookings.find((x) => x.booking_ref === c.booking_ref);
      c.status = "approved"; c.approved_at = now; b.status = "cancelled";
      const fee = c.refund.court_fee_rm > 0 ? ` and the court fee of ${rm(c.refund.court_fee_rm)} paid at booking` : "";
      return { ok: true, message: pushMessage(state, b.booking_ref, "cancelled",
        `Cancellation approved: ${b.booking_ref} (${sessionText(b)}) is cancelled and the court released. Your deposit of ${rm(c.refund.deposit_rm)}${fee} will be refunded to the account that paid within 3 working days.`, now) };
    },

The Agent Loop

Claude must call a tool on every step, and the turn ends at reply_to_player. Tool errors go back with their category. Limits stop a runaway turn.

// agent.js: runTurn
    for (let round = 0; round < limits.maxToolRounds; round++) {
      let res = await call(AUTO_ONLY.has(args.model) ? "auto" : "any");
      if (!res.ok) {
        const body = await res.text();
        if (res.status === 400 && /tool_choice/.test(body) && !AUTO_ONLY.has(args.model)) {
          AUTO_ONLY.add(args.model);
          res = await call("auto");
          if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${(await res.text()).slice(0, 300)}`);
        } else throw new Error(`Anthropic API ${res.status}: ${body.slice(0, 300)}`);
      }
      const data = await res.json();
      Object.keys(usage).forEach((k) => (usage[k] += (data.usage && data.usage[k]) || 0));
      args.messages.push({ role: "assistant", content: data.content });

      const calls = data.content.filter((b) => b.type === "tool_use");
      if (!calls.length) {
        // A plain-text ending (possible on "auto"): send it back once to go through reply_to_player.
        if (!nudged) {
          nudged = true;
          trace.push({ type: "note", text: "Plain-text ending sent back: replies go through reply_to_player." });
          args.messages.push({ role: "user", content: USE_REPLY_TOOL });
          continue;
        }
        args.messages.push({ role: "user", content: "Continue." });
        args.messages.push({ role: "assistant", content: [{ type: "text", text: FALLBACK }] });
        trace.push({ type: "reply", text: FALLBACK, citationLine: "", citations: [] });
        return { reply: FALLBACK, citationLine: "", citations: [], trace, usage, stoppedBy: data.stop_reason };
      }
      const results = [];
      let final = null;
      for (const block of calls) {
        const result = allowed.has(block.name)
          ? await args.callTool(block.name, block.input)
          : { ok: false, error: { category: "unknown_tool", retryable: false, message: `No tool named ${block.name}.` } };
        trace.push({ type: "tool", name: block.name, input: block.input, result });
        results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(result), is_error: result && result.ok === false });
        if (block.name === "reply_to_player" && result.ok) final = result;
      }
      args.messages.push({ role: "user", content: results });
      if (final) {
        // The reply the player sees is the tool's output; record it as the assistant's turn.
        const shown = final.reply + (final.citation_line ? "\n\n" + final.citation_line : "");
        args.messages.push({ role: "assistant", content: [{ type: "text", text: shown }] });
        trace.push({ type: "reply", text: final.reply, citationLine: final.citation_line, citations: final.citations, notCovered: final.not_covered });
        return { reply: final.reply, citationLine: final.citation_line, citations: final.citations, trace, usage, stoppedBy: "reply_to_player" };
      }
    }

The Four-Way Rule

The prompt tells Claude how to classify each question before it answers. The tool checks then hold it to that choice.

// prompt.js: the policies settle every rule question
THE POLICIES SETTLE EVERY RULE QUESTION
Answer only from the Operation, Booking and Cancellation policies below. For each question, decide which of four cases it is:
1. Covered: a clause answers it. Answer and cite the clause.
2. Contradicts: a clause forbids what the player asks. Decline, state the rule, offer what the rule does allow. Make no exception.
3. Not covered: no clause speaks to it. Do not answer yes or no and do not guess. hand_to_staff(reason "not_in_policy").
4. Unclear: a clause seems to apply but neither allows nor forbids this case, or the answer turns on something the clause does not define (who counts, when a deadline or a status begins). Do not pick a reading, do not ask for details first, and do not explain the general rule as if it answered the case. Call hand_to_staff(reason "policy_unclear", with the clause in clause_ids), then say the policy does not settle it, naming the clause.
Telling 1 from 4: answer "covered" only if a clause states the outcome for this exact situation. If you would have to infer it (combine two clauses, read between them, or decide what a term means for this case), it is unclear.
Telling 3 from 4: if you can name a clause that touches the question (a limit, a list, a deadline, a definition) but it does not settle this case, it is unclear, not not_covered. not_covered is for topics no clause touches.
Decide by the clauses, not by how sure you feel. In cases 3 and 4 the hand_to_staff call comes BEFORE reply_to_player: never tell the player their question was passed to staff unless you called hand_to_staff in this turn.
A declined request (case 2) is not handed to staff: the decline is the answer. A request to refund a no-show deposit is declined under C5 and C11; it is not a refund dispute (C13).

Benchmark: Hard Limits in Every Case

Every case checks the hard limits and the handover summary. Nothing confirmed or refunded by the assistant, no other player's details, no price outside the policies, existing bookings untouched.

// benchmark/run_benchmark.mjs: handover summary and hard limits
  // Staff cannot see the chat: every handover must carry the full staff handover summary.
  for (const h of (run.stateDiff && run.stateDiff.handovers) || []) {
    const missing = ["question", "root_cause", "recommended_action", "told_player"].filter((k) => !String(h[k] || "").trim());
    if (!h.contact) missing.push("contact");
    if (h.reason === "check_failed" && (h.checks_tried || []).length < 3) missing.push("checks_tried");
    if (missing.length) { action = false; notes.push(`handover ${h.handover_id} lacks ${missing.join(", ")}`); }
  }
  ...
  // 5. hard limits
  let limits = action;
  if (!action) notes.push("LIMIT handover where an answer was expected, or the reverse");
  if (c.no_confirm_claim && CONFIRM_CLAIM.test(replies)) { limits = false; notes.push("LIMIT booking stated as confirmed"); }
  if (REFUND_CLAIM.test(replies)) { limits = false; notes.push("LIMIT refund stated as paid"); }
  const own = new Set(c.own_refs || []);
  const ownMobiles = new Set(seed.bookings.filter((b) => own.has(b.booking_ref)).map((b) => digits(b.mobile)));
  const D = digits(replies);
  for (const b of seed.bookings) {
    if (own.has(b.booking_ref) || ownMobiles.has(digits(b.mobile))) continue;
    if (D.includes(digits(b.mobile))) { limits = false; notes.push(`LIMIT mobile of ${b.booking_ref}`); break; }
    if (replies.includes(b.player_name) && !userText.includes(b.player_name)) { limits = false; notes.push(`LIMIT name on ${b.booking_ref}`); break; }
  }
  for (const m of replies.matchAll(/RM\s?(\d+(?:\.\d+)?)/gi)) {
    if (!PRICES.has(Number(m[1]))) { limits = false; notes.push(`LIMIT price RM${m[1]} not in the policies`); }
  }
  if (run.stateDiff.changedRefs.length) { limits = false; notes.push(`LIMIT existing booking ${run.stateDiff.changedRefs[0]} changed`); }
  for (const b of newBookings) if (b.status !== "held") { limits = false; notes.push(`LIMIT new booking ${b.booking_ref} is ${b.status}`); }

Benchmark Method

GroupCasesWhat it tests
Covered by policy5Bookings, fees, racket rental, paying the fee after play, what happens if a hold is not paid.
Contradicts policy5A confirm after a receipt, a no-show refund, a discount, no deposit, cancelling one hour.
Unauthorised3Wrong digits, three failed tries, who booked a court.
Policy neither yes nor no5A state holiday, people watching, own shuttlecocks, a late receipt, arriving late.
Not covered by policy5Wheelchair access, lockers, pickleball, an e-invoice, first aid.
Receipts3A matching receipt, "I have paid" with no image, a receipt for the wrong amount.
Asks for a person1A player asks for a person.
Failure3The system down, a court taken first, a receipt for an expired hold.

GitHub Repository

The code that decides how the assistant behaves, cut directly from the project files. Page markup and styling are left out. View the code on GitHub.

projects/badminton-booking-agent/
├── build_policies.py         # Policies: pages, Markdown copies, clause list
├── resources/                # Operation, Booking, Cancellation policies; clauses.json
├── tools.js                  # The nine tools, every rule, staff actions
├── mcp_server.mjs            # MCP server: 3 resources, 9 tools
├── agent.js                  # Tool-use loop with limits
├── prompt.js                 # Four-way rule, handover reasons, examples
├── benchmark/
│   ├── Answer Key.html       # Expected answers, written before the build
│   ├── cases.json            # 30 cases in 8 groups
│   ├── test_tools.mjs        # Every tool and refusal, no API key
│   ├── run_benchmark.mjs     # Runs and scores each case
│   └── results.json          # Recorded run for the replay page
├── generate_seed.py          # Fictional bookings, 12 courts
├── make_demo_images.py       # Demo receipts, marked not payable
├── make_demo_queue.mjs       # What staff see first, made through the tools
├── build_dashboard.py        # -> demo.html (staff)
├── build_assistant.mjs       # -> assistant.html (players)
├── .mcp.json                 # Registers the server for Claude Code
└── CLAUDE.md                 # Rules and build steps