Most coding agents are built around a vendor. OneLoop is built around a loop.
One loop, six tools, no framework. Any model that speaks an OpenAI-compatible protocol can drive it. Models are chosen per run and switched mid-conversation, and everything said lands in one append-only session file you can grep.
Deeper: Overview · Architecture · Style guide
- One session, many models.
/modelswitches mid-conversation./cc,/ca, and/agask Claude Code or Antigravity about the conversation without switching your model at all. Plan with the expensive one, implement with the cheap one, take a second opinion from a third. - Subscriptions you already pay for. Claude Code and Antigravity are driven through their own CLI binaries, so their answers spend quota you have already bought rather than metered tokens. ChatGPT Plus/Pro is signed in to directly over OAuth and then behaves as an ordinary provider.
- Any OpenAI-compatible endpoint. A provider is one URL, one credential, one connection pool; the models listed under it get short aliases. The same code serves llama-server, OpenRouter, OpenCode Go, and anything else that speaks Chat Completions.
- Nothing is compacted. No summarization, no silent truncation. Every message, tool call, and result is appended to a JSONL file, so the session is what was said, in order — and it is greppable on disk.
- Small enough to read. Six tools, about eight thousand lines. No plugins, no hooks, no middleware — the whole agent reads in a sitting.
Tools: read (file contents), write (create or overwrite), edit
(exact-match replace), bash (run a command), elisp (live Emacs state through
emacsclient), and skill (load a markdown playbook on demand).
Web search is not a tool, because it does not need to be one. A hosted provider
that sets "web_tools": true runs the search and the page fetch server-side and
bills per use. Otherwise the model searches the way you would: a terminal client
like ddgr — DuckDuckGo from the command line —
run through bash, with curl for the full page. Put the commands you like in
a skill, and the model loads them when the task calls for it.
./ols # start a local model — no key, no account, nothing leaves the box
./ol # interactive REPLReach a hosted model only when you want one:
./ol login openrouter # API key
./ol login opencode-go # OpenCode Go API key
./ol login openai # ChatGPT Plus/Pro, in a browserOne-shot and piped runs are stateless — no session file, no AGENTS.md:
./ol "explain this codebase"
git diff | ./ol "summarise these changes"| Command | Effect |
|---|---|
/help |
current model, models, tools, session file, message count, loaded instructions |
/model |
list configured models and pick one |
/model <alias> |
switch straight to that alias |
/reload |
reload ~/.oneloop/config.json without restarting |
/clear |
wipe context and start a fresh session file |
/cc [focus] |
ask Claude Code to critique the discussion |
/ca <question> |
ask Claude Code about the discussion, or to implement something |
/cn <prompt> |
ask Claude Code with no discussion history |
/ag, /aga, /agn |
the same three for Antigravity |
A command can carry a prompt across the ; boundary, which is what makes the
multi-model workflow quick:
(flash)> /model chatgpt; plan how to split the parser into modules
(chatgpt)> /model flash; implement step 1 of that plan
(flash)> /ca is the plan still consistent with what we implemented?
/cc and /ca send the transcript, including tool arguments and results, as
untrusted reference material; /cn sends only your prompt. Nothing is resumed
on the other side — each request is a fresh review — and the answer comes back
as a reference message in this session, so the current model can reason about
it. /help lists the Claude Code and Antigravity commands only when their
binaries are on PATH. Ctrl+C stops a running request, or discards the draft
at the prompt. An unknown /word is sent as an ordinary prompt — a message can
legitimately start with a path.
A provider is a place to send requests — a base URL, a protocol, and shared credentials when it needs them. A model is one thing that place will run. OpenRouter is a single provider serving hundreds of models, so its URL and API key are stated once and the models listed under it. ChatGPT's Codex backend instead shares a renewable OAuth grant across its models.
Two protocols are spoken: Chat Completions for everything above, and the
Responses API for the Codex backend — which is the only way into a ChatGPT
subscription. A provider's api field names which.
Every model has a short alias, which is the name used everywhere else:
/model flash rather than the wire id it resolves to. Aliases are unique across
all providers, so naming one never has to say which provider it meant.
Config is ~/.oneloop/config.json, written from a template on first run —
shown here with a second OpenRouter model added:
{
"default": "qwen",
"providers": {
"local": {
"base_url": "http://localhost:8080/v1",
"models": {
"qwen": { "id": "qwen" },
"glimmer": { "id": "glimmer" }
}
},
"openai": {
"base_url": "https://chatgpt.com/backend-api",
"api": "codex",
"models": {
"chatgpt": { "id": "gpt-5.6-sol", "reasoning_effort": "medium" }
}
},
"openrouter": {
"base_url": "https://openrouter.ai/api/v1",
"api_key_env": "OPENROUTER_API_KEY",
"web_tools": true,
"models": {
"flash": { "id": "~deepseek/deepseek-v4-flash-latest" },
"pinned": { "id": "deepseek/deepseek-v4-flash-0731" }
}
}
}
}Credentials live in ~/.oneloop/auth.json (mode 0600), never in the config.
An API key is read from its environment variable first and from auth.json
second, so exporting a variable is the caller saying "use this". A subscription
has no key to name: ol login openai runs an OAuth authorization-code flow with
PKCE and stores the grant, which renews itself in the background afterwards —
one-time step, same account and quota the Codex CLI uses.
Setting a model, narrowest scope first: /model affects the session,
ONELOOP_MODEL a single run, and default in the config every run. Only the
last is persistent.
The local provider expects an OpenAI-compatible server on port 8080. This
flake builds and runs one:
./olsWith no model present it offers to download one (~20 GB, into ~/models/) and
starts the server once it lands. The download resumes if interrupted, and lands
as .part until complete — an aborted transfer never looks like a usable model.
To use different weights:
./ols -- /path/to/other.gguf
# or: ONELOOP_LOCAL_MODEL=/path/to/other.gguf ./olsThe offer is only made for the default, and only with a terminal attached: a
script or CI run gets the curl command printed instead of a surprise 20 GB
transfer. It wraps llama.cpp's Vulkan build with flags measured against
Qwen3.6-35B-A3B — see the comments in ols for what each one is worth.
ONELOOP_LOCAL_PORT moves it off 8080.
Sessions are linear append-only files at .oneloop/sessions/YYYY-MM-DD.jsonl;
/clear rotates to -001, -002, and so on, and nothing is ever deleted. On
restart the latest session for today opens automatically. A tool call left
without a result — after a Ctrl+C — is closed out on open, because providers
reject a conversation containing dangling calls.
Nothing is summarized and nothing is dropped behind your back. A conversation
grows until the server refuses it, and the refusal names the fix: /clear, or a
model with a larger window. A model may declare a context_window, in which
case the prompt shows roughly how much is still free.
Skills are markdown playbooks under ~/.oneloop/skills/ and
.oneloop/skills/ (project wins on a name collision). They are not in the
system prompt — the model sees a name and description for each and calls
skill("name") when the task matches. Anything that is a command plus
instructions — a search client, a formatter, a house checklist — belongs here
rather than in the core. If no skill files exist, the tool is not registered at
all.
OneLoop reports the active model and turn state via OSC 7501 (Program Status
Protocol) when running under Emacs comint (or any terminal that sets
INSIDE_EMACS=...,comint). This lets any client — editor, terminal, tooling —
read the current model and whether a turn is running, without scraping the
prompt.
ESC ] 7501 ; state=working|idle : app=oneloop : title=<base64 model> ESC \
state—workingwhile a turn runs,idlebetween turns. OSC 7501 defines further states (done,blocked,error,clear); OneLoop emits only these two.title— the model alias (base64-encoded UTF-8), so the client always knows the current model even in idle.
All optional:
ONELOOP_MODEL— run this one model instead of the config'sdefaultONELOOP_MAX_ITERATIONS— cap on agent-loop iterations per prompt (default:72)ONELOOP_MAX_RETRIES— attempts before offering another model (default:5)ONELOOP_WEB_TOOLS— override a provider'sweb_toolsfor this run
nix develop
cargo checkThis project is personal software that I maintain for my own use. I do not accept pull requests.
If it's useful to you: fork it, copy the code, adapt it freely. The only ask is that you keep the copyright notice intact (MIT license).
MIT — see LICENSE.