Skip to content

The LSP channel the agent already has

The last lesson sent OpenCode out over MCP to reach systems it can’t see on its own. This one is the opposite move: a channel into your codebase that the agent already has, sitting one config field away, that most people never turn on because they assume it’d need wiring.

Here’s the failure it prevents. feedmill parses a dozen differently-shaped feed sources, and you ask the agent to teach the JSON-feed parser to handle a new published_at field. It reads internal/parse/jsonfeed.go, proposes a clean-looking edit, applies it, and tells you it’s done. The diff reads fine. It even compiles in the agent’s head — the change is locally sensible. But published_at comes off the wire as a string and the struct field it’s being assigned to is a time.Time, and nothing in the diff shows you that, because a type mismatch isn’t a textual property of the edited lines — it’s a property of the whole typed program around them. You find out at go build. Or worse, your reviewer does.

OpenCode hands the agent the compiler’s eyes

Section titled “OpenCode hands the agent the compiler’s eyes”

OpenCode ships native Language Server Protocol integration. When the agent touches a file, OpenCode looks at the extension, spawns the matching language server if it isn’t already running, and pipes that server’s diagnostics — the same errors, warnings, and hints your editor underlines in red — straight back to the agent as feedback while it works. For a .go file that server is gopls, the standard Go language server. The agent isn’t guessing whether its edit type-checks. It’s being told.

This is not MCP. There’s no server to declare in the mcp field, no wrapper to write, no tool the model has to remember to call. LSP is a separate, first-class channel configured through its own lsp field in opencode.json. It’s disabled by default, and the docs are deliberate about when it earns its keep: language servers can drift out of sync, eat memory, vary by version, and slow the agent down, so OpenCode actually suggests that in many projects you let the agent run lint/typecheck CLI tools directly instead. Where it shines is a typed codebase like feedmill, where in-loop type feedback is worth the overhead. Turn it on for the whole project:

opencode.json
{
"lsp": true
}

That enables OpenCode’s built-in servers — there are dozens, keyed by extension — so the moment the agent edits a .go file in feedmill, gopls is in the loop.

OpenCode handles gopls itself. If it isn’t already on your PATH, OpenCode runs go install golang.org/x/tools/gopls@latest to fetch it on first use — provided the go toolchain is present and you haven’t suppressed downloads with OPENCODE_DISABLE_LSP_DOWNLOAD=true. The one thing it can’t supply is go itself: if you can run go build in feedmill you’re set; if go is missing, install the toolchain first and OpenCode takes care of gopls from there.

Now re-run that published_at request with lsp on. The shape of the turn changes in one important place — the agent catches its own mistake before you ever see it:

> teach the JSON-feed parser to read the new published_at field
into the item's timestamp
Read internal/parse/jsonfeed.go, internal/feed/item.go
I'll map published_at onto Item.Published.
Edit internal/parse/jsonfeed.go
+ item.Published = raw.PublishedAt
Diagnostics internal/parse/jsonfeed.go
✗ cannot use raw.PublishedAt (variable of type string) as
time.Time value in assignment [gopls]
That doesn't type-check — PublishedAt is a string off the wire and
Item.Published is a time.Time. I'll parse it as RFC 3339 first and
handle the error.
Edit internal/parse/jsonfeed.go
+ ts, err := time.Parse(time.RFC3339, raw.PublishedAt)
+ if err != nil {
+ return nil, fmt.Errorf("jsonfeed: bad published_at %q: %w", raw.PublishedAt, err)
+ }
+ item.Published = ts
Diagnostics internal/parse/jsonfeed.go
✓ no errors

The agent applied an edit, the diagnostic came back, and it reacted to the diagnostic — corrected course on the spot instead of finishing the turn on broken code and leaving the type error for go build to find. That feedback beat is the whole point. Without LSP the loop is read → propose → apply → (you discover the type error later). With it, a compiler-grade check lands inside the agent’s own loop, so the version that reaches your diff is one that already type-checks.

It matters more on feedmill than on most codebases. A feed aggregator is mostly parsers — a dozen of them, each marshalling untrusted, differently-shaped input into one shared Item struct. That’s exactly the terrain where a textually-plausible edit is silently the wrong type, and exactly where a diff lies to you most convincingly. LSP turns “looks right” into “the type checker agrees,” which is a different and much stronger claim.

"lsp": true is the right default. When you need to adjust something, switch to an object — that keeps the built-ins active while you override one server:

opencode.json
{
"lsp": {
"typescript": { "disabled": true }
}
}

feedmill is pure Go, so you’d rarely disable anything — but the same object form is how you’d register a server OpenCode doesn’t ship, by giving it a launch command and the file extensions that should trigger it:

opencode.json
{
"lsp": {
"my-lsp": {
"command": ["my-language-server", "--stdio"],
"extensions": [".myext"]
}
}
}

Server entries also take env for environment variables at launch and initialization for options sent to the server on startup.

You don’t need any of that to get the payoff. The reason this lesson is short is that the move is short: one field, "lsp": true, and the agent edits your typed code with the type checker watching over its shoulder — caught in its own loop instead of in your review. On the right codebase it’s one of the cheapest correctness upgrades you can make.

Diagnostics keep the agent honest about types. The next lesson is about keeping it honest about actions — running your own deterministic code at the edges of the agent’s loop, to gate or react to what it does. Next: gate moves with plugin hooks.