Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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):
Expand Down
44 changes: 44 additions & 0 deletions openbot/app/src/components/duet/page-text.tsx
Original file line number Diff line number Diff line change
@@ -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(
<a
key={start}
href={url.href}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2"
>
{source}
</a>,
);
cursor = start + source.length;
}
parts.push(text.slice(cursor));
return <>{parts}</>;
}
7 changes: 4 additions & 3 deletions openbot/app/src/components/duet/page-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -111,7 +112,7 @@ function Block({
block.text.length > HEADLINE_LENGTH ? "type-title" : "type-big",
)}
>
{block.text}
<PageText text={block.text} />
</p>
) : (
/*
Expand All @@ -121,7 +122,7 @@ function Block({
* reader was drawn with, at the default 17px.
*/
<p className="type-body mt-3 whitespace-pre-line text-duet-ink leading-[1.6] [overflow-wrap:anywhere] first:mt-0">
{block.text}
<PageText text={block.text} />
</p>
);
case "bullets":
Expand Down Expand Up @@ -566,7 +567,7 @@ function SlideBlock({
case "text":
return (
<p className="whitespace-pre-line text-[15px] text-duet-ink-2 leading-[20px] [overflow-wrap:anywhere]">
{block.text}
<PageText text={block.text} />
</p>
);
case "bullets":
Expand Down
53 changes: 53 additions & 0 deletions openbot/app/tests/pages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -914,3 +914,56 @@ test("a dashboard's headline is set large, and a long one steps down a size", ()
const plain = render(<PageBlocks blocks={[{ type: "text", text: short }]} />);
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(<PageBlocks blocks={[{ type: "text", text }]} />);
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(<PageBlocks blocks={[{ type: "text", text }]} />);
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 =
"<script>alert(1)</script> javascript:alert(1) data:text/html,unsafe https://user:secret@example.com/private https://";
const view = render(<PageBlocks blocks={[{ type: "text", text }]} />);
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(
<SlideDeck
slides={[
{
title: "Pricing sources",
blocks: [{ type: "text", text: "https://example.com/pricing" }],
},
]}
/>,
);
expect(view.getByRole("link").getAttribute("href")).toBe(
"https://example.com/pricing",
);
});
40 changes: 35 additions & 5 deletions openbot/docs/web-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 9 additions & 3 deletions openbot/examples/openduet/agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions openbot/server/src/plugins/catalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
{
Expand All @@ -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",
},
{
Expand Down
Loading
Loading