Skip to main content
Glama

goae-lookup

How to give an LLM agent access to reference data that is far too large for its context window — a worked example using the German medical fee schedule (GOÄ).

The code is a complete, dependency-free tool (CLI, MCP server, batch pipeline) for looking up GOÄ fee codes. The ideas behind it apply to any large reference dataset: price lists, product catalogs, code systems (ICD-10, customs tariffs), regulations, configuration references.

The problem

Many agent tasks need lookups in a dataset that does not fit into the prompt: "Find the right code for each of these 200 items." The usual approaches break down:

  • Put everything into the prompt. The GOÄ alone is 550 KB of text, about 137,000 tokens. It does not fit into a 32K or 64K window.

  • Generic RAG over text chunks. Each query returns several pages of text (~1,000+ tokens), of which a few lines are relevant. In a multi-step task every result stays in the conversation. After a few dozen lookups the window is full, and well before that the model starts losing track of what it has already found.

A larger context window only postpones this. The fix is to change what the agent gets back.

Related MCP server: Medical Billing MCP

The pattern

  1. Turn the source into structured data once. Parse it into records (here: code, text, points, section, notes, rules) in SQLite. A lookup becomes a query, not "read five pages and pick out the answer".

  2. Answer every question in a few lines. Compact line format instead of JSON, long texts truncated, top 5 results. A search result here is ~90 tokens.

  3. Disclose details progressively. A cheap search first, then get for the one record that matters, then rules/check only if needed.

  4. Compute in code, not in the model. Fees and exclusion rules are evaluated by the tool. The model never does arithmetic or parses legal text.

  5. Keep state in files, not in the conversation. For batch work, run a loop outside the agent: one small, independent LLM call per item, result written to a file immediately.

  6. Keep the tool schema small. Tool definitions are sent with every request. Four tools with terse descriptions cost 245 tokens here instead of 583.

  7. Measure. Define example tasks with known answers and measure tokens and correctness for every design change (see How the design was found).

Results for the GOÄ example

40 example tasks with known answers: find a code from a plain-language description, compute a fee, check whether two codes may be billed together, look up a rule.

Approach

Tokens per search

All 40 tasks in one conversation

Test tasks answered correctly¹

Whole fee schedule in the prompt

~137,000

does not fit

–

Generic RAG, top-5 text chunks

~1,150

~49,000

5/17

Straightforward tool (verbose, JSON, 10 results)

~680

~22,000

13/17

This repository

~90

~3,700

12/17, 17/17 with 6 added synonyms

¹ 17 of the 40 tasks are test tasks: they were not used while the tool was being improved, so they show how well it handles questions it was not tuned for. 12/17 is that result. Only in a final step were 6 synonyms added for the failed ones, which gives 17/17 (see How the design was found).

Token counts are estimates: number of characters divided by four. Results for every single task are in CONTEXT_STATS.md.

Quick start

Requirements: Python 3.10+ with SQLite FTS5 (part of standard CPython builds). No other dependencies.

git clone https://github.com/markusrt/goae-lookup && cd goae-lookup
python -m goae search "Blutabnahme aus der Vene"   # builds data/goae.db on first run (~1 s)
1012|74P|Blutentnahme beim Fetus
250|40P|Blutentnahme mittels Spritze, Kanüle oder Katheter aus der Vene
284|90P|Eigenbluteinspritzung - einschließlich Blutentnahme -
1013|178P|Blutentnahme beim Fetus - einschließlich pH-Messung(en) im Blut -
251|60P|Blutentnahme mittels Spritze oder Kanüle aus der Arterie
python -m goae get 250          # full text, points, fees, own rules
python -m goae check 1 3 250    # may these codes be billed together?
python -m goae rules 5          # general rules that mention a code
python -m goae price 250 --factor 2.0
$ python -m goae get 250
Nr. 250 · C.II Nichtgebietsbezogene Sonderleistungen
Blutentnahme mittels Spritze, Kanüle oder Katheter aus der Vene
40 Punkte · 1,0x 2,33 € · 1,8x 4,20 € (Regel) · 2,5x 5,83 € (max)
(+3 weitere Bestimmungen nennen Nr. 250 → rules)

$ python -m goae check 1 3 250
✗ 3 nicht neben 1, 250: Die Leistung nach Nummer 3 (Dauer mindestens 10 Minuten) ist nur berechnungsfähig als einzige Leistung oder im Zusammenhang mit einer Untersuchung nach Nummer 5, 6, 7, 8, 800 oder 801. … [Nr. 3]

Optionally pip install -e . provides a goae command.

Using it from an agent

The same operations are available in three ways, so it works with any agent.

Shell — for any agent that can run commands. Add the snippet from examples/AGENTS.md to your AGENTS.md, CLAUDE.md or system prompt.

MCP — stdio server, standard library only. Tools: goae_search, goae_get, goae_check, goae_rules.

opencode (opencode.json):

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "goae": {
      "type": "local",
      "command": ["python3", "-m", "goae", "mcp"],
      "environment": { "PYTHONPATH": "/path/to/goae-lookup" },
      "enabled": true
    }
  }
}

Claude Code, Claude Desktop and most other clients (.mcp.json):

{
  "mcpServers": {
    "goae": {
      "command": "python3",
      "args": ["-m", "goae", "mcp"],
      "env": { "PYTHONPATH": "/path/to/goae-lookup" }
    }
  }
}

Python — from goae import db; print(db.search("Hausbesuch")).

Processing many items: a loop outside the conversation

Compact results make the context fill up more slowly. But if one conversation handles all items, it still grows with every item, because each result stays in it:

system prompt + tool definitions
item 1  → tool call → result 1      ┐
item 2  → tool call → result 2      │  every result stays in the conversation;
…                                   │  with enough items the window overflows
item N  → tool call → result N      ┘

For batch work, move the loop into a script. For each item it runs the lookup itself, asks the model one short question and writes the answer to the output file. Every LLM call starts without any chat history, and the file holds the progress:

for each row in input.csv:
    candidates = search(row)        # database, no LLM, ~90 tokens
    prompt     = row + candidates   # ~140 tokens, the same for every row
    answer     = LLM(prompt)        # fresh call, no history
    write answer to output.csv      # the file is the memory

One row, end to end. For the input row Ruhe-EKG this is the complete prompt the model receives:

Wähle die passende GOÄ-Ziffer für die Leistung.
Leistung: Ruhe-EKG
Kandidaten (Ziffer|Punkte|Text):
651|253P|Elektrokardiographische Untersuchung in Ruhe - auch gegebenenfalls nach…
650|152P|Elektrokardiographische Untersuchung zur Feststellung einer Rhythmusstörung…
653|253P|Elektrokardiographische Untersuchung auf telemetrischem Wege
659|400P|Elektrokardiographische Untersuchung über mindestens 18 Stunden (Langzeit-EKG)…
5422|1000P|Szintigraphische Untersuchung des Myokards mit myokardaffinen Tracern in Ruhe…
Antworte nur mit JSON: {"code": "<Ziffer oder leer>", "confidence": <0..1>}

The model answers, for example, {"code": "651", "confidence": 0.9}, and the row in the output file gets three new columns:

leistung

…

goae

goae_confidence

goae_candidates

Ruhe-EKG

…

651

0.90

651 650 653 659 5422

An answer that is not among the candidates, or has a confidence below --min-confidence, leaves goae empty for a person to decide. An interrupted run continues with the rows that are not done yet.

# candidates only, no LLM: a person picks from goae_candidates (e.g. in a spreadsheet)
python -m goae fill examples/leistungen.csv

# fully automatic with a local model behind any OpenAI-compatible endpoint
# (Ollama, llama.cpp server, LM Studio, vLLM)
python -m goae fill examples/leistungen.csv --llm http://localhost:11434/v1 --model qwen2.5:7b-instruct

# fully automatic with any CLI that reads a prompt on stdin (see below)
python -m goae fill examples/leistungen.csv --llm-cmd "claude -p --model haiku"

# options: column with the description, number of candidates, output file
python -m goae fill my.csv --col Leistungsbeschreibung -k 5 -o my.done.csv

No API? Use the agent CLI you already have as the LLM

--llm-cmd runs a command line tool that reads a prompt on stdin, once per row. Each call is a new process, so every row starts with an empty conversation:

python -m goae fill examples/leistungen.csv --llm-cmd claude
python -m goae fill examples/leistungen.csv --llm-cmd "ollama run qwen2.5:7b-instruct"

claude is a preset for Claude Code that uses your normal subscription, no API key. The preset matters: a plain claude -p call carries Claude Code's complete system prompt, tool definitions, MCP servers and skills into every call. The preset replaces the system prompt and switches the rest off (--system-prompt … --tools "" --strict-mcp-config --disallowedTools "mcp__*" --no-chrome --disable-slash-commands --no-session-persistence, model haiku). Measured with Claude Code 2.1.321 on the example table:

Input tokens per row

8 rows

plain claude -p

~42,000

~340,000

--llm-cmd claude (preset)

~2,000

~16,000, 8/8 correct, 18 s

For comparison, an agent session is not free per step either: every turn re-sends the whole conversation, including the agent's own system prompt (~30,000 tokens in Claude Code), mostly as cheaper cached input. The per-row preset calls stay small and constant.

Letting an agent work through the table

If an agent should do the work inside its own session, two commands keep that session small. next shows a batch of open rows with three short candidates each; set saves a whole batch of answers and immediately prints the next batch. One tool call handles ten rows, and the agent never has to open the file:

$ python -m goae next examples/leistungen.csv
8 open; answer with: set leistungen.csv ROW=CODE … (? = nothing fits)
#1 Blutabnahme aus der Vene
  1012|74P|Blutentnahme beim Fetus
  250|40P|Blutentnahme mittels Spritze, Kanüle oder Katheter aus der…
  284|90P|Eigenbluteinspritzung - einschließlich Blutentnahme -
#2 Ruhe-EKG
  651|253P|Elektrokardiographische Untersuchung in Ruhe - auch…
…
$ python -m goae set examples/leistungen.csv 1=250 2=651 3=375 4=3 5=417 6=3550 7=50 8=70
saved 1=250 2=651 3=375 4=3 5=417 6=3550 7=50 8=70
done: 8 rows → leistungen.goae.csv

A prompt for the agent:

Find the GOÄ codes for examples/leistungen.csv. Work in batches:

1. Run `python -m goae next examples/leistungen.csv`. It lists up to 10 open rows
   (#row and description), each with three candidate codes.
2. For each row pick the matching candidate. Only if none fits, run
   `python -m goae search "<medical term>"` for that row; use ? if still nothing fits.
3. Save the whole batch with one command, e.g.
   `python -m goae set examples/leistungen.csv 1=250 2=651 3=?`
   It saves the answers and prints the next batch. Repeat 2–3 until it prints "done".

Rules: do not open, print or edit the CSV files yourself. Do not repeat rows or results
in your messages, and keep your reasoning short. At the end, report only the output file
and the rows marked ?.

What to expect: a batch of 10 rows is about 530 tokens of tool output. The agent's own reasoning and tool-call messages usually cost more than that, which is why batching matters: it pays this overhead once per ten rows instead of once per row.

Two more things keep the session small:

  • Results go to a separate file (leistungen.goae.csv); the input is never changed. Some agents re-insert a file into the conversation whenever a file they have read changes on disk (Claude Code does this as a "file updated" note with a diff). Writing to a file the agent has never opened avoids that.

  • Delegate the loop to a subagent if your agent supports it (Claude Code, opencode): start the prompt above with "Use a subagent for this and report only its summary." The subagent's context is discarded afterwards; your main conversation only receives the summary.

No MCP configuration is needed for any of this: an agent that can run shell commands uses the CLI. MCP (.mcp.json, opencode.json) is only needed for agents without shell access, or if you prefer tool calls to shell commands; next and set are CLI-only.

How the design was found

The defaults in this repository were not designed up front. They are the result of an experiment that you can rerun and reuse for your own data:

  1. Write tasks with known answers in bench/tasks.json, split into two groups:

    • 23 tuning tasks (dev in the file). While improving the tool you may look at these, e.g. when a search fails, find out why and fix it.

    • 17 test tasks (holdout in the file). These are only run, never used to decide on a change. If a change helps the tuning tasks but not the test tasks, it was probably tailored to those specific questions and will not help real users.

  2. Answer every task with the tools and measure three things: the size of the tool definitions, the size of all tool results, and whether the correct answer is contained in them.

  3. Change one thing, measure again, keep what helps.

Each step in the table is one such change. Tokens is the average size of a search result; the last two columns count correctly answered tasks.

Step

Change

Tokens

Tuning

Test

it1

Straightforward tool: JSON, full texts and notes, 10 results

678

17/23

13/17

it2

Line format code|points|text instead of JSON, notes only on request

171

17/23

13/17

it3

Texts truncated to 80 characters, 5 results — cheap, but loses hits

62

15/23

11/17

it4

Hybrid ranking (word search + character trigrams for German compounds)

88

19/23

12/17

it5

Synonym file for plain-language terms, built from failed tuning tasks

88

23/23

12/17

it6

Terse tool schema, 4 instead of 5 tools (583 → 245 tokens per request)

88

23/23

12/17

it8

6 synonyms for the failed test tasks (from here on the test tasks are no longer unseen)

89

23/23

17/17

The lesson: output format and size bring the largest savings, better ranking wins back the hits that truncation costs, and the remaining misses are vocabulary gaps.

Where the synonyms come from. Users write "Blutabnahme", "EKG" or "MRT"; the official text says "Blutentnahme", "Elektrokardiographische Untersuchung" and "Magnetresonanztomographie". No ranking method bridges that gap reliably, so data/synonyms.tsv maps everyday terms to the official wording and the search adds that wording to the query. Every entry was added by hand after a search failed: look at the task, look up the official text of the expected code, add one line. Ten entries came from failed tuning tasks (step it5) and one from the example table in examples/. Six more were added after test tasks failed (step it8, marked separately in the file; "CT" was added along with "MRT"). In a real deployment the best source is the searches your users run that find nothing; a domain expert or an LLM can propose candidates, but each entry should be checked against a failing example.

python bench/run.py                          # all steps → CONTEXT_STATS.md
python bench/run.py --only final             # current defaults, lists failed tasks
python bench/run.py --log "what I changed"   # also record the run in the history

CONTEXT_STATS.md has all numbers, including the two baselines (whole document, generic RAG) and a per-task breakdown.

Applying the pattern to your own data

The GOÄ-specific parts are small. To adapt the repository to another dataset:

Replace

With

goae/ingest.py

a parser for your source that produces records (Entry: code, text, points, notes) and calls write_db

goae/db.py → get, price, check

the operations your tasks need; keep search

goae/tools.py

tool names and one-line descriptions

data/synonyms.tsv

the vocabulary of your users

bench/tasks.json

20–40 tasks with known answers from your domain

Then run the benchmark and iterate. Search, MCP server, batch pipeline and benchmark work unchanged.

Example: briefing a coding agent. The following prompt applies the pattern to a completely different dataset — a parts catalog — and can be handed to any coding agent (Claude Code, opencode, Codex, …) together with a link to this repository:

Build a context-minimal lookup tool for our electronic parts catalog. Use the pattern
and project structure of https://github.com/markusrt/goae-lookup (read its README first).

Data
- catalog.csv (~400,000 rows, 300 MB): part_number, manufacturer, description,
  category, package, attributes (JSON: resistance, tolerance, voltage, …),
  price_breaks (JSON: quantity → unit price), stock, lifecycle (active/NRND/EOL)
- The file is far too large for any context window. Never put it, or large parts of
  it, into a prompt.

Questions the tool must answer
1. Which parts match a free-text description from a bill of materials,
   e.g. "10k resistor 0603 1%" or "100nF 50V X7R 0805"?
2. What does part X cost at quantity N (use the price breaks; compute in code)?
3. Is part X still active, and if not, which in-stock parts have the same category,
   package and key attributes?
4. Fill a BOM (bom.csv, one line per item) with a part number per line.

Requirements
- Parse the catalog once into SQLite with full-text search. Normalise units in
  descriptions and attributes (10k = 10 kΩ = 10000, 100nF = 0.1 µF).
- Operations: search, get, price, alternatives. Every answer at most ~10 short lines;
  search returns the top 5 as "part|key attributes|price@1|stock".
- Offer them as a CLI and as an MCP server with short tool descriptions.
- BOM filling runs as a script loop: one small LLM call per line (line + 5 candidates),
  result written to the output file immediately, resumable, codes outside the
  candidate list rejected.
- Write 40 tasks with known answers (25 tuning, 15 test) and a benchmark that reports
  tokens per answer and correctness. Iterate on output format, ranking and synonyms
  ("cap" → capacitor, "res" → resistor) until results are small and correct, using only
  the tuning tasks to decide on changes. Report the test results honestly.
- Write the results of each iteration to CONTEXT_STATS.md.

Building the database from another source

By default the database is built from the official GOÄ text. As an example of a different source, goae/sources/abrechnungsstelle.py builds it from the website https://abrechnungsstelle.com/goae/ziffern/, which adds analogue codes and an exclusion list per code. The website's content is not part of this repository; it is downloaded when you build:

python -m goae build --source abrechnungsstelle      # → data/goae-abrechnungsstelle.db
python -m goae --db data/goae-abrechnungsstelle.db search "Ruhe-EKG"
export GOAE_DB=data/goae-abrechnungsstelle.db        # use it for the MCP server / agents
  • Downloads 14 pages once (respects robots.txt, one request every 3 s), caches the HTML in data/cache/ and writes a separate database. Both are git-ignored. --refresh downloads again.

  • Takes service text, points, notes and exclusion codes per entry. The exclusion lists are converted into rule sentences, so check uses them as well. Sections, fee factors and general rules still come from the official text.

  • Tested offline against a hand-written HTML fixture (tests/fixtures/). If the website changes its layout, python -m goae.sources.abrechnungsstelle --debug 1-498 shows what the parser sees.

  • Check the website's terms before using the data beyond personal purposes.

Data and limitations

  • Default source: the official GOÄ text (BGBl. I 1996, last amended 21.10.2019) from the Markdown mirror of gesetze-im-internet.de, bundestag/gesetze (g/goae_1982/index.md, Unlicense). German statutes are official works without copyright (§ 5 UrhG). A copy is in data/source/.

  • The official text has no analogue codes (BÄK Analogbewertungen), no commentary and no case law, and the planned new GOÄ is not included.

  • check evaluates explicit exclusion rules ("nicht neben", "nur … im Zusammenhang mit"). Frequency limits ("im Behandlungsfall nur einmal") are shown by get and rules, but not enforced.

  • This is a technical example, not billing or legal advice.

Layout

goae/ingest.py      parse the official text → SQLite with full-text indexes
goae/db.py          search / get / price / check / rules, all returning short text
goae/tools.py       tool definitions (terse set, plus the verbose set used as a baseline)
goae/mcp_server.py  MCP server over stdio, no dependencies
goae/fill.py        batch pipeline (optional OpenAI-compatible LLM), next/set for agents
goae/sources/       optional adapters for other sources (abrechnungsstelle.com)
goae/__main__.py    CLI
bench/              tasks, benchmark, results, history
data/               source text and synonyms; databases and cache are generated
tests/              unittest suite (python -m unittest discover -s tests), HTML fixture

License

Code: MIT. GOÄ text: public domain (see above).

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      B
      maintenance
      Healthcare billing AI for agents — 12 tools for ICD-10/CPT/HCPCS code lookup (80K+ codes), prior auth prediction, medical NER, claims validation, HIPAA compliance auditing, and provider/drug enrichment. Pay-per-call via credits or USDC.
      32
      187 npm
      3
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables agents to search, look up, validate, and cross-translate Brazilian health terminology codes (CID-10, CIAP-2, TUSS, SIGTAP, ANVISA, CBO, LOINC, UCUM) returned as FHIR concepts, with structured handling of key, access, quota, network, and server errors.
      Apache 2.0
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables AI assistants to look up ICD-10-CM and HCPCS codes, explain insurance and billing terms, verify EOB arithmetic, and access guides for reading medical bills and explanations of benefits.
      MIT