2 Bootstrapping opencode
dkp edited this page 2026-08-26 05:57:34 -04:00

Bootstrapping opencode against a self-hosted OpenAI-compatible endpoint

What this is. A record of how one working opencode install was put together and pointed at a self-hosted OpenAI-compatible endpoint, written so you can reproduce it rather than guess at it. If you only want the shape: opencode is a single binary, its config is one JSON file you write by hand, and the only genuinely fiddly parts are where the API key comes from (section 5) and whether the numbers in your model block match your server (section 4). Sections 1-4 get you running; section 5 is worth reading even if you think you know the answer; sections 6-8 are reference.

If you are reading rather than installing, sections 1, 2 and 4 are the ones with transferable content -- what the thing actually is, what it genuinely needs, and what each config field does. Nothing here fires anything on contact; it is a document, and reading it costs nothing.

Everything below was established by inspecting a working installation on 2026-08-25, at opencode 1.18.21, Linux x86-64. Where I could not verify something without either writing to a live config or sending a request to the inference server, it is marked [unverified] and the reasoning is given instead of a fake certainty. Version numbers matter here: opencode's config schema has moved, and at least one thing in this guide is a compatibility behaviour rather than the documented path.


1. What you are actually installing

opencode is a single self-contained native binary. On this machine ~/.nvm/versions/node/v22.19.0/lib/node_modules/opencode-ai/bin/opencode.exe is an ELF 64-bit LSB executable, x86-64, dynamically linked -- despite the .exe name and despite living inside a node_modules tree. It does not run on Node.

The TUI is not a separate component. opencode --help lists

opencode [project]           start opencode tui                          [default]

so the bare opencode command is the TUI. There is nothing extra to install for it. The same binary also carries opencode run (one-shot, non-interactive), opencode serve (headless server), opencode web, and --mini (a minimal interactive interface instead of the full TUI).


2. Prerequisites, honestly

Required: a 64-bit Linux, macOS or Windows host on x64 or arm64. That is genuinely it. The binary is prebuilt per platform (opencode-linux-x64, opencode-linux-x64-musl, opencode-darwin-arm64, and so on -- the npm package carries all twelve as optional dependencies and selects one at install time, including an AVX2 check on x64).

Not required: Node, nvm, npm. This machine has opencode under an nvm-managed Node v22.19.0 purely because npm was the install channel used. Node is the delivery mechanism, not a runtime dependency. If you install by any other route, no Node is involved at all. Do not read the nvm path here as a recommendation -- it is an accident of how it was done, and it has one real downside: the binary is inside a specific Node version's global prefix, so nvm use of a different version takes opencode off your PATH.

Required at runtime: an OpenAI-compatible chat-completions endpoint and, usually, a bearer token for it. The endpoint this guide was written against is named in section 4; the credential is the one thing the guide cannot hand you, and section 9 says what to do about that.


3. Install

opencode's own upgrade/uninstall code recognises these installation methods, so these are the supported channels rather than my guesses: curl (the standalone script), npm, pnpm, bun, yarn, brew, choco, scoop.

Standalone script -- the binary itself fetches https://opencode.ai/install when it needs to self-upgrade a curl installation, so that is the canonical script URL:

curl -fsSL https://opencode.ai/install | bash

npm global (what was done here):

npm install -g opencode-ai

The npm package is named opencode-ai; the Homebrew/Chocolatey/Scoop formula is named opencode. A postinstall.mjs copies the correct prebuilt binary into bin/opencode.exe and the opencode bin-link points at it.

I did not run either installer -- the install here predates this guide -- so I am reporting the channels opencode knows about, not a command I watched succeed [unverified].

Verify:

opencode --version     # -> 1.18.21

opencode upgrade and opencode uninstall both detect the install method and do the right thing per channel.


4. The config file

Where it goes, and what to call it

Global config lives under ~/.config/opencode/ (XDG config dir -- explicitly not ~/.opencode/). The binary reads three filenames from that directory and shallow-merges them in this order. I verified the load order from the binary; that a later file's keys win over an earlier one's is inference from the merge call, not something I tested [unverified] -- it only matters if you keep more than one of these files.

  1. config.json
  2. opencode.json
  3. opencode.jsonc

Use opencode.json. config.json is the legacy name; it is still read, but opencode's own embedded documentation names opencode.json / opencode.jsonc as the global config, and there is code that migrates an older config directory into config.json. The installation this guide was reverse- engineered from uses config.json because it predates the rename -- that is history, not advice.

Two other loading facts worth having:

  • Project config is ./opencode.json, ./opencode.jsonc, or ./.opencode/opencode.json; opencode walks up from the cwd to the worktree root looking for one, and merges it over the global config.
  • OPENCODE_CONFIG=/path/to/file.json overrides the location entirely. Useful if you would rather keep the file somewhere your own dotfile machinery owns.

Unrecognised top-level keys are rejected with ConfigInvalidError rather than ignored, so a typo fails loudly. The top-level keys the binary accepts include $schema, provider, small_model, default_agent, agent, mode, permission, tools, instructions, attachment, layout, autoshare, enabled_providers, disabled_providers.

Minimal working shape

This is the structure in use here. Substitute <MODEL_ID> with a model your endpoint serves, supply the API key the way section 5 describes, and it is complete.

{
  "$schema": "https://opencode.ai/config.json",
  "small_model": "aksal/<MODEL_ID>",
  "provider": {
    "aksal": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Aksal Llama.cpp",
      "options": {
        "baseURL": "https://ai.aksal.dev/v1",
        "apiKey": "<see section 5 - do not paste a literal key here>"
      },
      "models": {
        "<MODEL_ID>": {
          "limit": {
            "context": 131072,
            "output": 32768
          }
        }
      }
    }
  },
  "permission": {
    "bash": "ask"
  }
}

aksal is just the local label used here for the provider -- it is yours to choose, it is not validated by anything, and it never leaves your machine. <MODEL_ID> is different: it is sent to the server as the model field of the request, so it has to match a model name the endpoint will accept. Together they form the provider/model string used everywhere else (opencode -m aksal/<MODEL_ID>, small_model, opencode models output), which is why the examples below all carry the aksal/ prefix.

Field by field

provider.<id> -- registers a provider that is not in opencode's built-in catalogue. Anything you define here is additive to the models opencode already knows; it does not replace them.

npm: "@ai-sdk/openai-compatible" -- names the Vercel AI SDK provider package to drive the endpoint with. Do not go looking for it in node_modules: opencode has the openai-compatible and Anthropic providers compiled into the binary and dispatches on this string rather than importing it from disk. It is also the default when the field is absent, so it is probably omissible -- I did not test omitting it [unverified], and since it costs one line, leave it in.

options.baseURL -- the API root, including the version segment. For a llama.cpp server behind a reverse proxy that is typically https://host/v1; opencode appends /chat/completions and friends. If you get 404s on every request, this is the field that is wrong.

options.apiKey -- the bearer token. Section 5 is entirely about not writing a literal here.

models.<id>.limit.context -- the context window in tokens, as opencode should believe it to be. This drives when opencode compacts a session, how much history it will send, and its token-budget accounting. It does not configure the server. Set it to your llama.cpp --ctx-size (or a little under it) -- if you set it higher than the server's real window, opencode will happily build a request the server rejects.

models.<id>.limit.output -- the maximum tokens opencode will ask the model to generate in one response.

A caution about the numbers, since you will copy them: the 131072 / 32768 pair in the skeleton is a round illustration, not a measurement. Take it as the shape of the field and set both to what your own server actually does.

And check the pair against itself before you trust it. An output cap larger than the declared context window cannot be right, and it is an easy mistake to make: a headline number from some model's published spec sheet gets pasted into the wrong one of two adjacent fields, and the result is a config that looks authoritative, parses cleanly, and misleads opencode's budgeting from the first request. If you inherit one of these blocks from anyone -- including this page -- the two numbers are the first thing worth re-deriving from your own --ctx-size.

small_model -- "provider/model-id". opencode's own description: "Small model to use for tasks like title generation." It is the cheap-path model for housekeeping (session titles, summarisation) so those do not burn your main model. Pointing it at the same model as your main one, as here, is legitimate when you only have one endpoint; it just means housekeeping costs full price.

$schema -- cosmetic but worth keeping; opencode writes it in itself if you omit it, and it gives you completion in any JSON-schema-aware editor.


5. Getting the API key in without committing it

There is no supported "keychain" integration. Three approaches, in increasing order of machinery. Pick on whether you already run a vault -- none of them is obviously correct.

Option A -- {env:VAR} interpolation (built in; simplest; nothing lands in the config dir)

opencode interpolates {env:VAR_NAME} and {file:/path} inside config values. Both forms are present in the binary and it advertises them in its own UI hints ("Use {env:VAR_NAME} for environment variables in config"). So:

"options": {
  "baseURL": "https://ai.aksal.dev/v1",
  "apiKey": "{env:MY_INFERENCE_KEY}"
}

and export MY_INFERENCE_KEY from wherever you already keep secrets -- your shell profile, a direnv .envrc, a systemd unit's EnvironmentFile, or a vault CLI's run-a-subprocess-with-env mode.

This is the only one of the three where the config file itself is safe to commit, and the key never exists on disk in the config directory at all. If you do not already run a vault, stop here -- a plain environment variable is a legitimate answer and not a lesser one.

Two things I could not test without writing a live config, so treat them as reasoned rather than confirmed [unverified]:

  • what happens when the variable is unset. The substitution code has a missing mode that appears to default to "error", but the same code path also has an || "" fallback that would produce an empty string. If you adopt this, prove it once by unsetting the variable deliberately and watching what the first request does. An empty bearer token usually surfaces as a 401.
  • {file:/path} semantics -- I assume "substitute the file's contents", but I have not run it.

Option B -- a committed template plus vault injection

The pattern in use here. Keep a template under version control carrying a reference to a vault item, and resolve it into the real config at mode 0600. With Proton Pass CLI that is:

pass-cli inject --force --file-mode 0600 \
  --in-file  ~/.config/opencode/opencode.json.template \
  --out-file ~/.config/opencode/opencode.json

Substitute whichever config filename you chose in section 4 -- the installation this guide was written against predates the rename and so uses config.json and config.json.template, which is why you should not copy these two paths without reading them. The template's name is yours to pick; only the output path has to be one of the three filenames section 4 lists.

with the template's apiKey reading

"apiKey": "{{ pass://<SHARE_ID>/<ITEM_ID>/API Key }}"

Be clear-eyed about what this buys. It keeps the secret out of the template, and therefore out of git -- that is real. It does not keep the secret off disk: the resolved file holds the literal key at rest, and something has to remember to re-resolve it after a rotation and to not back it up. For a config file that must exist on disk anyway this is a reasonable trade; for anything that could instead be handed to a subprocess as an environment variable, it is the worse choice. Option A is strictly better on this axis.

The handlebars trap -- the part that will actually bite you

The {{ ... }} delimiters are not optional, and the same tool accepts a bare reference elsewhere. Its run-a-subprocess mode reads a bare KEY=pass://share/item/field line from an env file quite happily. Its inject mode does not: given a bare pass://... URI as a value it produces an empty string, silently -- no error, no warning, exit status zero. You get a config file that looks perfectly well-formed, with an empty bearer token, and the first thing you see is an authentication error from the server that reads like a wrong key rather than a missing one.

This was reproduced twice on exactly this opencode config: bare pass://... resolved to empty; changing only the delimiters to {{ pass://... }} resolved correctly. If you use inject for anything, wrap every reference in double braces, and assert the resolved value is non-empty before you rely on it.

Finding your own share and item ids -- without printing the secret

You need a pass://<SHARE_ID>/<ITEM_ID>/<FIELD> triple. Get it from

pass-cli item list

which returns ids and metadata and no secret values.

Do not reach for the sibling item view subcommand to "check the field name first". It has no secrets gate and prints the password in cleartext regardless of output format -- the flag that gates secret display exists on the listing subcommand, not on the view one, because the view subcommand shows everything by default.

That asymmetry is worth stating plainly, because the inference that walks into it is the careful-sounding one: I will look at the item so that I can reference the field rather than read the secret. The looking is the disclosure. The value lands on your terminal, in your scrollback, in your shell's history of the session, and in any transcript or recording of it -- and a credential that has been displayed has to be treated as rotated, however briefly it was on screen.

So: get the ids from the listing, get the field name from the tool's documentation or from a throwaway item you created yourself, and let a wrong field name fail at resolution time -- a failed resolution costs you one retry. Inspecting the real item is never required.

Option C -- a literal key in the file

chmod 600 ~/.config/opencode/opencode.json

and paste it. Honest about what it is: fine on a single-user machine you control, as long as the file is not in a git repo, not in a backup set you do not control, and you remember it is there when you next screen-share. Option A costs one line more and removes the whole category.


6. "permission": {"bash": "ask"}

"permission": {
  "bash": "ask"
}

permission maps a tool name to one of exactly three actions -- "allow", "deny", "ask" -- and "ask" means opencode suspends the turn and puts a prompt in front of you before that tool runs. The keys the schema accepts include bash, edit, webfetch, glob, grep, list, task, todo, and external_directory. A value may also be an object mapping glob-ish patterns to actions, so you can allow a class of commands and ask on the rest.

Setting bash to ask is a deliberate posture, not a default that happened. The reasoning it encodes, which you are free to disagree with: edit is reviewable after the fact because the filesystem keeps the result and git shows the diff, whereas an arbitrary shell command is not -- by the time you see it, it has run. So bash gets the interactive gate and the others do not.

Two things to know before you decide:

  • --auto on the command line auto-approves everything not explicitly deny, and opencode's own help calls it "(dangerous!)". If you find yourself reaching for it habitually, that is the signal to move specific safe patterns to "allow" rather than to blanket-approve.
  • "ask" on bash is genuinely intrusive in an agentic loop. The honest trade is between approving a lot of commands and not seeing them. There is a middle setting -- pattern-scoped allow for the read-only commands you are bored of approving, ask for the rest -- and it is worth building once rather than flipping to --auto at the first annoyance.

7. Proving it works, and what failure looks like

The check that costs nothing

opencode models | grep 'aksal/'

One caution about that command shape, learned the hard way elsewhere: if it prints nothing, you have learned nothing. A grep that matches zero lines exits non-zero and prints nothing, which is indistinguishable on screen from a grep that never ran, or from opencode models failing and producing no output for the pipe to filter. Run opencode models bare first and look at it. The filtered form is for confirming a suspicion, not for forming one.

This is the useful first-run check and it is the one I actually ran here (it printed aksal/<MODEL_ID>). It is worth more than it looks: to print that line opencode must have found your config file, parsed it, accepted every top-level key, and registered the custom provider and its model. It does not touch the network or the API key, so a pass here narrows any remaining fault to "URL or credential", which is exactly the split you want.

The check that costs a round trip

opencode run -m 'aksal/<MODEL_ID>' "reply with the single word: ok"

then the TUI itself:

opencode

I did not run either of these -- they would have sent a request to the inference server, which was outside what I was permitted to do. So the first check below is observed and the rest are reasoned from opencode's own error handling [unverified].

Failure shapes

What you see Where it is
ConfigInvalidError at startup A typo'd or unrecognised top-level key. Unknown keys are rejected, not ignored -- so this is a good error.
Your provider is absent from opencode models The config file is not being read at all (wrong filename or wrong directory -- check section 4's three names) or the provider block is malformed.
401 / authentication error Bad key, or an empty key. Read section 5's handlebars trap before assuming the key is wrong -- a silently-empty substitution presents identically to a bad credential.
404 on every request baseURL is missing its version segment. opencode appends the path; you supply the root.
Connection refused / DNS failure Host or reverse proxy, not opencode. curl -sS https://ai.aksal.dev/v1/models from the same shell separates the two.
"Input exceeds context window of this model" limit.context is larger than the server's real window. opencode maps the upstream context_length_exceeded to this. Lower it to your --ctx-size.
Model-not-found <MODEL_ID> is a local label and the wire value. It has to be a name the endpoint serves.

For anything else: opencode --print-logs --log-level DEBUG, and the persistent logs under ~/.local/share/opencode/log/.


8. Files opencode creates that you should not create yourself

After a first run you will find things in the config directory that look like they want maintaining. They do not:

  • ~/.config/opencode/node_modules/, package.json, package-lock.json -- the dependency tree for opencode's plugin system (@opencode-ai/plugin, @opencode-ai/sdk and their transitive deps). Nothing to do with your provider: the openai-compatible driver is compiled into the binary, and @ai-sdk/openai-compatible is not on disk anywhere.
  • ~/.config/opencode/.gitignore -- written by opencode itself, listing exactly node_modules, package.json, package-lock.json, bun.lock, .gitignore. Which is a useful hint: opencode expects you may want to version-control this directory, and has pre-excluded its own scaffolding for you. If you do commit it, that .gitignore does not cover a config file holding a literal key -- see section 5.
  • ~/.local/share/opencode/ -- opencode.db (SQLite session store), log/, repos/, snapshot/.
  • ~/.cache/opencode/ -- models.json (the ~4 MB model catalogue it syncs) and a vendored ripgrep binary.

So the only file you author is the config. Everything else is generated.


9. The one thing this guide cannot give you

Everything above is reproducible on any machine, and the endpoint is named: https://ai.aksal.dev/v1. The remaining piece is the API key, and that is not ours to issue.

The endpoint is not ours; it is run by a third party, and access to it is that operator's to grant. There is no request form and no process to point you at -- what there is, is a precedent: the credential in use here arrived as a Signal message from Kacper. So if you need access, ask Kacper directly, over whatever channel you already have with him.

Please read that as a starting point and not as an entitlement. Whether you are issued your own credential, given access some other way, or not at all, is his call; nothing on this page commits him to anything, and nobody on this side can grant it on his behalf.

Two related habits while you wait:

  • Do not assume a key that works for one client works for you. Ask for your own rather than borrowing one; a shared credential cannot be revoked for one holder, and whoever's key you borrowed wears whatever you do with it.
  • Once you have one, it belongs in section 5's {env:VAR} form and not in a file you might commit.

And you do not have to wait to start: everything in sections 1-8 works unchanged against a local llama.cpp server --

llama-server -m <model.gguf> --host 127.0.0.1 --port 8080 --ctx-size 32768

with "baseURL": "http://127.0.0.1:8080/v1" and any non-empty string as the apiKey (llama.cpp ignores it unless started with --api-key). That gives you a working opencode against an OpenAI-compatible endpoint today, and swapping in the shared endpoint later is a two-line edit.


Verified against opencode 1.18.21 on Linux x86-64, 2026-08-25. Sections marked [unverified] were reasoned from the binary's own code and documentation rather than executed.