> youcanbuildthings.com
tutorials books topics about

Claude Chatbot Hallucinating Fix: The Hub-and-Spoke

by J Cook · 9 min read·

Summary:

  1. A confident-but-wrong chatbot is an architecture problem, not a prompt problem. One model answering from memory will invent.
  2. The fix is hub-and-spoke: a Coordinator that routes, a read-only Researcher that returns structured citations, a Synthesizer that may only cite or refuse.
  3. Scoped tool lists are the fence: Researcher gets search tools, Synthesizer gets [“Read”] only, so it cannot invent.
  4. Bonus deliverable: a runnable multi_agent_starter.py with a logged refusal path you can demo in an architect interview.

The Claude chatbot hallucinating fix that the Claude Certified Architect exam actually grades is not “write a better prompt.” It is an architecture. A customer in Atlanta asks your support bot about things to do in Albuquerque, and the bot confidently invents events that do not exist. Switching models, lowering temperature, and tightening the system prompt are knob-turns. The architectural fix is a Coordinator that refuses to answer from memory, a Researcher that returns only structured citations, and a Synthesizer that can cite or refuse and do nothing else.

A hub-and-spoke architecture: a user query about Albuquerque flows to a Coordinator running claude-opus-4-7 with allowed_tools [Read, Glob, Grep, Agent] that routes, plans, budgets, and verifies but never answers from memory; a Researcher subagent with tools [Read, Glob, Grep] returns structured citations only; a Synthesizer subagent with tools [Read] synthesizes from citations or refuses; a refusal path logs reason, context, and trace id.

Why does a single Claude agent hallucinate confidently?

Because one model answering directly has no layer between what it knows and what it says. A single-agent setup takes the user’s question and produces fluent prose. When the knowledge is missing, the prose is still fluent. There is no contractual point in the system where “I have no evidence for this” can stop the answer before it reaches the user. That missing layer is the bug, and you fix it by adding the layer, not by nudging the model.

The hub-and-spoke pattern adds three layers and a refusal path. The Coordinator routes and verifies. The Researcher gathers evidence and returns structured citations. The Synthesizer composes an answer that cites only that evidence. If there is no evidence, the system refuses and logs the refusal. Invention has nowhere to happen.

The Claude Agent SDK is the substrate. The canonical quickstart from github.com/anthropics/claude-agent-sdk-python shows the query() plus ClaudeAgentOptions shape every example builds on:

from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, TextBlock

# With options
options = ClaudeAgentOptions(
    system_prompt="You are a helpful assistant",
    max_turns=1
)

async for message in query(prompt="Tell me a joke", options=options):
    print(message)

That is the single-agent form. The fix is what you wrap around it.

What broke: the Albuquerque answer

The chatbot invented events because nothing forced it to ground the answer. The user in Atlanta asked about Albuquerque. The model had no retrieved facts, produced confident prose anyway, and the customer trusted it. No exception fired. The answer was well-formed and wrong. This is the canonical Domain 1 exam stem, and the wrong answers on the exam are always “switch to a more capable model,” “tighten the system prompt,” and “lower the temperature.” All three leave the system shape unchanged.

This scenario sits in Domain 1 (Agentic Architecture and Orchestration), the heaviest exam domain at 27% of the questions. The architectural answer changes the shape. Here is the build.

How do you build the hub-and-spoke fix?

Five steps. By the end you have a runnable multi_agent_starter.py with a Coordinator on claude-opus-4-7, two scoped subagents, and a logged refusal path.

Step 1. Install the SDK and set the key.

python -m venv .venv && source .venv/bin/activate
pip install claude-agent-sdk
export ANTHROPIC_API_KEY="sk-ant-..."

Step 2. Define the Coordinator with the Agent tool in its allow-list. The Coordinator runs on claude-opus-4-7. Its rule is absolute: it routes, plans, budgets, and verifies, and it never answers from memory. The single most exam-relevant detail is that "Agent" must be in allowed_tools or the Coordinator cannot delegate at all.

Step 3. Scope the subagent tool lists. The Researcher gets ["Read", "Glob", "Grep"]. It retrieves and returns structured citations only. The Synthesizer gets ["Read"] and nothing else. Because it cannot search, it cannot invent. Its contract is: cite or refuse, no invention, no extra facts.

Step 4. Write the file.

import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, AgentDefinition

RESEARCHER_PROMPT = """
Find evidence in the project files relevant to the user's question.
Return a JSON object with a "citations" array of {source, excerpt, timestamp, url}
and a "confidence" field of "low", "medium", or "high".
If you cannot find relevant evidence, return citations: [] and confidence: "low".
"""

SYNTHESIZER_PROMPT = """
Compose a final answer that cites only the material the researcher returned.
For every claim, cite the source from the researcher's citations.
If a claim cannot be cited, remove it. If nothing can be cited, refuse.
"""

async def main():
    options = ClaudeAgentOptions(
        model="claude-opus-4-7",
        allowed_tools=["Read", "Glob", "Grep", "Agent"],
        agents={
            "researcher": AgentDefinition(
                description="Searches sources and returns structured citations only.",
                prompt=RESEARCHER_PROMPT,
                tools=["Read", "Glob", "Grep"],
                model="claude-haiku-4-5",
            ),
            "synthesizer": AgentDefinition(
                description="Composes a cited answer from researcher output, or refuses.",
                prompt=SYNTHESIZER_PROMPT,
                tools=["Read"],
                model="claude-haiku-4-5",
            ),
        },
    )

    user_query = "Customer in Atlanta asks: what events are on in Albuquerque this weekend?"
    async for message in query(prompt=user_query, options=options):
        print(message)

asyncio.run(main())

Step 5. Wire the refusal path. When the Researcher returns zero citations or confidence: "low", the Coordinator does not synthesize. It routes to a refusal and logs it. The log line is the architect’s audit trail:

import json, uuid

def refuse_and_log(user_query: str, reason: str) -> str:
    trace_id = str(uuid.uuid4())
    log_entry = {
        "trace_id": trace_id,
        "user_query": user_query,
        "reason": reason,
        "context": "researcher returned 0 citations or low confidence",
    }
    print(json.dumps(log_entry))  # ship to your structured log store
    return "I can't answer that from our knowledge base."

Now the Albuquerque question returns “I can’t answer that from our knowledge base” with a logged reason, context, and trace id. It does not return invented events.

Why this fixes it

Typed responsibilities plus tool constraints plus citations plus a refusal path eliminate confident invention at the source. The Synthesizer cannot invent because ["Read"] gives it no way to search. The Coordinator cannot invent because its rule forbids answering from memory. The Researcher cannot smuggle prose past the contract because its output is a citations array, not free text. The only paths through the system are “cited answer” and “logged refusal.” There is no third path where a hallucination lives.

This is the part most study guides skip: scoping a subagent’s tool list is not a performance tweak. It is the safety fence. A Synthesizer with Bash could shell out. A Synthesizer with WebFetch could pull a random page and cite it. A Synthesizer with ["Read"] can do exactly one thing, and that one thing cannot hallucinate a flight number.

What should you actually do?

  • If your chatbot invents facts under load → split it into Coordinator plus Researcher plus Synthesizer before you touch the prompt. The prompt is the last lever, not the first.
  • If you only have time for one change → scope the answering agent’s tool list down to ["Read"] and add the refusal path. That alone removes most invention.
  • If you are prepping for the Claude Certified Architect exam → build this file, break it by removing "Agent" from allowed_tools, watch delegation fail, and restore it. That failure mode is a graded question.
  • If a competing guide tells you to “improve the system prompt” → it is treating an architecture question as a prompt question. The exam grades the architecture.

bottom_line

  • A hallucinating chatbot is a missing-layer problem. Add the layer; the prompt is not the fix.
  • The fence is the tool list. Researcher gets search tools, Synthesizer gets ["Read"], the Coordinator never answers from memory.
  • Ship the refusal path with a trace id. “I can’t answer that” plus a logged reason beats a confident wrong answer every time.

Frequently Asked Questions

How do I stop a Claude chatbot from hallucinating?+

Stop letting one model answer from memory. Route the query through a Coordinator to a read-only Researcher that returns structured citations, then a Synthesizer that may only cite or refuse. The architecture removes the path where invention happens.

What is the hub-and-spoke pattern in the Claude Agent SDK?+

A Coordinator (the hub) delegates to scoped subagents (the spokes). The Coordinator routes and verifies but never answers from memory. Each subagent has a narrowed tool list, so the Researcher can search and the Synthesizer can only read citations.

Why does scoping a subagent's tool list matter for hallucination?+

Because a Synthesizer with only ['Read'] cannot search the web or invent new facts. It can only compose an answer from the citations the Researcher already returned, or refuse. The tool list is the contractual fence.