Skip to content

feat(core): add pluggable web search - #35558

Merged
thdxr merged 2 commits into
v2from
search-integration
Jul 26, 2026
Merged

thdxr merged 2 commits into
v2from
search-integration

Conversation

@nexxeln

@nexxeln nexxeln commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add a Location-scoped WebSearch domain with an independent scoped provider registry
  • expose standalone provider registration to Effect and Promise plugins through ctx.websearch.register(...)
  • keep integrations optional and orthogonal: providers may use them for credentials, but WebSearch does not depend on Integration
  • ship Exa and Parallel as first-party WebSearch providers with provider-specific request and response schemas
  • persist first-use provider selection to global opencode.json while preserving JSONC comments and project overrides
  • expose provider discovery, selection, and queries through generated clients and SDKs
  • show both integration-associated and standalone WebSearch providers in the TUI

Architecture

WebSearch owns provider IDs, metadata, scoped registration, selection, and execution. Integration owns service identity, authentication methods, connections, and credentials. A provider can register without an Integration; providers that need credentials can independently resolve a same-ID Integration connection during execution.

Testing

  • full 38-package push-hook typecheck
  • focused Core WebSearch, provider plugin, Form, and Promise-plugin tests
  • Client, Schema event-manifest, and plugin contract tests
  • embedded SDK standalone-provider test
  • full TUI suite: 221 passed, 1 skipped
  • generated Promise, Effect, and legacy JavaScript SDK checks

Example WebSearch provider plugin

This example adds Brave Search from .opencode/plugin/brave-search.ts. The Integration is only responsible for credentials; WebSearch registration is independent.

import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema, Scope } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"

const BraveResponse = Schema.Struct({
  web: Schema.optional(
    Schema.Struct({
      results: Schema.Array(
        Schema.Struct({
          title: Schema.String,
          url: Schema.String,
          description: Schema.optional(Schema.String),
        }),
      ),
    }),
  ),
})

export default Plugin.define<HttpClient.HttpClient | Scope.Scope>({
  id: "brave-search",
  effect: Effect.fn("BraveSearch.Plugin")(function* (ctx) {
    const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)

    yield* ctx.integration.register({
      id: "brave",
      name: "Brave Search",
      methods: [
        { type: "key", label: "API key" },
        { type: "env", names: ["BRAVE_SEARCH_API_KEY"] },
      ],
    })

    yield* ctx.websearch.register({
      id: "brave",
      name: "Brave Search",
      execute: (input) =>
        Effect.gen(function* () {
          const connection = yield* ctx.integration.connection.active("brave")
          const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
          if (credential?.type !== "key") return yield* Effect.fail(new Error("Missing Brave API key"))

          const url = new URL("https://api.search.brave.com/res/v1/web/search")
          url.searchParams.set("q", input.query)
          url.searchParams.set("count", "8")

          const response = yield* HttpClientRequest.get(url.toString()).pipe(
            HttpClientRequest.acceptJson,
            HttpClientRequest.setHeader("X-Subscription-Token", credential.key),
            http.execute,
            Effect.flatMap(HttpClientResponse.schemaBodyJson(BraveResponse)),
          )
          const results = response.web?.results ?? []
          return {
            text: results
              .map((result) => `## [${result.title}](${result.url})${result.description ? `\n${result.description}` : ""}`)
              .join("\n\n"),
            metadata: response,
          }
        }),
    })
  }),
})

Omit the Integration registration entirely for a provider that does not need OpenCode-managed credentials.

Part 1 of #35038.

@nexxeln nexxeln changed the title feat(core): add integration-backed search feat(core): add pluggable web search Jul 9, 2026
@thdxr

thdxr commented Jul 26, 2026 •

Copy link
Copy Markdown
Member
  • Made search results look the same no matter which provider they come from, so the rest of the app does not need to know about provider-specific response formats.
  • Removed the new register shortcuts and used transforms like our other plugin APIs, so web search is not a special case.
  • Kept the web search domain pretty dumb: config is handled by a config plugin, and anything session- or form-related lives in the tool.
  • Used KV as a lower-priority config for choices made interactively. The tool can remember a provider or remember that web search was disabled, and a disabled tool is left out of model context.
  • Removed the rollout flags and the UI/API for changing the default provider. Both built-in providers are available in V2, but clients cannot change server config through the web search API for now.

@thdxr
thdxr force-pushed the search-integration branch from dd01e4c to 6490a0d Compare July 26, 2026 03:47
@thdxr
thdxr enabled auto-merge (squash) July 26, 2026 03:48
@thdxr
thdxr merged commit efb629a into v2 Jul 26, 2026
7 checks passed
@thdxr
thdxr deleted the search-integration branch July 26, 2026 03:55
@jonathansampson jonathansampson mentioned this pull request Sep 9, 2026
3 of 6 tasks
jinhuang712 pushed a commit to jinhuang712/opencode that referenced this pull request Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants