An LLM agent that reads, understands, and fills DOCX templates — preserving bold labels, fonts, and structure.
Built on Agno + FastMCP + python-docx.
Paste meeting notes or raw clinical data into the agent. It:
- Parses the DOCX template into a structured AST (headings, paragraphs, table cells — each with a stable
element_idand its run-level formatting) - Exposes 5 MCP tools the agent can call:
upload_document,get_session_documents,load_document_ast,edit_document,validate_document_state - The LLM reads the AST, matches fields to content, and calls
edit_documentwith the rightelement_id - A run-level formatter re-applies the template's bold/italic labels around the new plain-text values — automatically
No HTML conversion. No regex scraping. Pure AST surgery.
┌─────────────────────────────────────────────────────────┐
│ Agno Agent (Claude) │
│ system prompt: 5-step workflow │
│ tool calls ──────────────────────────────────────┐ │
└────────────────────────────────────────────────────│────┘
│ HTTP/MCP
┌────────────────────────────────────────────────────▼────┐
│ FastMCP Server (:8000) │
│ │
│ upload_document ──► converter.build_ast() │
│ load_document_ast ─► LocalDocumentStore.get_ast() │
│ edit_document ─────► converter.apply_cell_edit() │
│ + run_formatter.create_runs() │
│ validate_document_state ──► build_ast() comparison │
└─────────────────────────────────────────────────────────┘
│ python-docx read/write
┌────────▼────────────────────┐
│ DOCX template on disk │
│ + AST JSON (local store) │
└─────────────────────────────┘
The core algorithmic challenge: the template has cells like
[bold] "Study Title: " [/bold][plain] "" [/plain]
The LLM produces "A Phase III randomised trial of Zetaribumab".
The formatter:
- Finds the longest common prefix between the new text and the original template text
- Maps that prefix to template runs (preserving bold)
- For remaining text, scans for known bold-label fragments and applies their formatting
- LLM-generated values always end up plain — no false bold
# 1. Install
pip install -e ".[dev]"
# 2. Configure
cp .env.example .env
# → edit .env, add ANTHROPIC_API_KEY=sk-ant-...
# 3. Create the CSR template
python examples/csr_demo/create_template.py
# 4. Start the MCP server
uvicorn docfill.mcp.server:app --port 8000
# 5. Run the demo notebook
jupyter notebook examples/csr_demo/csr_fill_demo.ipynbdocfill-agent/
├── src/
│ └── docfill/
│ ├── ast/
│ │ ├── models.py # DocumentAST, TextRun, TableCellElement, …
│ │ ├── converter.py # build_ast(), apply_cell_edit(), apply_heading_edit()
│ │ └── run_formatter.py # create_runs_from_template() — the formatting engine
│ ├── storage/
│ │ ├── local.py # LocalDocumentStore (filesystem-backed)
│ │ └── session.py # SessionRegistry (in-memory)
│ └── mcp/
│ └── server.py # FastMCP server — 5 tools
├── examples/
│ └── csr_demo/
│ ├── create_template.py # Generates the fictional CSR DOCX template
│ ├── csr_fill_demo.ipynb # End-to-end demo notebook
│ └── templates/ # csr_template.docx (generated by create_template.py)
├── pyproject.toml
├── .env.example
└── README.md
| Tool | Args | Description |
|---|---|---|
upload_document |
file_path, file_id? |
Parse DOCX → AST, register in session |
get_session_documents |
include_metadata? |
List uploaded documents |
load_document_ast |
file_id |
Return full element list with element_id, text, runs |
edit_document |
file_id, edits |
Apply cell/heading edits with formatting preservation |
validate_document_state |
file_id, create_new_version? |
Verify AST integrity, bump version |
edit_document edit format:
[
{
"type": "table_cell",
"element_id": "cell-t0-r1-c1",
"changes": { "text": "A Phase III randomised trial of Zetaribumab 150 mg SC Q4W" }
}
]The agent uses Agno's model abstraction. To switch providers:
# Anthropic (default)
from agno.models.anthropic import Claude
model = Claude(id="claude-sonnet-5")
# OpenAI
from agno.models.openai import OpenAIChat
model = OpenAIChat(id="gpt-4o")
# Any LiteLLM-compatible endpoint
from agno.models.litellm import LiteLLM
model = LiteLLM(id="bedrock/anthropic.claude-sonnet-4-5", api_key="...")LocalDocumentStore mirrors the interface of an S3-backed store. To switch:
- Implement
store_file,store_ast,get_file,get_astwithboto3 - Pass the new store instance into
server.py
No changes to the converter or formatter needed.
apply_cell_editrebuilds paragraphs from scratch — complex nested tables with merged cells may lose merge state. (Works for standard clinical templates.)- The formatter heuristic works well for
Label: valuepatterns. Multi-column mixed formatting may need manual tuning. - The MCP server is single-process; the
SessionRegistryis in-memory and not shared across workers.
MIT