Skip to content

Tutorial: a documentation Q&A agent

This is the payoff chapter. We'll build a small but complete agent that answers questions about a body of documentation: it searches the docs, reads the hits, and writes a grounded answer — calling tools in a loop until it's done. Along the way we'll wire in streaming, durability, and a human approval step, so the pieces from earlier chapters come together in one program.

Nothing here is new machinery. It's the tool_loop pattern with real tools, plus a checkpointer and an interrupt.

What we're building

flowchart LR
    START --> agent
    agent -- wants a tool --> tools
    tools --> agent
    agent -- has an answer --> END

The model drives. When it wants to search or read, it emits a tool call; our graph runs the tool and feeds the result back; the loop continues until the model answers without asking for a tool.

1. The tools

Two tools over an in-memory corpus. A tool is just a callable plus a schema.

DOCS = {
    "state": "State is typed channels, each with a reducer that folds updates.",
    "backends": "A backend is how a node talks to a model or an agent.",
    "checkpointing": "A checkpointer persists each super-step so a run can resume.",
}

async def search_docs(query: str) -> str:
    hits = [k for k, v in DOCS.items() if query.lower() in (k + " " + v).lower()]
    return "matches: " + (", ".join(hits) or "none")

async def read_doc(doc_id: str) -> str:
    return DOCS.get(doc_id, f"no document {doc_id!r}")

Wrap each in a Tool with a JSON-Schema for its arguments:

from agentflow.prebuilt import Tool

tools = [
    Tool("search_docs", search_docs,
         description="Search the documentation; returns matching doc ids.",
         schema={"type": "object",
                 "properties": {"query": {"type": "string"}},
                 "required": ["query"]}),
    Tool("read_doc", read_doc,
         description="Read one document by its id.",
         schema={"type": "object",
                 "properties": {"doc_id": {"type": "string"}},
                 "required": ["doc_id"]}),
]

2. The backend and the loop

from agentflow import Message
from agentflow.backends.ollama import OllamaBackend
from agentflow.prebuilt import tool_loop

llm = OllamaBackend("llama3.2")
app = tool_loop(llm, tools, max_turns=8)

3. Run it

import asyncio

async def main():
    question = "How does state work in this framework?"
    async with llm:
        out = await app.invoke({"messages": [Message("user", question)]})
    if out["status"] != "completed":
        print("unresolved — ran out of turns with tools pending")
        return
    print(out["messages"][-1].content)

asyncio.run(main())

The agent will typically call search_docs("state"), then read_doc("state"), then answer. We check status before trusting the last message.

4. Stream the answer

Build with stream_text=True and stream the compiled graph; a backend event carries a TextChunk to print.

from agentflow import TextChunk, ToolCall

app = tool_loop(llm, tools, max_turns=8, stream_text=True)

async with llm:
    async for ev in app.stream({"messages": [Message("user", question)]}):
        if ev.kind == "backend" and isinstance(ev.data, TextChunk):
            print(ev.data.text, end="", flush=True)
        elif ev.kind == "backend" and isinstance(ev.data, ToolCall):
            print(f"\n[calling {ev.data.name}({ev.data.args})]")

5. Make it durable

Give the loop a checkpointer and name the run with a thread; if the process dies mid-answer, a fresh process resumes it.

from agentflow import SqliteCheckpointer

app = tool_loop(llm, tools, max_turns=8, checkpointer=SqliteCheckpointer(".runs/qa.db"))

async with llm:
    await app.invoke({"messages": [Message("user", question)]}, thread="q-1")

# later, elsewhere, same checkpointer:
async with llm:
    out = await app.resume("q-1")

6. Approve tool use with a human in the loop

To require sign-off before a sensitive tool runs, switch to an agent backend with the Interactive policy — every tool request then pauses the run for approval, using the human-in-the-loop machinery.

cp = await app.get_state("q-1")
if cp.interrupted:                    # a PermissionRequest is waiting
    req = cp.interrupt_payload
    await app.resume("q-1", value="allow" if trusted(req.tool) else "deny")

Where to go next

You've used every layer in one agent: the graph engine, an LLM backend and a tool loop, streaming, a durable checkpointer, and a human approval gate. From here:

  • Run these as background jobs across a worker pool — the control plane.
  • Add memory that outlives a question — the Store.
  • Gate the answer on quality before shipping — quality gates.
  • Exact signatures — the API cheatsheet.