In September 2026 I got an email saying Codex could use Ollama. A few months earlier I had already managed to run a local model inside Cursor, OpenCode, and Crush, then wrote up the arrangement in Running Local LLMs Inside Cursor, OpenCode, and Crush. I had a working LM Studio server on my network. I did not particularly want another local-model server just to satisfy the preferences of one more client.
So I tried pointing Codex at what I already had.
LM Studio was running at 192.168.1.118:10247 with gemma-3-27b-it, a 27-billion-parameter instruction-tuned Gemma model. The model name is less memorable than the network address, which is probably the correct order for these things.
The short version is that Codex works with it. It can chat, run shell commands, create files, and read them back. Getting a response from the model was easy. Getting Codex to stop calling OpenAI and GitHub in the background was the part that made the experiment worth writing down.
The first failure
LM Studio exposes an OpenAI-compatible API, so I started with the ordinary checks: request /v1/models, then send a small request to /v1/responses. Both worked. Codex supports custom model providers in config.toml, including a base URL, an environment variable for the API key, and wire_api = "responses". The Codex advanced configuration documentation supplied most of the pieces.
My first real Codex session still opened with this banner:
model: gpt-5.6-sol low
directory: /Users
Then I typed ds, because all serious debugging starts with random letters, and LM Studio returned:
{
"error": {
"message": "Invalid type for 'input'.",
"type": "invalid_request_error",
"param": "input",
"code": "invalid_union"
}
}
The error came from state I had forgotten about. I had used /model in an earlier session, and Codex had written gpt-5.6-sol and its reasoning level back into the isolated configuration. My provider still pointed at LM Studio. Codex was therefore asking a local server to run an OpenAI model it did not have.
I fixed that by pinning both the provider and model on the command line when starting Codex. Command-line values win over persisted model state and project configuration:
/Applications/ChatGPT.app/Contents/Resources/codex \
--model "gemma-3-27b-it" \
--config 'model_provider="lan_lmstudio"'
Once the banner showed gemma-3-27b-it, the same Codex binary completed a real turn through LM Studio. The basic model path was done. The network path was not.
Finding the callouts
Codex was now using my local model, but it was still making requests to the internet. I wanted to know what those requests were before deciding whether any of them mattered. “Something tried to reach the network” is an observation. “Codex requires the cloud” is a conclusion, and at that point I had not earned it.
I wrote a small blocking HTTP proxy that records each destination and returns 502 Bad Gateway. I routed normal HTTP and HTTPS traffic through it while excluding 192.168.1.118, allowing the LM Studio request to continue across the LAN. The first run found four destinations:
| Destination | What Codex was doing |
|---|---|
chatgpt.com |
Fetching /backend-api/plugins/featured?platform=codex |
github.com |
Running git ls-remote against openai/plugins.git |
api.github.com |
Trying the plugin repository API after Git failed |
ab.chatgpt.com |
Sending metrics to /otlp/v1/metrics |
Codex completed the LM Studio response while the proxy blocked all four. That separated the model request from the startup housekeeping: the callouts were real, but none was required for the turn to finish.
I checked the binary as well. It contains the exact metrics URL, https://ab.chatgpt.com/otlp/v1/metrics, along with the curated plugin repository and fallback API URLs. I still find a useful URL hiding in strings output more satisfying than I should.
The obvious setting to try was:
[features]
remote_plugin = false
That was not enough. Codex continued contacting ChatGPT and both GitHub endpoints because the remote plugin catalogue and the plugin subsystem are controlled separately. The startup synchronizer stopped only after I disabled both:
[features]
plugins = false
remote_plugin = false
The metrics request disappeared after I disabled analytics:
[analytics]
enabled = false
OpenAI documents the analytics switch and feature flags in the Codex configuration reference. The documentation told me which controls existed. The proxy told me which controls mattered to this version.
Keeping the configuration isolated
I gave the local setup its own CODEX_HOME at ~/.codex-lmstudio. I already have normal Codex and LLM gateway configurations, and I did not want an experiment quietly replacing either one. Isolation also made the later secret scan much less ambiguous: I could inspect one small directory instead of wondering which Codex profile had written what.
This is the complete ~/.codex-lmstudio/config.toml I ended up with:
model = "gemma-3-27b-it"
model_provider = "lan_lmstudio"
model_reasoning_summary = "none"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
check_for_update_on_startup = false
web_search = "disabled"
[model_providers.lan_lmstudio]
name = "LAN LM Studio"
base_url = "http://192.168.1.118:10247/v1"
wire_api = "responses"
env_key = "LMSTUDIO_API_KEY"
request_max_retries = 0
stream_max_retries = 0
stream_idle_timeout_ms = 120000
[sandbox_workspace_write]
network_access = false
[analytics]
enabled = false
[feedback]
enabled = false
[features]
plugins = false
remote_plugin = false
shell_snapshot = false
skill_mcp_dependency_install = false
[otel]
exporter = "none"
[shell_environment_policy]
inherit = "core"
ignore_default_excludes = false
Some of those settings did not produce a visible request during my short test. I disabled update checks, feedback, web search, dependency installation, and the OpenTelemetry exporter anyway because none of them belongs in the offline profile I was trying to build. I also set the model request retries to zero. If the machine across the room is asleep, asking it the same question several more times mostly extends the portion of the test where I stare at it.
The two network boundaries are worth separating. Codex itself can reach the configured model provider on the LAN. Commands run by the model remain inside a workspace-write sandbox with network access disabled. That lets the agent edit and test a repository without giving every generated shell command its own route to the internet.
Disabling plugins does mean that plugins are unavailable in this profile. That is a real trade-off, not a magic offline checkbox. Plugin startup accounted for three of the four callouts, so I accepted it. Normal local skills are separate and still load, including one broken skill on my machine that complains about missing YAML frontmatter every time Codex starts. I’ll fix that eventually, which is what people say about every small problem that has successfully become background noise.
The key that escaped through a side door
The API key produced the most useful failure in the whole setup.
I did not put it in config.toml. The provider reads LMSTUDIO_API_KEY, and my launcher prompts for the value with Zsh’s silent read:
read -rs "LMSTUDIO_API_KEY?LM Studio API key: "
print
export LMSTUDIO_API_KEY
That kept the key out of the configuration file and shell history. I scanned the test files afterward and found it anyway.
Codex has a shell snapshot feature that records the launch environment so later commands can start faster. One generated snapshot contained LMSTUDIO_API_KEY. I had protected the secret from the obvious storage locations, then handed it to a performance feature through the side door.
I deleted the snapshot and added:
[features]
shell_snapshot = false
[shell_environment_policy]
inherit = "core"
ignore_default_excludes = false
The shell environment policy enables Codex’s automatic filtering of variable names containing terms such as KEY, SECRET, and TOKEN before it launches model-generated commands.
I verified the boundary from inside a Codex turn. The host process still had the key and could call LM Studio. A shell command requested by the model reported KEY_UNSET. After cleanup, a recursive scan of the launcher, configuration, logs, and isolated Codex home found no copy of the value.
This is the part I would test rather than trust. I would not have guessed that a shell snapshot was where the key would land, which is exactly why checking only the places where I expected it to be was insufficient.
The launcher
I wrapped the setup in ~/.local/bin/codex-lmstudio so I would not have to remember the environment and command-line overrides every time:
#!/bin/zsh
set -eu
lmstudio_codex_home="${HOME}/.codex-lmstudio"
desktop_codex="/Applications/ChatGPT.app/Contents/Resources/codex"
if [[ ! -x "${desktop_codex}" ]]; then
print -u2 "Codex runtime not found at ${desktop_codex}"
exit 1
fi
if [[ -z "${LMSTUDIO_API_KEY:-}" ]]; then
if [[ ! -t 0 ]]; then
print -u2 "LMSTUDIO_API_KEY is unset and no interactive terminal is available."
exit 1
fi
read -rs "LMSTUDIO_API_KEY?LM Studio API key: "
print
export LMSTUDIO_API_KEY
fi
export CODEX_HOME="${lmstudio_codex_home}"
exec "${desktop_codex}" \
--model "gemma-3-27b-it" \
--config 'model_provider="lan_lmstudio"' \
"$@"
Now I can move into a project and start an interactive session with:
cd /path/to/a/project
codex-lmstudio
The same launcher works for a one-off task:
codex-lmstudio exec "Review the current changes without editing anything."
It asks for the key when the variable is not already set, and the value is not displayed. When Codex exits, the key goes with the process unless I exported it in the parent shell first.
Proving the useful part worked offline
For the final test, I pointed all external HTTP and HTTPS traffic at the blocking proxy. The proxy recorded its own startup line and nothing else.
My first prompt asked the model to return a fixed string. It replied with OFFLINE_FINAL_OK. That proved the Responses API path worked, although a chatbot returning a password-shaped phrase is still some distance from a coding agent doing useful work.
The next prompt asked Codex to create a file containing LM_STUDIO_OFFLINE_AGENT_OK, read it back, and verify its bytes. The local model produced two shell calls. It created the file, read it, ran od -c, and correctly reported one trailing newline. The proxy still had no external destination to report.
Codex warns that gemma-3-27b-it is an unknown model and falls back to generic metadata. The tiny file test consumed 1,331 tokens, and the model has occasionally spent close to two minutes producing a very short answer. This is a 27-billion-parameter model running locally. I did not expect autocomplete latency. Still, waiting two minutes gives a person plenty of time to reconsider how urgently they needed that file.
The setup works. More important, the test identified what “works offline” means here: Codex can use the LM Studio server across the LAN, execute a small agentic file task, keep generated shell commands off the network, and finish without successful calls to OpenAI or GitHub. Plugins are gone, the model metadata is generic, and patience remains part of the hardware requirement.
My next test is less elegant and probably more honest. I am going to unplug the WAN connection while leaving both machines on the same LAN, use the setup on a real project for an afternoon, and find out what I miss first. My guess is plugins. It may be patience.