# Web Search Implementation Plan > **Status: implemented**, with two deliberate departures from what is written > below. The hosted `web_search_20250305` tool was promoted from Appendix A to > a first-class option rather than being left for later, because the setup > wizard needed a "no setup required" answer to offer. And the client-side > provider is a *command* (`ddgs` by default, configurable) rather than a Go > client for Brave or Tavily: shelling out needs no API key, no account, and no > new module, and swapping in another tool is a line of config rather than a > new file. The URL jail, the fencing of fetched pages, the approval decisions > and the "no provider, no tool" rule all shipped as described. See the > `## Web search` section of README.md for what was built. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let the agent look things up: `web_search` returns a ranked list of titles, URLs and snippets from a configured provider, and `web_fetch` turns one of those URLs into capped, fenced text. **Architecture:** A new `internal/web` package owns everything that touches the network: a `Searcher` interface with one provider per file, a URL jail that refuses private address space, and an HTML-to-text reducer. `internal/tools/web.go` wires those into two `tools.Tool`s and owns the output formatting, exactly as `internal/tools/skill.go` wires `internal/skills`. Registration happens in `cmd/codey/main.go` only when a provider is configured, so an unconfigured run sends a byte-identical request to today's. **Tech Stack:** Go 1.26, stdlib `net/http` and `net/netip`, `golang.org/x/net/html` (already an indirect dependency via bluemonday — promote it to direct). No new third-party modules. **Spec:** `docs/superpowers/specs/2026-09-18-web-search-design.md` ## Global Constraints - Go module is `codey`; import paths are `codey/internal/...`. - `internal/web` imports no other `internal` package. `internal/tools` may import `internal/web`; nothing imports `internal/tools` back. - Every tool returns `(string, error)` where the error is text the model reads. A provider that is down, rate-limited or returns nothing is an error the model can act on, not a failed turn. - No test may touch the network. Providers take a `*http.Client` and a base URL; every test serves its own `httptest.Server`. - Nothing about the web tools may abort startup, and nothing may change the request when no provider is configured: no tool registered, no system-prompt text, no new fields on the wire. - Provider credentials never reach the event log, the transcript, or a URL query string. - `web_fetch` resolves every hop and refuses non-global addresses. This is the one rule in the plan with no exception and no flag to turn it off. - New exported identifiers get a doc comment in the existing house style. - Run `gofmt -w` on every file you touch, and `go test ./...` before each task is ticked off. --- ### Task 1: `internal/web` — types, the provider interface, and Brave **Files:** - Create: `internal/web/web.go`, `internal/web/brave.go`, `internal/web/brave_test.go` - Modify: `go.mod` (promote `golang.org/x/net` to a direct requirement) **Interfaces:** - Consumes: nothing. - Produces: ```go type Query struct { Text string; Count int; Site string } type Result struct { Title, URL, Snippet, Age string } type Searcher interface { Name() string Search(ctx context.Context, q Query) ([]Result, error) } func NewBrave(key string, opts ...Option) Searcher type Option func(*options) // WithHTTPClient, WithBaseURL ``` `MaxCount = 10`, `DefaultCount = 5`. `Search` clamps `Count` into `[1, MaxCount]`. - [ ] **Step 1: Write the failing tests** `brave_test.go` stands up an `httptest.Server` returning a canned Brave payload and asserts: the `X-Subscription-Token` header carries the key and the key appears nowhere in the URL; `q` carries the query and `site:` is appended when `Query.Site` is set; `count` is clamped to `MaxCount`; results map in order; an empty `web.results` array returns `([]Result{}, nil)` and not an error; a 401 returns an error naming the provider and *not* containing the key; a 429 with `Retry-After: 2` returns an error whose text says how long to wait; a context cancelled mid-flight returns `ctx.Err()`. - [ ] **Step 2: Implement `web.go`** The shared parts: `Query`, `Result`, `Searcher`, the `Option` plumbing, a `do(ctx, *http.Request) (*http.Response, error)` helper with one retry on 429/5xx honouring an integer `Retry-After` (cap the wait at 5 s; a search is not worth a minute of the user's turn), and a `redactErr` helper that guarantees a credential never lands in an error string. - [ ] **Step 3: Implement `brave.go`** `GET {base}/res/v1/web/search?q=…&count=…`, header `X-Subscription-Token`, `Accept: application/json`. Decode only the fields used — `web.results[].{title,url,description,age}`. - [ ] **Step 4: Verify** ```bash gofmt -w internal/web && go test ./internal/web/... ``` --- ### Task 2: Two more providers **Files:** - Create: `internal/web/tavily.go`, `internal/web/searxng.go`, `internal/web/tavily_test.go`, `internal/web/searxng_test.go` **Interfaces:** - Consumes: Task 1. - Produces: `func NewTavily(key string, opts ...Option) Searcher`, `func NewSearXNG(base string, opts ...Option) Searcher`. - [ ] **Step 1: Tests first**, same shape as Brave's: canned server, header or body placement of the credential, mapping, error paths. Tavily's key goes in the POST body (`{"query":…,"max_results":…}` with `Authorization: Bearer`); SearXNG has no key and takes `?q=…&format=json`. - [ ] **Step 2: Implement both.** Each file should stay near eighty lines. If a provider needs more than that, the extra belongs in `web.go` where all three can use it. - [ ] **Step 3:** `gofmt -w internal/web && go test ./internal/web/...` --- ### Task 3: The URL jail **Files:** - Create: `internal/web/jail.go`, `internal/web/jail_test.go` **Interfaces:** - Consumes: nothing. - Produces: `func CheckURL(ctx context.Context, raw string) (*url.URL, error)` and `func SafeClient(timeout time.Duration) *http.Client` — a client whose `DialContext` re-checks the resolved IP of every connection and whose `CheckRedirect` re-runs `CheckURL` on each hop. - [ ] **Step 1: Write the failing tests.** Refuse: `file://`, `ftp://`, a bare path, `http://localhost`, `http://127.0.0.1`, `http://[::1]`, `http://10.0.0.1`, `http://192.168.1.1`, `http://169.254.169.254`, `http://[fc00::1]`, a hostname resolving to `127.0.0.1` (use a test resolver or a hosts-style stub), and a redirect from a public host to any of the above. Accept a plain public URL and preserve its query string. Assert the refusal text names the address, because the model is going to read it and should not retry the same URL. - [ ] **Step 2: Implement.** Scheme allowlist first, then `net.DefaultResolver.LookupNetIP`, then `netip.Addr.IsGlobalUnicast() && !IsPrivate() && !IsLoopback() && !IsLinkLocalUnicast() && !IsInterfaceLocalMulticast()`, plus an explicit reject of unique-local `fc00::/7`. Re-check in `DialContext` so a DNS answer that changes between check and dial cannot slip through. - [ ] **Step 3:** `gofmt -w internal/web && go test ./internal/web/...` --- ### Task 4: Fetch and HTML-to-text **Files:** - Create: `internal/web/fetch.go`, `internal/web/text.go`, `internal/web/fetch_test.go`, `internal/web/text_test.go` - Modify: `go.mod`, `go.sum` **Interfaces:** - Consumes: Tasks 1 and 3. - Produces: ```go type Page struct { URL, ContentType, Text string; Bytes int; Truncated bool } func Fetch(ctx context.Context, raw string, maxBytes int, opts ...Option) (*Page, error) func HTMLToText(r io.Reader) (string, error) ``` `DefaultMaxBytes = 512 << 10`, `MaxBytes = 2 << 20`, `FetchTimeout = 15 * time.Second`. - [ ] **Step 1: Tests first.** `HTMLToText`: `script`/`style`/`nav`/`header`/`footer` contents are dropped; `

`, `
`, `

  • `, ``, `` produce line breaks; `&`/`'` are decoded; runs of blank lines collapse to one; malformed HTML does not error. `Fetch`: a 404 is an error naming the status; `text/plain` and `application/json` pass through unchanged; a body longer than `maxBytes` stops reading and sets `Truncated`; a `Content-Type` that is not text or JSON is refused without downloading it all; the jail refuses a private URL before any request is made (assert the test server was never hit). - [ ] **Step 2: Implement** using `golang.org/x/net/html` for tokenising and `SafeClient(FetchTimeout)` for transport. Read through an `io.LimitReader`; never `io.ReadAll` an unbounded body. - [ ] **Step 3:** `go get golang.org/x/net@latest` if the promotion in Task 1 was not enough, then `gofmt -w internal/web && go test ./internal/web/...` --- ### Task 5: The tools **Files:** - Create: `internal/tools/web.go`, `internal/tools/web_test.go` **Interfaces:** - Consumes: `internal/web`. - Produces: `func NewWebSearch(s web.Searcher) Tool` and `func NewWebFetch(opts ...web.Option) Tool`. `NewWebSearch(nil)` returns `nil`, matching `NewSkill`'s "nothing to offer, no tool" convention so `main.go` stays a one-line check. - [ ] **Step 1: Tests first.** A stub `web.Searcher` drives the search tool: results are numbered, each shows title, URL and snippet; `count` over `web.MaxCount` is clamped rather than refused; an empty result set returns a sentence saying so with `nil` error (nothing was wrong, there was just nothing there); a provider error comes back as an error whose text tells the model to try different words. For the fetch tool, an `httptest` page asserts the fence markers, the size and content type in the header line, and the "treat as data" trailer; assert a truncated page says so in the output. - [ ] **Step 2: Implement.** Argument structs with `jsonschema:"description=…"` tags, same style as `GlobArgs`: ```go type WebSearchArgs struct { Query string `json:"query" jsonschema:"description=What to search for, as you would type it into a search engine"` Count int `json:"count,omitempty" jsonschema:"description=How many results to return; defaults to 5, maximum 10"` Site string `json:"site,omitempty" jsonschema:"description=Restrict results to one domain, e.g. pkg.go.dev"` } type WebFetchArgs struct { URL string `json:"url" jsonschema:"description=Absolute http or https URL to fetch"` MaxBytes int `json:"max_bytes,omitempty" jsonschema:"description=Read ceiling in bytes; defaults to 524288"` } ``` Reuse `capLine` and `headTail` from `truncate.go` for the fetched body — the bash tool's caps are the house numbers and there is no reason for a second set. The descriptions carry the usage policy, because that is the only place the model reads it: say that search is for things that postdate its training or change often, that the repository's own files and `go doc` come first, and that `web_fetch` is how you read a result rather than guessing from a snippet. - [ ] **Step 3: Memoise within the session.** A `map[string][]web.Result` behind a mutex, keyed by the normalised query, so a model that loops does not bill ten identical searches. No expiry, no disk. - [ ] **Step 4:** `gofmt -w internal/tools && go test ./internal/tools/...` --- ### Task 6: Approval **Files:** - Modify: `internal/approval/policy.go` - Modify: `internal/approval/policy_test.go` **Interfaces:** - Consumes: nothing. Produces: two new `describe` cases. - [ ] **Step 1: Tests first.** `web_search` and `web_fetch` are absent from `readOnly` and therefore prompt; a `Deny` returns the standard refusal text; `AlwaysThisTool` suppresses the second prompt; `AlwaysThisCommand` on `web_fetch` remembers that exact URL and nothing else. - [ ] **Step 2: Implement** in `describe` (policy.go:155), adding `Query` and `URL` to the argument struct: ```go case "web_search": return Request{Tool: tool, Summary: "search the web", Detail: args.Query, Rememberable: false} case "web_fetch": return Request{Tool: tool, Summary: "fetch a web page", Detail: args.URL, Rememberable: true} ``` The search detail is not rememberable for the same reason `spawn_agent`'s is not: it differs every call, so remembering one exact query permits nothing. Do **not** add either tool to `readOnly` — they read nothing local and send the model's words off the machine, which is the opposite of what that map is for. - [ ] **Step 3:** `gofmt -w internal/approval && go test ./internal/approval/...` --- ### Task 7: Configuration and wiring **Files:** - Modify: `internal/config/config.go`, `internal/config/config_test.go`, `cmd/codey/main.go` **Interfaces:** - Consumes: Tasks 5 and 6. - Produces: `Config.WebProvider string` (`json:"web_provider,omitempty"`), the constant list `config.WebProviders = []string{"brave", "tavily", "searxng"}`, and `func ValidWebProvider(string) bool`. - [ ] **Step 1: Config tests first.** `sanitize` drops an unrecognised provider to the empty string, the way it already does for `Effort` and `Theme`; a config with no `web_provider` round-trips without gaining the key. - [ ] **Step 2: Implement the config field** and add it to `sanitize`. - [ ] **Step 3: Wire `main.go`.** Add `noWeb := flag.Bool("no-web", false, "do not offer the web tools, so the agent cannot reach the network")` beside `-no-skills` (main.go:63). Build the searcher once, before `toolsIn`: ```go // A provider is configured or it is not; there is no halfway. Without one the // tool set is exactly what it was before web search existed, so an agent is // never told about a capability it would then be denied. var searcher web.Searcher if !*noWeb { var note string searcher, note = webProvider(cfg) // reads env, falls back to config if note != "" { notice("%s", note) } } ``` Register inside the `toolsIn` factory (main.go:228-243) so the tools survive `/worktree` and so **subagents inherit them** — the factory is shared with the supervisor at main.go:296, and that is the whole point of it: ```go if t := tools.NewWebSearch(searcher); t != nil { reg.Register(t, tools.NewWebFetch()) } ``` `webProvider` is a small unexported helper in `main.go`: provider name from `config.Or(os.Getenv("CODEY_WEB_PROVIDER"), cfg.WebProvider)`, key from that provider's env var, and a `notice` when a provider is named but its key is missing — silence there is the failure mode where the user believes search works and the agent never mentions that it cannot. - [ ] **Step 4: Leave `evals/runner.go` alone.** The eval runner stays offline and deterministic; add a sentence saying so in a comment beside its registry (runner.go:191) so the omission reads as a decision. - [ ] **Step 5: Verify** ```bash gofmt -w cmd/codey internal/config && go build ./... && go test ./... ./codey -no-web -p 'list your tools' # must not mention web_search ``` --- ### Task 8: Documentation **Files:** - Modify: `README.md`, `ToDo.md` - [ ] **Step 1:** Add two rows to the tool table at README.md:748 — `web_search` and `web_fetch`, approval "asks", notes naming the provider requirement and the URL jail. - [ ] **Step 2:** Add a `## Web search` section after `## Skills` (README.md:757) covering the three providers, their env vars, `-no-web`, and the fenced-content warning about prompt injection. - [ ] **Step 3:** Extend the approval paragraph at README.md:823 so the list of prompting tools is not left saying `bash`, `edit_file`, `write_file` only. - [ ] **Step 4:** Tick a line in `ToDo.md`. --- ### Appendix A (optional, last): the hosted `web_search_20250305` Only if someone wants search without holding a provider key, and only behind an explicit `-server-web-search` flag that is off by default. It is listed last because it changes the wire format for every request, and a gateway that does not implement it 400s the whole session rather than just the search. The four sites, already surveyed: 1. `internal/llm/types.go:155` — `ToolDef` needs `Type`, `MaxUses`, `AllowedDomains`, `BlockedDomains`, and `omitempty` on `Description` and `InputSchema`. Today `input_schema` has no `omitempty`, so a hosted tool would be sent as `"input_schema":null` and rejected. 2. `internal/agent/loop.go:510-517` — `request()` builds `Tools` from the local registry only; it needs a hook to append hosted definitions. They sit in the cached prefix, so the definition must be constant for the session. 3. `internal/llm/stream.go:189-208` — `server_tool_use` arrives with its input as `input_json_delta`s, but `ContentBlock.MarshalJSON` (types.go:127) hits `default` and returns the start-event `raw`, in which `input` is still `{}`. The accumulated query would be lost on the next request. Fix by adding a `server_tool_use` case to `MarshalJSON`, or by rebuilding `raw` at `content_block_stop`. **Write the round-trip test first** — `types_test.go:74` already has the pattern. 4. `internal/llm/types.go:133` — the `default` path returns before the `cache_control` attach, so a breakpoint landing on a `web_search_tool_result` block is silently dropped. Harmless but wasteful; fix while you are in there. Two things already work and need nothing: `Response.ToolUses()` filters on `BlockToolUse`, so a `server_tool_use` block is never dispatched to the local registry, and `StopPauseTurn` is already handled at loop.go:421 by resending the conversation unchanged (`TestPauseTurnResends`, loop_test.go:354). What it still would not give you: an approval prompt or a chance to refuse — a hosted search happens inside a request that was already approved. (It *would* give you a fetch: Anthropic ships a hosted `web_fetch` alongside the search, which an earlier draft of this document wrongly said it did not.) **The version string is a decision, not a constant.** `web_search_20250305` is what shipped, and it is the conservative choice: it works on every model and every platform that offers the tool at all. Newer versions — `web_search_20260209` onwards — add dynamic filtering, where Claude runs code to strip search results down before they reach the context window, but they are model-gated (Opus 4.6 and later). Codey lets the user name any model id, including ones a gateway serves and this build has never heard of, so the version cannot simply be bumped: it has to be chosen from the model in force, or configured, with 20250305 as the floor. Until that is done, hosted search here is the basic one. --- ## Order and cost Tasks 1, 3 and 4 are independent and can run in parallel; 2 depends only on 1; 5 needs 1 and 4; 6 is independent; 7 needs 5 and 6. A reasonable single pass is 1 → 3 → 4 → 5 → 6 → 7 → 8, with 2 folded in whenever a second provider is wanted. Appendix A is separate work and should not block any of it.