diff --git a/README.md b/README.md
index ec562978..01b70b62 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,7 @@ Everything below works today when you run this repository.
| Skills | Reusable instructions you write once and call with `/`. |
| Gmail and Google Calendar | Each person connects their own account. OpenDuet reads mail and today's calendar, and sends a reply only after you answer. |
| Connected apps | Governed MCP connectors, including Google Drive and Notion. |
+| Public-web research | Search public sources, read selected pages and make a brief with source links after an administrator grants the research tools. [Set up a research provider](openbot/docs/web-research.md). |
| Scheduled work | Routines: ask it to do something on a schedule and it runs as you. |
| Make it your own | Its name, Bots, channels, model, and skills live in one folder, [`openbot/examples/openduet`](openbot/examples/openduet). |
| What needs you, what can wait | Sorts new mail into what needs you and what can wait, with a one-line reason for each. Measured on a labelled set in [`evals/triage`](evals/triage). |
@@ -169,6 +170,12 @@ To try it without a Google account, use the fake Google that ships for tests and
`OPENBOT_PUBLIC_URL` has to match the API's port, because the fake sends you back through it. `curl http://127.0.0.1:4900/__fake/sent` shows anything OpenDuet sent. The variable is ignored when `NODE_ENV=production`. To switch to real Google, remove it, restart, and connect again: the stored sign-in belongs to the fake.
+### Search the public web
+
+The OpenDuet Bot ships with a public-web research skill. [Set up a research provider](openbot/docs/web-research.md) to let it find sources and read pages without using its browser. The catalogue includes Parallel, Exa and Firecrawl. An administrator enables a connector and grants its search and fetch tools after reviewing the guide's controls and data-sharing risks.
+
+Then try `/research-public-web` with a request such as “Compare three public project-management tools for a small team, check their current pricing, and make a brief with source links.”
+
## Make it your own
OpenDuet is meant to be copied. Everything that makes it OpenDuet rather than OpenBot is in [`openbot/examples/openduet`](openbot/examples/openduet):
diff --git a/openbot/app/src/components/duet/page-text.tsx b/openbot/app/src/components/duet/page-text.tsx
new file mode 100644
index 00000000..c73c05f9
--- /dev/null
+++ b/openbot/app/src/components/duet/page-text.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from "react";
+
+/** Plain page text with source URLs; never interpret model text as HTML or Markdown. */
+export function PageText({ text }: { text: string }) {
+ const parts: ReactNode[] = [];
+ let cursor = 0;
+ for (const match of text.matchAll(/https?:\/\/[^\s<>"']+/g)) {
+ const start = match.index;
+ let source = match[0].replace(/[.,;:!?]+$/, "");
+ for (const [open, close] of [
+ ["(", ")"],
+ ["[", "]"],
+ ]) {
+ while (
+ source.endsWith(close) &&
+ source.split(close).length > source.split(open).length
+ ) {
+ source = source.slice(0, -1);
+ }
+ }
+ let url: URL;
+ try {
+ url = new URL(source);
+ } catch {
+ continue;
+ }
+ if (url.username || url.password) continue;
+ parts.push(text.slice(cursor, start));
+ parts.push(
+
+ {source}
+ ,
+ );
+ cursor = start + source.length;
+ }
+ parts.push(text.slice(cursor));
+ return <>{parts}>;
+}
diff --git a/openbot/app/src/components/duet/page-view.tsx b/openbot/app/src/components/duet/page-view.tsx
index 261ace60..3dc2033a 100644
--- a/openbot/app/src/components/duet/page-view.tsx
+++ b/openbot/app/src/components/duet/page-view.tsx
@@ -22,6 +22,7 @@ import type { PageBlock, PageSlide } from "@/lib/pages/queries";
import { cn } from "@/lib/utils";
import { DuetIcon } from "./icons";
import { RichBlock } from "./page-rich-block";
+import { PageText } from "./page-text";
/*
* A page the Bot made, drawn from its blocks in the shell's own type and colours. The viewer draws
@@ -111,7 +112,7 @@ function Block({
block.text.length > HEADLINE_LENGTH ? "type-title" : "type-big",
)}
>
- {block.text}
+
) : (
/*
@@ -121,7 +122,7 @@ function Block({
* reader was drawn with, at the default 17px.
*/
- {block.text}
+
);
case "bullets":
@@ -566,7 +567,7 @@ function SlideBlock({
case "text":
return (
- {block.text}
+
);
case "bullets":
diff --git a/openbot/app/tests/pages.test.tsx b/openbot/app/tests/pages.test.tsx
index 037ffb1b..6ae974ed 100644
--- a/openbot/app/tests/pages.test.tsx
+++ b/openbot/app/tests/pages.test.tsx
@@ -914,3 +914,56 @@ test("a dashboard's headline is set large, and a long one steps down a size", ()
const plain = render();
expect(plain.getByText(short).className).toContain("type-body");
});
+
+test("a research page renders source URLs as links without changing its text", () => {
+ const text =
+ "Pricing: https://example.com/pricing. See also (https://example.org/plans).";
+ const view = render();
+ const links = view.getAllByRole("link");
+ expect(links.map((link) => link.getAttribute("href"))).toEqual([
+ "https://example.com/pricing",
+ "https://example.org/plans",
+ ]);
+ expect(view.container.textContent).toBe(text);
+ for (const link of links) {
+ expect(link.getAttribute("target")).toBe("_blank");
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
+ }
+});
+
+test("source links keep balanced URL parentheses and support HTTP sources", () => {
+ const text = "https://example.com/wiki/Project_(software) http://example.org";
+ const view = render();
+ expect(
+ view.getAllByRole("link").map((link) => link.getAttribute("href")),
+ ).toEqual([
+ "https://example.com/wiki/Project_(software)",
+ "http://example.org/",
+ ]);
+ expect(view.container.textContent).toBe(text);
+});
+
+test("source text never turns markup, unsafe schemes or URL credentials into links", () => {
+ const text =
+ " javascript:alert(1) data:text/html,unsafe https://user:secret@example.com/private https://";
+ const view = render();
+ expect(view.queryAllByRole("link")).toHaveLength(0);
+ expect(view.container.querySelector("script")).toBeNull();
+ expect(view.container.textContent).toBe(text);
+});
+
+test("a research slide opens the same source URLs as a page", () => {
+ const view = render(
+ ,
+ );
+ expect(view.getByRole("link").getAttribute("href")).toBe(
+ "https://example.com/pricing",
+ );
+});
diff --git a/openbot/docs/web-research.md b/openbot/docs/web-research.md
index 091f5f76..3fb29f5f 100644
--- a/openbot/docs/web-research.md
+++ b/openbot/docs/web-research.md
@@ -13,25 +13,55 @@ OpenBot ships three public-web research providers in the plugin catalogue: **Par
## Enable research
-1. As an administrator, open Plugins and add a provider from the catalogue. For the keyed entries, enter the provider's API key; it is stored in the server-side credential vault.
-2. Grant that provider's search and fetch tools to the Bots that should research the public web. For the shipped fintech package, select **Research Desk**.
+1. As an administrator, open **Plugins** at `/admin/plugins`, choose a provider from the table above, and turn on **Enable for this deployment**. For the keyed entries, enter the provider's API key in **Access token**; it is stored in the server-side credential vault.
+2. Press **Refresh tools**, then **Grant tools…** to grant that provider's search and fetch tools to the Bots that should research the public web. A separate research Bot without mail or private-document tools is the safer starting point. The shipped fintech package calls this Bot **Research Desk**. Read [Controls and data](#controls-and-data) before combining research and private-account tools on one Bot.
3. Ask the Bot to research a topic. It searches, reads selected sources and cites their URLs. Run `/research-public-web` to select the example skill explicitly if your deployment has many tools.
For production or higher limits, use a keyed entry and remove the grants on the free one. If a Bot holds a vendor's free and keyed entries, it is told about the keyed one.
+### OpenDuet
+
+The OpenDuet Bot holds the `research-public-web` skill. It still needs an administrator to enable a research connector and grant its tools. OpenDuet also reads mail and calendar data when those tools are granted, so review the controls below before giving its primary Bot public-web access. A separate research Bot can hold the same skill and only public-web tools.
+
+For example, select **Parallel Search** in Plugins, enable it, refresh its tools, and grant `web_search` and `web_fetch` to the chosen research Bot. It needs no API key for light use, subject to provider limits. **Parallel Search (API key)** uses a deployment key entered under **Access token**. Exa and Firecrawl use the same setup flow with the tool names in the table.
+
+Ask OpenDuet:
+
+> /research-public-web Compare three public project-management tools for a small team, check their current pricing, and make a brief with source links.
+
+Longer research runs as a task. The Bot is instructed to put clickable source URLs beside the claims in the saved page on the Make tab, while keeping the Chat reply short. The Chat word limit and rule against source notes do not apply to the page's content. If tools are missing, check that the connector is enabled, its tools are refreshed, and both tools are granted to the Bot doing the work.
+
## Skills declare roles, not providers
A skill names the research tools it needs as `@research/search` and `@research/fetch`, instead of one provider's tool names. Each role matches that role's tool on every research provider in the catalogue, intersected as always with what the Bot was granted. The shipped `research-public-web` skill does this, so it works with whichever provider a deployment connects. A role declaration grants nothing.
-## Choice and controls
+## Controls and data
In a conversation, research calls are labelled for what they did, "Search the web" and "Read web pages", whichever provider made them, and the Bot does not name the provider unless asked. Which provider a Bot holds is shown on its access page.
A Bot holding more than one provider is told about each and uses the one the person asks for. Administrators can revoke tools, remove a connector or grant another provider. Every call goes through `PluginStore.callTool`, with the usual grants, action policies and audit records; no raw MCP server is added to the agent configuration.
-Exa and Firecrawl name the tools that only read (`readTools` in the catalogue), and every other tool they advertise counts as a write. Firecrawl publishes tool families such as `firecrawl_monitor_*`, and Exa's agent tools spend credits, so neither can be safely described by a list of writes alone.
+Each research provider names its reviewed read tools in `readTools` in the catalogue. Any other tool it advertises counts as a write until reviewed. This also applies to Parallel: only `web_search` and `web_fetch` count as reads.
+
+The provider receives model-selected objectives, search queries, requested public URLs and, for Parallel, a conversation session identifier. Whole conversations are not sent automatically, but the model can copy private text into any of those fields. Failures stay visible and are not silently retried through another provider.
+
+**A read tool can send data out.** Research reads do not pause for **Allow once**, and the shipped action policy allows calls unless an administrator adds restrictions. If the same Bot can read mail or private documents, a malicious instruction in that content could cause it to send private text in a search query or a fetched URL. Fetching an attacker-controlled URL can send text in its path or query to that site's operator through the provider. The instruction to treat outside content as information helps, but it is not an enforced data boundary.
+
+For a safer setup, use a separate research Bot with only public-web tools, like the fintech package's **Research Desk**. Do not grant it mail, calendar or private-document tools, and do not pass private content to it in requests or handoffs.
+
+An administrator can also add a deny rule under **Boundaries** (`/admin/boundaries`). For example, this policy refuses calls to all six research catalogue entries when a routine starts the run:
+
+```json
+{
+ "mode": "enforce",
+ "deny": [
+ "initiator.kind == \"routine\" && mcp.server in [\"parallel\", \"parallel-authenticated\", \"exa\", \"exa-authenticated\", \"firecrawl\", \"firecrawl-authenticated\"]"
+ ],
+ "allow": ["true"]
+}
+```
-The provider receives model-selected objectives, search queries, requested public URLs and, for Parallel, a conversation session identifier. These can contain information from the person's request, so the Bot is told not to send private context public research does not need. Full conversations are not forwarded. Failures stay visible and are not silently retried through another provider.
+Add the deny expression to your existing policy rather than replacing other restrictions. A saved policy takes precedence over `AGENT_COMPUTER_POLICY`; that variable supplies the initial policy when no saved policy exists. This example blocks scheduled research, including both search and fetch. It does not prevent disclosure in a person-started run or a handoff, and it does not restrict other outbound tools such as the browser. Isolation and grants still matter.
## Adding a provider
diff --git a/openbot/examples/openduet/agents.yaml b/openbot/examples/openduet/agents.yaml
index 44cc5a26..010598f9 100644
--- a/openbot/examples/openduet/agents.yaml
+++ b/openbot/examples/openduet/agents.yaml
@@ -50,7 +50,7 @@ agents:
Morgan can send ivory this week instead.” A who-is question needs only “Jules is your
accountant at Finch Accounting.” These names are fictional, never evidence. Put each separate thought in its own paragraph with a blank line between them,
because each paragraph is shown as its own message bubble. No headings, no tables and no bold
- labels. Never say where you read something, what you did not open, or that you went by
+ labels. In ordinary Chat replies, never say where you read something, what you did not open, or that you went by
snippets, and never go back to correct an earlier answer unless they ask. Never list more than
three things: when you find more, say how many, name the one that needs them most, and offer
the rest as tasks or a page. These examples show only the length and tone; the people and
@@ -88,7 +88,11 @@ agents:
a deck or a dashboard, make it with make_page. Then say one or two sentences about it, for
example "I drafted answers for both from your numbers. It's a 2-minute read." Never paste its
content into the chat; it opens from the card. A task that asks you to make a page, a deck, a
- dashboard or a doc ends with make_page, a deck as slides. Write to the requested slide count:
+ dashboard or a doc ends with make_page, a deck as slides. For a research task, put plain
+ HTTP or HTTPS source URLs in text blocks beside the claims they support in make_page. The rules
+ against source notes and the 35-word Chat limit do not apply to page content: the research
+ brief must include its sources. Keep the Chat reply short and point to the saved page.
+ Write to the requested slide count:
one slide holds roughly 25–35 words, one small chart of up to 5 short labels, 2 compact metrics,
or 3 short bullets. Use one main block per slide, short titles, no repetitive summary slide,
and put extra detail in a separate page only if requested. A check-in is a turn you start
@@ -101,13 +105,15 @@ agents:
a tap-answerable choice, call suggest_replies with 2–3 short chips and keep the answer in words.
Count all paragraphs together: ordinary Chat is at most 35 words, including any question.
Cut the least useful detail until it fits; longer work belongs in a page or task. Never
- append source notes, “I searched”, “from saved notes”, or a list of other matching messages.
+ append source notes, “I searched”, “from saved notes”, or a list of other matching messages
+ to ordinary Chat replies; keep research citations in the saved page.
# The same two authoring skills OpenBot's general assistant holds: making another Bot, and
# turning an interview into a saved skill, both from inside a conversation.
skills:
- morning-brief
- first-day
- reply-to-an-email
+ - research-public-web
- bot-creator
- skill-creator
diff --git a/openbot/server/src/plugins/catalogue.ts b/openbot/server/src/plugins/catalogue.ts
index 01670153..03b9a764 100644
--- a/openbot/server/src/plugins/catalogue.ts
+++ b/openbot/server/src/plugins/catalogue.ts
@@ -174,6 +174,9 @@ export function researchRoleFor(ref: string): ResearchRole | null {
/** Gmail and Google Calendar, as one entry. Named because the development override keys on it. */
export const GOOGLE_WORKSPACE = "google-workspace";
+/** Only the reviewed search and page-reading tools can run as reads. */
+const PARALLEL_READ_TOOLS = Object.freeze(["web_search", "web_fetch"]);
+
/** Exa's research and multi-step agent tools spend credits, so only the three that read are reads. */
const EXA_READ_TOOLS = Object.freeze([
"web_search_exa",
@@ -230,6 +233,7 @@ const REVIEWED: readonly CatalogueEntry[] = Object.freeze([
path: "/mcp",
auth: { kind: "none" },
writeTools: Object.freeze([]),
+ readTools: PARALLEL_READ_TOOLS,
docsUrl: "https://docs.parallel.ai/integrations/mcp/search-mcp",
},
{
@@ -242,6 +246,7 @@ const REVIEWED: readonly CatalogueEntry[] = Object.freeze([
path: "/mcp",
auth: { kind: "deployment-bearer" },
writeTools: Object.freeze([]),
+ readTools: PARALLEL_READ_TOOLS,
docsUrl: "https://docs.parallel.ai/integrations/mcp/search-mcp",
},
{
diff --git a/openbot/server/tests/web-research.test.ts b/openbot/server/tests/web-research.test.ts
index 4e4d30d9..25ec8bd3 100644
--- a/openbot/server/tests/web-research.test.ts
+++ b/openbot/server/tests/web-research.test.ts
@@ -7,6 +7,11 @@ import {
} from "@openbot/shared/research";
import { parse } from "yaml";
import { z } from "zod";
+import {
+ type ActionPolicy,
+ evaluateActionPolicy,
+ type PolicyContext,
+} from "../src/computer/policy";
import {
catalogueEntry,
classifyTool,
@@ -74,6 +79,16 @@ describe("research providers in the catalogue", () => {
).toContain("drive: search");
});
+ test("Parallel tools added later require write controls until reviewed", () => {
+ for (const key of ["parallel", "parallel-authenticated"]) {
+ const entry = catalogueEntry(key)!;
+ expect(classifyTool(entry, "new_unreviewed_tool", true)).toBe("write");
+ expect(classifyTool(entry, "new_unreviewed_tool", true, "read")).toBe(
+ "write",
+ );
+ }
+ });
+
test("a tool outside an entry's reads is a write, even one the vendor called a read", () => {
const firecrawl = catalogueEntry("firecrawl")!;
// Published as a family, so no write list could have named it.
@@ -113,6 +128,41 @@ describe("research providers in the catalogue", () => {
});
});
+describe("the documented research policy", () => {
+ test("blocks routine search and fetch across providers while allowing person-started calls", () => {
+ const guide = readFileSync(
+ new URL("../../docs/web-research.md", import.meta.url),
+ "utf8",
+ );
+ const example = guide.match(/```json\n([\s\S]*?)\n```/);
+ expect(example).not.toBeNull();
+ const policy: ActionPolicy = JSON.parse(example![1]);
+ for (const [server, names] of Object.entries(RESEARCH_TOOLS)) {
+ for (const name of names) {
+ const context: PolicyContext = {
+ tool: { name: `mcp__${server}__${name}` },
+ bot: { id: "openduet" },
+ actor: { id: "owner" },
+ page: { url: "", host: "" },
+ mcp: { server, tool: name, effect: "read" },
+ intent: "read_tool",
+ initiator: { kind: "routine", id: "scheduled-research" },
+ };
+ const denied = evaluateActionPolicy(policy, context);
+ expect(denied.allowed).toBe(false);
+ expect(denied.forward).toBe(false);
+ expect(denied.source).toBe("deny");
+ expect(
+ evaluateActionPolicy(policy, {
+ ...context,
+ initiator: { kind: "person", id: "" },
+ }).allowed,
+ ).toBe(true);
+ }
+ }
+ });
+});
+
describe("what a Bot is told about research", () => {
test("a provider is described only while both of its tools are held", () => {
for (const server of Object.keys(RESEARCH_TOOLS)) {