Start here · 45 minutes
A meeting notes summariser
A small command-line tool you point at a text file of a meeting. It gives you back what was decided, what is outstanding, and who owes what. Because it runs on your own model, you can feed it a conversation you would never paste into a website — which is the whole reason to build it rather than use one of the dozen that already exist.
- Python
- openai
- Pip text server
Before you start
- Running in Pip: a text server.
- 8 GB of memory or more. Less than that and the model you need will not load.
- Installed already: Python 3.10+, A terminal.
Worth reading first: Asking a local model well, Context: the model's short memory.
Prove the server is actually there
Before any code, confirm the thing the code will talk to. Start a text server in Pip and leave it running. Then ask it what it is — every OpenAI-compatible server answers this, and the reply gives you the model id you will need in a moment.
Doing this first is not ceremony. When a Python script fails later, this is how you tell a broken script from a stopped server, and that is the difference between two minutes of debugging and an afternoon.
curl http://127.0.0.1:8081/v1/models
How you know it worked
curl -s http://127.0.0.1:8081/v1/models
A JSON object with a "data" array, and an "id" inside it naming the model you loaded. If you get "Connection refused", the server is not running — start it in Pip. If you get nothing at all, check the port: Pip gives the first server 8081 and counts upward from there, so a second server is on 8082.
Make somewhere for it to live
A folder, a virtual environment, and one dependency. The openai package is the client library, not the cloud service — it will talk to anything that speaks the same shape, which is exactly what your local server does.
mkdir meeting-notes && cd meeting-notes
python3 -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
pip install openai
How you know it worked
python -c "import openai; print(openai.__version__)"
A version number, 1.0 or higher. If Python cannot find the package, the virtual environment is probably not active — your prompt should start with (.venv).
One request, and nothing else
The smallest program that proves the whole path works: your code, the client library, the local server, the model, and back. Write this before you write the real thing. If it prints a sentence, everything after this is just shaping the question.
Note what the api_key is. Local servers do not check it, but the client library refuses to start without something in the field, so you give it something honest.
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8081/v1", api_key="not-needed")
# Ask the server what it has rather than hard-coding a name, so this keeps
# working when you swap the model out for a bigger one.
model = client.models.list().data[0].id
print(f"talking to: {model}")
reply = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Say hello in exactly five words."}],
)
print(reply.choices[0].message.content)
How you know it worked
python hello.py
The model id, then a short greeting. It does not matter whether it is really five words — models are bad at counting. It matters that a sentence came back.
Feed it something real
Now the actual job. Two things make this work rather than nearly work: telling the model what shape you want the answer in, and giving it the transcript as a separate turn rather than gluing it onto the end of your instruction.
The system message sets the job once. The user message carries the material. Keeping them apart means you can change the material without retyping the job, and it is what the model was trained to expect.
import sys
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8081/v1", api_key="not-needed")
MODEL = client.models.list().data[0].id
JOB = """You summarise meeting transcripts. Reply with exactly three sections,
using these headings and nothing else:
DECIDED
- what was actually agreed, one line each
OUTSTANDING
- what was raised and left unresolved, one line each
ACTIONS
- name: what they agreed to do, one line each
If a section has nothing in it, write "- nothing". Do not invent an action
that nobody agreed to. Do not add any other commentary."""
def summarise(transcript: str) -> str:
reply = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": JOB},
{"role": "user", "content": transcript},
],
temperature=0.2, # low, because this is extraction, not writing
)
return reply.choices[0].message.content
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("usage: python summarise.py <transcript.txt>")
with open(sys.argv[1], encoding="utf-8") as handle:
print(summarise(handle.read()))
How you know it worked
python summarise.py sample.txt
Three headed sections. Write yourself a short fake transcript first — half a page of people agreeing and disagreeing about something — rather than testing on a real one, so you can see whether it got it right.
What happens when the meeting was an hour long
This is where a local model behaves differently from a cloud one, and where most first projects quietly break. Your model has a context window measured in thousands of tokens, not hundreds of thousands. An hour of talking will not fit, and what happens when it does not fit is worse than an error: some servers silently drop the middle, so you get a confident summary of a conversation that is missing its second half.
The fix is the oldest one in the book. Cut the transcript into pieces that fit, summarise each piece, then summarise the summaries. You lose a little nuance at the seams and you gain the ability to process a transcript of any length at all.
def chunks(text: str, size: int = 6000, overlap: int = 200):
"""Split on characters, not tokens — crude, but it never needs a tokenizer
and the overlap covers a sentence cut in half at a boundary."""
step = size - overlap
for start in range(0, len(text), step):
piece = text[start:start + size]
if piece.strip():
yield piece
def summarise_long(transcript: str) -> str:
pieces = list(chunks(transcript))
if len(pieces) == 1:
return summarise(transcript)
print(f"transcript is long — summarising in {len(pieces)} passes", file=sys.stderr)
partials = [summarise(piece) for piece in pieces]
joined = "\n\n---\n\n".join(partials)
# The second pass is given summaries, not transcript, and told so.
return summarise(
"These are partial summaries of consecutive parts of one meeting. "
"Merge them into a single summary in the same three sections, "
"removing anything repeated.\n\n" + joined
)
How you know it worked
Paste a transcript longer than 6,000 characters and watch it report more than one pass. Compare the merged result against summarising the first half alone — the merged one should mention things the half-summary could not have known about.
Fail like a tool, not like a script
One last thing, and it is the difference between something you wrote and something you would give to somebody else. When Pip is not running, the current code throws a connection error and a page of traceback. Nobody reading that learns what to do about it.
import httpx
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("usage: python summarise.py <transcript.txt>")
try:
with open(sys.argv[1], encoding="utf-8") as handle:
print(summarise_long(handle.read()))
except FileNotFoundError:
sys.exit(f"no such file: {sys.argv[1]}")
except (httpx.ConnectError, httpx.ReadTimeout):
sys.exit(
"Could not reach a model at http://127.0.0.1:8081/v1 — "
"open Pip and start a text server, then try again."
)
How you know it worked
python summarise.py sample.txt
Stop the server in Pip and run it again. You should get one clear sentence telling you what to do, and no traceback. Start the server and confirm it still works.
Building it with an agent
I want a command-line tool that reads a meeting transcript from a text file and prints what was decided, what is outstanding, and who owes what. It has to run entirely against a local OpenAI-compatible server, and it has to cope with transcripts too long for the model's context window.
With Claude Code
Start in an empty folder and give Claude Code the constraint before the feature:
Build a Python CLI that summarises a meeting transcript into
DECIDED / OUTSTANDING / ACTIONS sections. It must talk only to
a local OpenAI-compatible server at http://127.0.0.1:8081/v1
using the openai package — no cloud API. Get the model id from
/v1/models rather than hard-coding it.
Build it in two stages and let me run each one:
1. The smallest script that connects and prints one reply.
2. The real tool, including chunking for transcripts longer
than the context window, and a clear error when the local
server is not running.
The two-stage instruction is doing real work here. Without it you get all of it at once, and when it fails you cannot tell whether the problem is the chunking, the prompt, or the fact that nothing was ever listening on 8081.
With Codex
Give Codex the layout and the constraint together:
Create a Python project: summarise.py, requirements.txt,
README.md, and sample.txt containing a short fake meeting
transcript I can test with.
summarise.py uses the openai package against
base_url="http://127.0.0.1:8081/v1", api_key="not-needed",
and reads the model id from client.models.list(). It takes a
filename argument and prints three sections: DECIDED,
OUTSTANDING, ACTIONS. Use temperature 0.2.
Transcripts longer than 6000 characters are split into
overlapping chunks, summarised separately, and merged in a
second pass. A connection error prints one line telling the
user to start their local server — no traceback.
Asking for sample.txt in the same instruction is worth doing. You need something to test against, and an agent writing its own test material will make it exercise the features it just built.
Read what it gave you
Generated code is a draft. These are the things that go wrong on this particular project, in roughly the order they are worth checking.
- Check the base URL. Search for "api.openai.com" — if it is anywhere in the file, the agent reverted to habit and the tool is not local.
- Check the chunk merge. The second pass must be told it is reading summaries rather than transcript, or it will summarise them as though they were a conversation and invent a meeting that never happened.
- Check temperature. If it is missing or set high, the same transcript will give you different actions each run, which makes the tool useless for anything you need to trust.
- Check the overlap. Chunking with no overlap cuts sentences in half at every boundary and loses whatever was being decided at that moment.
- Run it with the server stopped. If you get a traceback, the error handling was written but never tested.
- Feed it a transcript where nobody agreed anything. It should say "nothing" rather than inventing an action to fill the section — this is the single most common failure in summarising tools and it is worth checking deliberately.
Where to take it next
- Take the transcript from stdin as well as a file, so you can pipe into it from a recording tool.
- Add a --json flag that returns structured output instead of headings, so another script can consume it.
- Have it write the actions straight into whatever you use for tasks. This is the point where a local model stops being a demonstration and starts being infrastructure.
- Compare a small model against a larger one on the same transcript. Summarising is one of the jobs where the difference is smaller than you would expect, which is useful to know before you buy more memory.