AWS Builder Center
The coaching agent that can't fake a citation, built on AWS (part 1 of 2)

The coaching agent that can't fake a citation, built on AWS (part 1 of 2)

Part 1 turns a book into a citable graph on AWS.

Series: Agentic Graph for Media Industry (4 articles)

  1. 1
    The coaching agent that can't fake a citation, built on AWS (part 1 of 2) This article
This is part 1 of a two-part series. Part 1 turns a book into a citable graph on AWS.  Part 2  builds the agents that read from it and proves they do.

Introduction

You finish a book on a groundbreaking method over a weekend and highlight half of it. On Monday you open your own document, and the book can neither reread it nor cite the rule you just broke. You would like to know whether your opening contravenes the second rule of the method. Rereading three hundred pages to find that passage costs more time than you have.
An agent connected to that book can answer in one second. It can also get it wrong in a way you will not see: state a rule that the book does not contain, with the poise of a citation. This failure mode matters more than the others, because an invented reproach on a correct text makes you rewrite what was already fine.
That consequence sets the engineering bar. An agent that talks about a book is easy to build. An agent whose every claim traces back to a precise line of the book is not. It is that second agent that delivers value.
This article describes the architecture built for the second case, on  Amazon Bedrock ,  Amazon Neptune Analytics  and  Amazon S3 Vectors , with the  Strands Agents SDK  and AWS Labs'  GraphRAG Toolkit . By the end you will know how to build five families of agents for a given book, expose them through the  Model Context Protocol  (MCP), and above all guarantee that no citation leaves the agent without being verifiable.

Solution overview

What you get at the end
An MCP server named  book-coach  that publishes 21 tools, 7 resources and 4 prompts for one indexed book. You plug your MCP client into it, hand it the path of your document, and receive an assessment of that document against the method of the book. Every finding carries a word-for-word citation, with the chapter and the passage it comes from, that you can open to verify.
The eight components
Two vocabulary points trap the reader coming from classic RAG, and I place them before anything else. A chunk here is a division of the book (a chapter, a section) named by the table of contents, not a window of N tokens. A statement is a sentence the extraction model has written about the book, not a sentence from the book: useful for search, never citable. The whole citation guarantee rests on this distinction.
The table below gives the eight components and points to the section where each is developed. A book is addressed by a  book_id , the string that  list_books  returns. Inside the graph, it carries a namespace called a  tenant  that suffixes every label.
#
Component
In one sentence
Detailed in
1
The prepared book
A document (EPUB, PDF, Word, etc.) repaired and converted into Markdown split by
 DoCO 
role (chapter, section, front-matter piece).
§ How the book enters the graph
2
The lexical graph
A
 GraphRAG Toolkit 
property graph stored in Amazon Neptune Analytics, with
 six labels 
( source ,  chunk ,  topic ,  statement ,  fact ,  entity ) that recur throughout the article.
§ How the book enters the graph
3
The embeddings
Three vector indexes in Amazon S3 Vectors: book chunks, book statements, agent memory.
§ Memory
4
The Method_Model
A typed JSON that distils the book's method into: steps, rules, thresholds, anti-patterns, audiences, each element carrying its word-for-word citation, or it is dropped.
§ Mechanism 2: word for word or deleted
5
The  SKILL.md  and the SOP
A
 skill 
(retrieval manual for the agent) and a
 SOP 
(step-by-step coaching procedure), one Markdown file each.
§ The skill
6
The five agent families
 assess ,  coach ,  writer ,  persona ,  generic , outputs of a single function on the
 Strands Agents SDK 
, with different layers.
§ How the agents are built (
 part 2 
)
7
The MCP server
A local process on stdio that exposes 21 tools, 7 resources and 4 prompts with the
 MCP Python SDK 
.
§ What a client sees
8
The verification chain
Five mechanisms defend the citation. If you read only one chapter of the article, read this one, it is the one that carries the guarantee.
§ How to prove the agent really reads the book (
 part 2 
)
Where each component runs
The datastores are deployed on AWS through managed services. The MCP server and the Strands agents stay local: they modify Markdown files on your own machine, and that file-based constraint closes the hosting question.
Component
Where it runs
MCP server
Local process on stdio, inside your session
Strands agents
In that same process
Lexical graph
Amazon Neptune Analytics
Embeddings
Amazon S3 Vectors
Method_Artefacts
Amazon S3
Extraction staging and checkpoints
Local file system
Inference
Amazon Bedrock
Output verification
Amazon Bedrock Guardrails
Your document
Local file system
A question's journey, end to end
  1. Your MCP client calls  list_books  and picks up the  book_id  of the book.
  1. It calls  assess_work  with that  book_id  and the path of your document.
  1. The server builds the  assess  agent: its skill, its steering (a second model that reviews every tool call before it happens), and its verification loop.
  1. The agent queries the graph ( book_search  to find,  book_passages  to obtain the exact words), and every result is recorded in the evidence ledger.
  1. Amazon Bedrock produces the assessment.
  1. Five checks run on that assessment before it leaves. The last one calls   ApplyGuardrail  . If one fails, the agent receives feedback naming the offending check and starts over, at most once.
Figure 1 shows this journey, with the ingestion that runs only once per book on its left.

Figure 1: from a document you own to a coaching agent that cites it.

How the book enters the graph

Two steps prepare the text and two index it.
Preparing the source
Four steps, each a script:  diagnose  reports what is broken,  clean  repairs only the defects diagnosis found,  verify  proves the repair lost no text, and  convert  splits the result.
We do not control the formatting of documents, so real files break structurally in ways that quietly corrupt a graph. Diagnosis classifies them into twenty-two defect codes (seventeen on the document, five on its navigation). Deterministic rules fix most of them. Amazon Bedrock is only used for defects that are pixels rather than text: chapter titles in scanned sources exist only as images, and a book for which you skipped them ships with no chapters at all.
Verification is a barrier, not a report, and its threshold is a one-sided floor: it passes only if the retained text reaches at least 99.5% of the original text (a longer file passes, whatever the gap) and a second diagnosis finds nothing further to repair. The count ignores whitespace, as well as the navigation document and watermark blocks.
And the level of a heading is not its role, because publishers put front matter, part separators, chapters and appendix entries all at the top level. Conversion therefore assigns each heading a role among six ( front_matter ,  part ,  chapter ,  section ,  subsection ,  back_matter ), from its text and its position, never from style class names. The chapter directory then means the same thing in every book. Without that, no count in the results table is a comparable unit.
The role vocabulary comes from DoCO
Those six roles are typed with the  Document Components Ontology  (DoCO) 1.4.0. A mapping table associates each role with DoCO classes ( TableOfContents ,  Foreword ,  Preface ,  Appendix ,  Bibliography , among others) through prefix matching. DoCO only serves for structural typing: the lexical graph in the toolkit has a fixed node model, it accepts neither OWL nor Turtle, and DoCO has no term for a method step or a rule anyway. A separate typed schema carries those, the Method_Model.
Mechanism 1: one chunk per book division
The whole citation argument rests on it. Character offsets do not survive indexing, so a citation can only identify a chunk. If a chunk is a fixed window of prose, a citation identifies nothing you can find.
A "book division" is simply a unit that the table of contents names: a chapter, a section, a subsection. Concretely, here is a real citation produced by this pipeline:
JSON
{
"chunk_id": "charpente.fr/chapter:0001#p2",
"node_id": "chapter:0001",
"chapter_title": "1. L'ESPACE ET LE TEMPS DU RÉCIT",
"file": "chapters/01-1-lespace-et-le-temps-du-recit.md",
"excerpt": "un récit qui tient est un organisme, non pas un mécanisme : il pousse au lieu de s'assembler"
}
The  chunk_id  is not a window number, it is an address: chapter 1, second block. You open the named file, you find the excerpt there word for word. With a cut every 500 tokens, the same citation would have said "chunk 87", which points to nothing openable.
The toolkit brings its own chunking, and we turn it off:   IndexingConfig   (chunking=None) . This is a setting in its indexing configuration, not a class to override. Passing  None  means "no chunking at all", and the nodes we hand it are taken as they are. Our divisions therefore become the chunks.
A small division (a one-line subsection, for example) is attached to its parent: it does not get its own chunk, but its metadata records that it is indeed the original division of the text. A citation from that subsection can therefore still be opened at the right page, even when it is the parent chapter's chunk that carries the text in the graph.
Mechanism 2: word for word or deleted
This step extracts the book's methodology into a typed model: ordered steps, rules with a severity, numerical thresholds, anti-patterns and audiences. It decides what the agent may assert, because anything it cannot cite never reaches the model.
It reads the text of the chunks, not the  statements  in the graph. Recall the distinction, which comes from the toolkit itself: a chunk is a portion of the book's text, a statement is a sentence the extraction model has written about the book. Copying an excerpt from a statement would attribute a wording to the book that is not its own, and the word-for-word check that compares the excerpt to the actual chunk text would reject it immediately. Better to read the source directly.
That does not mean the book is absent from the graph, and this is a point that invites misunderstanding. The full text of each division is in the graph: a  __Chunk__  node carries its identifier and its text, and that is precisely what the three tools that produce citable text read. What is not citable is the statements that get added to it, useful for finding, never for citing.
The citation rule is a type constraint rather than a validation pass. A citation requires at least one chunk identifier, and its excerpt must carry at least three words, so an element with no citation cannot be constructed at all. After extraction, the pipeline deletes every element whose excerpt is not found word for word in the chunk it cites, and writes each deletion into a readable file. Figure 2 shows this step.

Figure 2: what survives the method extraction, and what is dropped
Across the 533 chunks of our test document, the Method_Model retains 220 elements citing 112 distinct divisions, that is 21% of the book. This is not an indexing defect: the whole book is indexed and queryable through the tools. The Method_Model is something else, it is an orientation index over what the book prescribes (its steps, its rules, its thresholds). A large part of a method book is made of examples and commentary, which are neither, so they have no element of their own.
An initial hypothesis held that its steps would simply restate the table of contents. The measurement refuted it. Over the 604 outline nodes, ignoring case and accents, 7 step names out of 70 match a heading word for word and 63 appear nowhere in the outline. Headings are thematic names. Steps are imperatives.
These excerpts serve a second time at execution, and this second use has grown in importance: the agent receives them as citable blocks and the evidence ledger accepts them outright, so a citation drawn from one of them verifies without the agent needing extra retrieval. I return to this in mechanism 5.
What the GraphRAG Toolkit gives you, and what you must configure
The toolkit's  indexing pipeline  runs in two steps that each set checkpoints: extract turns documents into extraction collections on disk, then build writes nodes and edges into the graph store and vectors into the vector store. The resulting  graph model  has six labels and a provenance chain (the toolkit's lineage tier:  fact  →  statement  →  topic  →  chunk ) that figure 3 summarises. The entity attaches to chunks and statements there, but it is absent from the citable retrieval paths.
Figure 3: the provenance chain in the lexical graph. A chunk mentions several topics, a topic contains several statements, several facts support a statement, and entities attach to chunks and statements through several edges each. A citation starts from a statement (through semantic search), climbs back to the topic that contains it and the chunk that mentions that topic, then reads the chunk text.
Reading order is a separate edge on chunks, and it is not the procedure order. The distinction matters the moment you are tempted to read the sequence of a method from the graph.
The store carries two properties that will cost you time if you do not know them. Labels are suffixed by tenant, so a query on the bare label finds nothing. A  __Chunk__  node, for its part, carries exactly  chunkId  and  value  (its identifier, the toolkit's rather than ours, and its text). Every per-chunk metadata written on entry, DoCO typing included, stops there. What travels instead is what you put into the extraction entries, which is why the index and the chapter title climb up there and appear on every citation.
The table gives the actual names from the  toolkit configuration , so you can find them in your own code.  IndexingConfig  carries the first three blocks. The last two are arguments to the vector store factory and to  LexicalGraphIndex .
Setting (actual name)
Value here
Why
 IndexingConfig(chunking=...) 
 None 
Chunks are book divisions, supplied by us
 VectorStoreFactory.for_vector_store(index_names=) 
 ["chunk", "statement"] 
The statements index stays empty unless named
 ExtractionConfig(preferred_entity_classifications=) 
14 seeded terms
Steers extraction toward method vocabulary
 ExtractionConfig(preferred_topics=) 
The book's headings, max 200
Seeds topics from the book, not from priors
 BuildConfig(include_domain_labels=) 
 True 
Makes classifications queryable
 IndexingConfig(batch_config=...) 
 None 
Keeps extraction staging local
 tenant_id 
1–25 characters, lowercase
The constraint behind the two slugs
A trap that sits outside the table, because it does not show up on a read. The toolkit accepts a model in two forms: an already-built object, or a string that identifies it. Pass the string, and the toolkit builds its own client with its own settings. Your timeout and your retry policy are silently lost. Pass the models as instances.
What this graph does badly, and what governs the retriever choice
This graph is good at retrieval with provenance and weak at precise graph queries. Two measurements show it. First, the model picks its own relation predicates, with no controlled vocabulary, so semantically identical edges carry different names. Second, the same term exists as multiple entities: on a test book, the word "désir" appears five times in the graph.
Those five nodes are not spelling variants: they carry the same string, and what separates them is their classification. Those classifications are the ones we seed through  ExtractionConfig(preferred_entity_classifications=) . They are chosen by us, not supplied by the toolkit, and they reflect the vocabulary of a method or craft book: the concepts that this kind of book names ( Method ,  MethodStep ,  Rule ,  Principle ,  Threshold ,  AntiPattern ,  Audience ,  Persona ,  Example ), plus the narrative objects it uses as examples ( Character ,  Story ), plus structural classes that let a book division be treated as a citable entity ( Structure ,  Chapter ,  Section ). The toolkit treats this list as a preference, not a constraint: if an excerpt fits none of the fourteen seeded terms, the extractor can invent another. The list lives in our code, in  graph.py:PREFERRED_ENTITY_CLASSIFICATIONS , and the configuration table above shows where it enters the toolkit.
Value
Classification
Result in the graph
"désir"
 Principle 
node 1
"désir"
 Methodstep 
node 2
"désir"
 Structure 
node 3
"désir"
 Section 
node 4
"désir"
 Method 
node 5
An entity's identity is built from its value and its classification, so a term that the extractor approaches from five angles becomes five nodes. Entity resolution is not exposed by the toolkit in its current version, and cutting the domain labels would merge nothing since the classification enters the identifier itself. Reducing the fan-out would require a narrower classification vocabulary or a merge pass written after the build. Neither would improve citation, since entities sit on none of the three paths that produce citable text. This defect is therefore measured and left as is.
Those two defects bound what you can ask of this graph: it retrieves well, it does not reason precisely.
The  topic  distribution, by contrast, is healthy, and it is what governs the retriever choice. A  topic  is the toolkit label for the node that groups statements by theme (see figure 3). On the test book, extraction produces 411 topics for 12,832 statements, and the largest carries 427, that is 3.3%. This distribution results from two settings we impose on the toolkit at extraction. We first supply a list of preferred topics ( ExtractionConfig(preferred_topics=) ), capped at 200 and drawn directly from the book's own headings. We then supply the same list of fourteen entity classifications as above ( ExtractionConfig(preferred_entity_classifications=) ), which orients it toward a method vocabulary.
This distribution makes the toolkit's  traversal-based search  viable: the family of retrievers the current docs recommend, which starts from the topics closest to the question before going down to the statements they contain, and returns a structured answer with its chain of sources. As long as the topic distribution stays healthy, this family is enough.
A topic that absorbed most of the corpus would degenerate into a catch-all, and that is the case that pushed toward the other family the toolkit documented: the  semantic-guided search , which the docs now mark Deprecated.
A more fundamental reason bounds the reach of this setting anyway: a citation reads the chunk text, not the topic that indexes it. The three tools that produce citable text ( book_semantic_search ,  book_passages ,  book_passage ) read the chunk text directly, and the word-for-word check runs against that text. A poor topic distribution degrades precise graph queries. It cannot produce an invented citation that would pass the checks.

What a client sees: 21 tools, 7 resources, 4 prompts

The server publishes one tool per capability, with the   FastMCP   class from the official MCP Python SDK. The surface divides into three groups, and that division is the authorisation model: the book is read-only, your document is readable and modifiable, and the agents sit behind separate tools.
The eight book tools
Each is bound to a document by its  book_id  (the string that identifies the book for the tools, which  list_books  returns). The interesting point is not the search technique but what you are allowed to do with the result.
Tool
What it returns
Citable?
 list_books 
The converted books, their  book_id , their counters
n/a
 book_ask 
A synthesised answer drawn from the book's statements
No
 book_search 
The statements containing a term, accent- and case-insensitive
No, they are rewordings
 book_semantic_search 
The statements by meaning, with the original excerpt
Yes
 book_passages 
Raw passages, for the exact words of the book
Yes
 book_passage 
A whole passage, by chunk identifier
Yes
 book_chapters 
The chapter titles in order
n/a
 book_status 
Which share of the book is indexed
n/a
 book_ask  deserves its warning, written into the description the model reads. Its answer is not citable: it is synthesised prose whose sources resolve to the whole book rather than to a passage. The evidence ledger therefore accepts only four tools ( book_search ,  book_semantic_search ,  book_passages ,  book_passage ), and  book_ask  is excluded even though it retrieves content. The others are absent for a simpler reason: a chapter list or an index counter cannot be taken as support. Its second rule comes from a measurement: interrogate the concept, never the author. A question naming the author returns the preface and the bibliography, because that is where the author's name appears as a token.
The six tools for your document
Your document is a different context: it is modifiable, it is never indexed, and the server does not know where it lives. These tools therefore take a root path (the directory of your document) that the client supplies on every call rather than once and for all. The reason is concrete: two client sessions can have two projects open, and a cached root would silently serve the wrong document to the second one.
A section here is the titled unit of your document's Markdown (a  # title  or  ## subtitle  and the body that follows), just as a chunk is the titled unit of the book. The tools read and write at that granularity and never the whole file, because sending a complete author document to a model demands more context than one turn can carry, and because replacing a section surgically leaves the rest of the file intact. The manifest and the reading rely on   markdown-it-py  , a CommonMark tokeniser that gives, for each heading, its level and its position in the file.
Three tools read:  work_outline  gives the manifest,  work_section  reads a whole section,  work_search  searches through the text. Three write, and these are the only ones on the server that modify a file that belongs to you:  work_create_document  creates an absent document and refuses to write where a file already exists,  work_replace_section  replaces the body of a section while keeping its title unchanged,  work_undo  undoes the last of the two operations. No book is modifiable.
The main guard is on  work_replace_section : a replacement is refused if  work_section  has not returned that exact identifier earlier in the same session. It makes impossible, not improbable, the scenario where the model invents a plausible identifier and overwrites a section nobody has read. Every edit is journalled outside your repository, and  work_undo  refuses to act if the text has changed in the meantime, because that means you edited it yourself.
The manifest is what makes this affordable: an index of sections with identifier, size and position, without the text. The format is homegrown, with no third-party library, just  markdown-it-py  for the upstream tokenisation. One line per section would cost 185,920 characters, more than the budget it replaces. By grouping the lines by file and printing a title only where the identifier does not already reproduce it, it falls back to 64,805 characters, without losing anything: 1,385 of the 1,475 titles are reproduced exactly by their own identifier.
The seven tools that trigger work on the server side
Five call a model.  assess_work  returns the assessment,  coach_work  turns it into revisions,  rewrite_work  writes the prose and takes a root path because it modifies files,  persona_react  and  review_generic  cover reader reactions. The other two call no model, and the distinction is worth making rather than filing them together.  list_personas  reads the Method_Model on disk and returns a list, for free.  ingest_book  runs the indexing pipeline on an already-converted book. It refuses to convert, because conversion lasts longer than a tool call can wait.
The seven resources, and the one that does not exist
The resources are addressable and cacheable: one global resource, the list of books, and six parameterised by  book_id : the retrieval manual, the chapter titles, the Method_Model, the personas, the shape of the graph, and the coaching procedure.
The four prompts, and why they duplicate the tools
The prompts are entry points for a client that runs its own model: assess, coach, answer a question, react as an audience. Each has a twin tool that has the work performed by the server's agent.
This duality is an architectural choice. A tool means "the server's agent does it, it is billed, and my five output checks apply". A prompt means "here is the retrieval discipline, run it at your end". A client out of process cannot load a skill, cannot re-run the checks, cannot install steering. None of my verification layers reach it. What a prompt can do is disclose the discipline instead of enforcing it, and that is why the self-detection table travels with it.

What comes next

The substrate is ready. A book is repaired, split by DoCO role, indexed as a lexical graph with one chunk per book division, distilled into a Method_Model whose every element carries a word-for-word citation, and exposed by an MCP server with 21 tools, 7 resources and 4 prompts.
 Part 2  builds the agents that read from this substrate: the five families ( assess ,  coach ,  writer ,  persona ,  generic ), the layers (skill, steering, goal, memory, evidence ledger), sub-agent delegation for long coaching runs, and the verification chain that stops an agent from inventing a citation. It closes on how the agents are measured and optimised.

Series: Agentic Graph for Media Industry (4 articles)

  1. 1
    The coaching agent that can't fake a citation, built on AWS (part 1 of 2) This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article