
OpenCode + a Self-Hosted Qwen3.6 Server: Provider Setup and the Chat-Template Fix That Makes Tool Calling Reliable
Running a coding agent against a self-hosted model is not as simple as pointing a CLI at some URL and calling it done. Two separate things have to be correct at the same time.
The first is the client configuration: which model to call, how hard it should "think" before answering, and how much text it can read and write in one turn. The second is something less visible but just as important: the server's chat template — a small script that decides how a whole conversation (including tool calls the model made earlier and the results those tools returned) gets turned into the single block of text the model actually reads.
Get the client configuration wrong, and the agent simply can't reach the model — an obvious, loud failure. Get the chat template wrong, and something worse happens: the agent does reach the model, gets a response, and the tool-calling loop still quietly breaks. This is the hardest failure to diagnose, because the server keeps answering normally (HTTP 200, no error) the entire time. It just uses the model's tools incorrectly.
This article documents both halves of a working setup: an OpenCode provider pointed at a local FreeToken server (an inference engine — the program that actually loads the model and answers requests) running Qwen3.6-35B-A3B-FP8, and the community-made chat-template fix that had to be installed before tool calling was trustworthy enough to use for real agent work.
What is a "chat template," concretely? Language models don't receive a conversation as separate messages the way a chat app displays it. They receive one long string of text. The chat template is the piece of logic — usually a small Jinja template file named chat_template.jinja — that converts a list of messages (system prompt, user turns, assistant turns, tool calls, tool results) into that single string, using special marker tokens the model was trained to recognize. If the template gets this conversion wrong — even in a way a human reading the output wouldn't immediately notice — the model can lose track of which tool call belongs to which result, forget it already tried something, or ignore an instruction the client sent. That's why a "wrong template" bug looks nothing like a crash: the model is still doing something coherent, just not the right thing.
The provider config
OpenCode reads its settings from a single configuration file, opencode.jsonc (JSON with comments allowed). Inside it, a provider is just an entry describing one place OpenCode can send requests to — a hosted service like OpenAI or Anthropic, or, in this case, a self-hosted server on the local network. Because FreeToken speaks the same request format OpenAI's API uses ("OpenAI-compatible"), OpenCode can talk to it through a built-in adapter (@ai-sdk/openai-compatible) without any custom integration code. The whole provider entry is short:
"freetoken-88": {
"npm": "@ai-sdk/openai-compatible",
"name": "FreeToken (192.168.0.88)",
"options": { "baseURL": "http://192.168.0.88:8000/v1" },
"models": {
"qwen3.6-35b-a3b-88": {
"id": "qwen3.6-35b-a3b",
"name": "Qwen3.6 35B A3B (llm-server)",
"tool_call": true,
"reasoning": true,
"variants": {
"low": { "reasoningEffort": "low" },
"medium": { "reasoningEffort": "medium" },
"high": { "reasoningEffort": "high" }
},
"limit": { "context": 262144, "output": 32000 }
}
}
}
A few details in that block matter more than they look:
- The name shown to you is not the name sent to the server.
qwen3.6-35b-a3b-88(the outer key) is just the label OpenCode's model picker shows you. The actual identifier sent to FreeToken with every request is theidfield,qwen3.6-35b-a3b. That value has to exactly match--served-model-name, the name the FreeToken server was told to answer to when it was started — if the two don't match, the server rejects the request with a "model not found" error. tool_call: trueandreasoning: trueare promises, not switches. They tell OpenCode "this model can use tools, and it can show its reasoning process" — which unlocks features in the UI, like letting you pick how much the model should reason before answering. But setting these flags totruedoesn't make the server support them; the server still has to actually implement tool calling and reasoning correctly, which turns out to be the hard part (see the next section).variantscontrol how much the model "thinks" before answering. Qwen3.6 supports a range of reasoning effort levels; this config exposeslow,medium, andhighas options you can pick per request. (Higher effort tends to mean better answers on hard problems, at the cost of a slower, longer response.)- The
limitvalues are safety caps, not hard technical limits.context: 262144means the model can technically read up to 262,144 tokens of conversation and code before responding (roughly 200,000 words) — that's the model's native capability.output: 32000is a self-imposed ceiling on how long a single response is allowed to be, sized to leave room for a full agent turn (planning, tool calls, tool results, and a final answer) without letting one response eat the whole budget. - There is no API key. FreeToken's inference port has no login or token check in front of it at all (confirmed directly in FreeToken's server source) — only a separate administrative control channel does. That is an acceptable shortcut for a machine that's only reachable on the local home network; it would not be safe to expose the same unauthenticated endpoint to the public internet.
With that block added and the provider enabled, the local model shows up in OpenCode's model list (opencode models) alongside any hosted providers, and selecting it routes every chat message, streamed response, and tool call through the local server instead of a cloud API.
Once the endpoint has proven reliable (see the validation section below), it's worth making it the default, so every new session uses it automatically instead of requiring a manual selection each time:
"model": "freetoken-88/qwen3.6-35b-a3b-88"
That value combines two things you already defined above, joined by a slash: the provider's name (freetoken-88) and the model's key inside it (qwen3.6-35b-a3b-88) — not the inner id field, which is a different string used only for talking to the server itself. One caveat: OpenCode reads this setting only once, when it starts up. An already-running session keeps using whatever model it started with, so changing the default requires quitting and restarting OpenCode before it takes effect.
Why the vendor chat template is not enough
The provider config above is necessary, but on its own it isn't sufficient. Qwen ships its own default chat_template.jinja with every model download, and the first time this setup was wired into OpenCode using that stock, unmodified template, simple one-off questions worked fine. The problems only showed up in longer, multi-turn agent sessions — the kind where the model calls a tool, gets a result, and keeps working based on that result across several back-and-forth turns:
- Asking for a moderate amount of reasoning had no effect. OpenCode can request
reasoning_effort: medium— a moderate amount of internal "thinking" before answering — but the vendor template ignores that request and always forces the highest level instead. In practice, this meant a single turn could spend its entire response-length budget on invisible reasoning and return no visible answer at all. - Replaying an earlier turn could confuse the model about its own reasoning. When OpenCode sends the conversation history back to the model (which it does on every turn, so the model has full context), a turn that already contained a reasoning block would get an extra empty one glued in front of it by the template — a subtle corruption that made it harder for the server to correctly separate "thinking" from the actual answer on the next turn.
- Tool call history in a different — but equally valid — format could crash the whole request. Tool arguments can be represented as a nested data structure or as a single string containing that structure written out as text; both are valid ways to store the same information. The vendor template only expected one of those forms, so when OpenCode (or many other agent tools) sent the other form while replaying history, template rendering failed outright.
- Multiple tool calls in one turn had inconsistent formatting. When a model makes several tool calls at once, tiny whitespace inconsistencies between them were enough to break the server's internal caching of repeated conversation prefixes, quietly slowing down every subsequent turn in a long session.
- The word "error" alone could derail a working session. If a tool's successful output happened to contain the literal word "error" — for example, source code showing a
console.error(...)line, or the output of a log search — the template could mistake that for an actual tool failure and push the model into an unnecessary retry-and-recover routine.
None of this shows up as an obvious crash. It shows up as an agent that seems to work, then quietly stalls, repeats the same failing action, or returns nothing for no visible reason — the kind of bug that erodes trust in a self-hosted setup much faster than a clean connection failure would, because there's no error message pointing at the cause.
The fix: froggeric's Qwen-Fixed-Chat-Templates
The fix is a drop-in replacement chat_template.jinja maintained in the community project froggeric/Qwen-Fixed-Chat-Templates (currently v22.4). "Drop-in" means it's a single file that replaces the vendor's template without requiring any change to the model itself or to OpenCode.
The table below maps each bug described above to the specific fix, using the template project's own terminology — useful if you want to cross-reference the project's changelog or tests later, even though the bullets above already cover what each one means in practice:
| Bug in the vendor template (see explanation above) | Fix in froggeric's template |
|---|---|
| Hardcoded highest reasoning effort, ignoring client requests | Safe medium default, honors the client's requested effort level |
| Empty reasoning block glued in front of replayed history | "Empty think" poisoning cure |
| No way to adjust reasoning effort mid-conversation without a new request | Inline <|think_low|> / <|think_medium|> / <|think_xhigh|> / <|think_off|> control tags the model can react to directly in the text |
| Crashes when tool arguments arrive as text instead of structured data | Handles both formats without crashing |
| Inconsistent spacing between multiple tool calls in one turn | Fixed, consistent spacing that preserves the server's response cache |
| Agent can get stuck retrying the same failing tool call forever | A two-step escalation: retry once, then stop and report the failure instead of looping |
| Successful output containing the word "error" triggers a false retry | Smarter detection that tells real tool failures apart from output that merely mentions the word |
| Multiple leading system/developer instructions rejected or mishandled | Consecutive leading instructions are merged into a single one |
Installing it is a two-line change wherever the runtime accepts a custom Jinja template. For llama.cpp / llama-server:
llama-server -m your_model.gguf --jinja --chat-template-file chat_template.jinja --reasoning-format deepseek
For vLLM, replace the "chat_template" field in tokenizer_config.json and serve with:
vllm serve <model> --reasoning-parser qwen3 --tool-call-parser qwen3_xml
For FreeToken, there is no separate --chat-template-file flag to pass — FreeToken loads tokenizers through Hugging Face's standard AutoTokenizer.from_pretrained(model_path), and that loader already knows to look for a file named chat_template.jinja sitting in the model directory. So the "install" step is literally a file swap in the model's own folder:
cd ~/llm_models/Qwen3.6-35B-A3B-FP8
mv chat_template.jinja chat_template.jinja.orig # keep the vendor original for A/B testing
curl -L -o chat_template.jinja \
https://huggingface.co/froggeric/Qwen-Fixed-Chat-Templates/resolve/main/chat_template.jinja
Restart the server (or the ft serve process) so it re-reads the tokenizer files, and the new template is active — no code change, config flag, or model re-conversion needed.
Whatever the runtime, keeping the original vendor template as chat_template.jinja.orig next to the patched one is what makes an A/B comparison possible if a regression shows up later.
Validating the fix, not just trusting the changelog
Swapping the template on a server a coding agent will depend on for every single request deserves more than "it seems to work now" — especially given how many of the original bugs were silent, not crashes. So instead of trusting the fix by inspection, it was checked with an automated test suite: the llm-tests repository (its test module is called agentbench). Rather than simulating a fake server, these tests send real requests to the live FreeToken endpoint and check that tool calls, reasoning output, multi-turn conversation replay, and streamed responses all behave correctly — the same way a real coding agent would use them.
Run against this setup with froggeric's template installed, the results are:
- 55 tests passed — one for essentially every fix in the table above, including the inline reasoning-control tags, the tool-argument handling, the multi-call spacing fix, and the error-escalation behavior.
- 8 tests skipped — these cover slower, opt-in checks (very long conversations, many simultaneous requests) that aren't run by default to keep the suite fast.
- 2 tests fail on purpose, each documenting a known, separate bug in the FreeToken server itself — not in the chat template:
- The template correctly supports an inline tag that turns reasoning off for a single response, but FreeToken's own response-parsing code doesn't recognize that tag yet, so it still treats the answer as if it were hidden reasoning and never shows it to the user.
- OpenCode can ask the server to force a tool call rather than letting the model decide freely, but FreeToken doesn't actually enforce that — it silently falls back to letting the model choose anyway.
As a sanity check, re-running the same suite against the original, unmodified vendor template immediately reintroduces failures in the tests tied to the bugs above. That's the whole point of having this suite: it exists to catch it automatically if a future template or server update quietly breaks tool calling again.
Surviving a reboot: two systemd units, not one
A self-hosted endpoint that OpenCode depends on as its default model has one more failure mode that has nothing to do with the chat template: what happens after the server it runs on reboots — for a kernel update, a power blip, or just routine maintenance?
FreeToken ships a daemon — a small always-on supervisor process, separate from the model itself, that exposes a control API (/engine/start, /engine/stop, /engine/status) on a fixed port. The daemon's job is to launch and watch over the actual model server (the much heavier process that loads 30+ GB of weights onto the GPU and answers /v1/chat/completions). Because the daemon is lightweight and stateless between requests, it's easy to wrap in a standard systemd --user unit and have it start automatically at boot:
# ~/.config/systemd/user/freetoken-daemon.service
[Unit]
Description=FreeToken Daemon (supervisor, no model auto-load)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=%h/freetoken/.venv/bin/ft daemon --host 127.0.0.1 --port 1900 --state-dir %h/.local/state/freetoken
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
That much is enough to make systemctl --user status freetoken-daemon show enabled and active (running) right after a reboot — but it is a trap. The daemon coming back up does not mean the model comes back up. The daemon only supervises a model server that something else explicitly told it to start; it does not remember "last time I was asked to load this model" and repeat that on its own next boot. After a reboot, curl http://127.0.0.1:1900/engine/status will report "running": false even though the daemon itself is healthy — the exact kind of quiet, non-obvious gap this article has been about all along, just one layer further down the stack, in infrastructure rather than in the chat template.
The fix is a second systemd unit, one that runs after the daemon is confirmed healthy and issues the actual "load this model" request on the daemon's behalf:
# ~/.config/systemd/user/freetoken-engine.service
[Unit]
Description=FreeToken Engine Autostart (loads the model after the daemon is up)
After=freetoken-daemon.service network-online.target
Requires=freetoken-daemon.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do curl -sf http://127.0.0.1:1900/health >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1'
ExecStart=%h/freetoken/.venv/bin/ft daemon start /path/to/Qwen3.6-35B-A3B-FP8 -- \
--moe-backend offload --served-model-name qwen3.6-35b-a3b --host 0.0.0.0 --port 8000 \
--cuda-graph-max-bs 4 --max-running-requests 4 --kv-reserve-tokens 300000 \
--num-tokenizer 0 --tool-call-parser qwen3_coder --reasoning-parser qwen3
TimeoutStartSec=60
[Install]
WantedBy=default.target
A few details worth calling out:
Type=oneshotwithRemainAfterExit=yesis the right shape here, notType=simple. TheExecStartcommand doesn't run the model server itself — it makes one HTTP call to the daemon (ft daemon start ...) and exits as soon as the daemon accepts the request, while the actual multi-gigabyte model load continues in the background under the daemon's supervision.RemainAfterExit=yestells systemd to still consider the unit "active" after that short-lived process exits, instead of treating it as crashed.- The
ExecStartPrehealth check is not optional. Even withAfter=freetoken-daemon.servicecorrectly declared, systemd's ordering only guarantees the daemon's process has started — not that its HTTP server has finished initializing and is ready to accept the/engine/startcall a few hundred milliseconds later. Polling/healthfor up to 30 seconds before proceeding removes that race. - The exact flag order matters for FreeToken's own command-line parser, not just systemd.
ft daemon start <model> -- <flags>is the working form; putting--port 8000before the--(as the daemon client's own argument) rather than after it (as part of the model server's arguments) fails with an "unrecognized arguments" error. This is easy to get backwards and worth testing manually withft daemon start ... -- ...on the command line before trusting it inside a unit file.
With both units enabled (systemctl --user enable freetoken-daemon freetoken-engine), the sequence after any reboot is: network comes up, the daemon starts and answers health checks, the engine unit's ExecStartPre loop detects that and exits successfully, and ft daemon start loads the model — all without anyone needing to SSH in and remember the right flags by hand. This was verified without an actual reboot, by stopping the model, restarting the daemon unit (recreating the "daemon up, model down" state a real boot leaves behind), and confirming systemctl --user start freetoken-engine alone brought the model server back to answering real chat completions.
Putting it together
The full local setup, in order:
- Serve the model with FreeToken (see the companion article on running Qwen3.6-35B-A3B at 262K context with FreeToken MoE offload for how to fit a large model's memory needs onto a single RTX 4090 and configure it for long coding conversations specifically).
- Replace the chat template file on the server with froggeric's version, keeping the original as a backup for comparison.
- Run the test suite before trusting the endpoint for real work — a passing run is worth more than reading a changelog and assuming it's fine.
- Point OpenCode at the server using a provider block like the one above, making sure the model identifier matches the server's configured name exactly.
- Make it the default model once step 3 passes — at that point there's little reason to keep falling back to a paid, hosted model for routine coding work.
- Enable both systemd units (daemon and engine) so a server reboot doesn't silently turn the default model into a dead endpoint the next time OpenCode tries to use it.
None of these steps is individually difficult. But skipping any one of them produces a setup that looks fully connected and sometimes behaves correctly — which, for a coding agent that replays its entire tool-call history on every single turn, is more dangerous than an outright failure to connect. A loud failure gets fixed immediately; a quiet one erodes an hour of work before anyone notices.
Resources
- OpenCode: https://opencode.ai
- Jinja (template engine): https://jinja.palletsprojects.com/
- froggeric/Qwen-Fixed-Chat-Templates (the v22.4 fix): https://huggingface.co/froggeric/Qwen-Fixed-Chat-Templates
- llm-tests (agentbench test suite): https://github.com/berdachuk/llm-tests
Published on 9/1/2026