OpenCode has no offline mode upstream: at v1.18.34 (published 2026-09-30) there is no OPENCODE_OFFLINE variable, no offline config key and no --offline flag, and PR #18235, which proposes all three, has been open and untouched since 2026-08-22. Nothing exports telemetry by default either; OpenTelemetry only runs when you set OTEL_EXPORTER_OTLP_ENDPOINT. What leaves the machine are functional calls: a model catalog fetch from models.opencode.ai at startup and every 60 minutes, an update check, the free Zen provider, an always-allowed webfetch tool, and background downloads. Reading the v1.18.34 source gives 17 outbound paths, and four of them (the per-config-dir npm install of @opencode-ai/plugin, the ripgrep download, the TUI tree-sitter parser downloads, and the model-driven shell tool) have no OpenCode setting that closes them. The rest close with five environment variables and an opencode.json that pins a local provider, sets share to disabled, autoupdate to false and denies webfetch and websearch. Egress rules written for releases before v1.18.10 target models.dev and miss the current catalog host. Start by setting OPENCODE_DISABLE_MODELS_FETCH=1 and OPENCODE_DISABLE_AUTOUPDATE=1 on one host and running the egress check in this post against it.
The search for an OpenCode offline mode ends in the source: at v1.18.34, published 2026-09-30, there is no OPENCODE_OFFLINE variable, no offline key in opencode.json and no --offline flag. The root CLI options are --print-logs, --log-level and --pure, and a grep for "offline" across the core and CLI packages returns only OAuth offline_access scopes and one comment. If you are rolling OpenCode out to developers on an air-gapped or egress-restricted network, you close each outbound path separately.
That is a tractable job. Reading the v1.18.34 source gives 17 outbound paths. Most are off by default or close with one setting. Four have no setting at all, and one of those is the shell tool, which is where the real exfiltration risk lives. Telemetry is not on the list of problems: nothing is exported by default.
This post is the egress table for one pinned version, the environment block and opencode.json that close what can be closed, what to stage for an offline install, and a procedure you run on your own host to prove the result. Nothing here was run in a lab for this post; every row comes from the source at the v1.18.34 tag and the docs shipped with it. Treat the verification section as the step that turns this reading into evidence for your environment.
Does OpenCode have an offline mode?
No, not upstream. The names OPENCODE_OFFLINE=true, OPENCODE_OFFLINE_MODE and offline: true come from community forks or from PR #18235, "feat: add offline mode to disable non-essential outbound connections". That PR is open, not merged and not a draft, and its last update was 2026-08-22. The issue behind it, #18233, was closed as not planned on 2026-07-15.
Even as proposed, PR #18235 is narrower than its title. It covers the update check, session sharing and the web UI proxy to app.opencode.ai, and it explicitly leaves the model catalog fetch and Exa web search untouched. Merging it tomorrow would not give you an air-gapped agent.
The maintainers have been clear about why. In issue #10416, "OpenCode is not private by default?", a maintainer wrote on 2026-01-27 that they would "likely add a single offline: true flag that disables everything that needs network", and that it "cannot be the default because it's not the experience the majority of people want". Eight months later, no such flag is merged, and #10416 itself was closed as not planned on 2026-06-23 by an automated sweep of question issues. The older air-gapped installation request, #2224, was closed as not planned on 2026-05-16.
So the working answer for a platform team is the same one that applies to running Claude Code against a local model: enumerate the outbound calls at a pinned version, close each one with the setting that actually governs it, and verify at the network layer.
OpenCode telemetry: what is actually on by default
Nothing. OpenTelemetry log and trace export at v1.18.34 is created only when OTEL_EXPORTER_OTLP_ENDPOINT is set; without it, the logger list is empty and the tracing layer is a no-op. AI SDK spans need experimental.openTelemetry: true in config on top of that. When you do enable it, the only destination is the collector you named. A maintainer put it plainly in #10416: "there is no telemetry".
What earned OpenCode its privacy reputation was a fallback that no longer exists. Until early 2026, a session with no cheap model configured generated its titles with gpt-5-nano on OpenCode's hosted Zen provider. A maintainer described that behaviour on 2026-01-24 and confirmed on 2026-03-19 that "we did remove falling back to the free gpt nano by default". At v1.18.34, the small model resolves from small_model if you set it, then a plugin hook, then a model from the same provider as the session, and otherwise nothing. A local-only provider gets no cross-provider fallback.
The Zen provider itself is still there. With no API key, no auth entry and no config, the provider with id opencode autoloads, keeps only its free models and authenticates with the key public. If a developer picks one of those models from the list, their prompts go to OpenCode's hosted service. For a regulated deployment that default sends source code and prompts outside the perimeter, and it is one config line to remove.
The privacy policy question has a short answer too: a maintainer stated in #10416 that the policy applies only to the Zen hosted offering, not to the open-source CLI.
Every OpenCode outbound call in v1.18.34 and the setting that closes it
These 17 rows cover the CLI and TUI at v1.18.34. The desktop app is out of scope: its build is wired for Sentry error reporting when a DSN is configured at build time, and whether the shipped build carries one is not visible from the repository.
Three rows deserve a second look before they go into a change ticket.
The catalog host moved in July
Row 1 changed between v1.18.9 and v1.18.10, published 2026-07-30. Earlier releases fetched from models.dev; current ones fetch from models.opencode.ai, with 2 retries inside a 10 second timeout. models.dev survives only as the build-time source of the snapshot compiled into the binary. A proxy allowlist or a deny rule written last quarter targets the wrong host. The cache file lives at ~/.cache/opencode/models.json on Linux, or models-<hash>.json when OPENCODE_MODELS_URL points at a mirror.
Two settings that sound like they close paths and do not
OPENCODE_DISABLE_EMBEDDED_WEB_UI reads like a hardening switch. It is the opposite: the server falls back to proxying https://app.opencode.ai only when the embedded bundle is unavailable or that variable is set. Issue #18492 was closed on 2026-03-26 when v1.3.3 embedded the web UI in the binary precisely so it would work offline. Leave the variable unset. OPENCODE_PURE reads like "no network from plugins". It skips the npm plugins listed in your config and nothing else. The config-directory install in row 7 still runs.
| # | Path | Endpoint | Default at v1.18.34 | What closes it | Gap |
|---|---|---|---|---|---|
| 1 | Model catalog | https://models.opencode.ai/api.json | On: at startup, then every 60 minutes | OPENCODE_DISABLE_MODELS_FETCH=1 (reads cache, then bundled snapshot) | OPENCODE_MODELS_PATH alone does not stop the refresh; opencode models --refresh and opencode providers login fetch even with the flag |
| 2 | Update check | brew, npm registry, Chocolatey, Scoop bucket, GitHub releases API, opencode.ai/install | On at every TUI start | OPENCODE_DISABLE_AUTOUPDATE=1 or "autoupdate": false in the global config | "notify" still runs the check; autoupdate in a project config is ignored |
| 3 | Session share | https://opncd.ai | Manual: only on /share | "share": "disabled" plus OPENCODE_DISABLE_SHARE=1 | OPENCODE_AUTO_SHARE or "share": "auto" turns it on |
| 4 | Zen provider (opencode) | OpenCode hosted inference | Autoloads free models with no key | enabled_providers with only your provider, or disabled_providers: ["opencode"] | None once removed |
| 5 | Web search | mcp.exa.ai, search.parallel.ai | Off for a local provider | Do not set the Exa, Parallel or OPENCODE_EXPERIMENTAL flags; permission.websearch: "deny" | OPENCODE_EXPERIMENTAL alone turns Exa on |
| 6 | Web fetch | Any URL the model requests | Registered and allowed | permission.webfetch: "deny" | None in OpenCode |
| 7 | Config-dir plugin install | npm registry from .npmrc | On, per config dir, in the background | No setting; stage node_modules, use an internal registry, or make the dir read-only | Neither OPENCODE_PURE nor OPENCODE_DISABLE_DEFAULT_PLUGINS gates it |
| 8 | Plugins listed in config | npm registry | Only if plugin is set | OPENCODE_PURE=1 or --pure | Does not affect row 7 |
| 9 | Non-bundled provider packages | npm registry | Only for an npm value not bundled | Use @ai-sdk/openai-compatible, which is bundled | Any other package downloads |
| 10 | LSP servers and formatters | GitHub archives, go install, gem install, npm | Off unless lsp or formatter is set | Leave both unset, or OPENCODE_DISABLE_LSP_DOWNLOAD=1 (LSP only) | typescript-language-server and biome LSP fallbacks, and the prettier and biome formatters, download from npm ungated |
| 11 | ripgrep | GitHub releases (ripgrep 15.1.0) | On if rg is missing | No setting; install rg from your OS mirror | None once rg is on PATH |
| 12 | TUI tree-sitter parsers | github.com, raw.githubusercontent.com | On demand for 34 filetypes | No setting | Highlighting is lost for those filetypes |
| 13 | Web UI proxy | https://app.opencode.ai | Off: UI is embedded in the binary | Leave OPENCODE_DISABLE_EMBEDDED_WEB_UI unset | Setting it opens this path |
| 14 | OpenTelemetry | Your OTLP collector | Off | Leave OTEL_EXPORTER_OTLP_ENDPOINT unset or internal | None |
| 15 | Remote config | <url>/.well-known/opencode, console org config | Off unless you log in to a URL or a console account | Do not log in | None |
| 16 | Remote URLs in config | Remote MCP servers, instructions URLs, skills.urls | Off unless configured | Use local MCP servers and local file paths only | None |
| 17 | Shell tool | Anything curl, git or pip can reach | Available to the model | No OpenCode setting; host egress policy | The real exfiltration surface |
OpenCode offline config: environment variables and opencode.json
The environment block below closes rows 1, 2, 3, 8 and 10, and documents rows 5, 13 and 14 as variables to keep unset. Use 1 for every flag: flag.ts accepts 1 or true in any case, but OPENCODE_DISABLE_SHARE and the flags parsed through Effect (OPENCODE_PURE, OPENCODE_DISABLE_LSP_DOWNLOAD) accept only lowercase values. Put it in the profile your developers' shells load, or in the systemd unit or container spec that launches opencode serve.
# Model catalog: no network fetch; use the cache or the bundled snapshot export OPENCODE_DISABLE_MODELS_FETCH=1 # Optional: read a reviewed catalog file instead (still needs the flag above) # export OPENCODE_MODELS_PATH=/opt/opencode/models.json # No update check (autoupdate "notify" still checks; this does not) export OPENCODE_DISABLE_AUTOUPDATE=1 # Session sharing hard-off (belt and braces with "share": "disabled") export OPENCODE_DISABLE_SHARE=1 # Only matters if you enable LSP in config: no downloads export OPENCODE_DISABLE_LSP_DOWNLOAD=1 # Skip npm plugins listed in config export OPENCODE_PURE=1 # The TUI talks to a local HTTP server; never send it through a proxy export NO_PROXY=localhost,127.0.0.1 # Do NOT set: OPENCODE_EXPERIMENTAL (implies Exa web search), # OPENCODE_ENABLE_EXA, OPENCODE_ENABLE_PARALLEL, OPENCODE_AUTO_SHARE, # OPENCODE_DISABLE_EMBEDDED_WEB_UI (falls back to proxying app.opencode.ai), # OTEL_EXPORTER_OTLP_ENDPOINT (unless it points at your own collector)
OPENCODE_DISABLE_DEFAULT_PLUGINS=1 is a reasonable addition if nobody uses the hosted providers it covers (Codex, Copilot, GitLab, Azure, xAI and others). Those plugins are compiled into the binary, so the flag gates no download; it only skips loading them. The source review behind this post did not audit each plugin's initialisation path for network calls, so treat it as defence in depth, not as the fix for a known call.
Three of these variables (OPENCODE_MODELS_PATH, OPENCODE_DISABLE_SHARE, OPENCODE_PURE) do not appear in the docs' environment variable table at v1.18.34; they exist in source, with PURE documented as the --pure flag. That is a reason to re-read the source on upgrade rather than the docs page.
The config file handles rows 2 through 6 and pins the models. Put it in the global config, ~/.config/opencode/opencode.json for each developer: the update check reads autoupdate from the global config only, so the same file in a repository does not close row 2. This one targets a vLLM server; the provider id, host and model id are placeholders.
{
"$schema": "https://opencode.ai/config.json",
"enabled_providers": ["vllm"],
"disabled_providers": ["opencode"],
"model": "vllm/qwen3-coder",
"small_model": "vllm/qwen3-coder",
"share": "disabled",
"autoupdate": false,
"permission": {
"webfetch": "deny",
"websearch": "deny"
},
"provider": {
"vllm": {
"npm": "@ai-sdk/openai-compatible",
"name": "vLLM (internal)",
"options": {
"baseURL": "http://inference.internal:8000/v1"
},
"models": {
"qwen3-coder": { "name": "Qwen3-Coder (internal)" }
}
}
}
}The $schema URL is read by editors for completion; OpenCode does not fetch it. enabled_providers is the stronger control: per the schema, "When set, ONLY these providers will be enabled. All other providers will be ignored". That alone excludes Zen. disabled_providers: ["opencode"] is redundant beside it, and worth keeping because a reviewer reading the file sees the intent without knowing the semantics.
Pin small_model even though v1.18.34 has no cross-provider fallback. Title generation and other small tasks then run on a model you named, and a future release that changes the resolution order cannot route them elsewhere.
Use "autoupdate": false, not "notify". The update routine returns early only on false or the environment variable; notify still queries the latest version and just skips installing it.
OpenCode with a local model offline: Ollama, vLLM, llama.cpp
The same provider block works for all three servers because each exposes an OpenAI-compatible API. Only baseURL changes:
@ai-sdk/openai-compatible is in the set of provider packages bundled into the v1.18.34 binary, so pointing at a local endpoint triggers no npm download. Row 9 bites when someone copies a config that names a different provider package: anything outside the bundled set is fetched from the npm registry when the provider loads.
If the inference endpoint terminates TLS with an internal CA, OpenCode trusts it through NODE_EXTRA_CA_CERTS. If a corporate proxy is configured through HTTPS_PROXY or HTTP_PROXY, NO_PROXY=localhost,127.0.0.1 is required, because the TUI is a client of OpenCode's own local HTTP server.
Two adjacent problems belong to other posts. Getting reliable tool calls out of Ollama starts with the context window, covered in Ollama's num_ctx silent prompt truncation. And the server half of the air gap, stopping the inference engine itself from calling Hugging Face, is in the vLLM air-gapped deployment guide. If the Ollama box is reachable from more than one workstation, lock down the Ollama endpoint before you point a fleet of agents at it.
| Server | baseURL from the OpenCode docs or your deployment |
|---|---|
| Ollama | http://localhost:11434/v1 |
| llama.cpp llama-server | http://127.0.0.1:8080/v1 |
| vLLM | Your host and port, for example http://inference.internal:8000/v1 |
OpenCode offline install: what to pre-stage
Three artifacts have to arrive on the host before the first run: the binary, ripgrep, and the config-directory dependency.
# On the air-gapped host, with the v1.18.34 archive and install script copied in tar -xzf opencode-linux-x64.tar.gz # Point --binary at the opencode executable extracted from the archive ./install --binary ./opencode --no-modify-path # The binary lands in ~/.opencode/bin; add that to PATH in your managed shell profile # ripgrep from the OS mirror, so OpenCode never downloads it from GitHub sudo apt-get install -y ripgrep # or: sudo dnf install -y ripgrep
# Option A: internal npm mirror (OpenCode reads npm config)
printf 'registry=https://npm.internal.example/\n' >> ~/.npmrc
# Option B: populate once on a staging box with the SAME OpenCode version,
# then copy ~/.config/opencode/{package.json,package-lock.json,node_modules}
# to the target. The install is skipped when node_modules exists and every
# declared dependency is in package-lock.json, or when the dir is not writable.The binary and ripgrep
The v1.18.34 release ships per-platform CLI archives such as opencode-linux-x64.tar.gz, opencode-linux-arm64.tar.gz, opencode-darwin-arm64.zip and opencode-windows-x64.zip. The install script at the same tag accepts --binary <path> to install from a local file instead of downloading, and --no-modify-path to leave shell profiles alone. Copy the archive and the script across together. ripgrep is the quiet one. If rg is not on PATH and not in ~/.cache/opencode/bin, OpenCode downloads ripgrep 15.1.0 from GitHub, and no flag stops it. Installing it from your OS mirror closes row 11 permanently.
The config-directory npm install
Row 7 is easy to miss because nothing in opencode.json mentions it. For every config directory (the global ~/.config/opencode, project .opencode directories, ~/.opencode, and OPENCODE_CONFIG_DIR), OpenCode runs an npm Arborist install of @opencode-ai/plugin at the installed OpenCode version, in the background, with install scripts disabled. It uses the registry from your npm config. It is skipped when the directory is not writable, or when node_modules exists and every declared dependency is in package-lock.json. On failure it logs background dependency install failed and carries on. That background install replaced the failure reported in #2224, where an auth plugin was installed with bun add at startup in August 2025. At v1.18.34 the default auth plugins are imported directly into the binary and nothing installs them. What remains is this npm install, and no flag gates it. Project .opencode directories are their own config directories, so a repository that ships one triggers the same install inside the checkout. OPENCODE_DISABLE_PROJECT_CONFIG=1 skips project directories entirely, project config included, but not the global one. They also carry config that loads automatically, which is a trust question covered in auto-loaded files in untrusted repositories.
What degrades without network
The TUI registers 34 tree-sitter parsers (Python, Rust, Go, C, C++, C#, Java, Bash and others) by github.com and raw.githubusercontent.com URLs and downloads each on demand. A failed download returns an error rather than crashing, so the visible effect offline is no syntax highlighting for those filetypes. There is no OpenCode flag for this, and the cache location used by the underlying UI library is not documented in a form we could confirm, so plan to accept the degradation or allow those two GitHub hosts through a reviewed proxy. LSP is off by default at v1.18.34: with lsp unset, OpenCode logs all LSPs are disabled. If you enable it, OPENCODE_DISABLE_LSP_DOWNLOAD=1 gates 22 download paths but not the npm fallbacks for typescript-language-server and biome, which run when the project resolves typescript or biome. Formatters are off by default too; if you enable them, prettier and biome are fetched from npm into OpenCode's cache when the project depends on them, and no flag gates that. Put the language servers on PATH from your internal mirror first.
How to verify OpenCode makes no outbound calls
Reading source tells you which paths exist. Only a capture on your own host tells you which ones your configuration still triggers. This procedure runs OpenCode as a dedicated test user under a deny-by-default egress rule that allows loopback and the model server, logs every dropped packet, records DNS traffic, and traces every connect() the process tree makes. IP, port and user name are placeholders; give the test user the same global opencode.json your developers get.
# 1) Deny-by-default egress for one test user, allow loopback + model server, log drops
sudo nft -f - <<'EOF'
table inet oc_egress {
chain out {
type filter hook output priority 0; policy accept;
meta skuid != octest accept
oifname "lo" accept
ip daddr 10.0.0.20 tcp dport 8000 accept # your vLLM / Ollama host:port
log prefix "OC-EGRESS " counter drop
}
}
EOF
# 2) Watch DNS lookups (on hosts with a local resolver, the resolver forwards them)
sudo tcpdump -ni any port 53 -l | tee /tmp/oc-dns.log &
# 3) Trace every connect() OpenCode and its children make
sudo -u octest env OPENCODE_DISABLE_MODELS_FETCH=1 OPENCODE_DISABLE_AUTOUPDATE=1 \
OPENCODE_DISABLE_SHARE=1 OPENCODE_PURE=1 \
strace -f -e trace=connect -o /tmp/oc-connect.log \
opencode run --print-logs --log-level DEBUG "list the files in this repo"
# 4) Review: kernel log for drops, then non-loopback connect() calls
sudo journalctl -k | grep OC-EGRESS
grep connect /tmp/oc-connect.log | grep -v '127.0.0.1\|::1\|AF_UNIX'Run it twice: once with the environment variables removed from step 3, to see the baseline your host produces, and once as written. Then repeat in the interactive TUI and have the agent read a Python file, which exercises the update check and the tree-sitter download path, neither of which opencode run triggers.
Map each finding back to the table:
| What you see | Row | Fix |
|---|---|---|
| DNS for models.opencode.ai | 1 | OPENCODE_DISABLE_MODELS_FETCH=1 missing from the launching environment |
| DNS for formulae.brew.sh, the npm registry or api.github.com right after the TUI starts | 2 or 7 | Check autoupdate and the environment variable; check the config-dir node_modules |
Log line background dependency install failed | 7 | Stage node_modules or point .npmrc at the internal mirror |
Log line downloading ripgrep | 11 | Install rg from the OS mirror |
| DNS for github.com or raw.githubusercontent.com when a file opens in the TUI | 12 | Accept the lost highlighting or allow it through a reviewed proxy |
Log line fetching remote config | 15 | Remove the well-known auth entry or console login |
| Any other destination after a model turn | 6 or 17 | Check the webfetch permission; if denied, it was the shell tool |
Why OpenCode still needs an egress firewall
Rows 6 and 17 explain why the nftables rule is the control, not the configuration. Denying webfetch stops OpenCode's own fetch tool. It does nothing about the shell tool, which is registered unconditionally and runs whatever command the model produces. A model asked to "check the latest version of this library" can emit curl, pip download or git clone, and no OpenCode setting scopes that. The containment options for that tool, from OS sandboxes to user separation, are in sandboxing coding agents locally without a VM. Remote MCP servers and any npm plugin a developer adds later sit in the same category: each is an outbound path the moment someone configures it.
For a bank, insurer, hospital or defence supplier, that changes how the sign-off is written. The control an auditor can test is the host or network egress policy that allows only loopback and the inference server. The OpenCode settings above are defence in depth that keeps that policy's logs quiet, so a real drop stands out instead of drowning in hourly catalog retries. Write the ticket that way round.
It also changes change control. Row 1 moved hosts in a patch release, v1.18.10. Three of the variables in the environment block are missing from the docs' variable table. Treat every OpenCode upgrade as a re-read of packages/core/src/flag/flag.ts, packages/opencode/src/effect/runtime-flags.ts and the catalog module, plus a re-run of the verification procedure, before the new binary reaches developers. If PR #18235 ever merges, it closes three rows of this table, not all 17.
If you are still deciding between agents rather than hardening one, the token overhead comparison of Claude Code and OpenCode covers the cost side, and the rest of the AI development tools guide covers the surrounding toolchain.
This week, take one developer host, apply the environment block and the opencode.json above, install rg from your mirror, and run the four-step verification as a dedicated user. Whatever shows up in OC-EGRESS is your remaining list.
FAQ
Quick answers to the questions this post tends to raise.



