Table of Contents
- Bootstrapping opencode against a self-hosted OpenAI-compatible endpoint
- 1. What you are actually installing
- 2. Prerequisites, honestly
- 3. Install
- 4. The config file
- 5. Getting the API key in without committing it
- Option A -- {env:VAR} interpolation (built in; simplest; nothing lands in the config dir)
- Option B -- a committed template plus vault injection
- The handlebars trap -- the part that will actually bite you
- Finding your own share and item ids -- without printing the secret
- Option C -- a literal key in the file
- 6. "permission": {"bash": "ask"}
- 7. Proving it works, and what failure looks like
- 8. Files opencode creates that you should not create yourself
- 9. The one thing this guide cannot give you
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.
config.jsonopencode.jsonopencode.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.jsonoverrides 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
missingmode 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:
--autoon the command line auto-approves everything not explicitlydeny, 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"onbashis 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-scopedallowfor the read-only commands you are bored of approving,askfor the rest -- and it is worth building once rather than flipping to--autoat 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/sdkand their transitive deps). Nothing to do with your provider: the openai-compatible driver is compiled into the binary, and@ai-sdk/openai-compatibleis not on disk anywhere.~/.config/opencode/.gitignore-- written by opencode itself, listing exactlynode_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.gitignoredoes 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 vendoredripgrepbinary.
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.