RFC-031: Provider Web Search
RFC-031: Provider Web Search
Document Information
- RFC ID: RFC-031
- Related PRD: FEAT-031 (Provider Web Search)
- Created: 2026-03-01
- Last Updated: 2026-03-01
- Status: Draft
- Author: TBD
Overview
Background
OneClaw currently has two client-side web tools: webfetch (HTTP fetch + HTML-to-Markdown) and browser (WebView-based rendering). These require the AI to explicitly call tools and consume a tool-call round-trip per page. Meanwhile, all three supported providers offer built-in server-side web search capabilities:
- OpenAI:
web_search_optionsin Chat Completions API (search-capable models) - Anthropic:
web_search_20250305server-side tool in Messages API - Gemini:
google_searchgrounding in generateContent API
These server-side search capabilities run on the provider’s infrastructure, have access to the provider’s search index, and return structured citations – all without consuming client-side tool-call rounds.
Goals
- Add a
webSearchEnabledboolean to the Agent model and persist it in the database - Modify
ModelApiAdapter.sendMessageStream()to accept a web search flag and inject provider-specific search configuration - Parse provider-specific citation/grounding responses into a unified
Citationdomain model - Add new
StreamEventtypes for web search lifecycle events and citations - Store citations as JSON on
AI_RESPONSEmessages - Render a “Sources” section below AI response messages in the chat UI
- Handle Anthropic’s
server_tool_use/web_search_tool_resultcontent block types without triggering client-side tool execution
Non-Goals
- Domain filtering (allowed/blocked domain lists) – future enhancement
- User location configuration for localized search – future enhancement
- Inline footnote-style citation markers within response text
- Displaying the search query the provider generated
- Search result snippets/previews before the AI’s synthesized response
- Client-side search fallback when provider search is unavailable
Technical Design
Architecture Overview
┌───────────────────────────────────────────────────────────────────────┐
│ UI Layer │
│ │
│ AgentDetailScreen ChatScreen │
│ └── webSearchEnabled └── CitationSection (new) │
│ toggle (new) └── CitationItem (new, clickable) │
│ │
├───────────────────────────────────────────────────────────────────────┤
│ Use Case Layer │
│ │
│ SendMessageUseCase │
│ └── Passes webSearchEnabled to adapter │
│ └── Collects StreamEvent.Citations → stores on Message │
│ └── Skips tool execution for server_tool_use events │
│ │
├───────────────────────────────────────────────────────────────────────┤
│ Adapter Layer │
│ │
│ ModelApiAdapter.sendMessageStream() │
│ └── New parameter: webSearchEnabled: Boolean │
│ │
│ OpenAiAdapter │
│ └── Adds "web_search_options": {} to request body │
│ └── Parses annotations[].url_citation from response │
│ │
│ AnthropicAdapter │
│ └── Adds {type: "web_search_20250305", name: "web_search"} to tools│
│ └── Handles server_tool_use + web_search_tool_result blocks │
│ └── Extracts citations from text blocks │
│ │
│ GeminiAdapter │
│ └── Adds {google_search: {}} to tools array │
│ └── Parses groundingMetadata from response candidates │
│ │
├───────────────────────────────────────────────────────────────────────┤
│ Data Layer │
│ │
│ Agent (domain model) → + webSearchEnabled: Boolean │
│ AgentEntity (Room) → + web_search_enabled column │
│ Message (domain model) → + citations: List<Citation>? │
│ MessageEntity (Room) → + citations TEXT column (JSON) │
│ Citation (new domain model) │
│ │
│ Migration 7→8: Add columns to agents and messages tables │
│ │
└───────────────────────────────────────────────────────────────────────┘
Data Model
Citation (new domain model)
// core/model/Citation.kt
data class Citation(
val url: String,
val title: String,
val domain: String // extracted from url for display
)
Unified across all three providers. Provider-specific fields (encrypted_content for Anthropic, confidenceScores for Gemini) are not persisted – they are only needed during the API round-trip.
Agent (modified)
// core/model/Agent.kt
data class Agent(
val id: String,
val name: String,
val description: String?,
val systemPrompt: String,
val preferredProviderId: String?,
val preferredModelId: String?,
val isBuiltIn: Boolean,
val createdAt: Long,
val updatedAt: Long,
val webSearchEnabled: Boolean = false // NEW
)
AgentEntity (modified)
// data/local/entity/AgentEntity.kt
@Entity(tableName = "agents")
data class AgentEntity(
// ... existing fields ...
@ColumnInfo(name = "web_search_enabled", defaultValue = "0")
val webSearchEnabled: Boolean // NEW
)
Message (modified)
// core/model/Message.kt
data class Message(
// ... existing fields ...
val citations: List<Citation>? = null // NEW
)
MessageEntity (modified)
// data/local/entity/MessageEntity.kt
@Entity(tableName = "messages", ...)
data class MessageEntity(
// ... existing fields ...
@ColumnInfo(name = "citations")
val citations: String? = null // NEW - JSON array of Citation objects
)
Citations are serialized/deserialized as JSON using kotlinx.serialization in the mapper:
// In MessageMapper
private val json = Json { ignoreUnknownKeys = true }
fun MessageEntity.toDomain(): Message {
val parsedCitations = citations?.let {
try { json.decodeFromString<List<Citation>>(it) } catch (_: Exception) { null }
}
return Message(
// ... existing mappings ...
citations = parsedCitations
)
}
fun Message.toEntity(): MessageEntity {
return MessageEntity(
// ... existing mappings ...
citations = citations?.let { json.encodeToString(it) }
)
}
Database Migration (7 -> 8)
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
// RFC-031: Add web_search_enabled to agents
db.execSQL("ALTER TABLE agents ADD COLUMN web_search_enabled INTEGER NOT NULL DEFAULT 0")
// RFC-031: Add citations to messages
db.execSQL("ALTER TABLE messages ADD COLUMN citations TEXT")
}
}
StreamEvent Changes
New event types added to StreamEvent:
sealed class StreamEvent {
// ... existing events ...
/** Provider is performing a server-side web search. */
data class WebSearchStart(val query: String?) : StreamEvent()
/** Citations extracted from the provider's response. */
data class Citations(val citations: List<Citation>) : StreamEvent()
}
WebSearchStart: Emitted when Anthropic’sserver_tool_useblock starts (with the search query). For OpenAI and Gemini, this is not emitted since search is transparent.Citations: Emitted when citation data is available. For OpenAI, this comes fromannotationsin the final message chunk. For Anthropic, this is extracted fromcitationson text blocks. For Gemini, this is parsed fromgroundingMetadata.groundingChunks.
Adapter Changes
ModelApiAdapter Interface (modified)
interface ModelApiAdapter {
fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?,
webSearchEnabled: Boolean = false // NEW
): Flow<StreamEvent>
// ... other methods unchanged ...
}
OpenAI Adapter Changes
When webSearchEnabled = true, add web_search_options to the request body:
// In buildOpenAiRequest()
private fun buildOpenAiRequest(
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?,
webSearchEnabled: Boolean
): JsonObject = buildJsonObject {
put("model", modelId)
put("stream", true)
// ... existing fields ...
if (webSearchEnabled) {
putJsonObject("web_search_options") {
put("search_context_size", "medium")
}
}
}
Parse annotations from streamed response:
// In processOpenAiStreamLine()
// After accumulating the full message, parse annotations from the final delta
// OpenAI returns annotations in message.annotations[] with type "url_citation"
private fun parseAnnotations(messageJson: JsonObject): List<Citation> {
val annotations = messageJson.jsonArray("annotations") ?: return emptyList()
return annotations.mapNotNull { ann ->
val obj = ann.jsonObject
if (obj.string("type") == "url_citation") {
val url = obj.string("url") ?: return@mapNotNull null
Citation(
url = url,
title = obj.string("title") ?: url,
domain = extractDomain(url)
)
} else null
}.distinctBy { it.url }
}
Citations are emitted as StreamEvent.Citations after the final text delta (at [DONE] signal or when annotations are found).
Anthropic Adapter Changes
When webSearchEnabled = true, inject the web search tool into the tools array:
// In buildAnthropicRequest()
private fun buildAnthropicRequest(
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?,
webSearchEnabled: Boolean
): JsonObject = buildJsonObject {
// ... existing fields ...
// Build tools array
putJsonArray("tools") {
// Add client-side tools
tools?.forEach { tool ->
addJsonObject { /* existing tool formatting */ }
}
// Add server-side web search tool
if (webSearchEnabled) {
addJsonObject {
put("type", "web_search_20250305")
put("name", "web_search")
put("max_uses", 5)
}
}
}
}
Handle new content block types in stream parsing:
// In processAnthropicStreamEvent()
"content_block_start" -> {
val contentBlock = data.jsonObject("content_block") ?: return
val blockType = contentBlock.string("type")
val blockIndex = data.int("index") ?: return
when (blockType) {
"text" -> { /* existing: track text block index */ }
"thinking" -> { /* existing: track thinking block */ }
"tool_use" -> { /* existing: emit ToolCallStart */ }
// NEW: Server-side tool use (web search)
"server_tool_use" -> {
val name = contentBlock.string("name")
if (name == "web_search") {
serverToolUseIndices.add(blockIndex)
// Query will be extracted from input_json_delta
}
}
// NEW: Web search results
"web_search_tool_result" -> {
// Parse search results, extract citations
val content = contentBlock.jsonArray("content")
val citations = content?.mapNotNull { result ->
val obj = result.jsonObject
if (obj.string("type") == "web_search_result") {
val url = obj.string("url") ?: return@mapNotNull null
Citation(
url = url,
title = obj.string("title") ?: url,
domain = extractDomain(url)
)
} else null
} ?: emptyList()
if (citations.isNotEmpty()) {
emit(StreamEvent.Citations(citations))
}
}
}
}
// Also: when a server_tool_use block receives input_json_delta, extract query:
"content_block_delta" -> {
val index = data.int("index") ?: return
if (index in serverToolUseIndices) {
// Extract search query from input_json_delta
val delta = data.jsonObject("delta")
val partialJson = delta?.string("partial_json")
// Accumulate and parse query when complete
// Emit WebSearchStart(query) when block stops
}
}
Critical: The server_tool_use block must NOT be treated as a client-side tool call. It should NOT be added to pendingToolCalls and should NOT trigger tool execution in SendMessageUseCase.
Additionally, parse inline citations from text blocks:
// Anthropic text blocks may have citations array
// These are extracted during content_block_start or as separate fields
"content_block_start" -> {
val contentBlock = data.jsonObject("content_block")
val blockType = contentBlock?.string("type")
if (blockType == "text") {
val citations = contentBlock.jsonArray("citations")?.mapNotNull { c ->
val obj = c.jsonObject
if (obj.string("type") == "web_search_result_location") {
val url = obj.string("url") ?: return@mapNotNull null
Citation(
url = url,
title = obj.string("title") ?: url,
domain = extractDomain(url)
)
} else null
}
if (!citations.isNullOrEmpty()) {
emit(StreamEvent.Citations(citations))
}
}
}
Anthropic Multi-Turn Context
Anthropic’s web search results contain encrypted_content fields that must be passed back in subsequent turns. The current message-to-API conversion in buildAnthropicMessages() handles AI_RESPONSE messages by reconstructing content blocks. For web search context:
- The
server_tool_useandweb_search_tool_resultcontent blocks from the assistant’s response must be included when the message is sent back in context. - Since we store the full response content in
Message.content, and Anthropic needs the structured content blocks, we need to store the raw Anthropic content array for AI_RESPONSE messages that contain search results.
Approach: Store the raw Anthropic content blocks JSON in the thinkingContent field (repurposing for multi-turn context) is not ideal. Instead, add a rawContentBlocks nullable field to Message for this purpose. However, to minimize schema changes in v1, we will not persist raw content blocks. This means search context will not be preserved across message compaction or session reload – an acceptable trade-off for v1. The model can re-search if needed.
Gemini Adapter Changes
When webSearchEnabled = true, add google_search to the tools array:
// In buildGeminiRequest()
private fun buildGeminiRequest(
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?,
webSearchEnabled: Boolean
): JsonObject = buildJsonObject {
// ... existing fields ...
putJsonArray("tools") {
// Existing function declarations tool
if (!tools.isNullOrEmpty()) {
addJsonObject {
putJsonArray("function_declarations") {
tools.forEach { tool -> addJsonObject { /* ... */ } }
}
}
}
// NEW: Google Search grounding
if (webSearchEnabled) {
addJsonObject {
putJsonObject("google_search") {}
}
}
}
}
Parse groundingMetadata from response:
// In processGeminiStreamChunk()
// groundingMetadata typically appears in the final chunk
private fun parseGroundingMetadata(candidate: JsonObject): List<Citation> {
val metadata = candidate.jsonObject("groundingMetadata") ?: return emptyList()
val chunks = metadata.jsonArray("groundingChunks") ?: return emptyList()
return chunks.mapNotNull { chunk ->
val web = chunk.jsonObject.jsonObject("web") ?: return@mapNotNull null
val url = web.string("uri") ?: return@mapNotNull null
Citation(
url = url,
title = web.string("title") ?: url,
domain = web.string("domain") ?: extractDomain(url)
)
}.distinctBy { it.url }
}
// Emit when found:
val citations = parseGroundingMetadata(candidate)
if (citations.isNotEmpty()) {
emit(StreamEvent.Citations(citations))
}
SendMessageUseCase Changes
The use case needs three modifications:
1. Pass webSearchEnabled to adapter
// In execute()
val webSearchEnabled = agent.webSearchEnabled
adapter.sendMessageStream(
apiBaseUrl = provider.apiBaseUrl,
apiKey = apiKey,
modelId = model.modelId,
messages = apiMessages,
tools = toolDefinitions,
systemPrompt = finalSystemPrompt,
webSearchEnabled = webSearchEnabled // NEW
)
2. Collect citations from stream
// In the stream collection loop
val accumulatedCitations = mutableListOf<Citation>()
when (event) {
// ... existing event handling ...
is StreamEvent.WebSearchStart -> {
emit(ChatEvent.WebSearchStarted(event.query))
}
is StreamEvent.Citations -> {
accumulatedCitations.addAll(event.citations)
}
}
// When saving the AI_RESPONSE message:
val aiMessage = Message(
// ... existing fields ...
citations = accumulatedCitations.distinctBy { it.url }.ifEmpty { null }
)
3. Skip tool execution for server-side tool events
The server_tool_use content blocks from Anthropic are emitted as WebSearchStart, not as ToolCallStart. Therefore, they will naturally not end up in pendingToolCalls and will not trigger local tool execution. No special handling is needed beyond using the correct StreamEvent types in the Anthropic adapter.
ChatEvent Changes
New event type for the chat UI:
sealed class ChatEvent {
// ... existing events ...
/** Provider is performing a web search. */
data class WebSearchStarted(val query: String?) : ChatEvent()
}
UI Layer Design
Citation Section in Chat Messages
Below AI response messages that have citations, render a collapsible “Sources” section:
┌────────────────────────────────────────────┐
│ AI response text goes here. Based on │
│ recent reports, Android 16 introduces... │
│ │
│ ── Sources (3) ────────────────────────── │
│ │
│ [link] Android 16 Preview Released │
│ developer.android.com │
│ │
│ [link] What's New in Android 16 │
│ blog.google │
│ │
│ [link] Android 16 Features List │
│ arstechnica.com │
│ │
└────────────────────────────────────────────┘
@Composable
fun CitationSection(
citations: List<Citation>,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
Column(modifier = modifier.padding(top = 8.dp)) {
// Divider with label
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
text = "Sources (${citations.size})",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp)
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
// Citation items
citations.forEach { citation ->
CitationItem(
citation = citation,
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(citation.url))
context.startActivity(intent)
}
)
}
}
}
@Composable
fun CitationItem(
citation: Citation,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 4.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Link,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = citation.title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = citation.domain,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Web Search Indicator
While a web search is in progress (Anthropic only), show an indicator similar to the thinking indicator:
@Composable
fun WebSearchIndicator(query: String?) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (query != null) "Searching: $query" else "Searching the web...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = FontStyle.Italic
)
}
}
Agent Configuration Toggle
Add to AgentDetailScreen:
// In the agent form, after the model selector
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onWebSearchToggle(!webSearchEnabled) }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.TravelExplore, contentDescription = null)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Web Search", style = MaterialTheme.typography.bodyLarge)
Text(
"Allow the agent to search the web for up-to-date information. Additional API costs apply.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = webSearchEnabled,
onCheckedChange = onWebSearchToggle
)
}
AgentDetailUiState (modified)
data class AgentDetailUiState(
// ... existing fields ...
val webSearchEnabled: Boolean = false, // NEW
val savedWebSearchEnabled: Boolean = false, // NEW - for unsaved changes detection
)
DI Registration
No new DI registrations needed – the changes are modifications to existing components. The Citation model is a simple data class with no dependencies.
Utility Function
// core/util/UrlUtils.kt (or add to existing utils)
fun extractDomain(url: String): String {
return try {
java.net.URI(url).host?.removePrefix("www.") ?: url
} catch (_: Exception) {
url
}
}
Implementation Steps
Phase 1: Data Layer (models + migration)
- Create
Citationdata class incore/model/ - Add
webSearchEnabledfield toAgentdomain model - Add
web_search_enabledcolumn toAgentEntity - Add
citationsfield toMessagedomain model - Add
citationscolumn toMessageEntity - Update
AgentMapper(entity <-> domain conversions) - Update
MessageMapperwith citation JSON serialization - Create
MIGRATION_7_8and register inAppDatabase - Update database version to 8
- Add
extractDomain()utility function
Phase 2: Stream Events + Adapter Interface
- Add
StreamEvent.WebSearchStartandStreamEvent.CitationstoStreamEvent - Add
webSearchEnabledparameter toModelApiAdapter.sendMessageStream() - Update all three adapter implementations to accept the new parameter (no-op initially)
Phase 3: OpenAI Adapter
- Add
web_search_optionsto request body when enabled - Parse
annotationsarray from streamed response chunks - Emit
StreamEvent.Citationswith parsedurl_citationentries
Phase 4: Anthropic Adapter
- Inject
web_search_20250305tool into tools array when enabled - Handle
server_tool_usecontent block type (track index, emitWebSearchStart) - Handle
web_search_tool_resultcontent block type (extract citations) - Parse
citationsfrom text content blocks - Emit
StreamEvent.Citationswith parsed citations
Phase 5: Gemini Adapter
- Add
google_search: {}to tools array when enabled - Parse
groundingMetadatafrom response candidates - Extract
groundingChunksintoCitationlist - Emit
StreamEvent.Citations
Phase 6: SendMessageUseCase
- Pass
agent.webSearchEnabledtoadapter.sendMessageStream() - Add
ChatEvent.WebSearchStartedtoChatEvent - Collect
StreamEvent.Citationsinto accumulated citation list - Handle
StreamEvent.WebSearchStart-> emitChatEvent.WebSearchStarted - Store accumulated citations on the saved
AI_RESPONSEmessage
Phase 7: Agent UI
- Add
webSearchEnabledtoAgentDetailUiState - Add web search toggle to
AgentDetailScreen - Update
AgentDetailViewModelto load/save the new field - Update
AgentConstantsif the built-in agent should have web search disabled by default
Phase 8: Chat UI
- Create
CitationSectioncomposable - Create
CitationItemcomposable - Create
WebSearchIndicatorcomposable - Integrate
CitationSectioninto AI response message rendering (whenmessage.citationsis non-empty) - Show
WebSearchIndicatorwhenChatEvent.WebSearchStartedis received (hide on next text delta or response complete)
Testing Strategy
Unit Tests
CitationParsingTest (per adapter):
- OpenAI: Parse
url_citationannotations from mock response JSON - Anthropic: Parse
web_search_tool_resultcontent blocks from mock SSE data - Anthropic: Parse
citationsfrom text content blocks - Gemini: Parse
groundingMetadata.groundingChunksfrom mock response JSON - All: Deduplicate citations by URL
AgentMapperTest:
- Map
webSearchEnabledbetween entity and domain - Default value is
falsewhen column is missing (migration compat)
MessageMapperTest:
- Serialize
List<Citation>to JSON string - Deserialize JSON string to
List<Citation> - Handle null and empty citations
- Handle malformed JSON gracefully (return null)
SendMessageUseCaseTest:
- Verify
webSearchEnabledis passed through to adapter - Verify
StreamEvent.Citationsare accumulated and saved on message - Verify
StreamEvent.WebSearchStartemitsChatEvent.WebSearchStarted - Verify server-side tool events do not trigger local tool execution
Integration Tests
- Verify database migration 7->8 runs without errors
- Verify agent with
webSearchEnabled = truepersists and loads correctly
Manual Tests
- Enable web search on an agent, send a current-events question, verify citations appear
- Test with each provider (OpenAI, Anthropic, Gemini) individually
- Tap a citation link, verify it opens in browser
- Verify web search indicator appears for Anthropic searches
- Disable web search, verify no search configuration is sent
- Reload a session with citations, verify they display correctly
- Verify new agents default to web search OFF
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-03-01 | 0.1 | Initial version | - |