A pure-Python AI coding agent for your terminal, powered by DeepSeek V4.
Subagent-First · Context is Currency · Terminal-native
g023 Code reads your project, searches it, searches the web, runs commands, fetches pages, and looks at images — while telling you exactly what it is doing and what it just cost. Data-heavy work is delegated by default: a subagent reads the file in an isolated context and what comes back to the orchestrator is a structural summary plus a symbol → line-range map. When the summary is not enough, the orchestrator asks for an exact line range and gets verbatim source — so "summary first, raw on request" is the design, not "never raw".
That trade is a real trade, and it is worth being precise about which half of it is measured. The call counts below are asserted by the test suite. Whether delegation is cheaper overall is not: it depends on how many turns the file's text would otherwise have sat in context, which no test here can know. See break-even for the arithmetic and its assumptions.
It talks to DeepSeek exclusively through the Responses API, which is what makes DeepSeek's own native web search a first-class tool of the loop rather than a search provider bolted on the side.
Startup banner, live tool trace, and a permission prompt before anything runs a command. Captured on 1.1.0 — see media/README.md for what has changed since.
- Quick start
- What you see while it runs
- Configuration
- Architecture
- What delegation costs
- Drift signals
- Slash commands
- Verbosity, context & cost
- Web search (native)
- Vision (via Ollama)
- Fetching web pages
- Design principles
- Tests
- Extending
- License
Requirements: Python 3.11 or newer, and a DeepSeek API key.
Drop the folder anywhere you like and run the installer inside it:
cd /path/to/g023-code
./installer.shWindows
cd C:\path\to\g023-code
installer.batIt finds a Python 3.11+, builds a .venv in the folder, installs only the
dependencies you are actually missing, asks once for your API key, writes a
default config.json, and puts g023 on your PATH. Every step checks what is
already true first, so running it again is safe — it is also how you repair a
half-finished setup or add the optional extras later.
Installer options
| Linux / macOS | Windows | Effect |
|---|---|---|
--yes |
/y |
Non-interactive; take the recommended default everywhere |
--key sk-… |
/key sk-… |
Write the key into K.dat instead of prompting ($DEEPSEEK_API_KEY is used if set) |
--with-optional / --no-optional |
/optional / /nooptional |
Decide the extras (vision preprocessing, browser-grade fetching) up front |
--no-venv |
— | Install into the current interpreter instead of a venv |
--no-path |
/nopath |
Do not touch ~/.local/bin, your shell rc, or the user PATH |
--uninstall |
/uninstall |
Remove the launcher shim and PATH entry (the folder, .venv, K.dat and config.json stay) |
Then launch from whatever project you want it to work on:
cd ~/some/project
g023Doing it by hand instead is four commands — pip install -r requirements.txt,
your key on the first line of K.dat, then /path/to/g023-code/g023.sh (or
g023.bat) from a project folder. The launchers prefer .venv/ in the install
folder when one exists, and set two environment variables for you:
| Variable | Meaning |
|---|---|
G023_HOME |
The installation folder — where K.dat and config.json live |
G023_PROJECT_ROOT |
The directory you launched from — your project |
A per-project .g023/ folder holds the SQLite cache and your input history.
prompt_toolkit is listed as a dependency but is not strictly required: with it
you get tab completion, persistent history, and a live status bar pinned to the
bottom of the terminal. Without it the input line falls back to a plain prompt
and everything else behaves identically.
The point is to never wonder what it is doing, or what it just cost.
Tool traces say what happened (708 lines, 9 classes, cached), not what was
sent. The status line after every turn carries the numbers that only mean
something together — context used, cache hit rate, cost of this turn, cost
of the session — because on V4 Flash a cached input token is billed at $0.0028/M
against $0.14/M for an uncached one. That 50x is DeepSeek's price sheet, not
something this harness achieves; what the harness does is avoid breaking the
prefix so more tokens fall on the cheaper side.
Costs shown are computed locally from the token counts the API returns and the
price table in SPEC.md §3.2. They are an estimate of your bill, not
a reading of it.
Anything you type that does not start with / is a prompt. Everything else is
a slash command.
/settings shows every setting in one place and marks which ones persist;
/tools shows what the agent may do and what it has done this turn. Both are
editable from inside the session, and the ·saved keys are written to
config.json next to K.dat, so they follow you across projects.
Defaults out of the box: Flash for the orchestrator and subagents, thinking on at high effort, verbosity low, auto-compaction on at 85% of the window, acting tools set to ask, and vision disabled. Nothing that writes, runs, or leaves your machine happens without a prompt.
Everything — orchestrator, subagents, compaction — goes to a single endpoint:
POST /responses. That is a deliberate choice, not an implementation detail. It
is the only DeepSeek endpoint that exposes the server-side web_search tool, so
using it everywhere is what lets web search be part of the loop.
The Responses API does not take a list of chat messages. It takes items — and the model's own output items go back into the next request verbatim:
messageitems (your input; the model's narration and its final answer)reasoningitems — echoed back unmodified so the model keeps its own chainweb_search_callitems — the server's record of a search it ranfunction_call/function_call_outputpairs, one per tool call
Two rules fall out of that, and every part of the program respects them. A
function_call with no matching output is a hard 400 in either direction, so
compaction, truncation and error recovery all repair pairing before the next
request. And items are echoed byte-identical, because rewriting them both breaks
the prefix cache and desyncs the model's reasoning.
- Orchestrator (
deepseek-v4-flash) keeps only high-level reasoning, tool schemas, and compact summaries. - Subagents handle data-heavy work in isolated contexts:
FileReader→ structural metadata, a symbol → line-range map, and a 2–4 sentence summary, cached by content hashSearcher→ metadata-first grep results, pure local Python — no API callExplore/Plan→ isolated reasoning with thinking modeVision→ an Ollama daemon, local or remote (DeepSeek V4 Flash is text-only)
- Native
web_searchruns server-side inside the model's turn — no client executor, no provider chain - SQLite cache → a repeated read of unchanged bytes is a local lookup and costs no API call
- Prefix-cache-friendly: instructions and tool definitions stay static
- Thinking mode with a
reasoning_effortdial (low / high / max, or off — which maps toreasoning: {"effort": "none"}, the only thing/responsesactually honours)
One model. deepseek-v4-flash is the only model sent to the API, in every
role. /model refuses deepseek-v4-pro because calls to it on /responses
returned an error when this was last checked, and refusing with that reason
attached beats handing you a model every call fails on. If DeepSeek has since
enabled it, that refusal is stale — it is a hardcoded list in config.py, not a
capability probe. Vision is the only other exception, and it does not use the
DeepSeek API at all.
The presentation and command layers are deliberately separate from the agent:
| Module | Responsibility |
|---|---|
commands.py |
The command catalogue as data — one source of truth shared by /help, tab completion, and "did you mean". A command cannot be completable but undocumented. |
ui.py |
Theme, glyphs, gauges, and the compact renderings of tool calls and their results. Falls back to ASCII on terminals that cannot draw box characters. |
prompt.py |
The input line and the shared numbered picker. Degrades cleanly without prompt_toolkit. |
ollama_client.py |
Host resolution, reachability probes, model discovery, and vision inference against any daemon. |
See SPEC.md for the full specification.
Delegating a read is not free — it is a second API call, and sometimes it is the wrong trade. Here is what is actually true, separated by how well it is known.
Measured. These call counts are asserted in tests/test_call_accounting.py,
so they are checked rather than remembered:
| Operation | API calls |
|---|---|
SearchContent, any size |
0 — pure local regex |
ReadFile, Python under 12k chars, no focus |
0 — summarised from the local AST |
ReadFile, same bytes again (cache hit) |
0 |
ReadFile with start_line/end_line |
0 — returns verbatim source |
ReadFile, first read of a non-Python file, a file over 12k chars, or any focused read |
1 |
Arithmetic, given assumptions. A 40 KB file is roughly 10k tokens. Inlined into the orchestrator, it is re-sent on every subsequent turn — cheaply, if it stays in the cached prefix, at $0.0028/M. Summarising it costs one Flash call (~10k in, ~300 out ≈ $0.0023 at miss rates) and leaves ~300 tokens in context instead of 10k. Which wins depends on how many turns follow and whether the prefix survives them, and those depend on your session. Delegation is not unconditionally cheaper; it is unconditionally smaller, and smaller context is what keeps the model on task and the window from filling.
Not measured. Nobody has run a controlled A/B of end-to-end session cost. The flag to do it exists:
G023_READFILE_RAW=1 g023 # ReadFile returns raw content, no subagentWith it set, whole-file reads return the file's bytes and cost zero API calls —
the baseline a plain agent loop would have. Run the same task script with and
without it and compare /cost. Until someone does that on their own codebase,
the honest claim is the one above: fewer tokens in the window, an extra call to
get there.
When the summary is not enough. The known weakness of summary-first is that a summary can silently drop the one detail that mattered, and nothing detects that. What exists instead is a cheap way out: every Python summary carries
"symbols": {"Orchestrator.run_turn": [412, 498], "parse_reply": [199, 262]}with line numbers taken from the AST (decorators included), not from the model.
Escalating is then one targeted ReadFile with those two numbers, which returns
verbatim text and costs no API call. That turns "the summary was too thin" from
a re-read of the whole file into a precise second look. It does not turn it into
something the orchestrator notices automatically — that detector does not exist.
The client does not validate responses on purpose: an unknown field has to reach
the model without a client release. The price is that a renamed field fails
quietly — output_text returns "", the model looks silent, and nothing
raises. Silent degradation, not a crash, is the realistic worst case here.
/signals (alias /drift) shows the three cheapest observations that would move
first:
| Signal | What it catches | What it cannot tell you |
|---|---|---|
Unknown item type in a response |
An additive API change, at the earliest moment it is visible | Whether it matters |
Empty output with no incomplete_reason |
What a renamed content field looks like from outside | Whether the model simply said nothing |
| Prefix hit rate diffed against previous days | Anything that started breaking the prefix | Whether the cause is your prompt, the tool list, or the server |
The hit rate is stored per day in .g023/cache.db and compared against the
trailing baseline, so the comparison survives restarts. It needs two prior days
before it will call anything a baseline, and says so until then. /cache clear
does not erase it — it is a record of what happened, not cached data.
None of these is a diagnosis. A model behaviour change, a schema change, and drift in your own prompts all present identically from here: same call, worse output, no error. These signals make the change visible and date it; they do not separate the causes.
Commands that take a fixed set of options open a picker when typed bare, so
/vision is as usable as /vision qwen3.5:2b — there is nothing to memorise.
/help <command> explains any one of them in full.
Session
| Command | Action |
|---|---|
/help [command] |
List commands, or explain one in detail |
/status |
One-screen dashboard: model, context, cost, vision, cache |
/clear |
Reset the conversation and usage counters |
/exit |
Quit |
Model
| Command | Action |
|---|---|
/model [flash] |
Show or set the orchestrator model — flash is the only one the Responses API serves |
/thinking [low|high|max|off] |
Set reasoning effort, or turn thinking off |
/verbose [low|mid|high] |
How much detail to print while working |
/steps [n|off|default] |
Cap the tool steps one turn may take before it gives up (default 100; off for no cap, with progress printed every 10 steps) |
Context & cost
| Command | Action |
|---|---|
/compact [focus] |
Summarise the history (micro = free local pass, auto on|off) |
/context |
Break down what is occupying the context window, by role and size |
/cost |
Token usage and spend, split by cache hit/miss, with a per-turn sparkline |
/settings [save|reset] |
Every setting, marking which persist; save or restore defaults |
/signals (alias /drift) |
Prefix hit rate against its own history, unknown item types, unexplained empty responses |
Tools
| Command | Action |
|---|---|
/tools |
List tools, their permission level, and how often each ran |
/tools <tool> allow|ask|block |
Change one tool's permission |
/fetch <url> |
Fetch a URL yourself, with the cache/fresh prompt |
/fetch status |
Report how closely fetches imitate a real browser |
/cache [stats|web|clear] |
Inspect and purge the local SQLite caches |
Vision
| Command | Action |
|---|---|
/ollama |
Status: host, version, latency, installed model count |
/ollama host <addr> |
Point vision at another machine (tested before saving) |
/ollama host default |
Fall back to OLLAMA_HOST, then localhost |
/ollama models |
List installed models, sizes, quantisation, vision capability |
/ollama test [model] |
Run a real inference — proves the whole path, not just the port |
/ollama ps |
What the daemon currently holds in VRAM |
/vision |
Interactive picker of installed vision models |
/vision <model> · /vision off |
Enable a specific model, or disable |
Work
| Command | Action |
|---|---|
/goal <text> |
State a high-level objective; runs one turn at max reasoning effort |
/verbose has three levels, persisted to config.json:
| Level | Shows |
|---|---|
low (default) |
The answer, plus a one-line trace of every tool call |
mid |
↑ plus each tool's outcome and duration, and the per-turn token/cost line |
high |
↑ plus the orchestrator's reasoning excerpts and per-iteration token counts |
/context answers the question /cost cannot: what is filling the window.
If tool results dominate, /compact micro clears the stale ones for free; if
it is real conversation, /compact summarises it with the cheap model in an
isolated context. The command tells you which of the two applies. Compaction
also runs automatically once the context passes compact_threshold (85%) —
turn that off with /compact auto off.
Every API call — orchestrator and subagents — reports its token usage to a
single tracker, so /cost and /settings show the cache-hit / cache-miss /
output split per model and price it from the SPEC §3.2 table.
Web search is DeepSeek's own, not a search API wired in behind it. There is
no provider chain, no key to supply, no rate limit to nurse, and no
/websearch command — because there is nothing on this side to configure.
The entire integration is one line in tools/schemas.py:
WEB_SEARCH_TOOL = {"type": "web_search"}That marker is appended to the tool list. No name, no parameters, no executor. The server takes it from there.
The search happens inside the model's turn, on DeepSeek's infrastructure, before the response comes back. g023 never sees a tool call for it and never returns a result for it. What arrives instead is a record of what the server did, which shows up in the trace like any other tool:
● searching the web…
✓ web_search deepseek responses api web_search tool, deepseek v4 pricing
✓ web_search read platform.deepseek.com/docs/…
A few consequences are worth stating plainly:
- It never asks permission. There is no moment at which g023 could
interpose a prompt — the search is over by the time the response exists. So
web_searchhas no entry in/tools. Giving the model the tool is the decision; each individual search is not. Everything that does leave your machine under g023's own control —FetchUrl— still asks every time. - It can search several times per turn, following up on what it finds.
- Findings persist. The server's
web_search_callitems are carried in history like everything else, so what it learned survives across tool round-trips, across turns, and across/compact. - Slow searches are visible. The server can be out on the web for a while
without emitting anything, so g023 draws a live
searching the web…line rather than letting it read as a hang.
Just ask — what changed in the DeepSeek Responses API this month? — and it
searches when it decides it needs to.
DeepSeek V4 Flash is text-only, so image analysis is delegated to an Ollama vision model. Vision is disabled by default — enable it once and the choice persists.
/vision # interactive: lists installed models, pick one or disable
/vision qwen3.5:2b # enable a specific model directly
/vision off # disable (the default)
/vision status # show the current setting
- Only models Ollama reports as
vision-capable are listed (if none are, every model is listed so you can force a choice). Sizes are shown so you can stay inside your VRAM budget — on a 12 GB GPU anything up to ~8 GB is comfortable. - While vision is disabled the
AnalyzeImagetool is not even offered to the orchestrator, so it never proposes a call it cannot fulfil. - Images are downscaled to 1024 px on the longest edge (needs
pillow; skipped if not installed) and answers are cached by image hash + question, so re-asking the same question about the same image is a local SQLite lookup rather than another inference. A different question about the same image is a new inference — the question is part of the key.
Once enabled, just ask: analyze screenshot.png — what's the error?
The daemon does not have to be local — the usual reason to move it is that the GPU is somewhere else.
/ollama host 192.168.1.50 # bare IP: :11434 is appended for you
/ollama host gpu-box:11434
/ollama host https://ollama.example.com # behind a TLS proxy, port left alone
/ollama host default # back to OLLAMA_HOST, then localhost
Addresses are forgiving: a missing scheme becomes http://, a missing port
becomes :11434. The host is tested before it is saved, and if it does not
answer you are told why — connection refused reads differently from a timeout —
and asked whether to save it anyway.
Precedence, highest first: the vision_host setting (what /ollama host
writes) → the OLLAMA_HOST environment variable → http://localhost:11434.
/ollama always shows which one is in effect.
Three commands make a remote setup diagnosable rather than a guessing game:
| Command | Answers |
|---|---|
/ollama |
Is anything listening, what version, how far away (latency), how many models |
/ollama models |
What that machine actually has — names, sizes, quantisation, vision capability |
/ollama test |
A real inference round-trip: g023 sends a generated image and checks the answer. This proves the whole path — encoding, transport, and the model genuinely being image-capable — rather than just that the port is open. |
Warning
For a remote daemon to accept connections it must be bound to the network
(OLLAMA_HOST=0.0.0.0:11434 ollama serve) and the port must be open.
Ollama has no authentication. Anyone who can reach that port can use the
GPU and read whatever is sent to it — keep it on a trusted network, behind an
SSH tunnel (ssh -L 11434:localhost:11434 gpu-box, then leave the host at
default), or behind an authenticating reverse proxy.
Distinct from web search: that runs on DeepSeek's side and
you never see the request. FetchUrl is g023 reaching out from your machine,
to a URL you or the model named — so it is held to a different standard.
FetchUrl reads a page and hands back readable text rather than raw HTML, in
keeping with the context budget. Two things make it different from a plain
requests.get.
It asks first, every time. A fetch leaves your machine and touches someone
else's server, so it is never on the allow list. When a cached copy exists the
prompt is a three-way choice rather than yes/no:
Permission required to fetch https://example.com/pricing
engine: curl_cffi · requested mode: auto
Cached copy available — fetched 12m ago, HTTP 200, 48,210 chars
Use cached copy, fetch fresh, or deny? [c/f/d] (c):
Your answer overrides whatever the model asked for. The model can also request
a mode itself — auto (cache if younger than max_age), fresh, or cache
(never touch the network) — and cache mode is the cheap way to re-read a page
at a larger max_chars without a second request.
It presents itself as an ordinary browser. Requests carry a real browser's
headers in the order that browser sends them, negotiate HTTP/2, and keep
cookies per domain in .g023/cookies.json so a second visit looks like a
returning user rather than a stranger. Requests to the same host are spaced out
instead of fired back-to-back. Nothing identifies g023.
The weak point is TLS: Python's handshake has a recognisable fingerprint, and a
UA claiming Chrome over a Python TLS stack is a contradiction that fingerprint
checks notice. Installing curl_cffi fixes it properly — it reproduces Chrome's
actual handshake:
pip install curl_cffi # then: /fetch status| headers | HTTP/2 | TLS (JA4) | |
|---|---|---|---|
curl_cffi |
Chrome order | yes | t13d1516h2… — matches Chrome |
httpx + h2 |
Chrome order | yes | generic Python |
httpx alone |
Chrome order | no | generic Python |
/fetch status always tells you which of these you are on, and the JA4 column
reports what curl_cffi impersonates rather than a measurement — point /fetch https://tls.peet.ws/api/all at it to see your own fingerprint on the wire.
What is not claimed: that this gets you past any particular bot defence. Those systems weigh behaviour, IP reputation and history alongside the fingerprint, and they change. A handshake that matches Chrome removes one obvious tell; it is not a guarantee about any site, and any site may block you anyway.
Note
There is no JavaScript engine. Pages that build their body client-side
return the shell. Use a server-side check like tls.peet.ws/api/all to see
what your requests actually look like on the wire.
- Context is currency — keep raw files and search dumps out of the orchestrator unless they were asked for by line range.
- Subagents are the treasury — heavy I/O happens in isolated, minimal contexts, and the escalation path back to verbatim source stays open.
- Cache everything cacheable — file hashes, vision Q&A, fetched pages.
- Do it off-API where it is free — search the filesystem locally, see with a local GPU, and spend tokens only on reasoning.
- Thinking mode is a dial, not always-on max.
- Say what happened, not what was sent — and always say what it cost.
- Claim only what is checked. Numbers in this documentation are either asserted by a test, quoted from a published price sheet with attribution, or labelled as an estimate. Where something is not measured, it says so.
pip install pytest && python3 -m pytest tests/ -qNo plugins beyond pytest — async tests run through a small hook in
tests/conftest.py, and every test points G023_PROJECT_ROOT at a temporary
directory, so a run never reads your real cache and never touches the network.
| File | What it holds to account |
|---|---|
test_call_accounting.py |
The API-call table above, counted against a stub client |
test_file_reader.py |
Symbol ranges are exact and in-bounds; truncation is declared; local AST facts outrank the model's |
test_drift_signals.py |
Each signal fires on the shape that matters and stays quiet on ordinary turns; hit-rate history survives restarts |
test_history_integrity.py |
Tool-call pairing survives rollback and repair; output items are echoed back byte-identical |
test_usage_accounting.py |
Cost arithmetic, both usage spellings, worst-case assumption when the split is unreported, command/handler contract |
What these do not cover: anything requiring the real API, end-to-end session cost, or whether a summary was good enough for the question asked.
- New slash command — add a
Command(...)toCOMMANDSincommands.pyand a matchingcmd_*method onCLI. Help text, tab completion, and "did you mean" pick it up automatically;check_handlers()fails at startup if the method is missing, so the two halves cannot drift apart. - New tool — add a schema in
tools/schemas.pyplus an executor or a subagent route. Give it an entry inui.TOOL_STYLESand a case inui.describe_call/describe_resultso its trace reads like the others. - Vision backends —
ollama_client.pyandsubagents/vision.py; the router dispatchesAnalyzeImagethere. - Permissions —
permission_default(allow|ask|block) sets the starting level for the tools that act:Bash,WriteFile,Agent,AnalyzeImage. Read-only tools stayallow, andFetchUrlstill asks unless the default isblock. Per-tool overrides live intools/registry.pyand/tools.
Environment overrides:
| Variable | Effect |
|---|---|
G023_HOME |
Installation folder — where K.dat and config.json live |
G023_PROJECT_ROOT |
The project being worked on |
OLLAMA_HOST |
Vision daemon, when vision_host is unset |
G023_ASCII=1 |
Force plain-ASCII output on terminals that mangle box drawing |
G023_READFILE_RAW=1 |
ReadFile returns raw content instead of delegating — the A/B baseline described in What delegation costs |
MIT — built for the g023 workflow.


