Proton Lumo gives me a private model for real coding work, a backend my agentic coding tool can drive, with no per-token bill. It is a privacy-first assistant from the Swiss company behind Proton Mail and VPN: zero-access encryption, no chat logs used for training, nothing sold. Because I already pay for a Proton plan that bundles it, pointing my coding tool at it costs nothing extra per request. Private by design, flat-rate, and inside the tool I already live in.
I had mostly written Proton's AI off. Lumo 2.0, which landed at the end of June 2026, is what changed that. It brought a new architecture, real reasoning, memory, stronger web search, and image generation, and it pulled the assistant close enough to the frontier models that I started using it for actual work instead of curiosity. The x-pm-appversion value in the config below, [email protected], is that release. Reassessing a platform I had dismissed is part of why I bothered wiring it in at all.
The economics are the point
Lumo Plus is 12.99 USD a month, or about 120 USD a year: unlimited chats, longer history, bigger uploads. Proton Visionary is the top-tier legacy Proton plan at roughly 40 EUR a month, and it bundles every premium Proton service, Lumo included. A Visionary account gets served the Plus, unlimited tier in practice. So if you already carry Visionary or Lumo Plus, this is a flat-rate coding backend, not a metered one. There is no token counter running while you work. That is the difference between a private model I can afford to leave running and a private model I have to ration.
What Lumo exposes
Lumo is a chat product with no official developer API. Its web client, though, talks to an endpoint at https://lumo-api.proton.me/api/ai/v1, and it identifies itself with an HTTP header, x-pm-appversion, set to something like [email protected]. Point an OpenAI-compatible client at that base URL, send that app-version header, and authenticate with a token from a logged-in Lumo web session, and Lumo answers like any other chat-completions backend, SSE streaming included.
That is the whole mechanism, and it is the same one the community projects lumo-tamer (by ZeroTricks, GPLv3, an OpenAI-compatible proxy and CLI with experimental tool support) and pyLumo already use. Credit to both. I did not discover this, I wired it into a coding tool.
Wiring it into opencode
opencode is an open-source, terminal-based agentic coding tool that accepts any OpenAI-compatible provider through the @ai-sdk/openai-compatible package, configured in ~/.config/opencode/opencode.json. I add a provider named lumo with options.baseURL set to the Proton endpoint, options.apiKey pulled from an environment variable (LUMO_API_KEY, never inline in the file), and options.headers carrying the x-pm-appversion header. I expose two models, lumo-plus-v1 and lumo-basic-v1, both at 128k context (131072 tokens), 8192 max output tokens, vision enabled, and tool calls enabled. Then I set the top-level model to lumo/lumo-plus-v1, or leave a local model as the default and pick Lumo from the model menu when I want it.
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"lumo": {
"npm": "@ai-sdk/openai-compatible",
"name": "Proton Lumo",
"options": {
"baseURL": "https://lumo-api.proton.me/api/ai/v1",
"apiKey": "{env:LUMO_API_KEY}",
"headers": { "x-pm-appversion": "[email protected]" }
},
"models": {
"lumo-plus-v1": {
"name": "Lumo Plus (128k, vision, tools)",
"tool_call": true,
"attachment": true,
"modalities": { "input": ["text", "image"], "output": ["text"] },
"limit": { "context": 131072, "output": 8192 }
},
"lumo-basic-v1": {
"name": "Lumo Basic (128k, fast)",
"tool_call": true,
"attachment": true,
"modalities": { "input": ["text", "image"], "output": ["text"] },
"limit": { "context": 131072, "output": 8192 }
}
}
}
},
"model": "lumo/lumo-plus-v1"
}Getting the token is the fiddliest part, and I will not pretend otherwise. It comes out of a logged-in Lumo web session. lumo-tamer automates that extraction, reusing a browser session or an rclone token, and that is the path I would point you at rather than hand-rolling it. Treat the token like any other secret: it goes in the environment variable, never in the file.
Do not trust the label, read the receipt
Here is the part I care about most. When a black-box service tells you it served you the good model at the unlimited tier, that is a claim, not a fact. So I put a small proxy on localhost, between opencode and Proton, and read the actual receipt.
It is about forty lines of Node with zero dependencies, builtins only, in a single file, lumo-proxy.mjs. It forwards every request to Proton verbatim, fixing the Host header for the upstream and stripping accept-encoding so it can read the SSE stream in the clear, and it passes the response body back untouched, so opencode sees exactly what Proton sent. Beyond forwarding, it does one useful thing. It logs three fields per request:
- requested: the model opencode asked for, parsed from the request body.
- served: the model Proton actually returned, parsed from the
modelfield in the SSE response. - category: Proton's
applied_limit_category, the rate tier it applied to the call.
It redacts the bearer token in its own logs, printing only the first few characters, so the log is safe to keep and paste.
#!/usr/bin/env node
// Transparent logging proxy between opencode and Proton Lumo.
// Proves, per request, which model opencode ASKED for vs which model Proton SERVED.
//
// Zero dependencies (node builtins only). Run it, point opencode's lumo baseURL at it:
// node ~/.config/opencode/lumo-proxy.mjs
// # in opencode.json, temporarily set lumo options.baseURL to:
// # http://127.0.0.1:8788/api/ai/v1
// # then relaunch opencode, pick Proton Lumo > Lumo Plus, send a message, watch this terminal.
//
// Each call prints: requested=<model in opencode's body> served=<model in Proton's response> category=<tier>
// If requested==served==lumo-plus-v1 you have end-to-end proof. Revert baseURL when done (or keep it).
import http from 'node:http';
import https from 'node:https';
const UPSTREAM = process.env.LUMO_UPSTREAM || 'lumo-api.proton.me';
const PORT = parseInt(process.env.LUMO_PROXY_PORT || '8788', 10);
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks);
let requested = '(none)';
try { const j = JSON.parse(body.toString()); if (j && j.model) requested = j.model; } catch {}
// Forward headers verbatim, but fix host and drop compression so we can read the SSE stream.
const headers = { ...req.headers, host: UPSTREAM };
delete headers['accept-encoding'];
delete headers['content-length'];
const upstream = https.request(
{ host: UPSTREAM, servername: UPSTREAM, path: req.url, method: req.method, headers },
up => {
res.writeHead(up.statusCode, up.headers);
let served = null, category = null, seen = '';
up.on('data', chunk => {
res.write(chunk); // pass through untouched
seen += chunk.toString('utf8');
if (!served) { const m = seen.match(/"model":"([^"]+)"/); if (m) served = m[1]; }
const c = seen.match(/"applied_limit_category":"([^"]+)"/); if (c) category = c[1];
if (seen.length > 65536) seen = seen.slice(-8192); // cap memory
});
up.on('end', () => {
res.end();
const auth = (req.headers['authorization'] || '').replace(/Bearer\s+(\S{0,6}).*/, 'Bearer $1…') || '(none)';
console.log(
`${new Date().toISOString()} ${req.method} ${req.url} ` +
`status=${up.statusCode} requested=${requested} served=${served ?? '(n/a)'} ` +
`category=${category ?? '(n/a)'} auth=${auth}`
);
});
}
);
upstream.on('error', err => {
console.log(`${new Date().toISOString()} UPSTREAM ERROR ${err.message}`);
if (!res.headersSent) res.writeHead(502);
res.end(`proxy upstream error: ${err.message}`);
});
upstream.end(body);
});
});
server.listen(PORT, '127.0.0.1', () =>
console.log(`lumo-proxy: http://127.0.0.1:${PORT} -> https://${UPSTREAM}\nwaiting for opencode requests... (Ctrl-C to stop)`)
);To use it, I run the script, temporarily point the lumo provider's baseURL at the local proxy (http://127.0.0.1:8788/api/ai/v1), send one message from opencode, and watch the terminal. A line comes back like this:
lumo-proxy: http://127.0.0.1:8788 -> https://lumo-api.proton.me
waiting for opencode requests... (Ctrl-C to stop)
2026-07-03T20:14:07.512Z POST /api/ai/v1/chat/completions status=200 requested=lumo-plus-v1 served=lumo-plus-v1 category=unlimited auth=Bearer eyJ0e…That line is the evidence. I asked for lumo-plus-v1, Proton served lumo-plus-v1, and applied_limit_category shows the tier that actually applied to my call. The exact category string is whatever Proton returns for your account; what matters is that served matches what you asked for and the tier is not the free daily cap. That is the model's own response on my own machine, not a label on a pricing page. Once I have seen it, I revert the baseURL to the Proton endpoint and let opencode talk to Lumo directly. The proxy is a verification tool, not a permanent dependency. If Proton ever quietly downgraded the model or the tier, this same script is where I would see it.
What works, and what does not
I have built a couple of small apps with it, and I lean on it most for drafting and refining image-generation prompts, where it is genuinely good. It is also buggy and slow today. On one small web app it scaffolded everything around the edges, the HTML shell, a polished stylesheet, a service worker, and then stalled before writing the one script that made any of it run. Both the promise and the rough edges are real at once, and the shape of what works matters more than the pitch.
Tool calling is the weak spot, and it is the crux. Lumo frequently misroutes tool calls and retries with the wrong parameters, and lumo-tamer documents exactly this behavior. So fully autonomous, hands-off, multi-step agent loops are unreliable on Lumo today. I am not going to tell you unlimited agentic coding is solved here, because it is not. Where Lumo is genuinely strong: chat, planning, reading and explaining code, and targeted single edits. Where it is weak: long autonomous runs that depend on clean tool calls turn after turn.
The rest of the caveats, stated once:
- It is unofficial and undocumented. There is no Proton API yet. This is reverse-engineered from the web client, the same technique behind the projects credited above.
- It can break the moment Proton changes the endpoint or bumps the app version. Treat it as a hack that works today, not a supported integration.
- It is very likely a gray area under Proton's terms of service.
The same tool, two private backends

Earlier I wrote up the local end of this same idea, a private coding LLM on my own network, a Qwen3-Coder model on a spare GPU with the same coding tool pointed at it. That post already ran headfirst into the caveat that governs this one. Malformed and empty tool calls break agentic coding, which is exactly why that local build ended up on the inference server it did.
Lumo is the cloud, flat-rate counterpart to that build. Same tool, same honest limit, a model I rent through a subscription instead of hosting on my own hardware. Both providers can live in one opencode.json at the same time, a local model and Lumo side by side, and I pick per task: the local box for long autonomous runs, Lumo for private chat, planning, and the single edits I would rather not send anywhere as plaintext.
The real thing is coming
This is a bridge, and I would rather say so than dress it up. Proton's own spring and summer 2026 roadmap says an official Lumo API is on the way, built to let you add Lumo to your product or software with the same zero-access privacy as the rest of Proton. When it ships, the app-version header and the session-token extraction go away, replaced by a proper key I am actually meant to have. I am glad about that. I would rather pay for a sanctioned path than lean on a reverse-engineered one, and an official private-AI API is exactly the thing I did not expect Proton to build a year ago.
Neither backend is magic. Both are private, both are mine to inspect, and with the proxy I never have to take either one's word for what it served me. When Proton's own API arrives I will trade the hack for the front door, and keep the proxy anyway.
Comments
// Comments are reviewed before appearing. No spam. No noise.