In the first article (How I Built an Invisible Telegram CRM), I laid out the architecture of Lucky: an edge-based CRM built on Cloudflare Workers, D1 SQLite, and KV designed to sit directly on top of Telegram Business. The theoretical design was elegant—regex parsers extracting appointment dates from chat text, automated background reminders, and an in-app timeline.

Then the system met reality: a solo service professional (my girlfriend Anya) running a high-volume nail studio.

Solopreneurs are notoriously one of the hardest audiences to build software for. They have zero tolerance for friction, established muscle memory, and zero patience for software that gets in their way. Within five minutes of using the initial prototype, Anya made it clear: she liked the automated confirmation texts, and hated almost everything else.

Here is the technical breakdown of what actually survived in production, the code we had to rewrite, and how we worked around the hard platform limitations of Telegram Business.

1. Killing the Chat Parser for a Telegram Mini App Write Interface

My initial engineering assumption was that the bot should parse natural language chat messages (e.g., "Booked for tomorrow at 3:00 PM, manicure, 2h, $50"), extract the parameters, and tentatively slot the calendar.

Anya rejected that immediately. Service professionals do not want an automation guessing when they should work, negotiating appointment slots, or touching their schedule unprompted. She demanded100% manual control over the schedule.

We stripped out the conversational booking parser entirely. The Telegram Mini App (TMA), which was originally intended to be a read-only timeline, became the primary write interface.

The workflow became tactile and deterministic:

  1. Anya chats with the client normally.
  2. She opens the Mini App and taps the exact slot on her visual day timeline.
  3. When she commits or reschedules an appointment, the backend automatically dispatches a formatted confirmation back into the client's direct chat using the Telegram Business connection.

Here is the actual reschedule handler from src/api/appointments.ts. If dispatching through business_connection_id fails or drops, the system immediately falls back to alerting Anya directly:

// src/api/appointments.ts
if (client?.chat_id) {
  try {
    const connId = await getConfig(env, "business_connection_id");
    const { formatRussianDate, getClientTimezone } = await import("../services/timezone");
    const clientTimeZone = await getClientTimezone(env);

    const oldTimeStr = formatRussianDate(existing.scheduled_at, clientTimeZone);
    const newTimeStr = formatRussianDate(updated.scheduled_at, clientTimeZone);
    const tplRow = await getTemplate(env, "RESCHEDULE_CONFIRMATION");
    const tplText = tplRow?.template_text || DEFAULT_RESCHEDULE_CONFIRMATION;
    const msgText = renderTemplate(tplText, {
      name: client.first_name,
      old_time: oldTimeStr,
      new_time: newTimeStr,
      service: updated.service,
    });

    let notifFailed = false;
    let errorDesc = "No connection ID";

    if (connId) {
      const sendRes = await sendBusinessMessage(
        connId,
        client.chat_id,
        msgText,
        env,
      );
      if (!sendRes?.ok) {
        notifFailed = true;
        errorDesc = sendRes?.description || "Unknown Telegram Error";
      }
    } else {
      notifFailed = true;
    }

    // Fail-safe: if the business message dropped, alert the master immediately
    if (notifFailed) {
      await alertAnyaOnFailure(
        env,
        client.first_name,
        client.chat_id,
        client.username,
        newTimeStr,
        "перенос",
        errorDesc,
        msgText,
      );
    }
  } catch (e) {
    console.error("[Appointments] Failed to send reschedule message:", e);
  }
}

2. The Silent Reversion & Dynamic Working Days (3-Tier Cache)

Once the TMA calendar was live, another operational reality hit: her personal life doesn’t live inside Telegram. It lives in Google Calendar.

I initially configured working hours as static values in D1. But when Anya's schedule changed and she needed custom working days, she didn't ask for a new feature—she just quietly reverted to entering bookings back into Google Calendar.

To bring her back to the app, I built a dedicated working-days schedule editor into the Mini App (PUT /api/calendar/working-days). Because this endpoint is hit every time the calendar renders, we implemented a 3-tier cache hierarchy (In-Memory → Cloudflare KV → Cloudflare D1) so edge isolates don't hammer the database on every screen render:

// src/services/workingDaysService.ts
export async function setWorkingDays(env: Env, daysInput: unknown): Promise<string[]> {
  const sanitized = sanitizeWorkingDays(daysInput);
  const jsonValue = JSON.stringify(sanitized);

  // 1. Write to D1 database (source of truth)
  if (env.DB) {
    await env.DB.prepare(
      `INSERT INTO config (key, value)
       VALUES (?, ?)
       ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
    )
      .bind(WORKING_DAYS_CONFIG_KEY, jsonValue)
      .run();
  }

  // 2. Write to Cloudflare KV (persistent across isolate restarts)
  if (env.CRM_KV) {
    try {
      await env.CRM_KV.put(WORKING_DAYS_CACHE_KEY, jsonValue);
    } catch (err) {
      console.warn("Failed to save working_days to KV:", err);
    }
  }

  // 3. Invalidate In-Memory isolate caches
  memoryCache.set(WORKING_DAYS_CACHE_KEY, sanitized, WORKING_DAYS_TTL_MS);
  memoryCache.invalidate(`config:${WORKING_DAYS_CONFIG_KEY}`);
  memoryCache.invalidate("all_config");

  return sanitized;
}

3. Background Sync: RFC 5545 iCalendar (.ics) Feed with ETag Caching

To permanently prevent friction between Telegram and her personal schedule, the Worker exposes an authenticated RFC 5545 .icssubscription feed at /api/calendar/feed.ics?token=<secret_token>.

Google Calendar, Apple Calendar, and Outlook poll this endpoint every 15 minutes. To avoid running unneeded D1 queries and serialization loops during frequent poll requests, the feed computes an ETag from the dataset timestamp and returns HTTP 304 Not Modified:

// src/api/calendar.ts
export async function getCalendarFeed(request: Request, env: Env): Promise<Response> {
  const url = new URL(request.url);
  const token = url.searchParams.get("token");
  const validToken = (await getConfig(env, "calendar_token")) || "secret_cal_token";

  if (!token || token !== validToken) {
    return new Response("Unauthorized calendar token", { status: 401 });
  }

  const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
  const rows = await env.DB.prepare(`
    SELECT a.id, a.scheduled_at, a.duration_minutes, a.service, a.price, a.status,
           c.first_name, c.last_name, c.username, c.phone, c.notes
    FROM appointments a
    JOIN clients c ON a.client_id = c.id
    WHERE a.scheduled_at >= ? AND a.status IN ('CONFIRMED', 'COMPLETED')
    ORDER BY a.scheduled_at ASC
  `).bind(thirtyDaysAgo).all<IcsAppointment>();

  const appointments = rows.results || [];
  const lastTs = appointments.length > 0 ? appointments[appointments.length - 1].scheduled_at : 0;
  const etag = `"${appointments.length}_${lastTs}"`;

  // HTTP conditional validation prevents redundant payloads
  if (request.headers.get("If-None-Match") === etag) {
    return new Response(null, { status: 304 });
  }

  const timeZone = (await getConfig(env, "timezone")) || DEFAULT_TIMEZONE;
  const icsText = generateIcsFeed(appointments, timeZone);

  return new Response(icsText, {
    status: 200,
    headers: {
      "Content-Type": "text/calendar; charset=utf-8",
      "Cache-Control": "public, max-age=300, stale-while-revalidate=600",
      ETag: etag,
    },
  });
}

The VCALENDAR generator tags the subscription with client-side polling hints:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Lucky Assistant//Nail CRM Calendar 1.0//RU
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:Lucky Schedule
REFRESH-INTERVAL;VALUE=DURATION:PT15M
X-PUBLISHED-TTL:PT15M

4. The 24-Hour Business API Trap and the Deep-Link Pivot

The single feature I wanted most was automated appointment reminders sent 24 hours before a session to eliminate no-shows.

Then we hit the hard constraint of the Telegram Business API.

When a bot operates over a business_connection_id, it cannot broadcast unprompted messages outside the active conversational window. In our initial cron implementation, attempting to message clients who booked weeks earlier threw fatal errors:

BUSINESS_PEER_USAGE_MISSING: message can't be sent because business connection is restricted

Telegram enforces a strict 24-hour interaction window on business connections—identical to WhatsApp Business and Meta. If the client hasn't sent a message in that chat within the last 24 hours, the bot is blocked from initiating outbound messages. Because nail clients book slots weeks in advance, the window is long expired by reminder day.

The Workaround: 8:00 AM Master Digest with Pre-Encoded Deep-Links

Since the bot couldn't message clients directly, we inverted the flow. Instead of attempting an automated broadcast, a morning Cloudflare Cron Trigger compiles all next-day appointments and sends Anya a consolidated digest at 8:00 AM.

Inside that digest, every client’s name is a pre-filled t.medeep-link encoding the exact reminder text:

// src/services/reminderCron.ts
const reminderText = renderTemplate(templatePattern, {
  name: firstName,
  time: timeStr,
}).trim();

const username = appt.username ? appt.username.replace(/^@/, "").trim() : "";
let clientLink = "";

if (username) {
  // Deep-link opens the client's chat with the reminder pre-filled in Anya's input field
  clientLink = `https://t.me/${username}?text=${encodeURIComponent(reminderText)}`;
} else if (appt.telegram_id) {
  clientLink = `tg://user?id=${appt.telegram_id}`;
}

const safeName = escapeHtml(clientName);
const linkedName = clientLink
  ? `<a href="${clientLink}"><b>${safeName}</b></a>`
  : `<b>${safeName}</b>`;

lines.push(`• ${linkedName} — ${timeStr}`);

When Anya checks her morning digest in Telegram, she doesn't type anything. She simply taps the client's name: Telegram opens their personal chat, pastes the ready-made reminder message into her input box, and she taps Send.

It completely bypasses the bot API's 24-hour restriction while taking less than 10 seconds for the entire day.

5. Managing Rest Windows: Buffer & Gap Math in the TMA

The final operational detail was rest buffers. A 3-hour manicure set is physically taxing; if clients book back-to-back with zero buffer, fatigue causes mistakes.

In the Mini App timeline (useAppointments.ts), the client-side state dynamically computes three distinct slot realities in a single pass:

  • Direct Overlaps (isConflict): Hard collision where two appointments share the same timestamp.
  • Buffer Collisions (isBufferConflict): The appointment doesn't overlap directly, but cuts into her mandatory rest window (e.g. 15–30 minutes).
  • Free Gaps (gapMin): Positive space between appointments rendered as open blocks on the canvas where new clients can be slotted.
// src/frontend/hooks/useAppointments.ts
const effectiveBuffer = Math.max(restBufferMinutes || 15, 15);
const bufferMs = effectiveBuffer * 60 * 1000;

for (let i = 0; i < appts.length; i++) {
  const a = appts[i];
  const startTs = a.scheduled_at;
  const durMin = a.duration_minutes || 120;
  const endTs = startTs + durMin * 60 * 1000;

  let isConflict = false;
  let isBufferConflict = false;

  if (a.status === "CONFIRMED") {
    // Check previous appointment collision & buffer intrusion
    if (i > 0) {
      const prev = appts[i - 1];
      if (prev.status === "CONFIRMED") {
        const prevEnd = prev.scheduled_at + (prev.duration_minutes || 120) * 60 * 1000;
        if (startTs < prevEnd) {
          isConflict = true;
        } else if (startTs < prevEnd + bufferMs) {
          isBufferConflict = true; // Overlaps master's mandatory rest buffer
        }
      }
    }
  }

  // Calculate open rest gap before the next client
  if (i < appts.length - 1) {
    const next = appts[i + 1];
    const gapMin = Math.round((next.scheduled_at - endTs) / (60 * 1000));
    items.push({
      type: "gap",
      gapMin,
      startTs: endTs,
      endTs: next.scheduled_at,
    });
  }
}

Building software for solo operators is a brutal test of real-world UX:

  • Don't force AI into flows that require human certainty.A clean calendar with 1-tap confirmation dispatches beats an NLP parser every day of the week.
  • Support the user's existing habits. If they live in Google Calendar, build an RFC 5545 feed with proper ETag caching instead of fighting their muscle memory.
  • When platform APIs block your core feature, redesign the interaction.The 8:00 AM master digest with pre-filled deep-links achieved 95% of the automation benefit without violating Telegram's business connection policies.