Agent Playbook, build tutorial
A walkthrough of how to build a meeting-notes agent that captures the decisions and action items from your calls and pushes them to your tools: it summarizes the transcript, pulls out who owes what by when, and drafts the notes, while a person approves before they go to attendees. Every code step is shown in three frameworks, Google ADK, the Claude SDK, and LangGraph, so you can build it on the stack your team already knows. The code here is the agent's core logic; standing it up in production also means adding the consent, integrations, and hardening around it.
A lot gets decided in meetings, and a lot of it evaporates. Someone half types notes while half listening, the write-up is late or never sent, and by the next morning two people remember the same call differently. The action items, the small commitments that move work forward, are the first to go, because nobody opens a task tool the moment a call ends.
A meeting-notes agent does the write-up. It summarizes the transcript, lifts the decisions and action items into your tools with the right owner, and drafts the notes, so your team reviews and approves a record instead of reconstructing one from memory. The things it never does: record without consent, invent an action item nobody raised, or send the notes before a person signs off. For the calendar side of the work, this pairs with the scheduling and email-inbox playbooks.
You do not build the agent loop from scratch. A framework gives you the moving parts, and you supply the logic: a step is a model plus an instruction plus a typed schema or a few tools. The code tabs on the build steps show the same summarize, extract, and match flow three ways.
Google ADK composes the steps with a SequentialAgent and a Runner, the Claude SDK drives a tool-use loop, and LangGraph builds an explicit graph. The transcript intake, the owner matching, and the approval guardrail are deterministic and identical in all three; only the orchestration and the model calls differ.
Every call flows through one loop: a consented recording is transcribed and summarized, its decisions and action items extracted, then owners matched. Tasks and calendar items go to your tools, the notes are drafted and queued, and only on approval are they distributed and stored. Guardrails for consent, accuracy flagging, and audit wrap the whole thing.
Consented recordings from your meeting platform (Zoom, Google Meet, or Teams), an ASR service for transcription, and your people directory for owner matching.
A model API key (Gemini, Claude, or both), an automatic speech recognition API (Whisper, AssemblyAI, Deepgram), and the task manager and calendar you want items pushed to.
A recording-consent policy, which meeting types are in scope, who reviews notes before they are sent, DPDP Act 2023 handling, and a retention policy for recordings and notes.
Install the packages and set your Gemini key. That is enough to run the runnable file below, which summarizes, extracts, and drafts notes for a sample transcript locally. For production you also connect, with the right credentials, your meeting platform and ASR service, your people directory, and your task and calendar tools.
# Python 3.10 or newer
python -m venv venv && source venv/bin/activate
# The runnable path below uses Google ADK + Gemini:
pip install google-adk google-genai pydantic
# Your Gemini key from aistudio.google.com/apikey:
export GEMINI_API_KEY="your-key-here" | Layer | Pick | Why |
|---|---|---|
| Transcript source | A Zoom, Google Meet, or Microsoft Teams recording, plus an ASR API (Whisper, AssemblyAI, or Deepgram) | ASR is automatic speech recognition: it turns the call audio into text with speaker labels and timestamps. Recording and transcribing both need the participants' consent. The same transcript store works across platforms. |
| Summarizer | A model (Gemini 2.5 Flash, Claude) over the transcript | Condense a long call into a short, readable summary that a person can skim. The code tabs show this three ways. |
| Decision and action extractor | A model with a typed schema (structured output) | Pull the decisions and the action items, who, what, when, out of the transcript into structured items your task and calendar tools can use. The agent only extracts what is actually said. |
| Owner matcher | Deterministic: map spoken names to your people directory | Turn a first name in the transcript into a real person in your directory, so a task lands on the right owner instead of a guessed one. |
| Task and calendar push | Deterministic: your task and calendar APIs | Create the tasks and calendar items from the extracted, owner-matched data, each linked back to the source recording. |
| Notes distributor | Deterministic, on approval: email or chat to attendees | Send the notes to the people on the call, but only after a person has reviewed and approved them. The agent never distributes on its own. |
The summarize and extract steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The transcript intake, owner matching, and distribution steps are deterministic Python and identical everywhere. These are the building blocks, and they call your own systems (the ASR service, the people directory, the task tool) by name; the complete file you can run today is in "Put it together" below.
Start with the transcript, and start with consent. Recording a call and transcribing it are processing of personal data, so the agent runs only on meetings where the participants have agreed to be recorded, and the platform's own recording notice is on. The agent reads the recording through the meeting platform, or sends the audio to an ASR (automatic speech recognition) API such as Whisper, AssemblyAI, or Deepgram, and keys each transcript on the meeting id so a re-run never processes the same call twice. Deterministic, and the same in every framework.
def get_transcript(meeting, asr, seen: set) -> dict | None:
if not meeting["consent_to_record"]: # no consent, no processing
return None
if meeting["id"] in seen: # already transcribed
return None
seen.add(meeting["id"])
text = asr.transcribe(meeting["recording_url"], # Whisper / AssemblyAI / Deepgram
diarize=True) # speaker labels + timestamps
return {"id": meeting["id"], "title": meeting["title"],
"attendees": meeting["attendees"], "text": text}
# Meeting id makes it idempotent: each call is handled once, even on a re-run. A raw transcript is long and nobody re-reads it, so the agent writes a short summary a person can skim in under a minute: what the call was about and what came out of it. This is a plain summary of what was said, not an interpretation, and it is shown for review, never sent on its own. The tabs show the same summary in Gemini, Claude, and LangChain.
from google import genai
client = genai.Client()
def summarize(transcript: str) -> str:
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Summarize this meeting transcript in 4 to 6 sentences. "
f"Use only what is said; do not infer.\n{transcript}"]).text from anthropic import Anthropic
client = Anthropic()
def summarize(transcript: str) -> str:
res = client.messages.create(model="claude-opus-4-8", max_tokens=600,
messages=[{"role": "user", "content":
f"Summarize this meeting transcript in 4 to 6 sentences. "
f"Use only what is said; do not infer.\n{transcript}"}])
return res.content[0].text from langchain_google_genai import ChatGoogleGenerativeAI
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash") # or ChatAnthropic(...)
def summarize(transcript: str) -> str:
return llm.invoke(
f"Summarize this meeting transcript in 4 to 6 sentences. "
f"Use only what is said; do not infer.\n{transcript}").content The value of a call is the decisions and the action items, so the agent pulls them out into structured items: what was decided, and for each task who owns it, what it is, and when it is due. Because the schema is typed, the output is data your task and calendar tools can consume, not prose. The agent extracts only items actually present in the transcript, and leaves owner or due blank rather than inventing one. The tabs show structured extraction three ways.
from google import genai
from google.genai import types
from pydantic import BaseModel
client = genai.Client()
class Action(BaseModel):
what: str; owner: str | None = None; due: str | None = None
class Notes(BaseModel):
decisions: list[str]; actions: list[Action]
def extract(transcript: str) -> Notes:
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=["Extract decisions and action items. Use only what is "
"stated. Leave owner or due null if not said.\n" + transcript],
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Notes))
return res.parsed # a typed Notes object from anthropic import Anthropic
client = Anthropic()
SCHEMA = {"type": "object", "properties": {
"decisions": {"type": "array", "items": {"type": "string"}},
"actions": {"type": "array", "items": {"type": "object", "properties": {
"what": {"type": "string"}, "owner": {"type": "string"},
"due": {"type": "string"}}, "required": ["what"]}}},
"required": ["decisions", "actions"]}
def extract(transcript: str) -> dict:
res = client.messages.create(model="claude-opus-4-8", max_tokens=1024,
tools=[{"name": "save_notes", "input_schema": SCHEMA}],
tool_choice={"type": "tool", "name": "save_notes"},
messages=[{"role": "user", "content":
"Extract decisions and action items. Use only what is stated; "
"leave owner or due out if not said.\n" + transcript}])
return res.content[0].input from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
class Action(BaseModel):
what: str; owner: str | None = None; due: str | None = None
class Notes(BaseModel):
decisions: list[str]; actions: list[Action]
extractor = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Notes)
def extract(transcript: str) -> Notes:
return extractor.invoke(
"Extract decisions and action items. Use only what is stated; "
"leave owner or due null if not said.\n" + transcript) A transcript says "Priya will send the deck", but a task tool needs a real person. The agent maps each spoken name to your people directory, so the action lands on the right owner. A confident match keys the task to that person; an ambiguous name, two Priyas, or a name not in the directory goes to a human to assign rather than being guessed. Deterministic, and identical everywhere.
def match_owner(name: str | None, directory: list[dict]) -> dict | None:
if not name:
return None # no name was said
hits = [p for p in directory
if name.lower() in p["name"].lower()]
if len(hits) == 1:
return hits[0] # one clear match
return None # 0 or many -> a person assigns
# A simple, auditable lookup; an unresolved owner is left for review, not guessed. The action items the agent pulled out should land where work happens. Tasks go to your task tool, any follow-up meeting to your calendar, each linked back to the recording it came from so nothing is orphaned. Items with no confident owner or due date are created unassigned for a person to complete. This is straight integration code against your own systems, deterministic and the same in every framework.
def create_items(meeting: dict, notes, directory: list[dict]) -> None:
for a in notes.actions:
owner = match_owner(a.owner, directory)
tasks_api.create(title=a.what, due=a.due,
assignee=owner["id"] if owner else None,
source=meeting["id"]) # linked to the recording
audit.log("items_created", meeting["id"], notes.actions)
# Tasks land in the tools you already use, traceable to the source call. This is the guardrail that makes the whole thing trustworthy. The agent drafts the notes, the summary, the decisions, and the action items with owners, but it does not send them. They go into an approval queue where a person reads the draft against the call, fixes anything wrong, and approves. Only on approval does the agent distribute the notes to the attendees. Nothing leaves your domain without a human behind it. Deterministic, and the same in every framework.
def queue_notes(meeting: dict, summary: str, notes) -> None:
approval_queue.add(meeting=meeting["id"], summary=summary,
decisions=notes.decisions, actions=notes.actions)
def on_approval(meeting: dict, final_notes: dict) -> None:
distributor.send(to=meeting["attendees"], body=final_notes) # email / chat
audit.log("notes_sent", meeting["id"], final_notes) # only after approval
# The agent drafts and queues; distribution happens only when a person approves. Notes are only useful if you can find them again and trace them back. The agent stores the approved summary, decisions, and action items keyed to the meeting, with a link to the source recording and transcript, so anyone can later search past calls or confirm what was actually said. Tasks already carry the same link from the create step. Deterministic, and the same in every framework.
def store_notes(meeting: dict, summary: str, notes) -> None:
notes_store.put(meeting_id=meeting["id"], title=meeting["title"],
summary=summary, decisions=notes.decisions,
actions=notes.actions,
recording=meeting["recording_url"], # trace to the source
transcript_ref=meeting["id"])
search_index.add(meeting["id"], summary) # findable across past calls
# Every note links back to the recording it came from, and is searchable later. The pieces run on each new recording. ADK composes summarize, extract, and match under a SequentialAgent and a Runner, the Claude SDK runs them in a tool-use loop, and LangGraph builds an explicit summarize-extract-match graph. Whichever you pick, the same guardrails apply: the agent runs only on meetings with recording consent, transcripts are imperfect so it flags low-confidence lines for a person, it extracts only what is actually said and never invents an action item, the notes are distributed only after a human approves, personal data is handled in line with the DPDP Act 2023, whose core obligations phase in through 2027, and every step is written to an immutable audit log under a retention policy.
from google.adk.agents import SequentialAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
notes_pipeline = SequentialAgent(name="notes_pipeline",
sub_agents=[summarizer, extractor, matcher]) # each an LlmAgent with tools
runner = Runner(agent=notes_pipeline, app_name="notes",
session_service=InMemorySessionService())
def guard(meeting: dict) -> str | None:
if not meeting["consent_to_record"]: return "skip" # consent first
if store.seen(meeting["id"]): return "already_done" # idempotent
return None
# Run on each new recording. Notes are distributed only after a person approves. from anthropic import Anthropic
client = Anthropic()
TOOLS = [summarize_tool, extract_tool, match_tool, queue_tool] # JSON schemas
def run_notes(messages: list):
while True:
resp = client.messages.create(model="claude-opus-4-8", max_tokens=2048,
tools=TOOLS, messages=messages)
if resp.stop_reason != "tool_use":
return resp
messages.append({"role": "assistant", "content": resp.content})
for b in resp.content:
if b.type == "tool_use":
out = dispatch(b.name, b.input) # no send tool: drafts only
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": b.id, "content": str(out)}]}) from langgraph.graph import StateGraph, START, END
g = StateGraph(dict)
g.add_node("summarize", lambda s: {"summary": summarize(s["text"])})
g.add_node("extract", lambda s: {"notes": extract(s["text"])})
g.add_node("match", lambda s: {"owners": match_all(s["notes"], s["dir"])})
g.add_edge(START, "summarize")
g.add_edge("summarize", "extract")
g.add_edge("extract", "match")
g.add_edge("match", END)
notes_pipeline = g.compile() # invoke per recording; notes queue for approval
Here is a runnable file. It summarizes a sample transcript, extracts its
decisions and action items, and drafts notes, and it does not distribute
anything or invent items not in the transcript. The sample is inline text;
production reads a consented recording via an ASR service, as in the build
steps. Save it as main.py and run python main.py.
# main.py -- run: python main.py
# A runnable reference: summarizes a sample meeting transcript, extracts its
# decisions and action items, and drafts notes. It does NOT distribute anything
# (notes always need approval) and does NOT invent items not in the transcript.
# The sample is inline text; production reads a consented recording via an ASR
# service, as in the build steps.
import asyncio
from google import genai
from google.genai import types
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
client = genai.Client() # reads GEMINI_API_KEY
# ---- a sample transcript (replace with a real, consented recording) ----
SAMPLE_TRANSCRIPT = """[00:01] Anil: Quick sync on the Acme rollout.
[00:14] Priya: We agreed to ship the pilot to Acme on Friday 4 July.
[00:32] Anil: Priya, can you send Acme the updated rate card before then?
[00:40] Priya: Yes, I'll send it Wednesday.
[00:55] Anil: Let's review feedback in our next call on Monday 7 July."""
# -------------------------------------------------------------------------
def summarize(transcript: str) -> str:
"""Summarize the meeting using only what is said."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["Summarize this meeting transcript in 3 to 5 sentences. "
"Use only what is said; do not infer.\n" + transcript]).text
def extract_notes(transcript: str) -> str:
"""Pull decisions and action items as JSON. Do not invent items."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["From this transcript, extract JSON with keys decisions "
"(list of strings) and actions (list of {what, owner, due}). "
"Use ONLY items stated in the transcript; leave owner or due "
"out if not said. Do not invent action items.\n" + transcript],
config=types.GenerateContentConfig(response_mime_type="application/json")).text
def draft_notes(summary: str, items: str) -> str:
"""Draft notes for review. For approval only; not distributed."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["Draft short meeting notes from this summary and these "
"extracted items. They are for review and approval, not to be "
"sent automatically.\nSummary:\n" + summary
+ "\nItems:\n" + items]).text
agent = LlmAgent(
name="notes_agent", model="gemini-2.5-flash",
instruction=("Call summarize on the transcript, then extract_notes, then "
"draft_notes using both. Report the summary, the decisions and "
"action items, and the drafted notes. Make clear the notes are "
"queued for approval and not distributed, and that only items "
"in the transcript were used."),
tools=[summarize, extract_notes, draft_notes])
async def main():
runner = InMemoryRunner(agent=agent, app_name="notes")
session = await runner.session_service.create_session(app_name="notes", user_id="demo")
msg = types.Content(role="user",
parts=[types.Part(text=f"Process this transcript:\n{SAMPLE_TRANSCRIPT}")])
async for event in runner.run_async(user_id="demo", session_id=session.id, new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
if __name__ == "__main__":
asyncio.run(main()) The agent reports a short summary, the decisions (the pilot ships Friday 4 July), the action items it found (Priya sends the rate card Wednesday, the Monday 7 July review call), and a set of drafted notes queued for approval. The exact wording is model-generated, so it varies run to run; the notes are never distributed automatically, and only items present in the transcript are used.
A person sees one screen: recent calls, the draft notes with decisions and owner-matched action items, low-confidence lines flagged, and a single approve action before anything is distributed.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You want your own summary style, your people directory and task wiring, and the transcripts in-house | High | Build cost + API and model usage | Full |
| Notetaker SaaS (Otter, Fireflies, Fathom) | Fast start with recording, transcription, and notes in one tool, and you accept their format | Low | Subscription per seat | Medium |
| No-code (n8n or Make with an ASR node) | A simple pilot on one platform, low volume, a fixed template | Low | Cheap, but brittle as the logic grows | Low |
Consent comes first. Recording a call and transcribing it are processing of personal data, so the agent runs only on meetings where the participants have agreed to be recorded and the platform's recording notice is on. No consent, no transcript, and nothing downstream runs.
Accuracy is handled honestly. Transcripts are imperfect, so the agent flags the lines it is unsure about, names, numbers, dates, and a person reviews and corrects the notes before they are distributed. The agent extracts only what is actually said and never invents an action item, so the record does not claim a commitment nobody made.
Recordings, transcripts, and notes are sensitive, so personal data is handled in line with the DPDP Act 2023, whose core obligations phase in through 2027, and you can run the pipeline in your own cloud with access scoped to what it needs. Every step is written to an immutable audit log, and a retention policy controls how long recordings, transcripts, and notes are kept before deletion.
These are directional, not promises, and depend on how many meetings you run and how much of the work lives in the action items. The point is the shape of the change: less time writing up calls, more action items actually done, notes that reliably go out, fewer errors in the record.
| Metric | Manual | Agent |
|---|---|---|
| Time writing up calls | Someone types notes during or after every meeting, and the write-up eats the gap before the next call | Summary and action items drafted from the transcript, so the team reviews and approves instead of writing from scratch |
| Action items that get done | Decisions made on a call are forgotten by the time anyone opens a task tool | Extracted into your task and calendar tools, each owner-matched and traceable to the recording |
| Notes that actually go out | Write-ups are late, partial, or never sent, so attendees disagree on what was decided | Drafted right after the call and sent on one approval, so everyone has the same record |
| Errors in the record | Hand-written notes drift from what was said and nobody checks | Low-confidence lines are flagged for a person to confirm before the notes are distributed |
Time saved depends on meeting volume and how many action items each call produces. Figures are ranges that vary by team, not guarantees.
Everything you need to know about the service and how it works. Can’t find an answer? Mail us at info@galific.com
Galific designs, builds, and runs meeting-notes agents like this on ADK, the Claude SDK, or LangGraph, wired into your meeting platform, your people directory, and your task and calendar tools, with consent enforced and a human approving every set of notes. Or explore the ready-made versions in our agent suite.