# Research Assistant

> A help desk server whose research agent answers a hard question from knowledge-base articles and closed tickets, by handing the searching to another agent and returning only claims that cite the sources it was given.

Source: https://frontmcp.dev/examples/research-assistant

Some support questions have no article of their own: "Why do enterprise customers keep hitting login failures after the SSO change?" The answer is spread over a few knowledge-base articles and the tickets that came before. This example is a help desk server whose `research` [agent](https://frontmcp.dev/learn/your-first-agent) reads through both and answers with a few claims, each pointing at the article or ticket ids it came from. It splits the work between two agents: `research` hands the searching to a narrower agent, `find_sources`, [from its own code](https://frontmcp.dev/learn/agents-that-call-agents#handing-a-task-off-from-code), checks what comes back, and asks its own model to write only from the sources that checked out. The articles and tickets are also [resources](https://frontmcp.dev/learn/exposing-data-with-resources), so a client can open whatever a claim cites.

**You will learn**
- How a coordinating agent hands work to a narrower one, and why it checks what comes back
- How to make an answer checkable: a schema that demands sources, and code that checks the ids are real
- How articles and tickets become resources a client can open from a citation
- How an agent's tools report progress to the client, even when another agent started that agent
- How two scripted models stand in for real ones, and what changes with real models

## The server

Here is the whole server. Open the **Call** tab: `invoke_research` has answered the question with three claims, each with the ids of its sources, and the log messages the searches sent are above the result. To see what a claim cites, read the resource `kb://KB-2` or `tickets://T-43` in the Call tab. Then open the **Tests** tab.

```ts research.agent.ts active
import { Agent, AgentContext, PublicMcpError, z } from "@frontmcp/sdk";
import { KnowledgeBase } from "./knowledge";
import { writeModel } from "./model.example";

export const RESEARCH_INSTRUCTIONS = `You answer a support agent's question using only the sources you are given: articles (KB-...) and closed tickets (T-...).
Answer only with JSON: {"claims": [{"statement": "<one sentence>", "sources": [ids of the sources that say it]}]}.
Put the most important claim first, write at most four, and give every claim at least one source id.`;

type Answer = { claims: { statement: string; sources: string[] }[] };

@Agent({
  name: "research",
  description:
    "Answer a hard support question from the help desk's own articles and closed tickets. Returns a few claims, each with the ids of " +
    "the sources it came from: read KB-... ids as the resource kb://<id>, and T-... ids as tickets://<id>. Pass the question.",
  systemInstructions: RESEARCH_INSTRUCTIONS,
  inputSchema: {
    question: z.string().describe("The question, like: Why do enterprise customers keep hitting login failures after the SSO change?"),
  },
  outputSchema: {
    claims: z
      .array(
        z.object({
          statement: z.string(),
          sources: z.array(z.string().regex(/^(KB|T)-\d+$/)).min(1),
        }),
      )
      .min(1),
  },
  execution: { timeout: 60_000 },
  llm: { adapter: writeModel },
})
export class ResearchAgent extends AgentContext {
  async execute(input: { question: string }) {
    const found = await this.callTool("invoke_find_sources", { question: input.question });
    const { sourceIds } = found.structuredContent as { sourceIds: string[] };

    const knowledgeBase = this.get(KnowledgeBase);
    const sources = sourceIds.flatMap((id) => knowledgeBase.find(id) ?? []);
    if (sources.length === 0) {
      this.fail(new PublicMcpError("The help desk's articles and closed tickets don't cover that.", "NO_SOURCES_FOUND"));
    }

    const answer = (await super.execute({ question: input.question, sources })) as Answer;

    const givenIds = new Set(sources.map((source) => source.id));
    // An answer in the wrong format has no claims, and goes on to fail outputSchema.
    const citedIds = answer.claims?.flatMap((claim) => claim.sources) ?? [];
    const unknownIds = citedIds.filter((id) => !givenIds.has(id));
    if (unknownIds.length > 0) {
      this.fail(new PublicMcpError(`The answer cites ${unknownIds.join(", ")}, which the search didn't find.`, "UNKNOWN_SOURCE"));
    }
    return answer;
  }
}
```

```ts find-sources.agent.ts
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { searchModel } from "./model.example";
import { SearchArticles, SearchTickets } from "./search.tools";

export const FIND_SOURCES_INSTRUCTIONS = `You find the help desk's own material that helps answer a question.
1. Search the articles with search_articles and the closed tickets with search_tickets, using a few words a support agent would use.
2. Answer only with JSON: {"sourceIds": [ids of the articles and tickets worth reading, most useful first]}. Use [] if nothing helps.
Never list an id that a search didn't return.`;

@Agent({
  name: "find_sources",
  description: "Search the help desk's articles and closed tickets for a question, and return the ids worth reading. It finds sources; it doesn't answer.",
  systemInstructions: FIND_SOURCES_INSTRUCTIONS,
  inputSchema: { question: z.string().describe("What the support agent wants to know") },
  outputSchema: { sourceIds: z.array(z.string()) },
  tools: [SearchArticles, SearchTickets],
  hideFromDiscovery: true,
  execution: { maxIterations: 4, timeout: 30_000 },
  llm: { adapter: searchModel },
})
export class FindSourcesAgent extends AgentContext {}
```

```ts search.tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { KnowledgeBase } from "./knowledge";

@Tool({
  name: "search_articles",
  description: "Search the knowledge-base articles by the words in their title and text. Returns the best three, each with its id and title.",
  inputSchema: { query: z.string().describe("A few search words, like: sso certificate") },
})
export class SearchArticles extends ToolContext {
  async execute({ query }: { query: string }) {
    const articles = this.get(KnowledgeBase).searchArticles(query);
    await this.notify(`Articles for "${query}": ${articles.length} found`);
    return { articles: articles.map(({ id, title }) => ({ id, title })) };
  }
}

@Tool({
  name: "search_tickets",
  description: "Search closed tickets by the words in their title and resolution. Returns the best three, each with its id, title and the customer's plan.",
  inputSchema: { query: z.string().describe("A few search words, like: sso certificate") },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const tickets = this.get(KnowledgeBase).searchTickets(query);
    await this.notify(`Closed tickets for "${query}": ${tickets.length} found`);
    return { tickets: tickets.map(({ id, title, plan }) => ({ id, title, plan })) };
  }
}
```

```ts knowledge.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

type Article = { id: string; title: string; body: string };

type ClosedTicket = {
  id: string;
  customer: string;
  plan: "enterprise" | "standard";
  title: string;
  resolution: string;
};

function bestMatches<Item>(items: Item[], query: string, textOf: (item: Item) => string) {
  const searchWords = query.toLowerCase().split(/\W+/).filter(Boolean);
  return items
    .map((item) => {
      const wordsInItem = new Set(textOf(item).toLowerCase().split(/\W+/));
      return { item, matches: searchWords.filter((word) => wordsInItem.has(word)).length };
    })
    .filter(({ matches }) => matches > 0)
    .sort((first, second) => second.matches - first.matches)
    .slice(0, 3)
    .map(({ item }) => item);
}

@Provider({ name: "KnowledgeBase", scope: ProviderScope.GLOBAL })
export class KnowledgeBase {
  private articles: Article[] = [
    {
      id: "KB-1",
      title: "Signing in with SSO",
      body: "Enterprise plans can turn on SSO, so people use their company login instead of a password. Since release 3.2 (14 September), the dashboard only accepts SSO responses signed by a certificate an admin has uploaded in Settings > Security. Responses signed by any other certificate are refused.",
    },
    {
      id: "KB-2",
      title: "Error SSO-401: signature not trusted",
      body: "SSO-401 means the login response was signed by a certificate the dashboard doesn't know. Ask the customer's admin to upload the identity provider's current signing certificate in Settings > Security. Sign-in works again at once. Certificates expire, so expect the error again when the customer renews theirs.",
    },
    {
      id: "KB-3",
      title: "Resetting a password",
      body: "Customers on the standard plan use a login with a password. Send a reset link from the customer's page in the admin console. The link works for one hour.",
    },
    {
      id: "KB-4",
      title: "How long a session lasts",
      body: "A login session lasts 8 hours, then the person signs in again. Admins on enterprise plans can shorten it in Settings > Security.",
    },
    {
      id: "KB-5",
      title: "SSO and clock differences",
      body: "The dashboard refuses SSO login responses that are more than 5 minutes old. When the identity provider's clock is off by more than that, sign-in fails even with a valid certificate. Ask the customer's admin to sync the provider's clock.",
    },
    {
      id: "KB-6",
      title: "Refunds and duplicate charges",
      body: "Refund a duplicate charge in full, and send the customer the invoice number.",
    },
  ];

  private closedTickets: ClosedTicket[] = [
    {
      id: "T-41",
      customer: "Acme",
      plan: "enterprise",
      title: "SSO login fails with SSO-401",
      resolution: "Their admin uploaded the new signing certificate in Settings > Security. Sign-in worked at once.",
    },
    {
      id: "T-43",
      customer: "Globex",
      plan: "enterprise",
      title: "Nobody can use our company login",
      resolution: "SSO-401 after the 3.2 release: the certificate had never been uploaded. Uploaded it, and sign-in worked.",
    },
    {
      id: "T-46",
      customer: "Initech",
      plan: "standard",
      title: "Forgot my password",
      resolution: "Sent a reset link.",
    },
    {
      id: "T-48",
      customer: "Acme",
      plan: "enterprise",
      title: "Signed out after a few hours",
      resolution: "Working as intended: a login session lasts 8 hours.",
    },
    {
      id: "T-52",
      customer: "Globex",
      plan: "enterprise",
      title: "SSO login fails, but only sometimes",
      resolution: "The identity provider's clock was 9 minutes fast, so its responses looked too old. They synced the clock.",
    },
    {
      id: "T-55",
      customer: "Initech",
      plan: "standard",
      title: "Charged twice for INV-7",
      resolution: "Refunded the second charge.",
    },
  ];

  searchArticles(query: string) {
    return bestMatches(this.articles, query, ({ title, body }) => `${title} ${body}`);
  }

  searchTickets(query: string) {
    return bestMatches(this.closedTickets, query, ({ title, resolution }) => `${title} ${resolution}`);
  }

  getArticle(id: string) {
    return this.articles.find((article) => article.id === id);
  }

  getTicket(id: string) {
    return this.closedTickets.find((ticket) => ticket.id === id);
  }

  find(id: string) {
    return this.getArticle(id) ?? this.getTicket(id);
  }
}
```

```ts sources.resources.ts
import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk";
import { KnowledgeBase } from "./knowledge";

@ResourceTemplate({
  name: "article",
  uriTemplate: "kb://{id}",
  mimeType: "text/markdown",
  description: "One knowledge-base article, like kb://KB-2. The research agent cites articles by these ids.",
})
export class ArticleResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const article = this.get(KnowledgeBase).getArticle(id);
    if (!article) throw new ResourceNotFoundError(uri);
    return `# ${article.title}\n\n${article.body}`;
  }
}

@ResourceTemplate({
  name: "closed-ticket",
  uriTemplate: "tickets://{id}",
  mimeType: "application/json",
  description: "One closed ticket with its resolution, like tickets://T-41. The research agent cites tickets by these ids.",
})
export class ClosedTicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const ticket = this.get(KnowledgeBase).getTicket(id);
    if (!ticket) throw new ResourceNotFoundError(uri);
    return ticket;
  }
}
```

```ts model.example.ts
// Stands in for the two agents' models, which the Playground can't reach. A real server doesn't need this file.
import type { AgentCompletion, AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk";

type Slip =
  | "none"
  | "searcher invents a source"
  | "searcher answers in words"
  | "writer cites an unknown source"
  | "writer wraps its JSON in a code fence"
  | "writer cites nothing";

export const scenario: { slip: Slip } = { slip: "none" };

export const turns: { search: AgentPrompt[]; write: AgentPrompt[] } = { search: [], write: [] };

const SEARCH_WORDS = ["sso", "login", "password", "session", "invoice", "refund"];

export const searchModel: AgentLlmAdapter = {
  async completion(prompt) {
    turns.search.push(structuredClone(prompt));
    if (scenario.slip === "searcher answers in words") return { content: "I found KB-1 and T-41.", finishReason: "stop" };
    const { question } = JSON.parse(prompt.messages[0].content ?? "{}");
    const query = SEARCH_WORDS.filter((word) => question.toLowerCase().includes(word)).join(" ");
    if (!query) return replyWith({ sourceIds: [] });

    const searchResults = prompt.messages.filter((message) => message.role === "tool");
    if (searchResults.length === 0) {
      return askFor(["search_articles", { query }], ["search_tickets", { query }]);
    }

    const sourceIds = searchResults.flatMap((message) => {
      const { articles = [], tickets = [] } = JSON.parse(message.content ?? "{}");
      return [...articles, ...tickets].map((source: { id: string }) => source.id);
    });
    if (scenario.slip === "searcher invents a source") sourceIds.push("KB-99");
    return replyWith({ sourceIds });
  },
};

const CLAIMS_IT_CAN_WRITE = [
  {
    needs: ["KB-1", "KB-2"],
    statement:
      "Since release 3.2, SSO sign-in only accepts responses signed by a certificate uploaded in Settings > Security, and refuses the rest with SSO-401.",
  },
  {
    needs: ["T-41", "T-43"],
    statement: "Acme and Globex both hit SSO-401 and were fixed by uploading their certificate.",
  },
  {
    needs: ["KB-5", "T-52"],
    statement: "Sign-in that fails only sometimes can be the identity provider's clock being more than 5 minutes off.",
  },
  {
    needs: ["KB-4", "T-48"],
    statement: "A session lasts 8 hours, so being signed out after a few hours is expected. Enterprise admins can shorten it.",
  },
  {
    needs: ["KB-3", "T-46"],
    statement: "Customers on the standard plan reset a forgotten password with a link that works for one hour.",
  },
  {
    needs: ["KB-6"],
    statement: "A duplicate charge is refunded in full, and the customer gets the invoice number.",
  },
];

export const writeModel: AgentLlmAdapter = {
  async completion(prompt) {
    turns.write.push(structuredClone(prompt));
    const { sources } = JSON.parse(prompt.messages[0].content ?? "{}");
    const givenIds = sources.map((source: { id: string }) => source.id);

    const claims = CLAIMS_IT_CAN_WRITE.filter(({ needs }) => needs.every((id) => givenIds.includes(id))).map(
      ({ statement, needs }) => ({ statement, sources: [...needs] }),
    );
    if (scenario.slip === "writer cites an unknown source") claims[0].sources.push("KB-99");
    if (scenario.slip === "writer cites nothing") claims[0].sources = [];
    if (scenario.slip === "writer wraps its JSON in a code fence") {
      const fence = "`".repeat(3);
      return { content: `${fence}json\n${JSON.stringify({ claims })}\n${fence}`, finishReason: "stop" };
    }
    return replyWith({ claims });
  },
};

function replyWith(json: object): AgentCompletion {
  return { content: JSON.stringify(json), finishReason: "stop" };
}

let callCount = 0;

function askFor(...calls: [name: string, args: Record<string, unknown>][]): AgentCompletion {
  const toolCalls = calls.map(([name, args]) => ({ id: `call_${++callCount}`, name, arguments: args }));
  return { content: null, finishReason: "tool_calls", toolCalls };
}
```

```ts main.ts
import "reflect-metadata";
import { App, FrontMcp } from "@frontmcp/sdk";
import { FindSourcesAgent } from "./find-sources.agent";
import { KnowledgeBase } from "./knowledge";
import { ResearchAgent } from "./research.agent";
import { ArticleResource, ClosedTicketResource } from "./sources.resources";

@App({
  id: "help-desk",
  name: "Help Desk",
  agents: [ResearchAgent, FindSourcesAgent],
  resources: [ArticleResource, ClosedTicketResource],
  providers: [KnowledgeBase],
})
export class HelpDeskApp {}

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class HelpDeskServer {}
```

```ts research.test.ts
import { test, expect } from "@frontmcp/testing";
import { FIND_SOURCES_INSTRUCTIONS } from "./find-sources.agent";
import { scenario, turns } from "./model.example";
import { RESEARCH_INSTRUCTIONS } from "./research.agent";

const QUESTION = "Why do enterprise customers keep hitting login failures after the SSO change?";

const citedIds = (result: { raw: { structuredContent?: any } }): string[] =>
  result.raw.structuredContent.claims.flatMap((claim: { sources: string[] }) => claim.sources);

test("the client sees one agent, and the sources as resource templates", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools.map((tool) => tool.name)).toEqual(["invoke_research"]);
  expect(tools[0].outputSchema).toMatchObject({
    properties: { claims: { items: { properties: { sources: { minItems: 1 } } } } },
  });

  const templates = await mcp.resources.listTemplates();
  expect(templates).toContainResourceTemplate("kb://{id}");
  expect(templates).toContainResourceTemplate("tickets://{id}");
});

test("the searcher is hidden, not locked: it answers when called by name", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_find_sources", { question: QUESTION });
  expect(result.raw.structuredContent).toEqual({ sourceIds: ["KB-1", "KB-2", "KB-5", "T-41", "T-43", "T-52"] });
});

test("the answer is a few claims, each with the ids it came from", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_research", { question: QUESTION });
  expect(result).toBeSuccessful();
  expect(result.raw.structuredContent).toEqual({
    claims: [
      {
        statement:
          "Since release 3.2, SSO sign-in only accepts responses signed by a certificate uploaded in Settings > Security, and refuses the rest with SSO-401.",
        sources: ["KB-1", "KB-2"],
      },
      {
        statement: "Acme and Globex both hit SSO-401 and were fixed by uploading their certificate.",
        sources: ["T-41", "T-43"],
      },
      {
        statement: "Sign-in that fails only sometimes can be the identity provider's clock being more than 5 minutes off.",
        sources: ["KB-5", "T-52"],
      },
    ],
  });
});

test("a smaller question gets a smaller answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_research", { question: "How long does a session last?" });
  expect(result.raw.structuredContent).toEqual({
    claims: [
      {
        statement: "A session lasts 8 hours, so being signed out after a few hours is expected. Enterprise admins can shorten it.",
        sources: ["KB-4", "T-48"],
      },
    ],
  });
});

test("every id it cites opens as a resource", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_research", { question: QUESTION });
  for (const id of citedIds(result)) {
    const uri = id.startsWith("KB-") ? `kb://${id}` : `tickets://${id}`;
    expect(await mcp.resources.read(uri)).not.toBeError();
  }
  expect((await mcp.resources.read("kb://KB-2")).text()).toContain("# Error SSO-401: signature not trusted");
  expect((await mcp.resources.read("tickets://T-43")).json()).toMatchObject({ customer: "Globex", plan: "enterprise" });
  expect(await mcp.resources.read("kb://KB-99")).toBeError(-32602);
});

test("the searches are reported to the client while the agents work", async ({ mcp }) => {
  await mcp.tools.call("invoke_research", { question: QUESTION });
  const messages = mcp.notifications
    .collect()
    .received.filter((notification) => notification.method === "notifications/message")
    .map((notification) => notification.params as { logger: string; data: { message: string } })
    .map(({ logger, data }) => `${logger}: ${data.message}`);
  expect(messages.slice(-4)).toEqual([
    "find_sources: Calling tool: search_articles",
    'search_articles: Articles for "sso login": 3 found',
    "find_sources: Calling tool: search_tickets",
    'search_tickets: Closed tickets for "sso login": 3 found',
  ]);
});

test("two model turns to search, one to write, and the writer is sent only what was found", async ({ mcp }) => {
  turns.search.length = 0;
  turns.write.length = 0;
  await mcp.tools.call("invoke_research", { question: QUESTION });
  expect(turns.search).toHaveLength(2);
  expect(turns.write).toHaveLength(1);

  expect(turns.search[0].system).toBe(FIND_SOURCES_INSTRUCTIONS);
  expect(turns.write[0].system).toBe(RESEARCH_INSTRUCTIONS);
  const { question, sources } = JSON.parse(turns.write[0].messages[0].content ?? "{}");
  expect(question).toBe(QUESTION);
  expect(sources.map((source: { id: string }) => source.id)).toEqual(["KB-1", "KB-2", "KB-5", "T-41", "T-43", "T-52"]);
});

test("an id the searcher invents never reaches the writer", async ({ mcp }) => {
  turns.write.length = 0;
  scenario.slip = "searcher invents a source";
  const result = await mcp.tools.call("invoke_research", { question: QUESTION });
  scenario.slip = "none";

  expect(result).toBeSuccessful();
  const { sources } = JSON.parse(turns.write[0].messages[0].content ?? "{}");
  expect(sources.map((source: { id: string }) => source.id)).not.toContain("KB-99");
  expect(citedIds(result)).not.toContain("KB-99");
});

test("a question the help desk has nothing on is refused before the writer is asked", async ({ mcp }) => {
  turns.write.length = 0;
  const result = await mcp.tools.call("invoke_research", { question: "Where do I park at the office?" });
  expect(result).toBeError("NO_SOURCES_FOUND");
  expect(result.text()).toBe("The help desk's articles and closed tickets don't cover that.");
  expect(turns.write).toEqual([]);
});

test("a claim that cites an id the writer wasn't given is refused", async ({ mcp }) => {
  scenario.slip = "writer cites an unknown source";
  const result = await mcp.tools.call("invoke_research", { question: QUESTION });
  scenario.slip = "none";

  expect(result).toBeError("UNKNOWN_SOURCE");
  expect(result.text()).toBe("The answer cites KB-99, which the search didn't find.");
});

test("a searcher that answers in words fails the research call with its own error", async ({ mcp }) => {
  scenario.slip = "searcher answers in words";
  const result = await mcp.tools.call("invoke_research", { question: QUESTION });
  scenario.slip = "none";

  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at sourceIds)");
});

test("an answer in a code fence isn't read as JSON, so it fails the output schema", async ({ mcp }) => {
  scenario.slip = "writer wraps its JSON in a code fence";
  const result = await mcp.tools.call("invoke_research", { question: QUESTION });
  scenario.slip = "none";

  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at claims)");
});

test("a claim with no source fails the output schema", async ({ mcp }) => {
  scenario.slip = "writer cites nothing";
  const result = await mcp.tools.call("invoke_research", { question: QUESTION });
  scenario.slip = "none";

  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at claims.0.sources)");
});
```

In the Call tab, ask `invoke_research` a smaller question, like "How long does a session last?": the answer is one claim, from KB-4 and T-48. Ask "Where do I park at the office?" and the call is refused with `NO_SOURCES_FOUND`, before the research agent's own model is asked. The **Capabilities** tab lists the two resource templates, `kb://{id}` and `tickets://{id}`.

## How it fits together

*[Illustration: The Research Assistant at work. The client's model calls invoke_research with a question. The research agent's execute() calls the hidden find_sources agent with this.callTool. find_sources asks its model for both searches and runs them with its two private search tools, which send the client log messages while they work. find_sources returns a list of source ids. research drops the ids that don't exist, and fails with NO_SOURCES_FOUND if none are left. It sends its own model the question and the text of each source; the model answers with claims, each with the ids it came from. research fails with UNKNOWN_SOURCE if a claim cites an id it wasn't sent, FrontMCP checks the answer against outputSchema, and research returns it to the client as structuredContent.]*
1. A client calls `invoke_research` with a question. It's the only tool the client sees: `find_sources` is hidden, and the search tools belong to it.
2. `research`'s `execute()` hands the question to `find_sources` with `this.callTool()`.
3. `find_sources` runs its own loop. Its model asks for `search_articles` and `search_tickets` in one turn, and each search sends the client a log message when it finishes. On the next turn, the model answers with the ids worth reading.
4. Back in `research`, every id is looked up. An id that isn't an article or a ticket is dropped, and if none are left the call fails with `NO_SOURCES_FOUND`, without asking `research`'s own model.
5. `research` runs its own model loop, one turn and no tools, with the question and the full text of each source in the message. The model answers with claims, each with the ids it came from.
6. `execute()` checks that every id the claims cite is one it sent, and fails with `UNKNOWN_SOURCE` if not. FrontMCP then checks the answer against `outputSchema` and returns it as `structuredContent`.
7. The client opens what a claim cites by reading a resource: `kb://KB-2` for an article, `tickets://T-43` for a ticket.

The client's model gets one tool and two resource templates. `find_sources`'s model gets two tools, and its answer is only a list of ids. `research`'s model gets no tools at all, only the text it may cite. Each model has one job, and the code between them does the checking.

## The files

### `knowledge.ts`: articles, closed tickets and a search

One [provider](https://frontmcp.dev/reference/sdk/provider), `GLOBAL`, so the search tools, the resources and `research`'s `execute()` all read the same instance. It holds six articles and six closed tickets about signing in: the SSO change (KB-1, KB-2, T-41, T-43), a clock problem (KB-5, T-52), and a few about passwords, sessions and refunds that don't belong to the question, so the search has something to leave out. `searchArticles()` and `searchTickets()` match words and return the best three, and `find(id)` looks an id up in both. In a real server this is where your knowledge base's and ticket system's own search goes: nothing else in the example cares how it works.

### `main.ts`: one app

```ts main.ts
@App({
  id: "help-desk",
  name: "Help Desk",
  agents: [ResearchAgent, FindSourcesAgent],
  resources: [ArticleResource, ClosedTicketResource],
  providers: [KnowledgeBase],
})
```

The app lists both agents, both resource templates and the provider. The search tools run inside `find_sources`, and an agent's tools see the app's providers as the app's own tools do, so the searches, the resources and `research` all get the same `KnowledgeBase`: see [Sharing a provider with the agent's tools](https://frontmcp.dev/reference/sdk/agent#sharing-a-provider-with-the-agents-tools). A client sees one tool, `invoke_research`, and two templates in `resources/templates/list`, which the first test checks.

### `search.tools.ts`: two tools only the searcher has

```ts search.tools.ts
const articles = this.get(KnowledgeBase).searchArticles(query);
await this.notify(`Articles for "${query}": ${articles.length} found`);
return { articles: articles.map(({ id, title }) => ({ id, title })) };
```

They're in `find_sources`'s `tools` and nowhere else, so only its model can call them. They return ids and titles, not text: finding is one job, reading is another, and the writer gets the text later.

Each one ends with a log message, and that's how the client hears what the search found. `find_sources` adds one of its own before each search, `Calling tool: search_articles`, as [every agent does](https://frontmcp.dev/reference/sdk/agent#telling-the-client-how-the-loop-is-going) for each tool call. They reach the client even though `research` started `find_sources` with `this.callTool()`: a called agent and its tools report to the client of the outer call, as [called tools do](https://frontmcp.dev/reference/sdk/call-tool#progress-from-the-called-tool). The sixth test reads them. They're log messages rather than `progress()` calls because a tool only knows its own step, and progress has to keep going up across the whole call. [Reporting Progress and Logs](https://frontmcp.dev/learn/reporting-progress) covers both.

### `find-sources.agent.ts`: the narrower agent

- `systemInstructions` give the steps and the exact JSON to answer with, and tell the model never to list an id a search didn't return. The code doesn't rely on that: `research` checks anyway.
- `outputSchema: { sourceIds: z.array(z.string()) }` gives `research` a list, not prose. A searcher that answers in words fails its own call, instead of handing prose to `research`.
- `hideFromDiscovery: true` takes `find_sources` out of `tools/list`, because `research` is the way in. It hides the agent and doesn't lock it: the second test calls `invoke_find_sources` by name. An agent whose work needs protecting still needs [authorization](https://frontmcp.dev/learn/authorizing-calls) like any tool.
- `execution` allows four turns where two are enough, so a searcher that wants a second round of searches has room, and half a minute.

### `research.agent.ts`: the coordinator

The options divide the audience, as they do for [triage](https://frontmcp.dev/examples/triage-agent#triageagentts-the-agent):

- `description` is for the client's model. It says what comes back, and how to open a cited id, since an id alone doesn't say: `kb://<id>` for articles, `tickets://<id>` for tickets.
- `systemInstructions` are for `research`'s model: answer only from the sources, in JSON, the most important claim first, and every claim with a source.
- `outputSchema` makes the answer checkable:

```ts research.agent.ts
outputSchema: {
  claims: z
    .array(
      z.object({
        statement: z.string(),
        sources: z.array(z.string().regex(/^(KB|T)-\d+$/)).min(1),
      }),
    )
    .min(1),
},
```

The client gets `structuredContent` in this shape, or an error: at least one claim, at least one source on each, and every source looks like an id. A claim with no source fails the call with `INVALID_OUTPUT`, as the last test shows. See [Returning structured output](https://frontmcp.dev/reference/sdk/agent#returning-structured-output). What the schema can't know is which ids exist. That's `execute()`'s job.

The agent has no `tools`: the sources in its message are all its model has to work from. `execute()` works in three steps:

```ts research.agent.ts
const found = await this.callTool("invoke_find_sources", { question: input.question });
const { sourceIds } = found.structuredContent as { sourceIds: string[] };

const knowledgeBase = this.get(KnowledgeBase);
const sources = sourceIds.flatMap((id) => knowledgeBase.find(id) ?? []);
```

1. **Hand off.** `this.callTool("invoke_find_sources", …)` runs the other agent as if a client had called it, and the answer is its `structuredContent`, already checked by its `outputSchema`. Handing off in code, rather than offering `find_sources` to `research`'s model with `agents: [...]`, makes sure every question is searched first: [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents#handing-a-task-off-from-code) compares the two. If `find_sources` fails, `this.callTool()` throws and so does the research call: [When the other agent fails](https://frontmcp.dev/learn/agents-that-call-agents#when-the-other-agent-fails) shows what to answer instead, and the second idea below does it.
2. **Check what comes back.** A model's list of ids is a claim like any other. `find()` returns nothing for an id that isn't an article or a ticket, and `flatMap` drops it, as the eighth test shows with an id the searcher makes up. If nothing is left, the call fails with `NO_SOURCES_FOUND` before `research`'s own model is asked: a question the help desk has nothing on costs the searcher's turns and no more.
3. **Write, then check.** `super.execute({ question, sources })` runs `research`'s own loop. The input has no `query`, `message`, `prompt` or `input` field, so the model is sent it whole, as JSON: the question, and each source's text. Afterwards, every id the claims cite has to be one that was sent. A model that cites another fails the call with `UNKNOWN_SOURCE`, and doesn't get its answer trimmed: dropping the claim would change what the answer says, and returning it would send a support agent to a source that doesn't exist. An answer in the wrong format has no `claims`, so this check lets it through to fail `outputSchema`.

[Adding steps before and after the loop](https://frontmcp.dev/reference/sdk/agent#adding-steps-before-and-after-the-loop) has more on overriding `execute()`.

### `sources.resources.ts`: opening what an answer cites

Two [resource templates](https://frontmcp.dev/reference/sdk/resource-template): `kb://{id}` returns an article as markdown, and `tickets://{id}` returns a closed ticket as JSON. They're resources because the *person* decides to open a citation: the client reads the URI and shows it, and no model has to decide anything. [Exposing Data with Resources](https://frontmcp.dev/learn/exposing-data-with-resources#a-family-of-resources-with-a-template) covers templates. An id that doesn't exist throws `ResourceNotFoundError`, so the client gets a `-32602` "not found" error and not a server failure, [as that lesson explains](https://frontmcp.dev/learn/exposing-data-with-resources#when-the-ticket-doesnt-exist).

They aren't how `research` reads its sources: `research` puts each source's text in the model's message itself, so the model reads exactly the sources `find_sources` found, in one turn, without asking for them. An agent's model can read resources too, but only those the agent declares in its own [`resources` option](https://frontmcp.dev/reference/sdk/agent#reading-the-agents-resources-and-prompts), with a `read_resource` tool call per read (since 1.9.4). These templates are the app's, for clients, and no agent's model is offered them.

### `model.example.ts`: two scripted models

```ts model.example.ts
const CLAIMS_IT_CAN_WRITE = [
  {
    needs: ["KB-1", "KB-2"],
    statement: "Since release 3.2, SSO sign-in only accepts responses signed by …",
  },
  // ...
];
```

Each agent has its own `llm`, so each gets its own stand-in. `searchModel` picks search words from a short list, asks for both searches in one turn, then lists every id they returned. `writeModel` writes a claim only when it was sent every source the claim needs, so what it writes depends on what `research` handed it, as a real model's would. The sentences are ones it has seen, where a real model writes its own.

`scenario.slip` makes either model make a mistake that real models make, so the tests can check what `research` does about it. `turns` keeps what each model was sent. [Your First Agent](https://frontmcp.dev/learn/your-first-agent#what-the-agents-model-is-sent) looks at the prompt in detail.

### `research.test.ts`

The tests check what a client sees: one tool and two templates, the answer and its citations, every cited id opening as a resource, and the searches being reported while the agents work. Then they check what each model was sent: two searcher turns and one writer turn, with the writer sent only what was found. The last six are the failures: an id the searcher invents never reaches the writer, a question with nothing on it is refused before the writer is asked, a claim that cites an id the writer wasn't given is refused, a searcher that answers in words fails the call, an answer in a code fence fails `outputSchema`, and so does a claim with no source.

All the tests share one server, in order, and each slip is switched off again at the end of its test. The notifications test reads what the Playground's client asked for: under `frontmcp test`, the client has to ask for log messages first, as [Checking notifications](https://frontmcp.dev/reference/testing#checking-notifications) shows. [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) covers the test API.

## Running it with a real model

Give both agents a real model, and keep everything else:

```ts research.agent.ts
@Agent({
  // ...
  llm: { provider: "openai", model: "gpt-5", apiKey: { env: "OPENAI_API_KEY" } },
})
```

```bash
yarn add openai
export OPENAI_API_KEY=sk-...
```

`find-sources.agent.ts` gets the same `llm`, or another model: each agent has its own. Then delete `model.example.ts`, its imports, and the tests that use `scenario` and `turns`. Tests that check the exact claims go too, since a real model words them its own way. The ones on the tools, the templates and on every cited id opening as a resource still make sense.

The Playground can't run this: it has no network and no key. The server was run with the real `openai` package and these two `llm` lines, against a local stand-in for the API that answers like Chat Completions. That checks FrontMCP's side: what it sends, how it runs the tools the stand-in asks for, and how it reads the replies. It doesn't check a real model: how well one picks search words, reads the sources and cites them wasn't tested. [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model) covers the providers, and what to know about each:

- **Several model calls per question.** The searcher is asked at least twice, once for the searches and once for the ids, and again for every extra round of searches, up to `maxIterations: 4`. `research`'s model is asked once more, with the text of every source in the message. The local API's log shows the searcher's requests offering its two tools, and the writer's offering none.
- **Answers vary, and the checks catch it.** A searcher that answers in words instead of JSON fails its own `outputSchema`, and the research call fails with that same error, `INVALID_OUTPUT` and `Tool output validation failed (output does not match outputSchema at sourceIds)`, as the eleventh test shows. A writer that wraps its JSON in a code fence fails `outputSchema` too, because FrontMCP only reads text that starts with `{` or `[` as JSON. Tell the model not to use code fences, or strip them from its reply.
- **A failed call can be repeated.** Everything this agent does is a read, so unlike triage there's nothing to undo when a model cites an id it wasn't sent: the client asks again.
- **Every call costs model calls, and their time.** `@Agent`'s own `rateLimit` [caps how often clients can run it](https://frontmcp.dev/reference/sdk/agent#limiting-who-calls-an-agent-and-how-often), so set one before you expose the agent.
- **The search is yours.** `KnowledgeBase` is where the help desk's real search goes. The tools only pass the model's words on to it.

To strip a code fence, override [`parseAgentResponse()`](https://frontmcp.dev/reference/sdk/agent#agentcontext) on `ResearchAgent`:

```ts research.agent.ts
protected parseAgentResponse(content: string | null) {
  return super.parseAgentResponse(content?.replace(/^```(?:json)?\s*|\s*```$/g, "") ?? null);
}
```

## Ideas to try

Each of these is a change to the Playground above. Add a test for each.

1. Let `research` search again when fewer than two sources come back: call `invoke_find_sources` a second time, and never a third. Have the stand-in use other words the second time, and check how many turns the searcher's model was asked.
2. Catch a searcher that fails. Add a `slip` that makes `searchModel` throw, catch it in `execute()`, and fail with a `PublicMcpError` whose message tells the client's model what to do. Check what a client sees.
3. Split the search in two: a `find_articles` agent and a `find_tickets` agent, each with one tool, called one after the other from `execute()`. Check that the writer is sent both agents' sources.
4. Let the client narrow by plan: add an optional `plan` to `research`'s input, pass it to `find_sources`, and keep the other plan's tickets out of the results. Check that an enterprise question never cites T-46 or T-55.
