diff --git a/README.md b/README.md
index a038b06c..9ba7e099 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,6 @@
-
+
+
+
@@ -36,7 +38,7 @@ You can do things like:
- Visualize, edit, and update your knowledge graph anytime (it’s just Markdown)
- Record voice memos that automatically capture and update key takeaways in the graph
-Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/)
+Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads)
## Demo
@@ -50,10 +52,27 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/)
## Installation
-**Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/)
+**Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/downloads)
**All release files:** https://github.com/rowboatlabs/rowboat/releases/latest
+### Google setup
+To connect Google services (Gmail, Calendar, and Drive), follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md).
+
+### Voice notes
+To enable voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json:
+```
+{
+ "apiKey": ""
+}
+```
+### Web search
+To use Brave web search (optional), add the Brave API key in ~/.rowboat/config/brave-search.json.
+
+To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json.
+
+(same format as above)
+
## What it does
Rowboat is a **local-first AI coworker** that can:
@@ -130,5 +149,5 @@ If you’re looking for Rowboat web Studio, start [here](https://docs.rowboatlab
---
-[Discord](https://discord.com/invite/htdKpBZF) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
+[Discord](https://discord.gg/wajrgmJQ6b) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index 6e951f34..7658c4c8 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -33,9 +33,10 @@ import {
usePromptInputController,
type FileMention,
} from '@/components/ai-elements/prompt-input';
-import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning';
+
import { Shimmer } from '@/components/ai-elements/shimmer';
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
+import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { Suggestions } from '@/components/ai-elements/suggestions';
@@ -54,6 +55,7 @@ import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
+import { toast } from "sonner"
type DirEntry = z.infer
type RunEventType = z.infer
@@ -80,20 +82,20 @@ interface ToolCall {
timestamp: number;
}
-interface ReasoningBlock {
+interface ErrorMessage {
id: string;
- content: string;
+ kind: 'error';
+ message: string;
timestamp: number;
}
-type ConversationItem = ChatMessage | ToolCall | ReasoningBlock;
+type ConversationItem = ChatMessage | ToolCall | ErrorMessage;
type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
-const isReasoningBlock = (item: ConversationItem): item is ReasoningBlock =>
- 'content' in item && !('role' in item) && !('name' in item)
+const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error'
const toToolState = (status: ToolCall['status']): ToolState => {
switch (status) {
@@ -642,7 +644,6 @@ function App() {
const [message, setMessage] = useState('')
const [conversation, setConversation] = useState([])
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('')
- const [currentReasoning, setCurrentReasoning] = useState('')
const [, setModelUsage] = useState(null)
const [runId, setRunId] = useState(null)
const runIdRef = useRef(null)
@@ -650,7 +651,7 @@ function App() {
const [isProcessing, setIsProcessing] = useState(false)
const [processingRunIds, setProcessingRunIds] = useState>(new Set())
const processingRunIdsRef = useRef>(new Set())
- const streamingBuffersRef = useRef>(new Map())
+ const streamingBuffersRef = useRef>(new Map())
const [isStopping, setIsStopping] = useState(false)
const [stopClickedAt, setStopClickedAt] = useState(null)
const [agentId] = useState('copilot')
@@ -722,7 +723,6 @@ function App() {
if (!runId) {
setIsProcessing(false)
setCurrentAssistantMessage('')
- setCurrentReasoning('')
return
}
const isRunProcessing = processingRunIdsRef.current.has(runId)
@@ -730,10 +730,8 @@ function App() {
if (isRunProcessing) {
const buffer = streamingBuffersRef.current.get(runId)
setCurrentAssistantMessage(buffer?.assistant ?? '')
- setCurrentReasoning(buffer?.reasoning ?? '')
} else {
setCurrentAssistantMessage('')
- setCurrentReasoning('')
streamingBuffersRef.current.delete(runId)
}
}, [runId])
@@ -1113,6 +1111,15 @@ function App() {
}
break
}
+ case 'error': {
+ items.push({
+ id: `error-${Date.now()}-${Math.random()}`,
+ kind: 'error',
+ message: event.error,
+ timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
+ })
+ break
+ }
case 'llm-stream-event': {
// We don't need to reconstruct streaming events for history
// Reasoning is captured in the final message
@@ -1182,15 +1189,15 @@ function App() {
const getStreamingBuffer = (id: string) => {
const existing = streamingBuffersRef.current.get(id)
if (existing) return existing
- const next = { assistant: '', reasoning: '' }
+ const next = { assistant: '' }
streamingBuffersRef.current.set(id, next)
return next
}
- const appendStreamingBuffer = (id: string, field: 'assistant' | 'reasoning', delta: string) => {
+ const appendStreamingBuffer = (id: string, delta: string) => {
if (!delta) return
const buffer = getStreamingBuffer(id)
- buffer[field] += delta
+ buffer.assistant += delta
}
const clearStreamingBuffer = (id: string) => {
@@ -1231,7 +1238,6 @@ function App() {
case 'start':
if (!isActiveRun) return
setCurrentAssistantMessage('')
- setCurrentReasoning('')
setModelUsage(null)
break
@@ -1239,29 +1245,13 @@ function App() {
{
const llmEvent = event.event
if (!isActiveRun) {
- if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) {
- appendStreamingBuffer(event.runId, 'reasoning', llmEvent.delta)
- } else if (llmEvent.type === 'text-delta' && llmEvent.delta) {
- appendStreamingBuffer(event.runId, 'assistant', llmEvent.delta)
+ if (llmEvent.type === 'text-delta' && llmEvent.delta) {
+ appendStreamingBuffer(event.runId, llmEvent.delta)
}
return
}
- if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) {
- appendStreamingBuffer(event.runId, 'reasoning', llmEvent.delta)
- setCurrentReasoning(prev => prev + llmEvent.delta)
- } else if (llmEvent.type === 'reasoning-end') {
- setCurrentReasoning(reasoning => {
- if (reasoning) {
- setConversation(prev => [...prev, {
- id: `reasoning-${Date.now()}`,
- content: reasoning,
- timestamp: Date.now(),
- }])
- }
- return ''
- })
- } else if (llmEvent.type === 'text-delta' && llmEvent.delta) {
- appendStreamingBuffer(event.runId, 'assistant', llmEvent.delta)
+ if (llmEvent.type === 'text-delta' && llmEvent.delta) {
+ appendStreamingBuffer(event.runId, llmEvent.delta)
setCurrentAssistantMessage(prev => prev + llmEvent.delta)
} else if (llmEvent.type === 'tool-call') {
setConversation(prev => [...prev, {
@@ -1454,7 +1444,6 @@ function App() {
}
return ''
})
- setCurrentReasoning('')
break
case 'error':
@@ -1468,6 +1457,13 @@ function App() {
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
+ setConversation(prev => [...prev, {
+ id: `error-${Date.now()}`,
+ kind: 'error',
+ message: event.error,
+ timestamp: Date.now(),
+ }])
+ toast.error(event.error.split('\n')[0] || 'Model error')
console.error('Run error:', event.error)
break
}
@@ -1602,7 +1598,6 @@ function App() {
loadRunRequestIdRef.current += 1
setConversation([])
setCurrentAssistantMessage('')
- setCurrentReasoning('')
setRunId(null)
setMessage('')
setModelUsage(null)
@@ -2203,6 +2198,39 @@ function App() {
}
if (isToolCall(item)) {
+ if (item.name === 'web-search') {
+ const input = normalizeToolInput(item.input) as Record | undefined
+ const result = item.result as Record | undefined
+ return (
+ ) || []}
+ status={item.status}
+ />
+ )
+ }
+ if (item.name === 'research-search') {
+ const input = normalizeToolInput(item.input) as Record | undefined
+ const result = item.result as Record | undefined
+ const rawResults = (result?.results as Array<{ title: string; url: string; highlights?: string[]; text?: string }>) || []
+ const mapped = rawResults.map(r => ({
+ title: r.title,
+ url: r.url,
+ description: r.highlights?.[0] || (r.text ? r.text.slice(0, 200) : ''),
+ }))
+ const category = input?.category as string | undefined
+ const cardTitle = category ? `${category.charAt(0).toUpperCase() + category.slice(1)} search` : 'Researched the web'
+ return (
+
+ )
+ }
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
@@ -2223,19 +2251,20 @@ function App() {
)
}
- if (isReasoningBlock(item)) {
+ if (isErrorMessage(item)) {
return (
-
-
- {item.content}
-
+
+
+ {item.message}
+
+
)
}
return null
}
- const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
+ const hasConversation = conversation.length > 0 || currentAssistantMessage
const conversationContentClassName = hasConversation
? "mx-auto w-full max-w-4xl pb-28"
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
@@ -2447,13 +2476,6 @@ function App() {
/>
))}
- {currentReasoning && (
-
-
- {currentReasoning}
-
- )}
-
{currentAssistantMessage && (
@@ -2462,7 +2484,7 @@ function App() {
)}
- {isProcessing && !currentAssistantMessage && !currentReasoning && (
+ {isProcessing && !currentAssistantMessage && (
Thinking...
@@ -2508,7 +2530,6 @@ function App() {
onOpenFullScreen={navigateToFullScreenChat}
conversation={conversation}
currentAssistantMessage={currentAssistantMessage}
- currentReasoning={currentReasoning}
isProcessing={isProcessing}
isStopping={isStopping}
onStop={handleStop}
diff --git a/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx
new file mode 100644
index 00000000..30e5c002
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import {
+ CheckCircleIcon,
+ ChevronDownIcon,
+ GlobeIcon,
+ LoaderIcon,
+} from "lucide-react";
+
+interface WebSearchResultProps {
+ query: string;
+ results: Array<{ title: string; url: string; description: string }>;
+ status: "pending" | "running" | "completed" | "error";
+ title?: string;
+}
+
+function getDomain(url: string): string {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return url;
+ }
+}
+
+export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
+ const isRunning = status === "pending" || status === "running";
+
+ return (
+
+
+
+
+ {title}
+
+
+
+
+
+ {/* Query + result count */}
+
+
+
+ {query}
+
+ {results.length > 0 && (
+
+ {results.length} result{results.length !== 1 ? "s" : ""}
+
+ )}
+
+
+ {/* Results list */}
+ {results.length > 0 && (
+
+ )}
+
+ {/* Status */}
+
+ {isRunning ? (
+ <>
+
+ Searching...
+ >
+ ) : (
+ <>
+
+ Done
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
index e0048781..8d2a005f 100644
--- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx
+++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
@@ -19,7 +19,7 @@ import {
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
-import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
+
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
@@ -52,20 +52,20 @@ interface ToolCall {
timestamp: number
}
-interface ReasoningBlock {
+interface ErrorMessage {
id: string
- content: string
+ kind: 'error'
+ message: string
timestamp: number
}
-type ConversationItem = ChatMessage | ToolCall | ReasoningBlock
+type ConversationItem = ChatMessage | ToolCall | ErrorMessage
type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
-const isReasoningBlock = (item: ConversationItem): item is ReasoningBlock =>
- 'content' in item && !('role' in item) && !('name' in item)
+const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error'
const toToolState = (status: ToolCall['status']): ToolState => {
switch (status) {
@@ -118,7 +118,6 @@ interface ChatSidebarProps {
onOpenFullScreen?: () => void
conversation: ConversationItem[]
currentAssistantMessage: string
- currentReasoning: string
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
@@ -145,7 +144,6 @@ export function ChatSidebar({
onOpenFullScreen,
conversation,
currentAssistantMessage,
- currentReasoning,
isProcessing,
isStopping,
onStop,
@@ -326,7 +324,7 @@ export function ChatSidebar({
autoMentionRef.current = { path: selectedPath, displayName }
}, [selectedPath, message, onMessageChange])
- const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
+ const hasConversation = conversation.length > 0 || currentAssistantMessage
const canSubmit = Boolean(message.trim()) && !isProcessing
const handleSubmit = () => {
@@ -427,12 +425,13 @@ export function ChatSidebar({
)
}
- if (isReasoningBlock(item)) {
+ if (isErrorMessage(item)) {
return (
-
-
- {item.content}
-
+
+
+ {item.message}
+
+
)
}
@@ -535,13 +534,6 @@ export function ChatSidebar({
/>
))}
- {currentReasoning && (
-
-
- {currentReasoning}
-
- )}
-
{currentAssistantMessage && (
@@ -550,7 +542,7 @@ export function ChatSidebar({
)}
- {isProcessing && !currentAssistantMessage && !currentReasoning && (
+ {isProcessing && !currentAssistantMessage && (
Thinking...
diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx
index dbb9925b..87401f5b 100644
--- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx
+++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx
@@ -98,6 +98,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)
const activeConfig = providerConfigs[llmProvider]
+ const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible"
const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway"
const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible"
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
@@ -690,9 +691,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)}
- {requiresApiKey && (
+ {showApiKey && (
-
API Key
+
+ {llmProvider === "openai-compatible" ? "API Key (optional)" : "API Key"}
+
{/* API Key */}
- {requiresApiKey && (
+ {showApiKey && (
-
API Key
+
+ {provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
+
;
+ const nested = (err.error && typeof err.error === "object") ? err.error as Record
: null;
+ const nameValue = err.name ?? nested?.name;
+ const responseBodyValue = err.responseBody ?? nested?.responseBody;
+ if (nameValue !== undefined) {
+ name = String(nameValue);
+ }
+ if (responseBodyValue !== undefined) {
+ responseBody = String(responseBodyValue);
+ }
+ } else if (typeof rawError === "string") {
+ responseBody = rawError;
+ }
+
+ const lines: string[] = [];
+ if (name) lines.push(`name: ${name}`);
+ if (responseBody) lines.push(`responseBody: ${responseBody}`);
+ return lines.length ? lines.join("\n") : "Model stream error";
+}
+
export async function loadAgent(id: string): Promise> {
if (id === "copilot" || id === "rowboatx") {
return CopilotAgent;
@@ -401,6 +428,13 @@ async function buildTools(agent: z.infer): Promise {
const tools: ToolSet = {};
for (const [name, tool] of Object.entries(agent.tools ?? {})) {
try {
+ // Skip builtin tools that declare themselves unavailable
+ if (tool.type === 'builtin') {
+ const builtin = BuiltinTools[tool.name];
+ if (builtin?.isAvailable && !(await builtin.isAvailable())) {
+ continue;
+ }
+ }
tools[name] = await mapAgentTool(tool);
} catch (error) {
console.error(`Error mapping tool ${name}:`, error);
@@ -785,6 +819,7 @@ export async function* streamAgent({
timeZoneName: 'short'
});
const instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
+ let streamError: string | null = null;
for await (const event of streamLlm(
model,
state.messages,
@@ -803,6 +838,16 @@ export async function* streamAgent({
event: event,
subflow: [],
});
+ if (event.type === "error") {
+ streamError = event.error;
+ yield* processEvent({
+ runId,
+ type: "error",
+ error: streamError,
+ subflow: [],
+ });
+ break;
+ }
}
// build and emit final message from agent response
@@ -815,6 +860,10 @@ export async function* streamAgent({
subflow: [],
});
+ if (streamError) {
+ return;
+ }
+
// if there were any ask-human calls, emit those events
if (message.content instanceof Array) {
for (const part of message.content) {
@@ -888,6 +937,12 @@ async function* streamLlm(
signal?.throwIfAborted();
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
switch (event.type) {
+ case "error":
+ yield {
+ type: "error",
+ error: formatLlmStreamError((event as { error?: unknown }).error ?? event),
+ };
+ return;
case "reasoning-start":
yield {
type: "reasoning-start",
@@ -938,7 +993,7 @@ async function* streamLlm(
};
break;
default:
- // console.warn("Unknown event type", event);
+ console.log('unknown stream event:', JSON.stringify(event));
continue;
}
}
diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts
index 1a70f28f..5e0ce472 100644
--- a/apps/x/packages/core/src/application/assistant/instructions.ts
+++ b/apps/x/packages/core/src/application/assistant/instructions.ts
@@ -164,6 +164,7 @@ When a user asks for ANY task that might require external capabilities (web sear
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`loadSkill\` - Skill loading
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
+- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do.
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts
index 0d167a52..97217f76 100644
--- a/apps/x/packages/core/src/application/assistant/skills/index.ts
+++ b/apps/x/packages/core/src/application/assistant/skills/index.ts
@@ -10,6 +10,7 @@ import organizeFilesSkill from "./organize-files/skill.js";
import slackSkill from "./slack/skill.js";
import backgroundAgentsSkill from "./background-agents/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
+import webSearchSkill from "./web-search/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
@@ -82,6 +83,12 @@ const definitions: SkillDefinition[] = [
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
content: mcpIntegrationSkill,
},
+ {
+ id: "web-search",
+ title: "Web Search",
+ summary: "Searching the web or researching a topic. Guidance on when to use web-search vs research-search, and how many searches to do.",
+ content: webSearchSkill,
+ },
{
id: "deletion-guardrails",
title: "Deletion Guardrails",
diff --git a/apps/x/packages/core/src/application/assistant/skills/web-search/skill.ts b/apps/x/packages/core/src/application/assistant/skills/web-search/skill.ts
new file mode 100644
index 00000000..d3d0e6e1
--- /dev/null
+++ b/apps/x/packages/core/src/application/assistant/skills/web-search/skill.ts
@@ -0,0 +1,52 @@
+export const skill = String.raw`
+# Web Search Skill
+
+You have access to two search tools for finding information on the internet. Choose the right one based on the user's intent.
+
+## Tools
+
+### web-search (Brave Search)
+Quick, general-purpose web search. Returns titles, URLs, and short descriptions.
+
+**Best for:**
+- Quick lookups for things that change ("current price of Bitcoin", "weather in SF")
+- Current events and breaking news
+- Finding a specific website or page
+- Simple questions with direct answers
+- Checking a fact or date
+
+### research-search (Exa Search)
+Deep, research-oriented search. Returns full article text, highlights, and metadata (author, published date).
+
+**Best for:**
+- Exploring a topic in depth ("what are the latest advances in CRISPR")
+- Finding articles, blog posts, papers, and quality sources
+- Discovering companies, people, or organizations
+- Research where you need rich context, not just links
+- When the user says "research", "find articles about", "look into", "deep dive"
+
+**Category filter:** Use the category parameter when the user's intent clearly maps to one: company, research paper, news, tweet, personal site, financial report, people.
+
+## How Many Searches to Do
+
+**CRITICAL: Always start with exactly ONE search call.** Pick the single best tool (\`web-search\` or \`research-search\`) and make one request. Wait for the result before deciding if more searches are needed.
+
+**NEVER call multiple search tools simultaneously.** No parallel web-search + research-search. No firing off two web-searches at once. Always sequential: one search at a time.
+
+Only make a follow-up search if:
+- The first search returned truly uninformative or irrelevant results
+- The query has clearly distinct sub-topics that the first search couldn't cover (e.g., "compare X and Y" after getting results for X only)
+- The user explicitly asks you to dig deeper
+
+One good search is almost always enough. Default to one and stop.
+
+## Choosing Between the Two
+
+If both tools are attached, prefer:
+- \`web-search\` when the user wants a quick answer or specific link
+- \`research-search\` when the user wants to learn, explore, or gather sources
+
+If only one is attached, use whichever is available.
+`;
+
+export default skill;
diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts
index 19fbc4e5..feb41a7f 100644
--- a/apps/x/packages/core/src/application/lib/builtin-tools.ts
+++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts
@@ -33,6 +33,7 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({
input: z.any(), // (input, ctx?) => Promise
output: z.promise(z.any()),
}),
+ isAvailable: z.custom<() => Promise>().optional(),
}));
type SlackToolHint = {
@@ -1265,4 +1266,215 @@ export const BuiltinTools: z.infer = {
return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 });
},
},
+
+ // ============================================================================
+ // Web Search (Brave Search API)
+ // ============================================================================
+
+ 'web-search': {
+ description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.',
+ inputSchema: z.object({
+ query: z.string().describe('The search query'),
+ count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
+ freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'),
+ }),
+ isAvailable: async () => {
+ try {
+ const homedir = process.env.HOME || process.env.USERPROFILE || '';
+ const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json');
+ const raw = await fs.readFile(braveConfigPath, 'utf8');
+ const config = JSON.parse(raw);
+ return !!config.apiKey;
+ } catch {
+ return false;
+ }
+ },
+ execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
+ try {
+ // Read API key from config
+ const homedir = process.env.HOME || process.env.USERPROFILE || '';
+ const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json');
+
+ let apiKey: string;
+ try {
+ const raw = await fs.readFile(braveConfigPath, 'utf8');
+ const config = JSON.parse(raw);
+ apiKey = config.apiKey;
+ } catch {
+ return {
+ success: false,
+ error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "" }',
+ };
+ }
+
+ if (!apiKey) {
+ return {
+ success: false,
+ error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json',
+ };
+ }
+
+ // Build query params
+ const resultCount = Math.min(Math.max(count || 5, 1), 20);
+ const params = new URLSearchParams({
+ q: query,
+ count: String(resultCount),
+ });
+ if (freshness) {
+ params.set('freshness', freshness);
+ }
+
+ const url = `https://api.search.brave.com/res/v1/web/search?${params.toString()}`;
+ const response = await fetch(url, {
+ headers: {
+ 'X-Subscription-Token': apiKey,
+ 'Accept': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text();
+ return {
+ success: false,
+ error: `Brave Search API error (${response.status}): ${body}`,
+ };
+ }
+
+ const data = await response.json() as {
+ web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
+ };
+
+ const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({
+ title: r.title || '',
+ url: r.url || '',
+ description: r.description || '',
+ }));
+
+ return {
+ success: true,
+ query,
+ results,
+ count: results.length,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ },
+ },
+
+ // ============================================================================
+ // Research Search (Exa Search API)
+ // ============================================================================
+
+ 'research-search': {
+ description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.',
+ inputSchema: z.object({
+ query: z.string().describe('The search query'),
+ numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
+ category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
+ }),
+ isAvailable: async () => {
+ try {
+ const homedir = process.env.HOME || process.env.USERPROFILE || '';
+ const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json');
+ const raw = await fs.readFile(exaConfigPath, 'utf8');
+ const config = JSON.parse(raw);
+ return !!config.apiKey;
+ } catch {
+ return false;
+ }
+ },
+ execute: async ({ query, numResults, category }: { query: string; numResults?: number; category?: string }) => {
+ try {
+ const homedir = process.env.HOME || process.env.USERPROFILE || '';
+ const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json');
+
+ let apiKey: string;
+ try {
+ const raw = await fs.readFile(exaConfigPath, 'utf8');
+ const config = JSON.parse(raw);
+ apiKey = config.apiKey;
+ } catch {
+ return {
+ success: false,
+ error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "" }',
+ };
+ }
+
+ if (!apiKey) {
+ return {
+ success: false,
+ error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
+ };
+ }
+
+ const resultCount = Math.min(Math.max(numResults || 5, 1), 20);
+
+ const body: Record = {
+ query,
+ numResults: resultCount,
+ type: 'auto',
+ contents: {
+ text: { maxCharacters: 1000 },
+ highlights: true,
+ },
+ };
+ if (category) {
+ body.category = category;
+ }
+
+ const response = await fetch('https://api.exa.ai/search', {
+ method: 'POST',
+ headers: {
+ 'x-api-key': apiKey,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ return {
+ success: false,
+ error: `Exa Search API error (${response.status}): ${text}`,
+ };
+ }
+
+ const data = await response.json() as {
+ results?: Array<{
+ title?: string;
+ url?: string;
+ publishedDate?: string;
+ author?: string;
+ highlights?: string[];
+ text?: string;
+ }>;
+ };
+
+ const results = (data.results || []).map((r) => ({
+ title: r.title || '',
+ url: r.url || '',
+ publishedDate: r.publishedDate || '',
+ author: r.author || '',
+ highlights: r.highlights || [],
+ text: r.text || '',
+ }));
+
+ return {
+ success: true,
+ query,
+ results,
+ count: results.length,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ },
+ },
};
diff --git a/apps/x/packages/shared/src/llm-step-events.ts b/apps/x/packages/shared/src/llm-step-events.ts
index b9c428cf..8f9ff12c 100644
--- a/apps/x/packages/shared/src/llm-step-events.ts
+++ b/apps/x/packages/shared/src/llm-step-events.ts
@@ -51,6 +51,11 @@ export const LlmStepStreamFinishStepEvent = z.object({
providerOptions: ProviderOptions.optional(),
});
+export const LlmStepStreamErrorEvent = BaseEvent.extend({
+ type: z.literal("error"),
+ error: z.string(),
+});
+
export const LlmStepStreamEvent = z.union([
LlmStepStreamReasoningStartEvent,
LlmStepStreamReasoningDeltaEvent,
@@ -60,4 +65,5 @@ export const LlmStepStreamEvent = z.union([
LlmStepStreamTextEndEvent,
LlmStepStreamToolCallEvent,
LlmStepStreamFinishStepEvent,
-]);
\ No newline at end of file
+ LlmStepStreamErrorEvent,
+]);
diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml
index 7afa3ff3..ff5b1e91 100644
--- a/apps/x/pnpm-lock.yaml
+++ b/apps/x/pnpm-lock.yaml
@@ -300,19 +300,19 @@ importers:
packages/core:
dependencies:
'@ai-sdk/anthropic':
- specifier: ^2.0.44
- version: 2.0.57(zod@4.2.1)
+ specifier: ^2.0.63
+ version: 2.0.63(zod@4.2.1)
'@ai-sdk/google':
- specifier: ^2.0.25
- version: 2.0.52(zod@4.2.1)
- '@ai-sdk/openai':
specifier: ^2.0.53
- version: 2.0.89(zod@4.2.1)
+ version: 2.0.53(zod@4.2.1)
+ '@ai-sdk/openai':
+ specifier: ^2.0.91
+ version: 2.0.91(zod@4.2.1)
'@ai-sdk/openai-compatible':
- specifier: ^1.0.27
- version: 1.0.30(zod@4.2.1)
+ specifier: ^1.0.33
+ version: 1.0.33(zod@4.2.1)
'@ai-sdk/provider':
- specifier: ^2.0.0
+ specifier: ^2.0.1
version: 2.0.1
'@composio/core':
specifier: ^0.6.0
@@ -325,7 +325,7 @@ importers:
version: 1.25.1(hono@4.11.3)(zod@4.2.1)
'@openrouter/ai-sdk-provider':
specifier: ^1.2.6
- version: 1.5.4(ai@5.0.117(zod@4.2.1))(zod@4.2.1)
+ version: 1.5.4(ai@5.0.133(zod@4.2.1))(zod@4.2.1)
'@react-pdf/renderer':
specifier: ^4.3.2
version: 4.3.2(react@19.2.3)
@@ -336,8 +336,8 @@ importers:
specifier: workspace:*
version: link:../shared
ai:
- specifier: ^5.0.102
- version: 5.0.117(zod@4.2.1)
+ specifier: ^5.0.133
+ version: 5.0.133(zod@4.2.1)
awilix:
specifier: ^12.0.5
version: 12.0.5
@@ -405,8 +405,8 @@ importers:
packages:
- '@ai-sdk/anthropic@2.0.57':
- resolution: {integrity: sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg==}
+ '@ai-sdk/anthropic@2.0.63':
+ resolution: {integrity: sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -417,20 +417,26 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
- '@ai-sdk/google@2.0.52':
- resolution: {integrity: sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g==}
+ '@ai-sdk/gateway@2.0.39':
+ resolution: {integrity: sha512-ULnefGmRHG0/tRrf+dtDwgQYAttGi/TR0FmASAzTs1dtpeZp4Xoh1VyWrX3Z1bM3WDs9RM3ZeSE77kQT/jbfjw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
- '@ai-sdk/openai-compatible@1.0.30':
- resolution: {integrity: sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w==}
+ '@ai-sdk/google@2.0.53':
+ resolution: {integrity: sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
- '@ai-sdk/openai@2.0.89':
- resolution: {integrity: sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw==}
+ '@ai-sdk/openai-compatible@1.0.33':
+ resolution: {integrity: sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
+ '@ai-sdk/openai@2.0.91':
+ resolution: {integrity: sha512-lozfRHfSTHg5/UliQjTDcOtISYGbEpt4FS/6QM5PcLmhdT0HmROllaBmG7+JaK+uqFtDXZGgMIpz3bqB9nzqCQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -441,6 +447,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ '@ai-sdk/provider-utils@3.0.21':
+ resolution: {integrity: sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
'@ai-sdk/provider@2.0.1':
resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==}
engines: {node: '>=18'}
@@ -3129,6 +3141,10 @@ packages:
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
engines: {node: '>= 20'}
+ '@vercel/oidc@3.1.0':
+ resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
+ engines: {node: '>= 20'}
+
'@vitejs/plugin-react@5.1.2':
resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3245,6 +3261,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ ai@5.0.133:
+ resolution: {integrity: sha512-N6KnwSWKcXEWPnAri3anRuzRvcrvtDz1W1JG9CvMrQ0Xdp8Vu8ZToNW/eHt63CmrbmzTwVw/HaCtJuO+MYtS7A==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -7065,10 +7087,10 @@ packages:
snapshots:
- '@ai-sdk/anthropic@2.0.57(zod@4.2.1)':
+ '@ai-sdk/anthropic@2.0.63(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
- '@ai-sdk/provider-utils': 3.0.20(zod@4.2.1)
+ '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
zod: 4.2.1
'@ai-sdk/gateway@2.0.24(zod@4.2.1)':
@@ -7078,22 +7100,29 @@ snapshots:
'@vercel/oidc': 3.0.5
zod: 4.2.1
- '@ai-sdk/google@2.0.52(zod@4.2.1)':
+ '@ai-sdk/gateway@2.0.39(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
- '@ai-sdk/provider-utils': 3.0.20(zod@4.2.1)
+ '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
+ '@vercel/oidc': 3.1.0
zod: 4.2.1
- '@ai-sdk/openai-compatible@1.0.30(zod@4.2.1)':
+ '@ai-sdk/google@2.0.53(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
- '@ai-sdk/provider-utils': 3.0.20(zod@4.2.1)
+ '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
zod: 4.2.1
- '@ai-sdk/openai@2.0.89(zod@4.2.1)':
+ '@ai-sdk/openai-compatible@1.0.33(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
- '@ai-sdk/provider-utils': 3.0.20(zod@4.2.1)
+ '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
+ zod: 4.2.1
+
+ '@ai-sdk/openai@2.0.91(zod@4.2.1)':
+ dependencies:
+ '@ai-sdk/provider': 2.0.1
+ '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
zod: 4.2.1
'@ai-sdk/provider-utils@3.0.20(zod@4.2.1)':
@@ -7103,6 +7132,13 @@ snapshots:
eventsource-parser: 3.0.6
zod: 4.2.1
+ '@ai-sdk/provider-utils@3.0.21(zod@4.2.1)':
+ dependencies:
+ '@ai-sdk/provider': 2.0.1
+ '@standard-schema/spec': 1.1.0
+ eventsource-parser: 3.0.6
+ zod: 4.2.1
+
'@ai-sdk/provider@2.0.1':
dependencies:
json-schema: 0.4.0
@@ -8754,10 +8790,10 @@ snapshots:
dependencies:
'@octokit/openapi-types': 12.11.0
- '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.117(zod@4.2.1))(zod@4.2.1)':
+ '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.133(zod@4.2.1))(zod@4.2.1)':
dependencies:
'@openrouter/sdk': 0.1.27
- ai: 5.0.117(zod@4.2.1)
+ ai: 5.0.133(zod@4.2.1)
zod: 4.2.1
'@openrouter/sdk@0.1.27':
@@ -10551,6 +10587,8 @@ snapshots:
'@vercel/oidc@3.0.5': {}
+ '@vercel/oidc@3.1.0': {}
+
'@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.28.5
@@ -10693,6 +10731,14 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 4.2.1
+ ai@5.0.133(zod@4.2.1):
+ dependencies:
+ '@ai-sdk/gateway': 2.0.39(zod@4.2.1)
+ '@ai-sdk/provider': 2.0.1
+ '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
+ '@opentelemetry/api': 1.9.0
+ zod: 4.2.1
+
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1