Giving LLMs and agents fund data safely: tools, grounding, guardrails
Why factsheet PDFs are poor tool input and JSON is good, a getFund(isin) tool schema, grounding in dataAsOf, refusal rules and an MCP-style tool definition.Published 11 September 2026 · 5 min read · by FundFacts APIAn assistant that can answer "what does this fund hold and what does it cost?" is one of the more useful things to put in front of advisers, support teams and self-directed investors. It is also one of the easier ways to ship confident nonsense. The difference is almost entirely in what the model is given as tool output, and in the rules wrapped around it. This post is about the runtime side: an LLM or agent answering questions about funds, not an agent writing code (that is covered in the coding agent post). We use FundFacts API as the data tool, and the AI fund assistant use case as the target.
Why a factsheet PDF is a poor tool input
The obvious approach is retrieval: index the fund's factsheet and KID, let the model read the relevant chunks, answer. It works in demos and fails in production for reasons that are specific to fund documents:
- The same fact has many names. "Fund size", "Net assets", "Total net assets" and "AUM" are one field. A chunk retriever does not know that; the model has to guess, and it guesses differently on different days.
- Numbers lose their units. A table cell reading
151.7is meaningless without the header three rows up saying "USD bn". Chunking separates them. - Dates are implicit. A factsheet says "as at 31 July" somewhere on page one. A chunk from page two carries no date, so the model reports July holdings as current in October.
- Documents disagree. The KID risk indicator and the factsheet risk indicator can differ, as the SRRI and SRI explainer describes. Retrieval will surface whichever chunk scores higher.
- Long documents cost tokens. Feeding several pages per question is slow and expensive, and most of the text is boilerplate.
Structured JSON fixes each point. A field called keyFacts.aum with the value "USD 151.7bn" has one name, carries its unit, sits next to a dataAsOf date, and costs a few tokens. The model does not extract; it reads.
The tool: getFund(isin)
Expose the API to the model as a single function. Keep the description factual and say what the tool does not do, because the model will otherwise try to search by name or ask for "all funds".
json{"type": "function","name": "getFund","description": "Return the structured factsheet for one fund or ETF share class identified by its 12-character ISIN: key facts, risk indicator (1-7), top holdings, sector and country exposure, calendar and cumulative returns, fees (TER) and risk metrics, with the date the figures refer to. There is no search: the caller must already have the ISIN. Returns not_found if the ISIN is not a covered fund.","parameters": {"type": "object","properties": {"isin": { "type": "string", "pattern": "^[A-Z]{2}[A-Z0-9]{9}[0-9]$", "description": "ISIN, e.g. IE00B4L5Y983" }},"required": ["isin"],"additionalProperties": false}}
The executor behind it does four things: validates the ISIN, checks a local cache, calls the API with the key on the server, and reshapes the response to the fields the assistant is allowed to talk about. Returning the entire payload is tempting and wasteful; the model only needs what the conversation is about, and a smaller tool result is a cheaper and more accurate one.
typescript// tools/getFund.tsconst cache = new Map<string, { expiresAt: number; result: unknown }>();const ISIN_RE = /^[A-Z]{2}[A-Z0-9]{9}\d$/;export async function getFund({ isin }: { isin: string }) {const code = isin.trim().toUpperCase();if (!ISIN_RE.test(code)) return { error: "invalid_isin", message: "Not a valid ISIN." };const hit = cache.get(code);if (hit && hit.expiresAt > Date.now()) return hit.result;const r = await fetch(`https://fundfactsapi.com/api/v1/funds/${code}`, {headers: { Authorization: `Bearer ${process.env.FUNDFACTS_API_KEY}` },signal: AbortSignal.timeout(300_000), // a fund nobody has asked for yet can take 1–3 minutes});if (r.status === 404) return { error: "not_found", message: "This ISIN is not a covered fund." };if (r.status === 429) return { error: "rate_limited", message: "Daily quota reached; try again later." };if (!r.ok) return { error: "upstream_error", message: "Fund data temporarily unavailable." };const env = await r.json();const d = env.data;const result = {isin: env.isin,name: env.name,dataAsOf: d.dataAsOf,category: d.profile?.category,assetClass: d.keyFacts?.assetClass,currency: d.keyFacts?.currency,aum: d.keyFacts?.aum,riskRating: d.riskRating,ter: d.headlineMetrics?.ter,topHoldings: (d.topHoldings ?? []).slice(0, 10),sector: (d.sector ?? []).slice(0, 8),geography: (d.geography ?? []).slice(0, 8),calendarReturns: d.calendarReturns,cumulativePerformance: d.cumulativePerformance,objective: d.investmentObjective,};cache.set(code, { expiresAt: new Date(env.expiresAt).getTime(), result });return result;}
Cache until expiresAt, not for an arbitrary TTL. The API refreshes each fund every 24 hours, so a conversation with twenty follow-up questions about one fund costs one request, and a team of advisers asking about the same fund all day costs one request. That is also the cost model: on Pro (100 requests a day) an assistant can cover a hundred distinct funds a day; on Scale a thousand. The ISIN lookup walkthrough covers the envelope fields the executor uses.
Grounding: cite the field and the date
The system prompt should turn "answer from the tool" into a habit of citation. Three rules cover most of it:
- Every figure names its field and its date. "Ongoing charges are 0.20% (
headlineMetrics.ter, data as of 31 July 2026)" is verifiable; "the fund is cheap" is not. - Weights are as published.
topHoldingsandsectorweights are already percentages and may not sum to 100. The model must not renormalise or invent a remainder. - Absent means absent. If
riskRatingis missing, say the indicator is not available in the documents read, and do not infer one from volatility.
A short instruction block that works in practice:
Guardrails
The instruction block is necessary and not sufficient. Enforce the important rules in code as well:
- No advice. Filter user turns for "should I buy", "is this a good fund", "which is better" patterns and route them to a fixed response that offers to describe the data instead. Models comply with prompts most of the time; regulators expect all of the time.
- Disclose the as-of date. Post-process the assistant's reply: if it contains a percentage or currency amount and no date, append the
dataAsOffrom the last tool result. - Refuse on 404. When the tool returns
not_found, the model must not fall back to its training data, which knows something about well-known funds and will happily produce a plausible TER from memory. Make the tool result explicit ("do not answer from memory") and check the reply for numbers when no successful tool call occurred in the turn. - One ISIN per call, ISIN required. If the user gives a fund name or a ticker, ask for the ISIN. The ISIN guide explains why the ticker is not enough.
- Log tool calls and replies with the
generatedAtof the payload, so a disputed answer can be traced to the exact data it was based on.
An MCP-style tool definition
If your assistant stack uses the Model Context Protocol, the same tool is a server exposing one tool. The description carries the operational rules, because MCP clients show it to the model verbatim.
typescript// mcp/fundfacts-server.ts (excerpt)server.tool("getFund",["Structured factsheet for one fund/ETF share class by ISIN.","Input: a 12-character ISIN. No search by name; ask the user for the ISIN if missing.","Output fields: name, dataAsOf, category, assetClass, currency, aum, riskRating (1-7), ter,","topHoldings[{name, weight}], sector[{label, weight}], geography[{label, weight}],","calendarReturns, cumulativePerformance, objective. Weights are percentages as published.","Always quote dataAsOf with figures. Never give investment advice.","If the result is {error: 'not_found'}, tell the user the fund is not covered; do not answer from memory.","First call for a new ISIN may take 1-3 minutes; do not retry while waiting.",].join(" "),{ isin: z.string().regex(/^[A-Z]{2}[A-Z0-9]{9}\d$/) },async ({ isin }) => ({ content: [{ type: "text", text: JSON.stringify(await getFund({ isin })) }] }),);
Generating this from the API's OpenAPI spec is an option, but a hand-written description that includes the behavioural rules performs better than a schema-only one.
Where the AI companion kit fits
Pro and Scale plans ship an AI companion kit: an AGENTS.md with the full field reference and rules for agents, a Cursor rule and a Claude Code skill that restate the hard rules, an OpenAPI 3.1 spec and TypeScript types. For a runtime assistant, the pieces map as follows. The OpenAPI spec generates the tool schema or feeds an OpenAPI-to-MCP bridge. The field reference in AGENTS.md is the source for the tool description and for the allow-list of fields in the executor. The rules section (show dataAsOf, never retry 4xx, cache until expiresAt, no advice) becomes your system prompt's rule block almost verbatim. And the TypeScript types keep the executor honest when you narrow the payload.
Start with a free key, wire getFund into whichever agent framework you use, and test the guardrails with the questions your users actually ask: "is this a good fund?", "what does it hold today?", and a made-up ISIN. If all three come back grounded, dated and unopinionated, the assistant is ready. The documentation has every field the tool can expose.