
The 400
ZCode ships with GLM coding-plan models (Z.ai / BigModel) as its built-in defaults. I don’t use those for everyday work — I added my own OpenAI-compatible provider, OpenCode Go, to ~/.zcode/v2/config.json and run deepseek-v4-pro from it as my main model.
deepseek-v4-pro cannot see. Not “sees badly” — sees nothing. Its declared input modality is text, full stop, same as deepseek-v4-flash. Every time I pasted a screenshot, ZCode dutifully bundled the image into the request anyway, and the provider bounced it:
Failed to deserialize the JSON body into the target type: messages[5]:
unknown variant `image_url`, expected `text` at line 1 column 346775
Annoying, but also predictable: the request never reaches the model, so the model can’t even apologize. It just dies with a 400.
What I wanted was simple: keep the custom deepseek models as the main model, and whenever a request involves an image, hand it to a vision model — without me remembering that a /vision command exists.
Two files I wrote by hand
ZCode doesn’t ship with vision tooling. Before any of the routing below makes sense, two pieces have to exist, and both are just markdown files.
On the OpenCode Go provider, only two models declare image input: qwen3.6-plus and mimo-v2.5. Everything below pins to qwen3.6-plus.
The /vision custom command
ZCode turns markdown files in ~/.zcode/commands/ into slash commands — the filename becomes the command name, so vision.md becomes /vision. The frontmatter is where the interesting part lives:
---
description: Analyze an attached image or screenshot using the vision-capable model (qwen3.6-plus)
argument-hint: [question about the image]
model: 8ec2eacb-47e4-4e8c-853c-19b568fe34ee/qwen3.6-plus
allowed-tools: Read
---
Analyze the image(s) attached to this message and answer the user's request.
- If the user asked a question, answer it precisely based on what is visible in the image(s).
- If no question was given, provide a clear, structured description: text content (transcribe verbatim where relevant), UI layout and elements, colors, diagrams, charts, error messages, and code shown in screenshots.
- Be factual: never invent details that are not visible in the image.
- Keep the response concise but complete; use lists or tables when they help clarity.
- If the image is unreadable, blurry, or appears blank, say so instead of guessing.
User request: $ARGUMENTS
Two frontmatter keys do the work:
model—<provider-id>/<model-id>, where the provider id is the entry in~/.zcode/v2/config.json. Any turn that starts with/visionruns onqwen3.6-plusinstead of the main model. This is the one place in ZCode where the user can switch models with a keystroke instead of the model selector.allowed-tools— caps the tools available for that turn. Vision turns only needRead, which is also how the model loads an attached image.
Everything after the frontmatter is the prompt the vision model receives, with $ARGUMENTS replaced by whatever follows /vision.
The vision sub-agent
The command alone is a dead end for automation: the main model can’t type /vision for you. What it can do is dispatch a sub-agent. The second file defines one, in ~/.zcode/agents/vision.md:
---
name: "vision"
description: "Vision assistant for reading images, screenshots, photos, and UI mockups. Use whenever an image needs to be described, transcribed, or answered against — the main model is text-only."
color: blue
model: "custom:8ec2eacb-47e4-4e8c-853c-19b568fe34ee:qwen3.6-plus"
tools:
- Read
injectAgentsMd: true
---
You are a vision assistant. You receive one or more images plus a question or task about them.
- Describe the image precisely: text content (transcribe verbatim when asked), UI layout and elements, colors, diagrams, charts, error messages, and code shown in screenshots.
- Answer the specific question asked about the image; if no question is given, provide a clear, structured description of what the image shows.
- Be factual: never invent details that are not visible in the image.
- Keep responses concise but complete; use lists or tables when they help clarity.
- If the image is unreadable, blurry, or appears blank, say so instead of guessing.
Same idea as the command — model pins the agent to qwen3.6-plus — but the syntax differs slightly: sub-agents take custom:<provider-id>:<model-id>. tools limits it to Read, and injectAgentsMd makes sure the agent inherits my global instruction file so it behaves like the rest of the setup.
One ZCode quirk worth knowing before you rely on this: there’s no @vision mention syntax. The @ picker only resolves file paths, so the sub-agent can’t be summoned by typing. The only way to invoke it is programmatically, through the Agent tool — which is exactly what the next section does.
Both pieces work on their own. Neither is automatic yet: /vision needs typing, and the sub-agent needs the main model to remember to dispatch it.
Three facts that shaped the whole design
Before writing anything, I checked what ZCode can actually do:
- There is no model routing. The config file has providers and models. No router, no fallback chain, no “if image, use model X” setting.
- Hooks cannot switch models. ZCode supports seven hook events (
SessionStart,UserPromptSubmit,PreToolUse, and so on), but the actions are limited to adding context, making permission decisions, and blocking. There is no action that says “run this turn on another model.” - Commands can switch models, but only by hand. The
modelfrontmatter on/visionis the only model-switching mechanism, and it requires the user to type the command.
So routing had to happen in one of two places: inside the model (for requests that are pure text) or inside a hook (which can block a request before it’s sent, but can’t redirect it).
Part 1: File paths — routing in the model
If the user mentions an image file path, the message itself is plain text. deepseek receives it fine. From there, routing is just instructions:
## Vision routing
- AUTOMATIC ROUTING: whenever the user's request references an image — a file
path, URL, screenshot, photo, diagram, or any request to describe/read/
transcribe/compare something visual — immediately dispatch the `vision`
sub-agent (Agent tool, subagent_type `vision`) with a self-contained prompt
containing the image location and the user's question. Do NOT answer visual
questions yourself, and do NOT merely warn the user or tell them to run
`/vision`.
These lines go in ~/.zcode/AGENTS.md, which ZCode injects into every session. The main model reads the rule, dispatches the vision sub-agent, and the sub-agent — running on qwen3.6-plus with the Read tool — loads the file and answers. From my side it’s just: “describe this screenshot” with a path, and I get a description back. No command, no model switching.
This covers paths and URLs. It does not cover pasted images, because those never arrive as text.
Part 2: Pasted images — probe first, guess never
A pasted image goes straight into the outgoing request, before the model or any routing logic sees it. The only lever left is the UserPromptSubmit hook, which fires before the request is sent and can block it.
First question: what does the hook actually receive? The docs tell you the event exists. They don’t tell you the payload. Instead of guessing, I deployed the hook in probe mode:
#!/usr/bin/env bash
input="$(cat)"
echo "$input" >> "$HOME/.zcode/logs/hook-probe.log"
exit 0
Registered it in ~/.zcode/cli/config.json (config-file hooks need "enabled": true, by the way — they’re off by default), restarted, pasted a test image, and read the log:
{
"attachmentsSummary": "1:image:/Users/sipamungkas/Documents/CleanShot/CleanShot 2026-08-13 at [email protected]",
"prompt": "what is inside the image, the layout, the ui styles what is called what is the primary color?",
"sessionId": "sess_...",
"hookEventName": "UserPromptSubmit"
}
attachmentsSummary. One field answers both questions: is there an image? and where is it? ZCode stages the attachment as a real file on disk, which turned out to matter more than I expected.
Part 3: The guard hook
Version 1 of the hook just blocked. If attachmentsSummary contains an image, exit with code 2 and write guidance to stderr — ZCode shows it as the block notice. Two details:
- Carve-out for slash commands. If the prompt starts with
/, pass it through. Otherwise/vision <question>with an image attached would get blocked too, and the one working recovery path would be dead. - Hook registration loads at app start, but the script content is re-read on every prompt. That second part made iterating fast — I never had to restart while changing the script, only when changing the config.
A block beats a 400, but it’s still not routing. The user pastes an image, gets told “use /vision instead”, and has to resend. I wanted the paste itself to produce an answer.
Part 4: Making the hook answer
Then the obvious thing clicked. The hook has the image path. The API key for the OpenCode Go provider sits in ~/.zcode/v2/config.json. qwen3.6-plus lives on an OpenAI-compatible endpoint. So the hook doesn’t have to block with a hint — it can read the file, base64 it, call the vision model itself, and put the answer in the notice.
The main model never runs the turn. The answer appears exactly where the block message used to appear.
First live test: HTTP 403, error code: 1010. Not an auth problem — ZCode itself calls the same endpoint fine. 1010 is Cloudflare’s browser-signature ban, and it was triggered by Python’s default Python-urllib/3.x user agent. The fix was one header:
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
Ten minutes of confusion, one line of fix. Standard.
The full setup
Five files in total. The two markdown files from above, plus these three:
1. ~/.zcode/cli/config.json — register the hook (with timeout raised, because a vision call takes 10–40 seconds, not 10):
{
"hooks": {
"enabled": true,
"events": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash /Users/sipamungkas/.zcode/hooks/vision-guard.sh",
"timeout": 120,
"statusMessage": "Analyzing image with the vision model…"
}
]
}
]
}
}
}
2. ~/.zcode/hooks/vision-guard.sh — the hook itself:
#!/usr/bin/env bash
# Vision guard hook — UserPromptSubmit
#
# The main model (deepseek-v4-pro) is text-only. ZCode sends attached images
# to the active model unconditionally, which 400s at the provider, and hooks
# cannot switch models. This hook intercepts image-bearing prompts instead:
# it sends the image(s) to the vision-capable model (qwen3.6-plus via the
# OpenCode Go API, using the credentials in ~/.zcode/v2/config.json) and shows
# the answer in the blocked-turn notice. Slash-command turns (e.g. /vision)
# pass through untouched so the real vision turn can handle them.
#
# Exit 0 = pass, 2 = block (answer or guidance written to stderr).
input="$(cat)"
python3 - "$input" <<'PY'
import base64
import json
import mimetypes
import os
import sys
import urllib.error
import urllib.request
CONFIG_PATH = os.path.expanduser("~/.zcode/v2/config.json")
# Provider id of the OpenAI-compatible "OpenCode Go" entry in config.json.
# Change to whatever provider exposes qwen3.6-plus on your machine.
PROVIDER_ID = os.environ.get("VISION_PROVIDER_ID", "8ec2eacb-47e4-4e8c-853c-19b568fe34ee")
VISION_MODEL = os.environ.get("VISION_MODEL", "qwen3.6-plus")
MAX_IMAGE_BYTES = 15 * 1024 * 1024
MAX_ANSWER_CHARS = 6000
API_TIMEOUT = 100
FALLBACK_HINT = "Re-send with /vision <question> and the image attached (or switch the model selector to qwen3.6-plus / mimo-v2.5)."
def fail(msg):
sys.stderr.write(msg + "\n")
sys.exit(2)
def extract_images(summary):
images = []
for part in summary.split(","):
part = part.strip()
idx, sep, rest = part.partition(":")
if not sep or not idx.isdigit():
continue
kind, _, path = rest.partition(":")
if kind == "image" and path:
images.append(path)
return images
def mime_for(path):
mime, _ = mimetypes.guess_type(path)
if mime and mime.startswith("image/"):
return mime
ext = os.path.splitext(path)[1].lower()
return {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".heic": "image/heic",
".heif": "image/heif",
".tiff": "image/tiff",
}.get(ext, "image/png")
def build_message(prompt, images):
content = []
text = (prompt or "").strip()
if not text:
text = "Describe this image in detail."
content.append({"type": "text", "text": text})
for path in images:
try:
if os.path.getsize(path) > MAX_IMAGE_BYTES:
fail("Image too large to analyze automatically. " + FALLBACK_HINT)
with open(path, "rb") as f:
data = f.read()
except OSError:
fail("Could not read attached image (%s). %s" % (path, FALLBACK_HINT))
b64 = base64.b64encode(data).decode("ascii")
content.append(
{"type": "image_url", "image_url": {"url": "data:%s;base64,%s" % (mime_for(path), b64)}}
)
return [{"role": "user", "content": content}]
def call_vision(prompt, images):
try:
cfg = json.load(open(CONFIG_PATH))
opts = cfg["provider"][PROVIDER_ID]["options"]
api_key = opts["apiKey"]
base_url = opts.get("baseURL", "https://opencode.ai/zen/go/v1").rstrip("/")
except Exception:
fail("Vision auto-answer unavailable: could not read API credentials. " + FALLBACK_HINT)
body = json.dumps({"model": VISION_MODEL, "messages": build_message(prompt, images)}).encode("utf-8")
req = urllib.request.Request(
base_url + "/chat/completions",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": "Bearer " + api_key,
# opencode.ai (Cloudflare) blocks non-browser user agents with 1010.
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=API_TIMEOUT) as resp:
result = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
detail = ""
try:
detail = e.read().decode("utf-8")[:300]
except Exception:
pass
fail("Vision API call failed (HTTP %s). %s\n%s" % (e.code, FALLBACK_HINT, detail))
except Exception as e:
fail("Vision API call failed: %s. %s" % (e, FALLBACK_HINT))
try:
answer = result["choices"][0]["message"]["content"]
if isinstance(answer, list):
answer = "".join(part.get("text", "") for part in answer if isinstance(part, dict))
answer = str(answer).strip()
except Exception:
fail("Vision API returned an unexpected response. " + FALLBACK_HINT)
if not answer:
fail("Vision model returned an empty answer. " + FALLBACK_HINT)
if len(answer) > MAX_ANSWER_CHARS:
answer = answer[:MAX_ANSWER_CHARS] + "\n…(truncated)"
return answer
try:
data = json.loads(sys.argv[1])
except Exception:
sys.exit(0) # Unparseable input: pass.
summary = (data.get("attachmentsSummary") or "").strip()
prompt = (data.get("prompt") or "").lstrip()
images = extract_images(summary)
has_image = bool(images) or ("image" in summary.lower())
is_slash_command = prompt.startswith("/")
if not has_image or is_slash_command:
sys.exit(0) # No image, or a slash-command turn (e.g. /vision): pass.
answer = call_vision(prompt, images)
sys.stderr.write(
"Vision answer (qwen3.6-plus):\n\n%s\n\n"
"— answered automatically by the vision guard hook; the text-only main model did not run.\n"
% answer
)
sys.exit(2)
PY
3. ~/.zcode/AGENTS.md — the routing rules from Part 1, plus a note on what the hook does.
What it looks like now
| Input | What happens |
|---|---|
| Image file path in text | Main model dispatches the vision sub-agent; qwen3.6-plus reads the file and answers |
| Pasted image + question | Guard hook calls qwen3.6-plus directly; answer appears in the block notice |
Pasted image + /vision <question> | Passes through; full vision turn |
| Plain text | Untouched |
Honest limitations
- The auto-answer arrives as a plain-text block notice, not a normal chat reply. No markdown, no follow-ups on that answer — the main model never saw the image or the response. It’s a one-shot.
- Each attached image makes a direct API call. Same model as
/vision, different path, same bill. - Expect 10–40 seconds per answer. The status message (“Analyzing image with the vision model…”) shows while it runs, which at least tells you the hook didn’t hang.
- The registration config only reloads when the app starts. The script itself is re-read per prompt, which is what made iterating on it fast.
The trade-off is the same one hooks always force on you: you can’t redirect, so you have to make the intercept do the work itself. In this case that meant the hook becoming a tiny client for the vision API. It’s a hack with a small surface — two markdown files, one script, one config block, one rules file — and it turned “paste screenshot, get 400” into “paste screenshot, get answer.”
If ZCode ever adds real model routing, this whole thing gets deleted. Until then, it’s five files.
References
- ZCode — official site; made by Zhipu (Beijing Zhipu Huazhang Technology), positioned as the official GLM-5.2 coding tool with multi-agent support.
- ZCode docs: Commands — custom slash commands stored as
.mdfiles under~/.zcode/commands/(Chinese). - ZCode docs: Hooks — the seven hook events (
SessionStart,UserPromptSubmit,PreToolUse,PermissionRequest,PostToolUse,PostToolUseFailure,Stop), including blocking a prompt before it reaches the model (Chinese). - Z.ai model platform and the GLM Coding Plan — the GLM models ZCode ships with by default.
- GLM-5V-Turbo — Z.ai’s multimodal GLM model, described as “specializing in visual programming”.
- DeepSeek API docs — DeepSeek’s official API documentation. The “text-only” claim about
deepseek-v4-pro/-flashis what my provider entry declares in ZCode’s config; the vendor docs don’t advertise image input. - Qwen2.5-VL — the Qwen team’s official vision-language model announcement (image understanding, OCR, object grounding), the model line behind the vision-capable qwen models.
- MiMo-VL — Xiaomi’s open vision-language model line.
- OpenCode agents docs — the primary-agent/sub-agent model in the OpenCode ecosystem, the same pattern ZCode agents follow.