Scripts for running halogen-flash-server (Qwen3.8-Flash-Next) as a local coding-agent backend, and for pointing Claude Code at it.
Built and tuned on a GMKtec EVO-X2 (AMD Strix Halo, gfx1151, 128 GB unified memory) running Pop!_OS 24.04 / COSMIC, with rootless podman. Nothing here is Strix-specific in principle, but the memory numbers are sized for a 128 GB unified-memory box, and every figure below was measured on that machine rather than taken from a datasheet.
| script | what it does |
|---|---|
halogen.sh |
pull the latest image, prune old ones, run the server in one of several tuned profiles |
halogen-watch.py |
follow the server log and tell you when a conversation is about to run out of context |
claude-box.sh + Containerfile.claude |
run Claude Code in a podman sandbox against the server |
# serve the model, tuned for long agentic coding sessions
./halogen.sh run-optimal
# in another terminal, point Claude Code at it
source ./claude-env.sh
claude
# optionally, watch how close you are to the context ceiling
./halogen-watch.pyusage: ./halogen.sh <recipe> [options] VERSION=x.y.z pins the image tag
recipes: run | serve | run-optimal | run-optimal-slots8 | run-optimal-vision
run-optimal-fork | run-abliterated | run-swift-abliterated
run-optimal-npu | run-optimal-ht43 | run-optimal-npu-ht43
other: clean | latest | list | config | help
A recipe is a starting point, not a fixed thing: every option below overrides what a recipe implies. Anything that was a recipe can also be composed.
| recipe | model | vision | NPU | slots | pool |
|---|---|---|---|---|---|
run |
server default | no | no | server default | server default — nothing tuned |
serve / run-optimal |
v2 | no | no | 4 | 786432 — the one to use |
run-optimal-slots8 |
v2 | no | no | 8 | 786432 |
run-optimal-vision |
v2 | yes | no | 4 | 786432 |
run-optimal-fork |
v2 | no | no | 4 | 786432, Claude Code sessions (0.18.1+), see Sessions |
run-abliterated |
abliterated v2 | yes | no | 4 | 786432 |
run-swift-abliterated |
swift15 abliterated | yes | no | 4 | 786432 |
run-signal |
Signal v2 abliterated | yes | no | 4 | 786432 |
run-heretic |
Heretic Reverse Engineering v2 | yes | no | 4 | 786432 |
run-optimal-npu |
v2 | yes | yes | 4 | 524288 |
run-optimal-ht43 |
ht43 | no | no | 4 | 786432 |
run-optimal-npu-ht43 |
ht43 | no | yes | 4 | 786432 |
./halogen.sh run-optimal --vision on # a recipe plus one feature
./halogen.sh serve --model swift15 --vision off --slots 8
./halogen.sh run-optimal-fork --model abliterated --effort xhigh
./halogen.sh serve --set HALOGEN_MTP_PREFILL=1 --cache-dir /mnt/data/halogen-cache-test
./halogen.sh run-optimal --dry-run # print the podman command, start nothing| option | meaning |
|---|---|
--model NAME |
v2, ht43, abliterated, swift15, or one you added (./halogen.sh list) |
--vision on|off |
the vision tower |
--npu on|off |
the NPU models (needs the driver, XRT and the fabric-clock unit) |
--fork on|off |
Claude Code sessions |
--slots N, --pool N |
concurrent conversations, KV pool positions |
--effort LEVEL |
the server's default reasoning effort |
--cache-dir PATH |
prompt cache directory (default: the main one) |
--set NAME=VALUE |
any container variable; repeatable; applied last, so it wins |
-n, --dry-run |
print the command; no pull, no prune, no compaction |
--dry-run is also the quickest way to see what a recipe really does. With VERSION set it
touches nothing; without it, it makes one read-only podman images call to find the local tag.
Combinations the recipes never covered are just flags; they are not all tested on hardware, and
a bigger pool with vision or the NPU can run out of host memory (the server's own pre-flight
warns before it fails). The NPU recipe uses a smaller pool for that reason; --npu on alone does
not change the pool.
Paths, models and tuning are variables in one block at the top of the script. To keep them when
you replace the script, put overrides in ~/.config/halogen/halogen.conf (or the file named by
$HALOGEN_SH_CONFIG); it is plain bash, read after the defaults:
MODEL_DIR=/srv/models/halogen
CACHE_DIR=/srv/halogen-cache
MODEL_FILE[mine]="my-finetune.hgn" # then: ./halogen.sh serve --model mine
KV_SLOTS=6Precedence, highest first: flags, environment, config file, defaults. The environment
variables that count are REGISTRY, REPO, PORT, KEEP_TAGS, MODEL_DIR, CACHE_DIR and
XRT_LIB_DIR, plus VERSION. ./halogen.sh config prints the resolved values and says whether
the config file and the API-key file were found.
This makes the paths and models portable. It is still written for rootless podman and an AMD
GPU (/dev/kfd, /dev/dri, --group-add keep-groups); it needs bash 4.4 or newer.
The tag is resolved from GHCR each run, falling back to the newest local image if the
registry is unreachable. VERSION=0.15.3 ./halogen.sh run-optimal pins it, and
KEEP_TAGS=0.15.3,0.16.0 protects old images from clean so you can keep a benchmark
baseline around.
compact_memory runs before each start. On a fragmented host, reserving the KV pool can
take tens of minutes at 100% of one core and look like a hang; after compaction it takes
under a second. It logs the order-9 block count before and after rather than gating on it —
that number and the one halogen reports measure different pools and disagree by orders of
magnitude, so it is for correlation after a bad start, not for prediction.
| flag | value | reason |
|---|---|---|
HALOGEN_CTX |
262144 |
native window; no YaRN, so no quality cost on short prompts |
HALOGEN_KV_POOL_POSITIONS |
786432 |
3 full-length conversations; at 524288 a parent plus subagents fills the pool and max_tokens is clamped every turn |
HALOGEN_KV_SLOTS |
4 |
agents fan out into parallel subagent calls |
HALOGEN_MAX_TOK |
16384 |
measured: 32768 costs 19% of prefill on this box, 8192 gains nothing |
HALOGEN_SPEC_ADAPT |
0 |
measured -33% TTFT (363 -> 245 ms p50) against the adaptive default. The policy reads acceptance over a 32-round window before deciding, and that costs the first rounds of every request. Decode unchanged within noise |
HALOGEN_MTP_PREFILL |
0 |
measured +3% @32k, +7% @6.2k; speculation cannot help during prefill, where every token is known. Undocumented in FLAGS.md — re-check after upgrades |
HALOGEN_WEIGHTS_LOCK |
1 |
mlocks the weight pages. Without it, reclaim makes the GPU driver tear down and restore mappings, which can stall or fault |
HALOGEN_HOST_RESERVE_GIB |
24 |
page cache for the 47.7 GiB n-gram table, which is read from disk and never held. Raising it to 32 was measured and changed nothing |
HALOGEN_CACHE_BRANCHES |
3 |
subagent fan-out; at the default of 2 the parent re-reads its whole history every turn |
HALOGEN_COMPOSABLE_CONTEXT |
1 |
retains messages >=2048 tokens and reuses them at any later offset — the shape of a harness compaction |
HALOGEN_TEMPERATURE |
1.0 |
the model card's sampling setting; agent harnesses send no sampling fields, so without this every turn runs greedy |
HALOGEN_MAX_TOKENS_DEFAULT |
16384 |
thinking and the answer share one budget |
HALOGEN_MAX_TOKENS_CAP |
32768 |
a request reserves prompt + max_tokens on admission; a huge cap lets one request eat the pool |
HALOGEN_REASONING_EFFORT |
medium |
the template's own default is xhigh, which was the model card's guidance for multi-turn agentic work; these profiles send medium (shorter thinking). The quality difference has not been measured on this setup. A request's own reasoning_effort wins |
HALOGEN_MAX_THINKING_TOKENS |
8192 |
bounds thinking explicitly instead of relying on the answer-room floor |
HALOGEN_ENGINE_WATCHDOG_S |
0 |
required given --rm with no restart policy — otherwise the watchdog kills the server and nothing brings it back |
HALOGEN_CACHE_DIR + DISK_GIB=128 |
prompt cache survives restarts, ~27 KiB/token | |
HALOGEN_KEEPALIVE_TIMEOUT |
900 |
agents idle between turns; short keep-alives cause SocketError: terminated |
Deliberately not set:
HALOGEN_PREFILL_CHUNK— the default 32768 is the fastest measured value; lowering it is a pure loss.HALOGEN_ADMIT_CHUNK— only pays with several conversations generating concurrently, and it gives up the byte-identity property.HALOGEN_MTP_DEPTH=3— faster for raw code generation, 5-8% slower on agent traffic.HALOGEN_ROPE_YARN— extends context past 262k, but rescales RoPE for every request including short ones. Only worth it if you genuinely send single prompts past 262k.
All on 0.16.0 unless noted, iommu=pt, omp bench --par 1, two consecutive five-run
blocks per cell.
| profile | prefill @32k | decode | free RAM |
|---|---|---|---|
| run-optimal (v2) | 1,704 / 1,678 | 42.9 / 42.8 | 21.3 GiB |
| run-optimal-ht43 | 1,583 / 1,571 | 40.4 / 40.4 | 30.3 GiB |
ht43 + HOST_RESERVE_GIB=32 |
1,567 / 1,556 | 40.5 / 40.3 | 29.7 GiB |
| run-optimal-npu, NPU idle | 1,686 / 1,672 | 43.0 / 43.2 | 27.6 GiB |
| run-optimal-npu, NPU saturated | — | 40.9 | 27.6 GiB |
On 0.16.1, which claims faster decode on both checkpoints:
| 0.16.0 | 0.16.1 | |
|---|---|---|
| v2 decode | 42.9 / 42.8 | 44.4 / 43.9 |
| ht43 decode | 40.4 / 40.4 | 42.9 / 43.5 |
| gap v2 to ht43 | -5.7% | -1.5% |
| change | effect |
|---|---|
amd_iommu=off instead of iommu=pt |
+5% prefill, but the NPU stops working |
HALOGEN_MTP_PREFILL=0 |
+3% @32k, +7% @6.2k |
HALOGEN_SPEC_ADAPT=0 |
-33% TTFT; decode +1.2%, inside noise |
HALOGEN_MAX_TOK=32768 |
-19% prefill |
HALOGEN_MAX_TOK=8192 |
no change |
CPU governor performance |
0.8% — noise |
HALOGEN_HOST_RESERVE_GIB 24 -> 32 |
no change |
| vision tower loaded | no change |
| NPU models loaded, idle | no change |
- Prefill rises with prompt length then falls: 1,180 @6k, 1,599 @42k, 1,425 @262k. Per-chunk rate decays ~15% from the start of a 262k prompt to its end.
- Warm follow-up at 74k context: 257 ms, against 41.5 s cold. 73,775 of 73,790 tokens reused.
- Greedy + prompt lookup at 118k context: 68.4 tok/s, with 1,450 of 1,495 drafted tokens accepted. Only available on
temperature: 0, so not on the sampled production setting. - Decode falls with context: 43 at short, 41.5 @24k, 36 @74k.
- MTP speculates only while a request is alone. At 4 concurrent each stream drops to ~18 tok/s with the head off; aggregate still rises to ~74.
Two sequential runs, decode phase only, 0.16.2. spec_adapt=0 is the shipped setting.
| category | adaptive (default) | spec_adapt=0 |
|---|---|---|
| chat | 49.6 | 48.8 |
| code | 51.8 | 52.7 |
| file_edit | 50.4 | 51.0 |
| json | 50.9 | 51.5 |
| math | 53.2 | 53.1 |
| prose | 43.8 | 43.8 |
| reasoning | 44.6 | 45.5 |
| summarization | 46.9 | 47.3 |
| combined | 48.4 | 49.0 |
| TTFT p50 | 363 ms | 245 ms |
| TTFA p50 | ~1,050 ms | ~930 ms |
The decode difference is inside the noise band; the TTFT difference is not, and it is consistent across every category. Note prose is identical to one decimal both ways — the case the adaptive policy exists for showed no difference at all on this corpus.
Both runs truncated 27-29% of passes at max_tokens, so they measure answer decode with
thinking mostly off. Fine for comparing two configurations, not a picture of a real turn.
KV_SLOTS=4 (the run-optimal default) collapses when a 5th request arrives.
run-optimal-slots8 is the same profile with 8 slots. BetterBench concurrency phase,
0.16.2, v2, pool 786432:
| concurrent | 4 slots: aggregate / TTFT p50 | 8 slots: aggregate / TTFT p50 |
|---|---|---|
| 1 | 47.2 t/s / 339 ms | 44.4 / 448 ms |
| 2 | 53.5 / 411 ms | 52.1 / 440 ms |
| 4 | 69.5 / 419 ms | 67.4 / 441 ms |
| 8 | 69.7 / 13,048 ms | 81.0 / 509 ms |
At 8 concurrent, 8 slots is +16% aggregate and 25x better TTFT — the 5th through 8th requests no longer queue. The cost is ~2-3% at levels 1-4 and about 100 ms of TTFT throughout, plus 0.5 GiB held (92.3 vs 91.8) and a prompt cache of 64 entries rather than 32.
Worth it only if your harness actually exceeds four concurrent calls. Claude Code with subagents can; a single stream never will.
A harness that runs several branches from one conversation can ask the server to keep a
conversation's state under a name and start new requests from it. Halogen documents this as
request headers: X-Halogen-Keep: <name> keeps the state where a reply ends,
X-Halogen-From: <name> starts a request from it, and both together continue from one name
and keep the result under another (fork and join). Every request stays an ordinary request;
a server without HALOGEN_FORK=1 answers it the same way. The X-Halogen-From response
header says used, unmatched (the history does not repeat the kept one) or unknown.
Claude Code needs none of that. With HALOGEN_FORK=1 and HALOGEN_FORK_MAP=claude-code
the server keeps each Claude Code thread's state, the main conversation and every subagent,
on its own and starts that thread's next request from it. Claude Code sends its own session
and agent identifiers (X-Claude-Code-Session-Id, -Agent-Id, -Parent-Agent-Id, v2.1.129+),
which is what the mapping reads. Use the profile:
./halogen.sh run-optimal-forkIt shares the main cache directory by default. A changed engine configuration changes the
cache fingerprint (it did for the vision tower, and the fork flag may too), and
HALOGEN_CACHE_PRUNE_OLD=1 would then delete the run-optimal cache; to keep them apart, add
--cache-dir "$CACHE_DIR-fork" (create the directory first or let the script do it). Names are
released after 10 minutes unused, at most 8 live at once (HALOGEN_FORK_TTL_S,
HALOGEN_FORK_HANDLES), and survive a restart when HALOGEN_CACHE_DIR is set.
Status: not yet measured here. The behaviour is from the 0.18.1 changelog and README. A
useful test is a subagent-heavy task with the flag off and on, comparing the cached
percentage on each subagent's first request in the container log, the evicted, dropped
and entries counters from /cache, and how warm the parent's next turn is. My expectation,
not a measurement, is that the gain over the automatic prefix cache is modest for Claude Code,
because subagents mostly share only the system prompt and tool definitions with the parent.
Security. Every client of the server shares one set of names, and GET /halogen/handles
lists them. Keep the port private or set HALOGEN_API_KEY (and put the key in your
clients). I have not checked how unguessable Claude Code's session identifiers are, so do not rely on
that on an open network.
- Structured output at any temperature. A JSON schema (
response_format,text.formatoroutput_config.format) may now be combined with a temperature above 0, and the reply still validates. A schema with an image, or withlogprobs, is still a 400. prompt_cache_policy: "no_store". A top-level request field that reads the prompt cache but stores nothing, for side requests (a title generator, a summarizer) that share the server with a conversation. Under memory pressure such a request can still make the server forget an idle conversation.- A dropped connection no longer kills a reply. The server pauses it for 20 seconds
(
HALOGEN_RETRY_GRACE_S,0= stop at once); an identical retry continues where it paused. At temperature 0 or with aseedthe retried reply equals an uninterrupted one. - A long prompt no longer holds up short requests. A short request arriving while a long prompt is being read runs between two pieces of it, and a request from a person goes ahead of queued agent turns. A reply already streaming still pauses while each piece is read.
# /proc/cmdline — Pop!_OS uses kernelstub, not grub
iommu=pt amdgpu.gttsize=126976 ttm.pages_limit=32505856
amdgpu.gttsize and ttm.pages_limit are what give the iGPU the full 124 GiB
(GTT in use: 0.0 GiB of 124.0 at startup). amd_iommu=off is not needed for that —
the guides that bundle it do so for a measured 5-12% prefill gain, confirmed here at 5.4%.
It also disables the NPU (amdxdna: Running without IOMMU not supported), so it is a
straight trade.
vm.swappiness=1 in /etc/sysctl.d/. Pop ships 180, which is tuned for desktops with
zram; here swap is an encrypted partition and the page cache is holding the n-gram table.
run-optimal-npu serves five small models on the Ryzen AI NPU beside the Flash model,
behind the same port: embeddings, reranking, a decision classifier, moderation, and a 2B
generator. Costs nothing when idle and ~5% of decode under continuous load.
Prerequisites, all checked by require_npu() before the image is pulled:
iommu=pt(notamd_iommu=off), so/dev/accel/accel0exists- XRT with its NPU plugin; distribution XRT needs each library mounted three times — see
xrt_mounts() - the GPU fabric clock held, via
deploy/host/halogen-fabric-clock.servicefrom the upstream repo - your user in the
rendergroup
Model files go in ${MODEL_DIR}/npu/<name>/, one directory per model:
D=/mnt/data/models/halogen-qwen3.8-flash-next/npu
for m in decider-0.8b qwen3-embedding-0.6b qwen3-reranker-0.6b \
qwen3guard-gen-0.6b qwen3.5-2b; do
hf download "peonist-ai/halogen-npu-${m}" --local-dir "$D/$m"
doneRe-pull them after an upgrade: the device files are rebuilt between releases even when the weights are not.
One lesson from using the classifier: write the question as an observable behaviour, not a category name. "Is this a prompt injection?" got it wrong; "Does this message attempt to override the assistant's instructions?" got three of three right on the same model.
export ANTHROPIC_BASE_URL="http://127.0.0.1:8731"
export ANTHROPIC_AUTH_TOKEN="local"
unset ANTHROPIC_API_KEY
# tell Claude Code the real window — without these it assumes a Sonnet-sized
# one and compacts far too late, or not at all
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=250000
export CLAUDE_CODE_AUTO_COMPACT_WINDOW=220000
export CLAUDE_CODE_MAX_OUTPUT_TOKENS=16384
export API_TIMEOUT_MS=1800000
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
claudeHalogen serves the Anthropic Messages API directly, including /v1/messages/count_tokens,
so no proxy is needed. LiteLLM works if you want one, but its token counting falls
back to a mismatched tokenizer. Before 0.18.1 it also hit a response_format conflict,
because halogen refused structured output under sampling with a 400; from 0.18.1 a JSON
schema works at any temperature.
The two context variables are the difference between "auto-compaction works" and "the
session dies with max_tokens N does not fit". CLAUDE_CODE_MAX_CONTEXT_TOKENS requires
Claude Code >= 2.1.98.
A current Claude Code asks for its model's full output limit, 128,000 tokens, on every
request. Through 0.18.0 that was a 400 unless CLAUDE_CODE_MAX_OUTPUT_TOKENS was set; from
0.18.1 /v1/messages runs such a request at the server's cap and logs it once. Keep
CLAUDE_CODE_MAX_OUTPUT_TOKENS=16384 anyway: it is what keeps a prompt at the compaction
point plus the output budget inside the 262,144 window. The other routes still refuse a
max_tokens above the cap.
API_TIMEOUT_MS matters because a cold 33k prompt takes ~23 s to prefill, and the default
client timeout hangs up before the first token.
Claude Code also sends context_management, which halogen ignores — so it is not pruning
context server-side, and manual /compact discipline still matters.
omp bench for quick comparisons:
omp bench halogen/halogen-qwen3.8-flash-next --profile generation --par 1
omp bench halogen/halogen-qwen3.8-flash-next --profile prefill --prefill-bytes 175000 --par 1
omp bench halogen/halogen-qwen3.8-flash-next --cache --cache-prefix-bytes 400000 --jsonRun each twice; the first populates the cache.
BetterBench for anything you intend to publish or act on. Per-category decode over a versioned corpus, nonce-busted prefill, a concurrency sweep, and — the reason to prefer it — paired A/B with a confidence interval that refuses to call a winner inside the noise band. Run-to-run noise here is 3-8%, which buries exactly the 1-2% differences this kind of tuning produces.
betterbench run --endpoint http://127.0.0.1:8731/v1 --model halogen-qwen3.8-flash-next \
--decode --name v2 --note checkpoint=v2 --note image=0.16.1 --out results/0161-v2.jsonNote BetterBench's corpus produces short outputs, so on this model every pass logs
closed at 1 by answer room — it measures answer decode with thinking off. Fine for
comparing two checkpoints; not the same thing as a real agent turn.
Qwen3.8-Flash-Next on a DGX Spark (GB10, 128 GB), measured with the same omp bench
commands:
| halogen / Strix Halo | flash-DGX (vLLM) | TensorFold | |
|---|---|---|---|
| prefill @32k | 1,704 | 3,116 | 2,436 |
| decode | 42.9 | 30.4 | 54.5 |
| KV pool | 786,432 | 450,424 | 1,310,720 |
All three independently keep the 48 GiB n-gram table off the device and read it from storage, which seems to be the settled answer for this model.
./halogen-watch.py # auto-detect the container, follow
./halogen-watch.py --quiet # NOTICE and above only
./halogen-watch.py --budget 32768 # if your client asks for more output
./halogen-watch.py --stdin < saved.log[OK ] conv146 .............. 52.3% prompt 120,722 headroom 125,038 ~159 turns
Each line is one request. Conversations are reconstructed from the prompt-cache chain
(prompt N (M cached) links a request to whichever conversation last ended at M), so
subagents separate from the parent automatically. headroom is
ctx - prompt - output_budget — the exact quantity in the error you get when it runs out.
Levels: OK < 60%, NOTICE >= 60%, WARN >= 75%, CRIT >= 88% or on any max_tokens clamped.
WARN and CRIT also fire notify-send.
Runs Claude Code in a podman container with --dangerously-skip-permissions, so the agent
works unattended inside the sandbox without touching your SSH keys, gpg keys, shell
history, or anything outside the mounted project.
./claude-box.sh --build # once
./claude-box.sh # sandbox $PWD
./claude-box.sh /path/to/repo
./claude-box.sh --shell--userns=keep-idso files written into the repo belong to you--memory=6gso a runaway build can't starve the pinned model weights- a persistent home volume, so
/resumeand installed tooling survive - reaches the server via
host.containers.internal
It does not restrict outbound network — the agent needs it for package installs. For egress control, a micro-VM sandbox such as gondolin is the better tool.
Pair it with a git worktree for long refactors:
git worktree add ../proj-refactor -b refactor/thing
cd ../proj-refactor && claude-box.sh- Compact memory before starting. Otherwise pool reservation can stall for a very long time and look like a hang. Zero compaction stalls is the healthy signature; thousands is not.
- The "N GiB of host RAM is in use" warning is noise on this host. It is computed before
the GTT check and before the iGPU carve-out is known. It fired at 10.0 GiB before the
fastest start measured and at 11.9 GiB before the slowest.
MemAvailable now ...on the next line is what actually governs. free -glies while the server runs. The kernel counts the pinned weights as reclaimable file cache; the startup log says by how much.- Each profile is a different cache fingerprint. With
CACHE_PRUNE_OLD=1, switching profiles wipes the other's disk cache — 99 GiB in one case. Every profile inhalogen.shshares oneCACHE_DIRexceptrun-optimal-fork, so switching checkpoint prunes the previous cache; give a profile its own directory if you alternate often. - Pin
transformers==4.46.3if you use a client that needs it. v5 miscomputes mBART position ids and the engine indexes past its positional table. - A short keep-alive breaks agents, because POST is not idempotent and the client cannot transparently retry.
max_tokenscovers thinking and the answer. Below ~1,200 tokens of budget a request effectively gets no thinking at all — the answer room ismax(1024, 15%).- Greedy decode at long context can loop inside the think block. Run sampled.
- Watch for
cannot growandmax_tokens clampedin the server log. They appear several turns before a hard failure. omp benchdefaults to--par 4. A "slow" result where one run in five is fast is usually MTP switching off under concurrency, not a regression.
Apache License 2.0