diff --git a/app/public/assets/posts/uk-chat-cant-make-up-numbers.webp b/app/public/assets/posts/uk-chat-cant-make-up-numbers.webp
new file mode 100644
index 000000000..75041b561
Binary files /dev/null and b/app/public/assets/posts/uk-chat-cant-make-up-numbers.webp differ
diff --git a/app/src/components/blog/MarkdownFormatter.tsx b/app/src/components/blog/MarkdownFormatter.tsx
index 49de32934..747eb0d2f 100644
--- a/app/src/components/blog/MarkdownFormatter.tsx
+++ b/app/src/components/blog/MarkdownFormatter.tsx
@@ -476,6 +476,8 @@ export function MarkdownFormatter({
ul: ({ children }) => (
(
+ CALCULATE
+ SUPPORT
+
+ calculate_household
+
+ One synthetic household →
+ UC, Child Benefit, net income
+
+
+ run_economy_simulation
+
+ A reform across everyone →
+ budget, deciles,
+ gains and losses, poverty
+
+
+ analyse_microdata
+
+ Aggregate population
+ questions
+
+
+ generate_chart
+
+ Turns any result
+ into a chart
+
+
+ run_python
+
+ Sandboxed Python for
+ questions the tools
+ do not cover
+
+
+ validate_reform
+
+ Checks a reform definition
+ before it is used
+
+
+
+## Under the hood
+
+### The architecture
+
+The runtime is a FastAPI server-sent-events handler that calls the Anthropic SDK directly. There is no agent framework in the request path: the chat owns its tool loop, because the rules that matter are enforced inside that loop rather than around it. A request then runs in a fixed order:
+
+1. Validate the request and check the account's balance.
+2. Assemble the system blocks — the system prompt and an engine reference generated from the installed engine — both marked for prompt caching.
+3. Open a streaming call to a Claude model, defaulting to Claude Haiku and escalating to Claude Sonnet when the estimated input-token count of the assembled request (messages, system prompt, and engine reference) passes a fixed threshold.
+4. Read the model's output as it streams; whenever it asks for a tool, run that tool through a deterministic executor and feed the result back, repeating until the model stops.
+5. Stream events to the client throughout.
+
+The diagram below traces one request through that loop — the model requesting tools, the code running them and feeding results back, each iteration reaching the client as a server-sent event.
+
+
+
+### The tool-use loop
+
+Inside that loop, the streaming API returns content as a sequence of blocks, which the chat processes as they arrive:
+
+1. Watch for tool-use blocks, each naming a tool and carrying a JSON input.
+2. Resolve the name against a dispatch table and run the handler; independent tools requested in the same turn run concurrently.
+3. Append each result to the conversation as a tool-result turn before streaming the next.
+
+Two controls keep the loop bounded. An iteration cap limits how many tool rounds a single request can take, and a repeated-call check stops the loop with an error when three identical calls arrive in a row, which means the model is stuck. When a single tool raises, the error is caught and returned to the model as that tool's result so it can adjust rather than the request failing outright; if the cap is reached first, the chat returns a message naming the tools it attempted and the last error, then a clean terminal event, so the client always receives a proper ending.
+
+The tools are defined in one place, with shared schema fragments — the year, reform-property, dataset, filter, and chart schemas — reused across them, so a definition such as a reform property lives in a single location.
+
+### The model–code boundary
+
+The boundary between the model and the code is written down and treated as a contract. The model handles the parts that are open-ended: interpreting the question, planning, selecting tools, writing prose, and suggesting follow-ups. The code handles everything that affects a number or a displayed result: request validation, tool dispatch, the typed tools themselves — including Python execution and chart construction — result truncation and summarisation, billing, and database writes.
+
+Some of these could be left to the model and deliberately are not. If the model chose which rows of a large table to show, that choice would be a sampling decision, and a sampling decision the reader cannot see is one the reader cannot check. A deterministic helper makes it instead, so the choice can be inspected rather than taken on trust.
+
+Plan mode follows the same logic. It lets the model talk through an approach before running anything. Telling the model "do not call tools" in the prompt is a request it can ignore, so the chat does not rely on that: in plan mode the list of tools is left out of the request entirely, and **a model cannot call a tool it was never offered**.
+
+### The engine reference
+
+The model also needs to know what the engine can actually compute, and that surface changes every time `policyengine-uk-compiled` is updated. At deploy time, a build step snapshots the installed engine into a reference document: its reported capabilities, the public API signatures and docstrings, and the full parameter schema. That document is loaded into the system prompt and reflects the engine installed at deploy time. Because it is generated from the engine rather than written by hand, the model's account of what the engine covers cannot drift from what the engine actually does.
+
+## Limitations
+
+The chat is a **modelling tool, not advice**. It reports what the model calculates under stated assumptions, and it is not a substitute for professional guidance on an individual's circumstances.
+
+`run_economy_simulation` handles parametric reforms, meaning changes to existing rates, thresholds, and parameters. Reforms that introduce new mechanisms fall back to `run_python`, which we review, or sit outside the standard tools. Only the major UK tax and benefit programmes are modelled, so questions about minor or unmodelled provisions may not be answerable.
+
+Results depend on the dataset, the year, and the modelling assumptions, and the chat states these dependencies rather than presenting figures as universal. The aim is that **every path either produces a number you can check or says plainly that it cannot**. It is free to try, and its answers can be cited and reproduced: because the figures come from the same open engine that powers the rest of PolicyEngine, they can be checked against independent estimates, such as those from the IFS, the Resolution Foundation, or the OBR.
+
+## Next steps
+
+We want to widen the range of reforms the typed tools cover, so fewer questions fall back to reviewed Python, and to make that fallback quicker to run and inspect. PolicyEngine UK Chat works alongside our wider use of Claude and our plugin ecosystem, which bring the same engine to researchers building their own analyses. Try it with a reform you care about, and check the figures against the open-source engine that produced them.
diff --git a/app/src/data/posts/posts.json b/app/src/data/posts/posts.json
index 7ef2f15ae..b32b99e16 100644
--- a/app/src/data/posts/posts.json
+++ b/app/src/data/posts/posts.json
@@ -1725,5 +1725,14 @@
"authors": ["vahid-ahmadi"],
"filename": "multi-agent-workflows-policy-research.md",
"image": "multi-agent-workflows-policy-research.webp"
+ },
+ {
+ "title": "AI chatbot for policymaking",
+ "description": "An AI chat interface to PolicyEngine UK that answers tax and benefit questions by running the microsimulation engine for every figure.",
+ "date": "2026-06-25",
+ "tags": ["uk", "featured", "technology", "ai"],
+ "authors": ["vahid-ahmadi", "anthony-volk"],
+ "filename": "uk-chat-cant-make-up-numbers.md",
+ "image": "uk-chat-cant-make-up-numbers.webp"
}
]