Free playbook · A tool in 60 minutes

A bot that joins your meetings and sends the summary and tasks to Telegram

The promise: Within an afternoon, every Zoom, Meet or Teams meeting on your calendar gets a silent notetaker. A few minutes after the meeting ends you get a summary, decisions, tasks per person and proposed follow-ups on Telegram, and no follow-up goes out until you approve it.

  • Aviad Gindi

Note: general recommendations only, not professional advice. You are responsible for checking before use. The bot is visible in the meeting, but that doesn't replace telling the participants you're recording and getting their consent. The full text is at the end.

Who this is for

Anyone whose calendar is full of video meetings that all end with the same question: who's taking notes? Managers, consultants, freelancers working with several clients at once, team leads. If you still type notes while someone is talking, or the summary only gets written when you remember, this kit is for you.

You need basic comfort with a server and the command line. You don't need to write code: the full code is here, in four parts, and you paste it into one file. If a server is too big a step for you right now, start with the earlier stages of the series, which run on your own computer.

What you'll have at the end

  • One bot per meeting on your calendar, scheduled on its own every morning, joining one minute before the meeting starts.
  • A morning message on Telegram with today's meetings. Replying "no bot 2" cancels the bot for the second meeting.
  • A Hebrew (or other language) transcript with speaker names, kept on your side. The recording itself is deleted from the recording provider's servers right after the download.
  • A structured summary on Telegram: a short summary, decisions, your tasks, other people's tasks with an owner and a due date, and open questions.
  • Proposed follow-ups waiting for approval: an email to the client, a message to a supplier, a follow-up meeting. "approve 3" approves, "drop 3" removes it. Nothing goes out without approval.
  • The same process for phone calls and in-person meetings, from a recording on your phone.

How it's built

Four parts, and one small server that connects them:

  1. The calendar (Google Calendar) says which meetings are on today and where the link is.
  2. Recall.ai sends a bot to each meeting, records it and transcribes it. It's a paid service, priced per hour; the prices are below.
  3. A language model (Claude, ChatGPT or another) turns the transcript into a summary in a fixed format.
  4. Telegram is where you see everything and approve.

The server is closed to the internet. It doesn't wait for anyone to call it: every few minutes it asks Recall.ai whether a meeting has finished, and asks Telegram whether you wrote something. That's simpler and safer than a webhook, and five minutes of waiting for a summary makes no difference.

What you need

  • A small server (VPS) with Python 3.11 or later, or a computer that stays on all day. If you don't have a set-up, secured server, start with A server for AI agents that stays up and stays safe.
  • A Google account with your calendar.
  • An account at Recall.ai, where the first 5 recording hours are free.
  • An API key for a language model. The code here uses Claude, and you can switch to another provider in one function.
  • A Telegram bot (you create it in BotFather, for free) and your chat ID.

The kit, step by step (one afternoon)

Step 1 (20 minutes): A Recall.ai account and a key that stays on the server

What you do: Sign up for Recall.ai, create an API key and keep it only on your server.

How:

  1. When you sign up, choose the EU region (servers in Frankfurt). The account and the key belong to the region, and all recordings and transcripts are stored there. For a business in Israel or Europe that's usually the better choice for privacy than servers in the US.
  2. In the dashboard, create an API key. Copy it once.
  3. On the server, create a folder such as ~/meeting-bot, with a .env file for the settings. The file never goes into Git and is never sent to anyone.
The .env file (fill in your own values)
RECALL_API_KEY=your-recall-key
RECALL_BASE=https://eu-central-1.recall.ai
ANTHROPIC_API_KEY=your-language-model-key
TELEGRAM_TOKEN=token-from-botfather
TELEGRAM_CHAT_ID=your-chat-id
GOOGLE_KEY_FILE=/home/ops/meeting-bot/google-key.json
CALENDAR_ID=you@example.com
MY_NAME=Your name as it appears in meetings
BOT_NAME=Notetaker
BOT_LANG=en
Copy-ready
chmod 600 ~/meeting-bot/.env
pip install requests anthropic google-api-python-client google-auth

Common mistake: pasting the key into the code and then pushing the code to GitHub. A leaked key lets anyone send bots on your account. If it happens, delete the key in the dashboard and create a new one.

Step 2 (30 minutes): Read the calendar and find the link

What you do: Give the server read access to your calendar, and teach it to find the meeting link in every event.

How:

  1. In the Google Cloud Console, create a project, enable the Google Calendar API and create a Service Account. Download its key file (JSON) to the server, to the path you set in GOOGLE_KEY_FILE, and give it chmod 600.
  2. In Google Calendar, in your calendar's settings, share it with the Service Account's email address with "See all event details". Read only, no editing.
  3. Create a file called meeting_bot.py and paste part 1 into it.

A meeting link can sit in four places: hangoutLink (Meet meetings), conferenceData (Meet, and the Zoom and Teams add-ons), the location field, and the event description, sometimes inside HTML. The code checks them in that order and returns the first link it finds.

meeting_bot.py, part 1: settings, texts and meeting links
# meeting_bot.py, part 1 of 4: settings, texts and meeting links
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

RECALL_BASE = os.environ.get("RECALL_BASE", "https://eu-central-1.recall.ai")
RECALL_KEY = os.environ.get("RECALL_API_KEY", "")
BOT_NAME = os.environ.get("BOT_NAME", "Notetaker")
LANG = os.environ.get("BOT_LANG", "he")
STATE_FILE = Path(os.environ.get("STATE_FILE", "state.json"))
TRANSCRIPTS = Path(os.environ.get("TRANSCRIPTS_DIR", "transcripts"))
SKIP_TAGS = ("#ללא-בוט", "#no-bot")

TEXT = {
    "he": {
        "today": "הפגישות של היום",
        "tagged": "בלי בוט (מסומן)",
        "cancel_hint": "לביטול: בלי בוט <מספר>",
        "none": "אין היום פגישות עם קישור.",
        "cancelled": "בוטל הבוט לפגישה {n}.",
        "failed": "הבוט לא הצליח להקליט את {title}.",
        "summary": "סיכום",
        "decisions": "החלטות",
        "mine": "משימות שלי",
        "theirs": "משימות של אחרים",
        "follow_ups": "הצעות להמשך (שום דבר לא יוצא בלי אישור)",
        "questions": "שאלות פתוחות",
        "no_due": "בלי יעד",
        "approve_hint": "לאישור: אשר <מספר> · לביטול: בטל <מספר>",
        "approved": "אושר {n}. הנוסח הסופי:",
        "dropped": "בוטל {n}.",
    },
    "en": {
        "today": "Today's meetings",
        "tagged": "no bot (tagged)",
        "cancel_hint": "To cancel: no bot <number>",
        "none": "No meetings with a link today.",
        "cancelled": "Bot for meeting {n} cancelled.",
        "failed": "The bot could not record {title}.",
        "summary": "Summary",
        "decisions": "Decisions",
        "mine": "My tasks",
        "theirs": "Other people's tasks",
        "follow_ups": "Proposed follow-ups (nothing goes out without approval)",
        "questions": "Open questions",
        "no_due": "no due date",
        "approve_hint": "To approve: approve <number> · to drop: drop <number>",
        "approved": "Approved {n}. The final text:",
        "dropped": "Dropped {n}.",
    },
}
T = TEXT["en" if LANG == "en" else "he"]

# Zoom, Google Meet and Teams links, wherever the invite put them
MEETING_LINK = re.compile(
    r"https://(?:"
    r"(?:[\w-]+\.)?zoom\.us/(?:j|my|w)/[\w.?=&%/-]+"
    r"|meet\.google\.com/[a-z]{3}-[a-z]{4}-[a-z]{3}"
    r"|teams\.microsoft\.com/l/meetup-join/[^\s\"'<>]+"
    r"|teams\.live\.com/meet/[^\s\"'<>]+"
    r")",
    re.IGNORECASE,
)


def extract_meeting_url(event):
    """The video link of a Google Calendar event, or None."""
    if event.get("hangoutLink"):
        return event["hangoutLink"]
    for ep in (event.get("conferenceData") or {}).get("entryPoints", []):
        if ep.get("entryPointType") == "video" and ep.get("uri"):
            return ep["uri"]
    for field in ("location", "description"):
        m = MEETING_LINK.search(event.get(field) or "")
        if m:
            return m.group(0).rstrip(").,;")
    return None


def should_skip(event):
    text = " ".join(event.get(k) or "" for k in ("summary", "description"))
    return any(tag in text for tag in SKIP_TAGS)


def load_state():
    if STATE_FILE.exists():
        return json.loads(STATE_FILE.read_text(encoding="utf-8"))
    return {"events": {}, "bots": {}, "items": {}, "next_item": 1, "tg_offset": 0}


def save_state(state):
    STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=1), encoding="utf-8")

Before you rely on the code, check a week of your calendar. Export the week's events (title, time, location and description) and run this prompt in an AI tool. It shows you what your invites really look like, and whether there's a format the code misses.

1. Prompt: extract meeting links from the calendar
You check a calendar before a meeting-recording bot is connected to it.
Here is a list of events (title | time | location | description):
[paste the events]

For each event return one JSON line only, with no other text:
{"title":"","start":"","platform":"zoom|meet|teams|phone|in_person|none","meeting_url":"","skip":false,"reason":""}

Rules:
1. meeting_url: only a link that appears in the text, word for word. Don't complete or guess a link.
2. platform: "phone" if there's only a phone number, "in_person" if there's an address and no link, "none" if there's nothing.
3. skip=true if the title or description contains #no-bot or #ללא-בוט, or if the meeting looks sensitive (job interview, doctor, lawyer, a personal call). Say why in reason.
4. At the end, one line: how many events have a link, how many don't, and how many are marked to skip.

Step 3 (30 minutes): A bot per meeting, and a morning list on Telegram

What you do: Every morning the server goes over today's meetings and sends Recall.ai one request per meeting: which link, when to join, under which name and in which language to transcribe.

How: Paste part 2 under part 1. The request sends POST /api/v1/bot/ with:

  • meeting_url: the link from step 2.
  • join_at: one minute before the meeting starts. A bot scheduled more than ten minutes ahead is guaranteed to be available, which is why the scheduling happens in the morning and not at the last minute.
  • bot_name: the name the participants will see. "Notetaker" or "Dana's notetaker" is better than a name that looks like a person.
  • Recall.ai's built-in transcription (recallai_streaming) in prioritize_accuracy mode, with language_code "he". Accuracy mode is slower than the fast mode, and that's fine: the summary is written after the meeting, not live. For meetings in another language, change the language code.

A meeting tagged #no-bot (or #ללא-בוט) in its title or description gets no bot, and it shows up in the list so you can see the tag worked. The code remembers which event already has a bot, so running it again never sends two bots to the same meeting.

meeting_bot.py, part 2: the calendar and one bot per meeting
# meeting_bot.py, part 2 of 4: the calendar and one bot per meeting
import requests


def recall(method, path, **kwargs):
    r = requests.request(method, RECALL_BASE + path, timeout=60,
                         headers={"Authorization": "Token " + RECALL_KEY}, **kwargs)
    r.raise_for_status()
    return r.json() if r.content else {}


def todays_events():
    """Timed events from now until the end of the day (needs a service account key)."""
    from google.oauth2 import service_account
    from googleapiclient.discovery import build

    creds = service_account.Credentials.from_service_account_file(
        os.environ["GOOGLE_KEY_FILE"],
        scopes=["https://www.googleapis.com/auth/calendar.readonly"])
    service = build("calendar", "v3", credentials=creds, cache_discovery=False)
    now = datetime.now(timezone.utc)
    res = service.events().list(
        calendarId=os.environ["CALENDAR_ID"], timeMin=now.isoformat(),
        timeMax=(now + timedelta(hours=18)).isoformat(),
        singleEvents=True, orderBy="startTime").execute()
    return [e for e in res.get("items", []) if "dateTime" in e.get("start", {})]


def schedule_bot(event, url):
    start = datetime.fromisoformat(event["start"]["dateTime"])
    bot = recall("POST", "/api/v1/bot/", json={
        "meeting_url": url,
        "bot_name": BOT_NAME,
        "join_at": (start - timedelta(minutes=1)).astimezone(timezone.utc).isoformat(),
        "recording_config": {"transcript": {"provider": {"recallai_streaming": {
            "mode": "prioritize_accuracy", "language_code": "he"}}}},
    })
    return bot["id"]


def plan_day(send_list):
    """morning: schedule and send the list. sync: schedule only what was added since."""
    state = load_state()
    today = datetime.now().strftime("%Y-%m-%d")
    lines = []
    for ev in todays_events():
        url = extract_meeting_url(ev)
        start = datetime.fromisoformat(ev["start"]["dateTime"])
        if not url or start <= datetime.now(timezone.utc):
            continue
        title = ev.get("summary", "")
        if should_skip(ev):
            lines.append(f"- {start:%H:%M} {title}: {T['tagged']}")
            continue
        if ev["id"] not in state["events"]:
            n = 1 + sum(1 for b in state["bots"].values() if b["day"] == today)
            bot_id = schedule_bot(ev, url)
            state["events"][ev["id"]] = bot_id
            state["bots"][bot_id] = {"n": n, "day": today, "title": title,
                                     "start": ev["start"]["dateTime"], "status": "scheduled"}
        b = state["bots"][state["events"][ev["id"]]]
        lines.append(f"{b['n']}. {start:%H:%M} {title}")
    save_state(state)
    if send_list:
        tg("\n".join([T["today"], ""] + lines + ["", T["cancel_hint"]]) if lines else T["none"])

Common mistake: running the morning job only once. A meeting booked at noon won't get a bot. That's why there are two commands: morning sends the list, and sync runs quietly every hour and schedules only what was added. A meeting booked less than ten minutes before it starts may be left without a bot.

Step 4 (20 minutes): After the meeting, transcript and deletion

What you do: Every five minutes the server asks Recall.ai for the state of each open bot. When the meeting has ended and the transcript is ready, it downloads the transcript with speaker names and deletes the recording at Recall.ai.

How: Paste part 3. What happens in it:

  1. GET /api/v1/bot/{id}/ returns the bot's list of status changes. The last status done means the meeting ended and the recording was processed. fatal means the bot failed (for example, nobody let it in from the waiting room), and you get a message.
  2. The transcript arrives as JSON: for each segment, the speaker's name and the words with times. The code turns it into lines in the format [mm:ss] Name: text and keeps a copy in a transcripts folder on your side.
  3. POST /api/v1/bot/{id}/delete_media/ deletes the recording and transcript at Recall.ai. After that there's nothing to pay for storage, and no copy of the meeting at a third party.

Why this way: there's no webhook here, so there's no public address to protect. The server goes out; nobody comes in. The price is up to five minutes of waiting for the summary.

meeting_bot.py, part 3: transcript, summary and deletion
# meeting_bot.py, part 3 of 4: after the meeting, transcript, summary, delete
def bot_status(bot):
    changes = bot.get("status_changes") or []
    return changes[-1]["code"] if changes else "unknown"


def fetch_transcript(bot):
    for rec in bot.get("recordings") or []:
        tr = (rec.get("media_shortcuts") or {}).get("transcript") or {}
        if (tr.get("status") or {}).get("code") == "done":
            return requests.get(tr["data"]["download_url"], timeout=120).json()
    return None


def transcript_to_text(transcript):
    """One line per speaker turn: [mm:ss] Name: text"""
    lines = []
    for part in transcript:
        words = part.get("words") or []
        if not words:
            continue
        name = (part.get("participant") or {}).get("name") or "?"
        sec = int((words[0].get("start_timestamp") or {}).get("relative") or 0)
        lines.append(f"[{sec // 60:02d}:{sec % 60:02d}] {name}: " + " ".join(w["text"] for w in words))
    return "\n".join(lines)


def parse_json(text):
    s = text.strip()
    a, b = s.find("{"), s.rfind("}")
    if a < 0 or b <= a:
        raise ValueError("no JSON object in the answer")
    return json.loads(s[a:b + 1])


def _date_or_empty(v):
    v = str(v or "").strip()
    try:
        datetime.strptime(v, "%Y-%m-%d")
        return v
    except ValueError:
        return ""


def _strings(v):
    return [str(x).strip() for x in v if str(x).strip()] if isinstance(v, list) else []


def validate_summary(data):
    """Keeps only what the format allows. No date that is not a real yyyy-mm-dd."""
    if not isinstance(data, dict):
        raise ValueError("the answer is not a JSON object")
    out = {"summary": _strings(data.get("summary")), "decisions": _strings(data.get("decisions")),
           "open_questions": _strings(data.get("open_questions")),
           "tasks": {"mine": [], "theirs": []}, "follow_ups": []}
    tasks = data.get("tasks") if isinstance(data.get("tasks"), dict) else {}
    for side in ("mine", "theirs"):
        for t in tasks.get(side) or []:
            if isinstance(t, dict) and str(t.get("task") or "").strip():
                out["tasks"][side].append({
                    "task": str(t["task"]).strip(), "owner": str(t.get("owner") or "").strip(),
                    "due": _date_or_empty(t.get("due")), "quote": str(t.get("quote") or "").strip()})
    for f in data.get("follow_ups") or []:
        if isinstance(f, dict) and f.get("kind") in ("email", "message", "meeting") and str(f.get("draft") or "").strip():
            out["follow_ups"].append({k: str(f.get(k) or "").strip() for k in ("kind", "to", "subject", "draft", "why")})
    if not out["summary"]:
        raise ValueError("empty summary")
    return out


def summarize(transcript_text, title, date):
    import anthropic

    prompt = Path("summary_prompt.txt").read_text(encoding="utf-8")
    for key, value in (("{{TITLE}}", title), ("{{DATE}}", date),
                       ("{{ME}}", os.environ.get("MY_NAME", "")), ("{{TRANSCRIPT}}", transcript_text)):
        prompt = prompt.replace(key, value)
    client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY
    msg = client.beta.messages.create(
        model=os.environ.get("LLM_MODEL", "claude-opus-5-5"), max_tokens=16000,
        betas=["server-side-fallback-2026-07-01"], fallbacks="default",
        messages=[{"role": "user", "content": prompt}])
    if msg.stop_reason == "refusal":
        raise RuntimeError("the model declined this transcript")
    return validate_summary(parse_json("".join(b.text for b in msg.content if b.type == "text")))


def finish(state, title, date, text):
    TRANSCRIPTS.mkdir(exist_ok=True)
    (TRANSCRIPTS / f"{date} {title[:40]}.txt".replace("/", "-")).write_text(text, encoding="utf-8")
    send_summary(state, title, summarize(text, title, date))


def collect(state):
    for bot_id, b in state["bots"].items():
        if b["status"] != "scheduled":
            continue
        bot = recall("GET", f"/api/v1/bot/{bot_id}/")
        if bot_status(bot) == "fatal":
            b["status"] = "failed"
            tg(T["failed"].format(title=b["title"]))
            continue
        if bot_status(bot) != "done":
            continue
        transcript = fetch_transcript(bot)
        if transcript is None:
            continue  # still processing, try on the next run
        finish(state, b["title"], b["start"][:10], transcript_to_text(transcript))
        recall("POST", f"/api/v1/bot/{bot_id}/delete_media/")
        b["status"] = "done"
        save_state(state)

Step 5 (30 minutes): A summary in fixed JSON, and a check on it

What you do: Save this prompt in a file called summary_prompt.txt next to the code. The code puts the transcript in place of {{TRANSCRIPT}}, sends it to the model, and checks that the answer has the right format before it reaches you.

2. Prompt: from transcript to summary, tasks and follow-ups (summary_prompt.txt)
You are an accurate meeting secretary. Read the transcript and return a single JSON object only, with no text before or after it.

Meeting title: {{TITLE}}
Meeting date: {{DATE}}
Me (the person the bot works for): {{ME}}

The transcript (one line per speaker, with a time):
{{TRANSCRIPT}}

Binding rules:
1. summary: up to 6 short points. What was decided and what changed, not who said what.
2. decisions: only things that were explicitly agreed. A suggestion nobody accepted is not a decision.
3. tasks.mine: tasks I committed to or was asked to do. tasks.theirs: other people's tasks, with owner as the name used in the meeting.
4. due: only a date that was said or can be worked out for certain from the meeting date ("by Thursday"), as yyyy-mm-dd. Otherwise "".
5. quote: the sentence from the transcript where the task was agreed, word for word.
6. follow_ups: up to 4 follow-up actions I'll probably want: an email (email), a message (message) or a follow-up meeting (meeting). For each: to, subject, draft (the full text, ready to send, in the language of the meeting) and why (one sentence: why, pointing to what was said).
7. Don't invent names, amounts, dates or promises that weren't said. Anything unclear goes to open_questions.
8. Don't include account numbers, ID numbers, passwords or medical details, even if they were said.

The structure:
{"meeting":{"title":"","date":""},"summary":[],"decisions":[],"tasks":{"mine":[{"task":"","owner":"","due":"","quote":""}],"theirs":[{"task":"","owner":"","due":"","quote":""}]},"follow_ups":[{"kind":"email","to":"","subject":"","draft":"","why":""}],"open_questions":[]}

How the check works: validate_summary in part 3 doesn't trust the model. It throws out a task with no content, removes a date that isn't a real date (February 30, say), drops a follow-up of an unknown kind or with no draft, and fails if there's no summary at all. When the check fails, the recording isn't deleted and the server tries again on the next run, so no meeting is lost because of a malformed answer.

About the language model: the code uses Claude through Anthropic's API, with an automatic fallback to another model if the main model declines the transcript. You can replace the summarize function with any other provider: the prompt and the check stay the same.

Common mistake: asking "summarize the meeting" with no fixed structure. You get nice text you can't process: you can't count tasks, approve a follow-up by number, or see that a due date is missing.

Step 6 (20 minutes): Telegram, and follow-ups that wait for approval

What you do: Paste part 4, hook the commands up to cron, and test with a trial meeting.

How:

  1. In BotFather, create a bot, send it one message, and find your chat ID (for example through getUpdates). Put both in .env.
  2. The bot takes commands only from your chat. A message from any other chat is ignored.
  3. Each proposed follow-up gets a number. "approve 3" marks it approved and runs run_follow_up. "drop 3" removes it.
  4. As the code is written, approving sends you the final text, and you paste it and send it. Once you trust the process, you can connect run_follow_up to your email or calendar. Even then, only what you approved goes out.
Part 4: Telegram, approvals and the commands
# meeting_bot.py, part 4 of 4: Telegram, approvals and the commands
TG = "https://api.telegram.org/bot" + os.environ.get("TELEGRAM_TOKEN", "")
OWNER = str(os.environ.get("TELEGRAM_CHAT_ID", ""))
COMMAND = re.compile(r"^\s*(בלי בוט|no bot|אשר|approve|בטל|drop)\s+(\d+)\s*$", re.IGNORECASE)


def tg(text):
    for i in range(0, len(text), 4000):  # Telegram's limit is 4096 characters
        requests.post(TG + "/sendMessage", json={"chat_id": OWNER, "text": text[i:i + 4000]},
                      timeout=30).raise_for_status()


def format_summary(title, data, first_item):
    out = [title, "", T["summary"]] + [f"- {s}" for s in data["summary"]]
    if data["decisions"]:
        out += ["", T["decisions"]] + [f"- {s}" for s in data["decisions"]]
    for side in ("mine", "theirs"):
        if data["tasks"][side]:
            out += ["", T[side]]
            for t in data["tasks"][side]:
                who = f"{t['owner']}: " if side == "theirs" else ""
                out.append(f"- {who}{t['task']} ({t['due'] or T['no_due']})")
    if data["follow_ups"]:
        out += ["", T["follow_ups"]]
        for i, f in enumerate(data["follow_ups"]):
            out.append(f"{first_item + i}. {f['kind']} → {f['to']}: {f['subject']} ({f['why']})")
        out += ["", T["approve_hint"]]
    if data["open_questions"]:
        out += ["", T["questions"]] + [f"- {s}" for s in data["open_questions"]]
    return "\n".join(out)


def send_summary(state, title, data):
    first = state["next_item"]
    for f in data["follow_ups"]:
        state["items"][str(state["next_item"])] = {**f, "status": "waiting"}
        state["next_item"] += 1
    tg(format_summary(title, data, first))


def run_follow_up(item):
    """Runs only after "approve N". Connect your mail or calendar here.
    As written, it sends you the final text so you can paste it and send it yourself."""
    tg(f"{item['kind']} → {item['to']}\n{item['subject']}\n\n{item['draft']}")


def parse_command(text):
    m = COMMAND.match(text or "")
    if not m:
        return None
    word = m.group(1).lower()
    kind = {"בלי בוט": "cancel", "no bot": "cancel", "אשר": "approve", "approve": "approve"}.get(word, "drop")
    return kind, int(m.group(2))


def handle_telegram(state):
    res = requests.get(TG + "/getUpdates", params={"offset": state["tg_offset"], "timeout": 0}, timeout=30).json()
    today = datetime.now().strftime("%Y-%m-%d")
    for upd in res.get("result", []):
        state["tg_offset"] = upd["update_id"] + 1
        msg = upd.get("message") or {}
        if str((msg.get("chat") or {}).get("id")) != OWNER:
            continue  # only you can give commands
        cmd = parse_command(msg.get("text"))
        if not cmd:
            continue
        kind, n = cmd
        if kind == "cancel":
            for bot_id, b in state["bots"].items():
                if b["day"] == today and b["n"] == n and b["status"] == "scheduled":
                    recall("DELETE", f"/api/v1/bot/{bot_id}/")
                    b["status"] = "cancelled"
                    tg(T["cancelled"].format(n=n))
            continue
        item = state["items"].get(str(n))
        if not item or item["status"] != "waiting":
            continue
        item["status"] = "approved" if kind == "approve" else "dropped"
        if kind == "approve":
            tg(T["approved"].format(n=n))
            run_follow_up(item)
        else:
            tg(T["dropped"].format(n=n))
    save_state(state)


def main(cmd, *args):
    if cmd in ("morning", "sync"):
        plan_day(send_list=(cmd == "morning"))
    elif cmd == "poll":
        state = load_state()
        handle_telegram(state)
        collect(state)
    elif cmd == "file":  # a phone or in-person recording, transcribed on your computer
        path, title = Path(args[0]), args[1]
        state = load_state()
        finish(state, title, datetime.now().strftime("%Y-%m-%d"), path.read_text(encoding="utf-8"))
        save_state(state)


if __name__ == "__main__":
    main(*sys.argv[1:])
cron: what runs and when (crontab -e, in the server's time zone)
30 7 * * 0-5  cd ~/meeting-bot && set -a && . ./.env && python3 meeting_bot.py morning
5 8-20 * * 0-5  cd ~/meeting-bot && set -a && . ./.env && python3 meeting_bot.py sync
*/5 7-22 * * 0-5  cd ~/meeting-bot && set -a && . ./.env && python3 meeting_bot.py poll

Why this way: the follow-ups arrive with the full text and a "why" line. You approve text you've seen, not a general intention. One approval doesn't approve the rest.

Step 7 (15 minutes): Phone calls and in-person meetings

What you do: A phone call or a meeting in a room has no link, so no bot. You record on your phone and send the same recording through the same process.

How:

  1. A phone call: Samsung phones and some other Android phones have built-in call recording in the Phone app's settings. On an iPhone, check whether the option is available where you are; if not, use the speaker and record on another device.
  2. An in-person meeting: your phone's recorder app, on the table, in the middle.
  3. Share the file to your computer and transcribe it for free with stage 1 of the series. The transcript never leaves the computer.
  4. Copy the text file to the server and run:
Copy-ready
python3 meeting_bot.py file "transcripts/2026-11-12 call.txt" "Call with the print supplier"

From here everything is the same: summary, tasks and follow-ups on Telegram, waiting for approval.

Step 8 (10 minutes): Privacy and etiquette

  • The bot is visible. It joins with a name, and every participant sees it in the list. That's not enough: at the start of the meeting say "We have an automatic notetaker with us, so the summary and tasks are accurate. Does anyone mind?". If someone minds, remove the bot from the meeting.
  • In the invite itself you can add a line: "An automatic notetaker will join this meeting. If you'd prefer it didn't, let me know."
  • Sensitive meetings get #no-bot: job interviews, calls with a lawyer, medical or personal topics, and meetings where the other side asked not to be recorded.
  • The recording is deleted at Recall.ai right after the download, and the transcript is kept only on your side. Decide when you delete transcripts from your folder too, say after 90 days.
  • In Israel, recording a conversation and keeping information about people fall under the Wiretapping Law and the Privacy Protection Law. The safe path is consent from every participant, and checking with a lawyer what applies to you.

What it costs

Prices as of November 2026, from Recall.ai. Prices change, so check their pricing page before you plan a budget.

What Price Notes
Recording at Recall.ai US$0.50 per hour Prorated per second. The first 5 hours are free
Recall.ai built-in transcription US$0.15 per hour Supports Hebrew
Storage at Recall.ai 7 days free, then US$0.05 per recording hour per 30 days Nothing to pay if you delete after the download, as in step 4
Summary with a language model A few cents per meeting through an API Or nothing extra, if you paste it by hand into an existing Claude or ChatGPT subscription
Total About US$0.65 per meeting hour (about ₪2.4) 20 meeting hours a month: about US$13

You can save the transcription. Instead of Recall.ai's transcription, download the audio file and transcribe it with the free ivrit.ai model, as in stage 1. The cost drops to US$0.50 per hour. On a regular computer, transcribing a long meeting takes a while; with an on-demand GPU server (for example RunPod) it's cents per hour.

For comparison: a dedicated hardware notetaker usually costs the price of the device plus a subscription of about US$20 a month. Here there's no device and no fixed subscription: you pay only for the hours recorded.


When something doesn't work

What you see What to do
The morning list is empty Check that the calendar is shared with the Service Account, and that CALENDAR_ID holds your email address, not "primary".
A meeting with a link got no bot Run prompt 1 on the event. If the link is in a format the code doesn't know, add it to the MEETING_LINK pattern.
The bot stays in the waiting room Someone has to let it in. In your own meetings you can admit outside participants automatically, or add the bot to the invite list.
401 from Recall.ai The key is wrong, or RECALL_BASE points to a different region from the one you signed up in.
No summary after a meeting Check the poll log. If validate_summary failed, the next run tries again, and the recording hasn't been deleted yet.
"approve 3" does nothing The follow-up was already approved or dropped, or the message came from another chat.

Full example: a working day at Galim Design Studio (a fictional business)

Everything in the example is fictional: the studio, the clients, the people and the amounts.

Roni runs a small interior design studio. On Tuesday, November 3, the calendar has three meetings: an intro call on Meet with a new client, a team meeting on Zoom, and a call with the accountant on Teams tagged #no-bot.

07:30, on Telegram:
Today's meetings

1. 09:00 Intro: apartment in Givatayim
2. 11:30 Weekly team meeting
- 15:00 Accountant, quarterly reports: no bot (tagged)

To cancel: no bot <number>

Roni replies "no bot 2", because this week's team meeting is about salaries. The bot for that meeting is cancelled.

09:47, after the intro call:
Intro: apartment in Givatayim

Summary
- The clients want to open the kitchen to the living room and replace the flooring in the whole apartment
- Budget mentioned: "around 250 thousand"
- They want to move in before Passover

Decisions
- Site visit on Sunday at 10:00

My tasks
- Send a price quote for the design (2026-11-05)

Other people's tasks
- Yael: send the structural plan from the previous contractor (no due date)

Proposed follow-ups (nothing goes out without approval)
4. email → Yael: meeting summary and Sunday's site visit (agreed at minute 41)
5. meeting → Yael and Ori: site visit (agreed for Sunday at 10:00)

To approve: approve <number> · to drop: drop <number>

Open questions
- Is the wall between the kitchen and the living room load-bearing? Check the plan

Roni writes "approve 4" and gets the full email text, fixes a word and sends it. She approves 5 after checking that Sunday really is free. 52 minutes of meeting cost about US$0.56, and the recording no longer exists at Recall.ai.

Notice: the AI didn't invent a due date for Yael's task, because none was said, and the question about the wall went to open questions instead of becoming a decision.


Checklist before you switch it on

  • The Recall.ai account is in the EU region, and the key is only in .env with chmod 600
  • The calendar is shared read-only with the Service Account
  • Prompt 1 ran on a week of your calendar, and every link was caught
  • Sensitive meetings are tagged #no-bot
  • You checked in a trial meeting that the bot joins, the transcript is right, and the recording was deleted at Recall.ai
  • The Telegram bot takes commands only from your chat
  • run_follow_up sends nothing on its own, or you connected it only after a few weeks of manual approvals
  • There's a fixed sentence you say at the start of every meeting, and a line in the invites
  • You've decided when transcripts are deleted from your folder

Disclaimer

This content is general recommendations only, not professional, legal, accounting or tax advice. Every business and organization is different. You are responsible for checking that it fits your situation, and for checking the commands, prompts and code before you use them.

Recording a meeting or a call, and keeping recordings and transcripts, may be subject to privacy law, wiretapping law and other laws, which depend on the situation: who takes part, where, and for what purpose. This content doesn't explain what the law requires. Before you run a bot that records meetings with clients, employees or suppliers, consult a lawyer. In any case, tell the participants you're recording and why, get their consent, and keep transcripts only as long as needed.

Automatic transcription and AI summaries can be wrong or leave things out, especially names, numbers and dates. Check every follow-up before you approve it. Recall.ai, Google Calendar, Telegram, the language model providers and every other tool here are third-party services. Their prices, terms and APIs may change, and using them is subject to their terms. Check them at the source.

The content, prompts, templates and code are provided as is, with no warranty that they fit a particular purpose or are free of errors. Gindi Innovation and Aviad Gindi are not liable for any damage, direct or indirect, arising from using them or relying on them, including output from AI tools. Use is subject to the site terms.


What's next

This kit completes the series: from a recording you transcribe on your own computer, through a transcript that turns into tasks, to a bot that does all of it on its own. The approved follow-ups can be connected to the systems you already use: email, calendar, a CRM or a task board, so that approving on Telegram is the only step left to you.

Back to the start of the series: Stage 1, Free transcription on your computer

What's next

Related playbooks

A 30-minute call on one process

Bring one process that eats your time. By the end of the call you'll know if it's worth building for, roughly what it would cost and how we'd measure it.

Book a call

Prefer to write? Message me on WhatsApp