Skip to content
unzoi docs
Search and navigation
Start here
REST API
MCP
Limits and plans
Agent clients
SDKs
Guides

Building a news agent

Connecting an agent to the endpoint takes two minutes. Making it good takes a system prompt and two budget decisions.

1. Connect

import { query } from "@anthropic-ai/claude-agent-sdk";

const options = {
  mcpServers: {
    unzoi: {
      type: "http" as const,
      url: "https://api.unzoi.com/mcp",
      headers: { "x-api-key": process.env.UNZOI_KEY! },
    },
  },
};

Any framework works the same way — LangChain, LlamaIndex, the OpenAI Agents SDK. What follows is about the agent, not the plumbing.

2. The system prompt

The tool descriptions already tell the model what each tool does. What they cannot know is your application's priorities. Nine paragraphs cover almost everything:

You have access to a global news index through the unzoi tools. It holds article
metadata and links, never article text.

Start with list_stories, not search_news. One row per event, with an article count
and an outlet count, is almost always more useful than forty near-identical
articles, and the outlet count tells the user how much attention something got.
Use search_news only when the question is about the coverage itself: who reported
it, how it was framed, which countries or languages picked it up.

To go deeper on one event, call get_story with its story_id. That returns the whole
story in one call: every article, every outlet, and when it was first and last
seen. Do not re-run a search to expand a story you already have.

Always pass a time range. "from" defaults to the plan's window, which may be
narrower than you expect. Name the window the user means, and say which window
you searched.

Never conclude that something was not covered. If a result has partial true,
coverage anything other than "complete", or from_clamped true, the index did not
fully answer: say so instead of reporting absence. Zero results with a filter set
means the filter value may be wrong, not that nothing happened; drop the filter
and try again before drawing a conclusion.

Before filtering on an organization, person, location or topic, call
resolve_entity with the name the user gave you and pass the entity_id it returns
as entity_id. Filters are exact matches on the index's own spelling; a value the
index does not hold matches nothing and looks like no coverage. If resolve_entity
returns several candidates, pick the one whose count fits, or ask.

For questions about a place, filter with near or bbox rather than naming the place
in q; for events use event_type alongside q when recall matters.

For "who is connected to X" questions use related_entities, and present
co-mentions as reporting patterns, never as facts about the world. Say "reported
together in N stories", quote the evidence, and never call two entities partners,
owners or suppliers because they appear in the same articles.

Cite outlets by name and link the url from the result. Never state a fact from
these tools without attributing it to a source in the results, and never present
an outlet count as the number of independent newsrooms: wire services and
ownership groups put one report on many domains.

Each paragraph is fixing a specific failure:

  • Without the first, the agent promises to quote or summarise an article's text, which it has never seen, and invents it.
  • Without the second, it uses search_news by default, fills its context with duplicates, and loses the attention signal entirely.
  • Without the third, it expands a story by re-searching its headline — a query that inherits one outlet's framing and drifts — instead of calling get_story once.
  • Without the fourth, it reports "I found no coverage" when it actually hit a plan boundary, and it never tells the user what period it looked at.
  • Without the fifth, it turns a degraded index into a fact about the world. A short result with partial: true reads, to a model, exactly like a quiet news day — Reading completeness is the rule in full.
  • Without the sixth, it filters on organization=ASML Holding NV, gets nothing, and concludes there was no coverage. resolve_entity returns an id that matches every spelling the index holds.
  • Without the seventh, it puts "Rotterdam" in q, which finds articles that use the word rather than articles that mention a city near it, and it reads an event_type result as the complete list. near and bbox match the cities an article mentions; event classes are precise rather than complete, so q alongside catches what the class misses.
  • Without the eighth, it answers "who is ASML connected to?" with a list of partners. Two companies in the same articles may be rivals, or two examples in one market report. related_entities counts co-mentions and returns the articles; the prompt makes the agent say what they are.
  • Without the ninth, it summarises the news into ungrounded assertions — the failure that makes a news agent untrustworthy rather than merely wrong — and it calls forty syndicated copies "forty newsrooms".

3. Restrict the tools

Every tool you leave enabled is a tool the agent can spend credits on.

allowedTools: [
  "mcp__unzoi__resolve_entity",
  "mcp__unzoi__list_stories",
  "mcp__unzoi__get_story",
  "mcp__unzoi__search_news",
  "mcp__unzoi__get_article",
  "mcp__unzoi__find_related",
  // Relationship questions: co-mentions with the articles as evidence. Each call
  // is charged the index queries it issued, so leave them out if nobody asks.
  "mcp__unzoi__related_entities",
  "mcp__unzoi__connection_path",
  "mcp__unzoi__entity_network",
  "mcp__unzoi__shared_exposures",
],
// Omitted: top_headlines, which a question-answering agent rarely needs;
// aggregate_news, unless it answers "is this growing?" questions;
// account_status, which is for your code rather than for the model; and the
// watch tools. A monitoring agent adds create_watch, list_watches, get_watch,
// delete_watch and watch_events, so the index polls instead of the agent.

Tool names are namespaced mcp__<server>__<tool> by most SDKs; a bare name in an allow-list silently matches nothing.

4. Cap the loop

const result = query({
  prompt: userQuestion,
  options: { ...options, maxTurns: 8 },
});

The shape that works

The strongest pattern is narrow-then-deepen, and the agent will follow it if the tools are available:

  1. resolve_entity, when the question names an organization, a person or a place — the spelling the index holds for it. Optional: a question with no entity in it skips this.
  2. list_stories with a time range and that value as a filter — what happened.
  3. get_story with a row's story_id — the whole event: every outlet, when it was first and last seen, what it is about, its newest articles. One call, no re-search.
  4. get_article — the full record for a specific piece, including quotations.
  5. find_related — what else is adjacent, without inventing a new query.

Five calls at most, each narrowing, and only the first two carry a query. The alternative — repeated broad searches with slightly different wording — costs more and returns overlapping results, because it is asking the same question in synonyms. search_news with story_id is still the call when the question is about the members themselves: how many outlets in each country, which languages, faceted and paged.

5. Handle the refusals

Two failures arrive as tool errors on an open session rather than as transport errors: a suspended account and an account out of credits. Your agent will see them as tool output. Surface them:

If a tool returns an error containing "insufficient_credits" or "suspended", stop
searching and tell the user the account needs attention — do not retry, and do
not answer from memory as though you had searched.

A third kind of refusal is the model's own mistake: an argument the tool does not declare, or a signal name that does not exist, comes back as a JSON-RPC error with a did you mean hint rather than being silently dropped. Most SDKs surface that to the model, which corrects the call. If yours swallows it, the agent will retry the same typo; MCP errors has the payload.

The last clause matters more than it looks. A model that cannot search will otherwise answer from training data and present it with the confidence of a fresh search.

6. Check it actually works

Ask it three questions with known-different shapes and watch which tools it reaches for:

QuestionShould use
"What happened with X this week?"list_stories
"How did European outlets frame X?"search_news with publisher_country
"Tell me more about that one"get_story with the row's story_id, not a new search
"Is there anything about Acme Holdings?"resolve_entity first, then list_stories with the value it returned
"What is happening right now in Y?"top_headlines, if you enabled it
"Has coverage of X picked up this month?"aggregate_news with interval: "day", if you enabled it — not a search per day
"Who is Acme Holdings connected to?"resolve_entity, then related_entities with the id; the answer says "reported together" and cites the evidence, never "partners"
"What is happening around the port of Rotterdam?"list_stories with near and radius_km, not "Rotterdam" in q
"Which chipmakers reported outages this month?"list_stories with event_type: "outage" and a q alongside, not the class alone
"How are X and Y linked?"connection_path with both ids, not two searches compared by hand; a path through a neighbour is described as both being reported alongside it

If it reaches for search_news every time, the second paragraph of your system prompt is not landing. If it answers "nothing was reported" without mentioning the window or the completeness of the result, the fourth and fifth are not.